Java并发之ReentrantLock源码解析(三)
ReentrantLock和BlockingQueue
首先,看到这个标题,不要怀疑自己进错文章,也不要怀疑笔者写错,哈哈。本章笔者会从BlockingQueue(阻塞队列)的角度,看看juc包下的阻塞队列是如何使用ReentrantLock。这个章节笔者会介绍部分阻塞队列的源码,但不会着墨过多,我们的重点依旧在ReentrantLock上。
BlockingQueue(阻塞队列)是juc包下提供一种数据结构,相比普通的队列,阻塞队列可以让我们在不需要关心线程安全的情况下往队列中存取数据。此外,阻塞队列还支持我们从一个队列中获取元素时,如果队列为空则陷入阻塞,直到队列有元素入队为止;也支持我们向队列中存储元素时,如果队列已满则陷入阻塞,直到队列有多余的位置可以容纳待入队的元素。
不论是入队、出队亦或是检查队列元素,阻塞队列都提供了多种方法,这些方法都可以实现入队、出队和检查,但每个方法都有自己的特性和适用场景:
- 入队:
- add(E e):尝试将指定元素e插入到队列,如果没有超过队列的容量限制则返回true表示成功,否则抛出IllegalStateException异常表示队列没有多余的空间容纳元素。
- offer(E e):尝试将指定元素e插入到队列,如果没有超过队列的容量限制则返回true表示成功,如果队列没有多余的空间容纳元素则返回false。
- put(E e):尝试将指定元素e插入到队列,如果没有超过队列的容量则立即返回,否则陷入阻塞直到队列有多余的空间容纳元素。
- offer(E e, long timeout, TimeUnit unit):尝试将指定元素e插入到队列,如果没有超过队列的容量则立即返回;如果超时前队列有多余的空间可容纳元素则返回true,否则返回false。
- 出队:
- take():返回并移除队头元素,如果队列为空则陷入阻塞直到有元素入队。
- poll():返回并移除队头元素,此方法不会陷入阻塞,如果队列为空则返回null。
- poll(time, unit):返回并移除队头元素,队列为空则陷入阻塞,如果在超时前都没有元素入队则返回null。
- remove():返回并移除队头元素,如果队列为空则抛出NoSuchElementException异常。
- 检查:
- element():返回队头元素,但不移除,如果队列为空则抛出NoSuchElementException异常。
- peek():返回队头元素,但不移除,如果队列为空则返回null。
public interface Queue<E> extends Collection<E> { //... element(); peek(); E poll(); //... } public interface BlockingQueue<E> extends Queue<E> { boolean add(E e); boolean offer(E e); void put(E e) throws InterruptedException; boolean offer(E e, long timeout, TimeUnit unit) throws InterruptedException; E take() throws InterruptedException; E poll(long timeout, TimeUnit unit) throws InterruptedException; boolean remove(Object o); //... }
当我们试图向阻塞队列中添加一个空元素(null),阻塞队列会抛出NullPointerException异常。在阻塞队列中null是一个敏感值,一般用于表示队列无元素或者超时获取元素失败,如果允许往队列中添加空元素,就无法分辨返回的null到底是获取元素失败,还是这个空元素本身就是队列中的元素。
阻塞队列可以设置容量,如果我们添加的元素超过队列剩余可容纳的数量,可能会陷入阻塞。阻塞队列主要用于生产者-消费者队列这样的场景,此外阻塞队列还实现了Collection接口。我们可以调用remove(Object o)从队列中移除一个元素,例如从队列中移除一条消息,但这个方法的实现效率通常不是很高,应谨慎使用。
阻塞队列是线程安全的,其实现需要通过内部锁或者其他形式的并发控制来保证原子性。但是阻塞队列继承自Collection接口的addAll、containsAll、retainAll 、removeAll并不保证原子性。例如:addAll(c)的实现是循环将元素通过add(e)方法入队,那么在超过队列容量的时候,就会抛出异常。
下面,让我们思考下如何实现一个线程安全的阻塞队列。可能很多人看到文章的标题,都会直接想到ReentrantLock,只要涉及到元素的存取,我们都可以在业务执行前和业务执行后加上lock()和unlock(),这样就能保证线程的安全性。于是,我们阻塞队列的实现就变得尤为简单了:
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class MyBlockingQueue<E> { private Lock lock = new ReentrantLock(); public void put(E e) { try { lock.lock(); //put method body... } finally { lock.unlock(); } } public E take() { try { lock.lock(); //take method body... } finally { lock.unlock(); } } }
但如果我们的实现真的这么简单暴力,会出现一个问题,假设Thread-1线程执行put(e)方法并占用了锁,但发现队列已满陷入阻塞。此时Thread-2需要从队列中获取元素,同样要占有锁后再从队列中获取元素。但先前Thread-1已经占有了锁,Thread-2还能占有锁吗?或者换一个说法,在队列是空的时候Thread-2要从队列中获取元素,此时会先占有锁在陷入阻塞,Thread-1想往队列中添加元素,它能获取到锁吗?如果仅仅是使用ReentrantLock,那肯定是获取不到的。
按照我们现有的实现方式,队列满时获取元素的线程一定要优先在添加元素的线程,如果添加元素的线程优先于获取元素的线程,会出现添加元素的线程占有了锁,并等待队列空出多余的位置容纳元素,而获取元素的线程等待前一个线程释放锁,两个线程永远无法结束;或者队列为空的时候添加元素的线程一定要优先在获取元素的线程,如果获取元素的线程优先于添加元素的线程,会出现获取元素占有了锁,并等待有元素入队,同时添加元素的线程等待前一个线程释放锁,两个线程同样无法结束。
但我们要知道,这种实现方式一定是不合理的,我们不能要求开发者在往阻塞队列中存取元素的时候,要求哪个线程要优先哪个线程,甚至是开发者自己都很难做这个优先顺序。那么像juc包下的阻塞队列又是如何在保证线程安全的情况下,避免出现上面所说的死锁呢?我们来看看实现较为简单的ArrayBlockingQueue:
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable { final ReentrantLock lock; private final Condition notEmpty; private final Condition notFull; //... public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); return dequeue(); } finally { lock.unlock(); } } //... public void put(E e) throws InterruptedException { Objects.requireNonNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); enqueue(e); } finally { lock.unlock(); } } //... }
可以看到,在ArrayBlockingQueue的实现中,也是非常简单暴力的用ReentrantLock的lock()、unlock()来实现队列的存取。那么我们用一段代码来测试下,看看ArrayBlockingQueue在队列满时还有线程往内添加元素的时候,能否从队列获取元素。
putAndTake(BlockingQueue<Integer> queue, int n)接收一个阻塞队列和一个数值n,会启动n个线程往阻塞队列添加元素,之后休眠1s,确认目前n个线程要嘛添加元素成功,要嘛队列已满处于阻塞状态,最后循环n次从阻塞队列中获取元素。这里我们只要保证n的数值大于阻塞队列的容量,就可以保证n个线程里会存在部分线程往阻塞队列添加元素时被阻塞。在main方法中我们设定线程数n为5,阻塞线程的容量为5/2=2,我们分别创建两个阻塞队列ArrayBlockingQueue和LinkedBlockingQueue,看看当队列满时仍然有线程向阻塞队列存放元素,获取元素的线程能否正常从队列中获取元素。
import java.util.Random; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; public class BlockQueueTest { public static void putAndTake(BlockingQueue<Integer> queue, int n) { for (int i = 0; i < n; i++) {//<1> new Thread(() -> { try { int r = new Random().nextInt(20); queue.put(r); System.out.println("往队列存放数值:" + r); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } try { Thread.sleep(1000);//<2> } catch (InterruptedException e) { e.printStackTrace(); } for (int i = 0; i < n; i++) {//<3> try { System.out.println("从队列取出数值:" + queue.take()); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { int n = 5; int capacity = n / 2; BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(capacity); System.out.println("_____ArrayBlockingQueue_____"); putAndTake(queue, n); System.out.println("_____LinkedBlockingQueue_____"); queue = new LinkedBlockingQueue<>(capacity); putAndTake(queue, n); } }
执行结果:
_____ArrayBlockingQueue_____ 往队列存放数值:9 往队列存放数值:16 往队列存放数值:11 从队列取出数值:9 从队列取出数值:16 往队列存放数值:10 往队列存放数值:4 从队列取出数值:11 从队列取出数值:10 从队列取出数值:4 _____LinkedBlockingQueue_____ 往队列存放数值:4 往队列存放数值:19 从队列取出数值:4 往队列存放数值:13 往队列存放数值:17 从队列取出数值:19 从队列取出数值:13 往队列存放数值:4 从队列取出数值:17 从队列取出数值:4
可以看到ArrayBlockingQueue和LinkedBlockingQueue并没有我们先前假定的情况,但按照先前我们看到的ArrayBlockingQueue的take()和put(e)的实现,又会在方法的开始便占有锁。那么ArrayBlockingQueue是怎么做到当队列满时,存放元素的线程占有锁后陷入阻塞,又允许获取元素的线程抢锁,然后从队列中获取元素呢?
首先我们能肯定,只要是不同的线程在竞争同一个可重入互斥锁,如果锁被占用,一定要等到锁被完全释放成为无主状态,别的线程才可以占有锁。因此不论时队列满时有线程占有锁并尝试往队列存放元素,又或者队列为空时有线程占有锁从队列获取元素,这两种情况一定会在某一个时机释放锁,因为这两个线程一定会被阻塞起来,直到有线程从已满的队列中获取元素,或者有线程向空队列存放元素,且这两个线程不能影响别的线程从已满的队列获取元素,或者向空队列存放元素。
那么在下面的take()和put(e)两个方法中会是哪一段代码偷偷完成释放锁并阻塞当前线程这一操作呢?很有可能是<1>处和<2>处。这里我们看到ReentrantLock另外一种用法,我们可以用ReentrantLock生成一个Condition条件对象,相信会有人产生疑问,Condition对象在下面的代码究竟起到什么样的作用呢?
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable { final Object[] items; final ReentrantLock lock; private final Condition notEmpty; private final Condition notFull; //... public ArrayBlockingQueue(int capacity, boolean fair) { if (capacity <= 0) throw new IllegalArgumentException(); this.items = new Object[capacity]; lock = new ReentrantLock(fair); notEmpty = lock.newCondition(); notFull = lock.newCondition(); } //... public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await();//<2> return dequeue(); } finally { lock.unlock(); } } //... public void put(E e) throws InterruptedException { Objects.requireNonNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await();//<1> enqueue(e); } finally { lock.unlock(); } } //... }
在介绍Condition之前,我们先来回顾下synchronized关键字,我们知道synchronized可以对一个对象加锁以保证并发情况下线程以串行的方式访问同步代码块,当线程执行完同步代码块里的代码,就会释放对象锁。但是synchronized也支持线程在尚未执行完同步代码块中的代码时,就调用object.wait()释放对象锁从而让当前线程进入阻塞,当需要线程继续往下执行时,调用object.notify()或者object.notifyAll()唤醒阻塞线程。
synchronized (object){ //... }
下面我们用一个例子来加深对synchronized的理解,Restaurant类的main方法中,我们先在<1>处生成一个厨师对象cook,然后在<2>处会循环生成6个线程,每个线程都作为一个顾客获取cook的对象锁,在通知厨师做菜后陷入阻塞。之后主线程休眠1s,确保6个线程都陷入阻塞后,调用<3>处的cook.finishOne()方法后间接调用cook.notify()随机选择一个线程唤醒,被唤醒的线程会退出object.wait()然后重新竞争object对象锁,在占有对象锁后线程继续执行cook.wait()之后的代码,取走菜并销毁当前线程。之后主方法又休眠了1s,调用<4>处的cook.finishAll()方法后间接调用cook.notifyAll()唤醒所有等待cook对象锁的线程,所有等待线程会退出cook.wait()开始竞争cook对象锁,接着按照之前的逻辑,抢锁成功的线程取走菜然后销毁线程。
public class Restaurant { //厨师类 static class Cook { public synchronized void finishOne() { System.out.println("厨师完成一道菜"); this.notify(); } public synchronized void finishAll() { System.out.println("厨师完成所有菜"); this.notifyAll(); } } public static void main(String[] args) { final Cook cook = new Cook();//<1> for (int i = 1; i <= 6; i++) { final int no = i; new Thread(() -> {//<2> synchronized (cook) { try { System.out.println("顾客" + no + "号通知厨师做菜"); cook.wait(); System.out.println("顾客" + no + "号取菜"); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } try { Thread.sleep(1000); cook.finishOne();//<3> Thread.sleep(1000); cook.finishAll();//<4> } catch (InterruptedException e) { e.printStackTrace(); } } }
执行结果:
顾客1号通知厨师做菜 顾客2号通知厨师做菜 顾客5号通知厨师做菜 顾客3号通知厨师做菜 顾客4号通知厨师做菜 顾客6号通知厨师做菜 厨师完成一道菜 顾客1号取菜 厨师完成所有菜 顾客2号取菜 顾客6号取菜 顾客4号取菜 顾客3号取菜 顾客5号取菜
那么会有人问,这里的synchronized和之前我们说的Condition又有什么关系呢?别着急,现在就来回答这个问题。如果我们把synchronized等同于ReentrantLock,那么Condition就相当于对象锁,当我们占有互斥锁后,我们可以调用Condition.await()释放当前线程对锁的占用,并让当前线程陷入阻塞,当我们希望当前线程重新执行时,可以调用Condition.signal()或Condition.signalAll()唤醒陷入阻塞的线程,被唤醒的线程会重新开始抢锁,抢到锁的线程会继续执行Condition.await()之后的代码,没有抢到锁的线程则接着等待。
我们用新的一个Restaurant2类来模拟上面的通知厨师做菜->厨师通知取菜的例子,这里我们用ReentrantLock和Condition来实现。我们在<1>处生成一个ReentrantLock类型的厨师锁cook,在<2>处根据厨师锁生成一个条件condition对象。之后我们在main方法的<3>处循环启动6个线程,每个线程代表一个顾客去占有厨师锁通知厨师做菜然后陷入阻塞。之后主线程分次调用finishOne()和finishAll(),继而调用condition.signal()和condition.signalAll()通知一个线程取菜和通知所有线程取菜。
import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class Restaurant2 { private static final ReentrantLock cook = new ReentrantLock();//<1> private static final Condition condition = cook.newCondition();//<2> public static void finishOne() { try { cook.lock(); System.out.println("厨师完成一道菜"); condition.signal(); } finally { cook.unlock(); } } public static void finishAll() { try { cook.lock(); System.out.println("厨师完成所有菜"); condition.signalAll(); } finally { cook.unlock(); } } public static void main(String[] args) { for (int i = 1; i <= 6; i++) { final int no = i; new Thread(() -> {//<3> try { cook.lock(); System.out.println("顾客" + no + "通知厨师做菜"); condition.await(); System.out.println("顾客" + no + "号取菜"); } catch (InterruptedException e) { e.printStackTrace(); } finally { cook.unlock(); } }).start(); } try { Thread.sleep(1000); finishOne();//<4> Thread.sleep(1000); finishAll();//<5> } catch (InterruptedException e) { e.printStackTrace(); } } }
执行结果:
顾客3通知厨师做菜 顾客5通知厨师做菜 顾客2通知厨师做菜 顾客4通知厨师做菜 顾客1通知厨师做菜 顾客6通知厨师做菜 厨师完成一道菜 顾客3号取菜 厨师完成所有菜 顾客5号取菜 顾客2号取菜 顾客4号取菜 顾客1号取菜 顾客6号取菜
可以看到ReentrantLock也可以完成类似synchronized阻塞-通知的工作,那么ReentrantLock相比于synchronized的优势又在哪里呢?是可扩展性,之前介绍ReentrantLock的时候就说过,ReentrantLock完成的工作虽然和synchronized相似,但比synchronized多了扩展性。如果在synchronized代码块里调用对象锁的wait()方法,当前占有对象锁的线程会释放锁并进入等待,JVM会帮我们维护这个对象锁的等待线程集合,当我们调用object.notify(),JVM会帮我们选择一个线程唤醒,我们只知道这个线程会退出object.wait()方法并开始抢锁,但抢到锁之后,我们并不清楚这个线程会从哪里开始执行。
比如下面的代码,methodA()和methodB()都有对象锁object的同步代码块,调用object.wait()都会陷入阻塞,但是当我们调用object.notify()时,我们无法指定要唤醒的线程是执行methodB()的线程而非执行methodA()的线程,JVM唤醒的线程,也可能是正在执行methodA()的线程。
public void methodA() { synchronized (object) { //... object.wait(); //... } } public void methodB() { synchronized (object) { //... object.wait(); //... } }
基于wait()、notify()、notifyAll()带来的限制,juc包的作者Doug Lea大师推出了Condition对象,我们可以基于一个锁生成多个Condition对象,每个Condition对象都类似synchronized的对象锁,会维护自己的一个等待线程集合。当线程要执行某项工作,发现条件不满足时可以调用Condition.await()方法释放锁并陷入阻塞,当线程在执行某项工作后发现条件满足,也可以调用Condition.singal()通知需要此条件的线程。这样讲可能有些人还是不太理解,这里笔者接着以之前的阻塞队列为例,看看如何借助ReentrantLock和Condition在阻塞队列满的情况下,存储线程在占有锁后释放锁,直到收到队列有多余空间的消息为止,同理我们也可以看看,在队列为空时,获取线程在占有锁后释放锁,直到收到队列不为空的消息为止。
在初始化MyBlockingQueue的时候,会在<1>处生成一个可重入互斥锁lock,同时会分别在<2>、<3>处生成队列未满(notFull)和队列非空(notEmpty)两个条件对象。当有存储线程要存储元素,调用put(e)方法并占有了锁,如果发现队列已满,会先调用<4>处的未满条件对象(notFull)释放锁并陷入阻塞,之后获取线程要获取元素,调用take()方法发现队列非空,于是在取走一个元素后多出空余的空间,调用<7>处的未满条件对象(notFull)通知其陷入等待的存储线程,现在队列中有空余的空间可以容纳元素。同理,当有获取线程要从队列获取元素,调用take()方法并占有了锁,如果发现队列是空的情况下会调用<6>处非空条件对象(notEmpty)释放锁并陷入阻塞,之后存储线程向队列存储元素,先调用put(e)占有了锁,发现队列未满,于是将元素存储进队列,然后调用<5>处的非空条件对象通知等待元素的线程可以从队列中获取元素了。
import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class MyBlockingQueue<E> { final Lock lock = new ReentrantLock();//<1> final Condition notFull = lock.newCondition();//<2> final Condition notEmpty = lock.newCondition();//<3> final Object[] items; int putptr, takeptr, count; public MyBlockingQueue(int n) { items = new Object[n]; } public void put(E x) throws InterruptedException { lock.lock(); try { while (count == items.length) notFull.await();//<4> items[putptr] = x; if (++putptr == items.length) putptr = 0; ++count; notEmpty.signal();//<5> } finally { lock.unlock(); } } public E take() throws InterruptedException { lock.lock(); try { while (count == 0) notEmpty.await();//<6> E x = (E) items[takeptr]; if (++takeptr == items.length) takeptr = 0; --count; notFull.signal();//<7> return x; } finally { lock.unlock(); } } }