【Java并发工具类】Lock和Condition

和自甴很熟 提交于 2020-02-09 20:40:39

前言

Java SDK并发包通过LockCondition两个接口来实现管程,其中Lock用于解决互斥问题,Condition用于解决同步问题。我们需要知道,Java语言本身使用synchronized实现了管程的,那么为什么还在SDK中提供另外一种实现呢?欲知为何请看下文。

下面将先阐述再造管程的理由,然后详细介绍Lock和Condition,最后再看实现同步机制时是选择synchronized还是SDK中的管程。

再造管程的理由

Java本就从语言层面实现了管程,然而后面又在SDK中再次现实,这只能说明语言层面的实现的管程有所不足。要说谈synchronized的不足,我们就要要回顾一下破坏死锁的不可抢占问题

破坏不可抢占条件,需要线程在获取不到锁的情况下主动释放它拥有的资源。当我们使用synchronized的时候,线程是没有办法主动释放它占有的资源的。因为,synchronized在申请不到资源时,会使线程直接进入阻塞状态,而线程进入了阻塞状态就不能主动释放占有的资源。

所以,有没有一种办法可以使得线程处于阻塞状态时也能够响应中断主动释放资源或者获取不到资源的时候不阻塞呢?答案是有的,使用SDK中的管程。

SDK中管程的实现java.util.concurrent中的Lock接口,提供了如下三种设计思想都可以解决死锁的不可抢占条件:

  1. 能够响应中断

    线程处于阻塞状态时可以接收中断信号。我们便可以给阻塞的线程发送中断信号,唤醒线程,线程便有机会释放它曾经拥有的锁。这样便可破坏不可抢占条件。

  2. 支持超时

    如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。

  3. 非阻塞地获取锁

    如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也可以破坏不可抢占条件。

这三种方案就可全面弥补synchronized的问题。也就是再造管程的原因。这三种思想体现在Lock接口的API上,便是如下三个方法:

// 支持中断的 API
void lockInterruptibly() throws InterruptedException;

// 支持超时的 API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

// 支持非阻塞获取锁的 API
boolean tryLock();

下面我们便继续介绍Lock。

Lock和ReentrantLock

Lock接口中定义了一组抽象的加锁操作:

public interface Lock{
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition(); // 关联Condition对象使用
}

与synchronized内置加锁不同,Lock提供的是无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁都是显式的。在Lock的实现中必须要提供与内置锁相同的内存可见性语义,但是加锁语义、调度算法、顺序保证以及性能等方面可以不同。

ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。在获取ReentrantLock时,有着进入同步代码块相同的内存语义,在释放ReentrantLock时,同样有着与退出同步代码块相同的内存语义。见名知义,ReentrantLock还提供了同synchronized一样的可重入加锁的语义。

👉 扩展:可重入函数
可重入函数怎么理解呢?指的是线程可以重复调用?显然不是,所谓可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。多线程可以同时执行,还支持线程切换,这意味着什么呢?线程安全。所以,可重入函数是线程安全的。

Lock的标准使用形式

Lock l = ...; //使用ReentrantLock实现类 Lock l = new ReentrantLock();
l.lock();
try {
    // access the resource protected by this lock
} finally {
    l.unlock();
}

Lock的使用形式比synchronized要复杂一些,所有的加锁和解锁的操作都是显式的。解锁操作必须在finally块中,否则,如果在被保护的代码块中抛出了异常,那么这个锁将永远无法释放。当使用加锁时,还必须考虑在try块中抛出异常的情况,如果可能使对象处于不一致状态,那么就需要try-catch或者try-finally块。

轮询锁与定时锁

可定时与可轮询的锁获取方式是由tryLock方法实现的。与无条件获取锁的模式比较,它具有更完善的错误恢复机制。

使用轮询锁解决动态顺序死锁问题

如果不能获得所有需要的锁,那么可以使用可定时的或者可轮询的锁获取方式,它会释放已经获得的锁,然后尝试重新获取所有锁。下面一个例子(来自参考[2]),给出了使用轮询锁来解决转账时动态顺序死锁问题:使用tryLock来获取两个账户的锁,如果不能同时获得,那么就回退并重新尝试。程序中还在休眠时间中做了随机处理,从而降低发生活锁的可能性。

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
import static java.util.concurrent.TimeUnit.NANOSECONDS;

