本文已参与「新人创作礼」活动,一起开启掘金创作之路。
引言
上一期我们讲到了使用可重入锁ReentrantLock来提高v2版本的效率。而且不会引发lost wake up问题。但v3版本还是存在一定的性能问题。比如v3版本中signalAll的效率问题:jdk的Condition条件变量提供了signal和signalAll这两个方法用于唤醒等待在条件变量中的线程,其中signalAll会唤醒等待在条件变量上的所有线程,而signal则只会唤醒其中一个。
举个例子,v3版本中消费者线程在队列已满时进行出队操作后,通过signalAll会唤醒所有等待入队的多个生产者线程,但最终只会有一个线程成功竞争到互斥锁并成功执行入队操作,其它的生产者线程在被唤醒后发现队列依然是满的,而继续等待。v3版本中的signalAll唤醒操作造成了惊群效应,无意义的唤醒了过多的等待中的线程。
但仅仅将v3版本中的signalAll改成signal是不行的,因为生产者和消费者线程是等待在同一个条件变量中的,如果消费者在出队后通过signal唤醒的不是与之对应的生产者线程,而是另一个消费者线程,则本该被唤醒的生产者线程可能迟迟无法被唤醒,甚至在一些场景下会永远被阻塞,无法再唤醒。
仔细思索后可以发现,对于生产者线程其在队列已满时阻塞等待,等待的是队列不满的条件(notFull);而对于消费者线程其在队列为空时阻塞等待,等待的是队列不空的条件(notEmpty)。队列不满和队列不空实质上是两个互不相关的条件。
双条件变量优化唤醒效率
经过上述分析,v4版本中我们将生产者线程和消费者线程关注的条件变量拆分成两个:生产者线程在队列已满时阻塞等待在notFull条件变量上,消费者线程出队后通过notFull.signal尝试着唤醒一个等待的生产者线程;与之相对的,消费者线程在队列为空时阻塞等待在notEmpty条件变量上,生产者线程入队后通过notEmpty.signal尝试着唤醒一个等待的消费者线程。
通过拆分出两个互相独立的条件变量,避免了v3版本中signalAll操作带来的惊群效应,避免了signalAll操作无效唤醒带来的额外开销。实现代码如下:
package com;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* 数组作为底层结构的阻塞队列 v4版本
*/
public class MyArrayBlockingQueueV4<E> implements MyBlockingQueue<E> {
/**
* 队列默认的容量大小
* */
private static final int DEFAULT_CAPACITY = 16;
/**
* 承载队列元素的底层数组
* */
private final Object[] elements;
/**
* 当前头部元素的下标
* */
private int head;
/**
* 下一个元素插入时的下标
* */
private int tail;
/**
* 队列中元素个数
* */
private int count;
private final ReentrantLock reentrantLock;
private final Condition notEmpty;
private final Condition notFull;
//构造方法
/**
* 默认构造方法
* */
public MyArrayBlockingQueueV4() {
this(DEFAULT_CAPACITY);
}
/**
* 默认构造方法
* */
public MyArrayBlockingQueueV4(int initCapacity) {
assert initCapacity > 0;
// 设置数组大小为默认
this.elements = new Object[initCapacity];
// 初始化队列 头部,尾部下标
this.head = 0;
this.tail = 0;
this.reentrantLock = new ReentrantLock();
this.notEmpty = this.reentrantLock.newCondition();
this.notFull = this.reentrantLock.newCondition();
}
/**
* 下标取模
* */
private int getMod(int logicIndex){
int innerArrayLength = this.elements.length;
// 由于队列下标逻辑上是循环的
if(logicIndex < 0){
// 当逻辑下标小于零时
// 真实下标 = 逻辑下标 + 加上当前数组长度
return logicIndex + innerArrayLength;
} else if(logicIndex >= innerArrayLength){
// 当逻辑下标大于数组长度时
// 真实下标 = 逻辑下标 - 减去当前数组长度
return logicIndex - innerArrayLength;
} else {
// 真实下标 = 逻辑下标
return logicIndex;
}
}
/**
* 入队
* */
private void enqueue(E e){
// 存放新插入的元素
this.elements[this.tail] = e;
// 尾部插入新元素后 tail下标后移一位
this.tail = getMod(this.tail + 1);
this.count++;
}
/**
* 出队
* */
private E dequeue(){
// 暂存需要被删除的数据
E dataNeedRemove = (E)this.elements[this.head];
// 将当前头部元素引用释放
this.elements[this.head] = null;
// 头部下标 后移一位
this.head = getMod(this.head + 1);
this.count--;
return dataNeedRemove;
}
@Override
public void put(E e) throws InterruptedException {
// 先尝试获得互斥锁,以进入临界区
reentrantLock.lockInterruptibly();
try {
// 因为被消费者唤醒后可能会被其它的生产者再度填满队列,需要循环的判断
while (this.count == elements.length) {
// put操作时,如果队列已满则进入notFull条件变量的等待队列,并释放条件变量对应的互斥锁
notFull.await();
// 消费者进行出队操作时
}
// 走到这里,说明当前队列不满,可以执行入队操作
enqueue(e);
// 唤醒可能等待在notEmpty中的一个消费者线程
notEmpty.signal();
} finally {
// 入队完毕,释放锁
reentrantLock.unlock();
}
}
@Override
public E take() throws InterruptedException {
// 先尝试获得互斥锁,以进入临界区
reentrantLock.lockInterruptibly();
try {
// 因为被生产者唤醒后可能会被其它的消费者消费而使得队列再次为空,需要循环的判断
while(this.count == 0){
notEmpty.await();
}
E headElement = dequeue();
// 唤醒可能等待在notFull中的一个生产者线程
notFull.signal();
return headElement;
} finally {
// 出队完毕,释放锁
reentrantLock.unlock();
}
}
@Override
public boolean isEmpty() {
return this.count == 0;
}
}
v4版本的阻塞队列采用双条件变量之后,其性能已经不错了,但仍存在进一步优化的空间。比如 v4版本单锁的性能问题。v4版本中阻塞队列的出队、入队操作是使用同一个互斥锁进行并发同步的,这意味着生产者线程和消费者线程无法并发工作,消费者线程必须等待生产者线程操作完成退出临界区之后才能继续执行,反之亦然。单锁的设计在生产者和消费者都很活跃的高并发场景下会一定程度限制阻塞队列的吞吐量。
因此v5版本在v4版本的基础上,将出队和入队操作使用两把锁分别管理,使得生产者线程和消费者线程可以并发的操作阻塞队列,达到进一步提高吞吐量的目的。使用两把锁分别控制出队、入队后,还需要一些调整来解决生产者/消费者并发操作队列所带来的问题。我们对put和take两个方法进行细微的调整,如下:
/**
this.takeLock = new ReentrantLock();
this.notEmpty = this.takeLock.newCondition();
this.putLock = new ReentrantLock();
this.notFull = this.putLock.newCondition();
*/
@Override
public void put(E e) throws InterruptedException {
// 先尝试获得互斥锁,以进入临界区
putLock.lockInterruptibly();
try {
// 因为被消费者唤醒后可能会被其它的生产者再度填满队列,需要循环的判断
while (this.count == elements.length) {
// put操作时,如果队列已满则进入notFull条件变量的等待队列,并释放条件变量对应的互斥锁
notFull.await();
}
// 走到这里,说明当前队列不满,可以执行入队操作
enqueue(e);
// 唤醒可能等待在notEmpty中的一个消费者线程
notEmpty.signal();
} finally {
// 入队完毕,释放锁
putLock.unlock();
}
}
@Override
public E take() throws InterruptedException {
// 先尝试获得互斥锁,以进入临界区
takeLock.lockInterruptibly();
try {
// 因为被生产者唤醒后可能会被其它的消费者消费而使得队列再次为空,需要循环的判断
while(this.count == 0){
notEmpty.await();
}
E headElement = dequeue();
// 唤醒可能等待在notFull中的一个生产者线程
notFull.signal();
return headElement;
} finally {
// 出队完毕,释放锁
takeLock.unlock();
}
}
上面基于v4版本微调的双锁实现虽然容易理解,但由于允许消费者和生产者线程并发的访问队列而存在几个严重问题。其中一个就是 count属性线程不安全:队列长度count字段是一个用于判断队列是否已满,队列是否为空的重要属性。在v5之前的版本count属性一直被唯一的同步锁保护着,任意时刻至多只有一个线程可以进入临界区修改count的值。而引入双锁令消费者线程/生产者线程能并发访问后,count变量的自增/自减操作会出现线程不安全的问题。
解决方案:将int类型的count修改为AtomicInteger来解决生产者/消费者同时访问、修改count时导致的并发问题。AtomicInteger是位于java.util.concurrent.atomic包下,是对int的封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAS。
还有一个就是生产者/消费者线程死锁问题:在上述的代码示例中,生产者线程首先获得生产者锁去执行入队操作,然后唤醒可能阻塞在notEmpty上的消费者线程。由于使用条件变量前首先需要获得其所属的互斥锁,如果生产者线程不先释放生产者锁就去获取消费者的互斥锁,那么就存在出现死锁的风险。消费者线程和生产者线程可以并发的先分别获得消费者锁和生产者锁,并且也同时尝试着获取另一把锁,这样双方都在等待着对方释放锁,互相阻塞出现死锁现象。
解决方案:先释放已获得的锁之后再去获得另一个锁执行唤醒操作。我们再次对上述的put和take方法就行调整,结果如下:
/**
private final AtomicInteger count = new AtomicInteger();
this.takeLock = new ReentrantLock();
this.notEmpty = this.takeLock.newCondition();
this.putLock = new ReentrantLock();
this.notFull = this.putLock.newCondition();
*/
@Override
public void put(E e) throws InterruptedException {
int currentCount;
// 先尝试获得互斥锁,以进入临界区
putLock.lockInterruptibly();
try {
// 因为被消费者唤醒后可能会被其它的生产者再度填满队列,需要循环的判断
while (count.get() == elements.length) {
// put操作时,如果队列已满则进入notFull条件变量的等待队列,并释放条件变量对应的互斥锁
notFull.await();
// 消费者进行出队操作时
}
// 走到这里,说明当前队列不满,可以执行入队操作
enqueue(e);
currentCount = count.getAndIncrement();
} finally {
// 入队完毕,释放锁
putLock.unlock();
}
// 如果插入之前队列为空,才唤醒等待弹出元素的线程
if (currentCount == 0) {
signalNotEmpty();
}
}
@Override
public E take() throws InterruptedException {
E headElement;
int currentCount;
// 先尝试获得互斥锁,以进入临界区
takeLock.lockInterruptibly();
try {
// 因为被生产者唤醒后可能会被其它的消费者消费而使得队列再次为空,需要循环的判断
while(this.count.get() == 0){
notEmpty.await();
}
headElement = dequeue();
currentCount = this.count.getAndDecrement();
} finally {
// 出队完毕,释放锁
takeLock.unlock();
}
// 只有在弹出之前队列已满的情况下才唤醒等待插入元素的线程
if (currentCount == elements.length) {
signalNotFull();
}
return headElement;
}
/**
* 唤醒等待队列非空条件的线程
*/
private void signalNotEmpty() {
// 为了唤醒等待队列非空条件的线程,需要先获取对应的takeLock
takeLock.lock();
try {
// 唤醒一个等待非空条件的线程
notEmpty.signal();
} finally {
takeLock.unlock();
}
}
/**
* 唤醒等待队列未满条件的线程
*/
private void signalNotFull() {
// 为了唤醒等待队列未满条件的线程,需要先获取对应的putLock
putLock.lock();
try {
// 唤醒一个等待队列未满条件的线程
notFull.signal();
} finally {
putLock.unlock();
}
}
解决lost wake up问题
在上述进行了两次调整的的双锁实现第二版中,阻塞在notFull中的生产者线程完全依赖相对应的消费者线程来将其唤醒(阻塞在notEmpty中的消费者线程也同样依赖对应的生产者线程将其唤醒),这在生产者线程和消费者线程并发时会出现lost wake up的问题。下面构造一个简单而不失一般性的例子来说明,为什么上述第二版的实现中会出现问题。
时序图(假设阻塞队列的长度为5(element.length=5),且一开始时队列已满)
| 生产者线程P1 | 生产者线程P2 | 消费者线程C | |
|---|---|---|---|
| 1 | 执行put操作,此时队列已满。执行while循环中的notfull.await陷入阻塞状态(await会释放putLock) | ||
| 2 | 执行take操作,队列未满,成功执行完dequeue。此时currentCount=5,this.count=4,执行takeLock.unLock释放takeLock锁 | ||
| 3 | 执行put操作,拿到putLock锁,由于消费者C已经执行完出队操作,成功执行enqueue。此时currentCount=4,this.count=5,执行putLock.unLock释放putLock锁 | ||
| 4 | 判断currentCount == elements.length为真,执行signalNotFull,并成功拿到putLock。notFull.signal唤醒等待在其上的生产者线程P1。take方法执行完毕,return返回 | ||
| 5 | 被消费者C唤醒,但此时count=5,无法跳出while循环,继续await阻塞在notFull条件变量中 | ||
| 6 | 判断currentCount == 0为假,进行处理。put方法执行完毕 ,return返回 |
可以看到,虽然生产者线程P1由于队列已满而先被阻塞,而消费者线程C在出队后也确实通知唤醒了生产者线程P1。但是由于生产者线程P2和消费者线程C的并发执行,导致了生产者线程P1在被唤醒后依然无法成功执行入队操作,只能继续的阻塞下去。在一些情况下,P1生产者线程可能再也不会被唤醒而永久的阻塞在条件变量notFull上。
为了解决这一问题,双锁版本的阻塞队列其生产者线程不能仅仅依靠消费者线程来将其唤醒,而是需要在其它生产者线程在入队操作完成后,发现队列未满时也尝试着唤醒由于上述并发场景发生lost wake up问题的生产者线程(消费者线程在出队时的优化亦是如此)。最终优化的V5版本的出队、入队实现,代码如下:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
private final AtomicInteger count = new AtomicInteger();
this.takeLock = new ReentrantLock();
this.notEmpty = this.takeLock.newCondition();
this.putLock = new ReentrantLock();
this.notFull = this.putLock.newCondition();
*/
@Override
public void put(E e) throws InterruptedException {
int currentCount;
// 先尝试获得互斥锁,以进入临界区
putLock.lockInterruptibly();
try {
// 因为被消费者唤醒后可能会被其它的生产者再度填满队列,需要循环的判断
while (count.get() == elements.length) {
// put操作时,如果队列已满则进入notFull条件变量的等待队列,并释放条件变量对应的互斥锁
notFull.await();
// 消费者进行出队操作时
}
// 走到这里,说明当前队列不满,可以执行入队操作
enqueue(e);
currentCount = count.getAndIncrement();
// 如果在插入后队列仍然没满,则唤醒其他等待插入的线程
if (currentCount + 1 < elements.length) {
notFull.signal();
}
} finally {
// 入队完毕,释放锁
putLock.unlock();
}
// 如果插入之前队列为空,才唤醒等待弹出元素的线程
// 为了防止死锁,不能在释放putLock之前获取takeLock
if (currentCount == 0) {
signalNotEmpty();
}
}
@Override
public E take() throws InterruptedException {
E headElement;
int currentCount;
// 先尝试获得互斥锁,以进入临界区
takeLock.lockInterruptibly();
try {
// 因为被生产者唤醒后可能会被其它的消费者消费而使得队列再次为空,需要循环的判断
while(this.count.get() == 0){
notEmpty.await();
}
headElement = dequeue();
currentCount = this.count.getAndDecrement();
// 如果队列在弹出一个元素后仍然非空,则唤醒其他等待队列非空的线程
if (currentCount - 1 > 0) {
notEmpty.signal();
}
} finally {
// 出队完毕,释放锁
takeLock.unlock();
}
// 只有在弹出之前队列已满的情况下才唤醒等待插入元素的线程
// 为了防止死锁,不能在释放takeLock之前获取putLock
if (currentCount == elements.length) {
signalNotFull();
}
return headElement;
}
/**
* 唤醒等待队列非空条件的线程
*/
private void signalNotEmpty() {
// 为了唤醒等待队列非空条件的线程,需要先获取对应的takeLock
takeLock.lock();
try {
// 唤醒一个等待非空条件的线程
notEmpty.signal();
} finally {
takeLock.unlock();
}
}
/**
* 唤醒等待队列未满条件的线程
*/
private void signalNotFull() {
// 为了唤醒等待队列未满条件的线程,需要先获取对应的putLock
putLock.lock();
try {
// 唤醒一个等待队列未满条件的线程
notFull.signal();
} finally {
putLock.unlock();
}
}
OK,前面从v2版本开始,对所实现的阻塞队列进行了一系列的优化,一直到最终的V5版本实现了一个基于双锁,双条件变量的高性能版本。