【Java并发工具类】Lock和Condition

前言

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

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

再造管程的理由

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

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

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

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

  1. 可以响应中断多线程

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

  2. 支持超时dom

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

  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

相关文章
相关标签/搜索