public class DeadlockAvoidance {
    private static Random rnd = new Random();
    public boolean transferMoney(Account fromAcct,
                                 Account toAcct,
                                 DollarAmount amount,
                                 long timeout,
                                 TimeUnit unit)
        throws InsufficientFundsException, InterruptedException {
        long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
        long randMod = getRandomDelayModulusNanos(timeout, unit);
        long stopTime = System.nanoTime() + unit.toNanos(timeout);

        while (true) {
            // 使用tryLock()获取锁,如果获取了锁,则返回 true;否则返回 false.
            if (fromAcct.lock.tryLock()) { 
                try {
                    if (toAcct.lock.tryLock()) { // 使用tryLock()获取锁
                        try {
                            if (fromAcct.getBalance().compareTo(amount) < 0)
                                throw new InsufficientFundsException();
                            else {
                                fromAcct.debit(amount);
                                toAcct.credit(amount);
                                return true;
                            }
                        } finally {
                            toAcct.lock.unlock();
                        }
                    }
                } finally {
                    fromAcct.lock.unlock();
                }
            }
            if (System.nanoTime() < stopTime) // 获取锁的时间大于给定时间,则返回失败
                return false;
            NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod); // 休眠时间加入随机成分
        }
    }

    private static final int DELAY_FIXED = 1;
    private static final int DELAY_RANDOM = 2;

    static long getFixedDelayComponentNanos(long timeout, TimeUnit unit) {
        return DELAY_FIXED;
    }

    static long getRandomDelayModulusNanos(long timeout, TimeUnit unit) {
        return DELAY_RANDOM;
    }

    static class DollarAmount implements Comparable<DollarAmount> {
        public int compareTo(DollarAmount other) {
            return 0;
        }
        DollarAmount(int dollars) {
        }
    }

    class Account {
        public Lock lock;
        void debit(DollarAmount d) {
        }
        void credit(DollarAmount d) {
        }
        DollarAmount getBalance() {
            return null;
        }
    }
    class InsufficientFundsException extends Exception {
    }
}

定时锁

在实现具有时间限制操作时,定时锁将非常有用。当在带有时间限制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果,那么它就会使程序提前结束。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。

可中断的锁获取操作

可中断的锁获取操作适用在可取消的操作中获取锁。内置锁是不能响应中断的。lockInterruptibly()方法能够在获取锁的同时保持对中断的响应,因为它包含在Lock中,因此无须创建其他类型的不可中断阻塞机制。

非块结构的加锁

在内置锁中,锁的获取和释放都是基于代码块的,且是自动获取和释放锁。虽然这样避免了编码的复杂性,但是却不太灵活。例如,某些遍历并发访问的数据结果的算法要求使用连锁式加锁"hand-over-hand"锁耦合 "chain locking":获取节点 A 的锁,然后再获取节点 B 的锁,然后释放 A 并获取 C,然后释放 B 并获取 D,依此类推。Lock 接口的实现允许锁在不同的作用范围内获取和释放,并允许以任何顺序获取和释放多个锁,从而支持使用这种技术。

公平性

ReentrantLock的构造函数中提供了两种公平性选择:创建一个非公平的锁(默认)或者一个公平的锁。

public ReentrantLock(){}
// fair参数代表的是锁的公平策略,如果传入true就表示需要构造一个公平锁,否则就是构造一个非公平锁。
public ReentrantLock(boolean fair) {}    

在公平的锁上,线程将按照它们发出请求的顺序来获得锁;在非公平的锁上,则允许“插队”:当一个线程请求非公平锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁(来得早不如来得巧)。

关于请求线程是否进入队列排队等待锁:在公平的锁中,如果有一个线程持有这个锁或者有其他线程正在队列中等待这个锁,那么新发出请求的线程将被放入队列中等待(FIFO原则);在非公平锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中(如上面所述的来得巧就会直接获得锁)。

在现实生活中我们往往期望事事公平,但是为什么在并发的锁上却存在不公平锁?其实想想也简单,恢复挂起的一个线程到这个线程到这个线程真正开始执行之前是存在延迟的,如果在此期间有一个线程刚好达到并且在被唤醒的线程真正执行之前又刚好可以利用完资源,那么这种充分利用资源的精神恰恰是可取的。
公平性将由于在挂起线程和恢复线程时产生开销而极大地降低性能,于是,大多数情况下,非公平锁的性能要高于公平锁的性能。

只有当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么就应该使用公平锁。与默认的ReentrantLock一样,内置加锁并不会提供确定的公平性保证。

synchronized和ReentrantLock之间的抉择

ReentrantLock在加锁和内存上提供的语义都与内置锁相同,除此之外,它还提供我们上述的定时的锁等待、可中断的锁等待、公平性以及实现非块结构的加锁。ReentrantLock在性能上也优于内置锁(在Java 5.0中远远胜出,在Java 6.0中略有胜出,synchronized在Java 6.0中做了优化),但是是否就意味着开发都使用ReentrantLock替代synchronized呢?

synchronized与ReentrantLock相比,还是具有很大优势。例如为开发人员所熟悉、简洁紧凑。加锁和释放锁都是自动进行的,而显式锁需要手动在finally中进行,如果忘记将引发严重后果。在现有很多程序中使用的时内置锁,贸然混合入显式锁也会让人困惑,也容易引起错误。

所以,在一般情况下使用内置锁,仅当内置锁不能满足需求时,才可以考虑使用ReentrantLock。未来synchronized被提升优化的可能也会很大,因为synchronized作为JVM的内置属性,可以便于一些代码优化,如对线程封闭的锁对象的锁消除优化,通过增加锁的粒度来消除内置锁的同步。

Condition对象

Lock可以看作是一种广义的内置锁,Condition则可以看作是一种广义的内置条件队列。我们前面介绍管程时说过,每个内置锁只能有一个相关联的条件队列(条件变量等待队列)。 一个Condition和一个Lock关联在一起,就像一个条件队列和一个内置锁关联一样。创建一个Condition,可以在关联的Lock上调用Lock.newCondition()方法 。Condition比内置条件队列提供了更丰富的功能:在每个锁上加锁存在多个等待、条件等待是可中断的或不可中断的、基于限时的等待,以及公平的或非公平的队列操作。

每个Lock可以拥有任意数量的Condition对象。Condition对象继承了相关的Lock对象的公平性,对于公平的锁,线程会依照FIFO顺序从Condition.await中释放。

Condition的接口如下:

public interface Condition{
    void await() throws InterruptedException;
    boolean await(long time, TimeUnit unit) throws InterruptedException;
    long awaitNanos(long nanosTimeout) throws InterruptedException;
    void awaitUninterruptibly();
    boolean awaitUntil(Date deadline) throws InterruptedException;
    void signal();
    void signalAll();
}

注意,在Condition对象中,与(等待—唤醒机制中介绍的)内置锁中waitnotifynotifyAll方法相对应的是awaitsignalsignaAll方法。因为Condition也继承了Object,所以它也包含了wait、notify和notifyAll方法,在使用时一定要使用正确的版本。

使用Lock和Condition实现有界缓存(代码来自参考[2])。使用两个Condition,分别为notFull和notEmpty,用于表示“非满”与“非空”两个条件谓词(使某个操作成为状态依赖操作的前提,对下面的take方法来说,它的条件谓词就是“缓存不空”,take方法在执行前必须首先测试该条件谓词)。当缓存为空时,take将阻塞并等待notEmpty,此时put想notEmpty发送信号,可以解除任何在take中阻塞的线程。

public class ConditionBoundedBuffer <T> {
    protected final Lock lock = new ReentrantLock();
    // 条件谓词: notFull (count < items.length)
    private final Condition notFull = lock.newCondition();
    // 条件谓词: notEmpty (count > 0)
    private final Condition notEmpty = lock.newCondition();
    private static final int BUFFER_SIZE = 100;
    @GuardedBy("lock") private final T[] items = (T[]) new Object[BUFFER_SIZE];
    @GuardedBy("lock") private int tail, head, count;

    // 阻塞并直到: notFull
    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await(); // 阻塞当前线程在notFull的条件队列上
            items[tail] = x;
            if (++tail == items.length)
                tail = 0;
            ++count;
            notEmpty.signal(); // 唤醒notEmpty条件队列上的一个线程
        } finally {
            lock.unlock();
        }
    }

    // 阻塞并直到: notEmpty
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await(); // 阻塞当前线程在notEmpty的条件队列上
            T x = items[head];
            items[head] = null;
            if (++head == items.length)
                head = 0;
            --count;
            notFull.signal(); // 唤醒notFull条件队列上的一个线程
            return x;
        } finally {
            lock.unlock();
        }
    }
}

通过将两个条件谓词分开并放到两个等待线程集中,Condition使其更容易满足单次通知的需求。signal将比signalAll更高效,它能极大地减少在每次缓存操作中发生的上下文切换与锁请求的次数。因为如果使用内置锁来实现,所有被阻塞的线程都将在一个队列上等待。

小结

在开发并发程序时,是使用原生的synchronized还是java.util.concurrent.*下的显式锁Lock工具类,它们各有优劣还需要根据具体要求进行选择。以上仅是学习笔记的整合,若有不明不白之处,还望各位看官指出,先在此谢过。

参考:
[1]极客时间专栏王宝令《Java并发编程实战》
[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!