本文已参与「新人创作礼」活动,一起开启掘金创作之路。
引言
我们平时在编程过程中往往会遇到需要编写阻塞队列。队列大家都知道是一个具备先进先出特性的数据结构,从队列末尾插入数据,从队列头部取出数据。阻塞队列与普通队列的最大不同在于阻塞队列提供了阻塞式的同步插入(阻塞入队put)、取出数据的功能(阻塞出队take)。
那么什么是阻塞式同步插入呢?就是使用put插入数据时,如果队列空间已满并不直接返回,而是令当前操作的线程陷入阻塞态(生产者线程),等待着阻塞队列中的元素被其它线程(消费者线程)取走,令队列重新变得不满时被唤醒再次尝试插入数据。同样的,在使用take取出数据时,如果队列空间为空并不直接返回,而是令当前操作的线程陷入阻塞态(消费者线程),等待其它线程(生产者线程)插入新元素,令队列非空时被唤醒再次尝试取出数据。
阻塞队列主要用于解决并发场景下消费者线程与生产者线程处理速度不一致的问题。例如jdk的线程池实现中,线程池核心线程(消费者线程)处理速度一定的情况下,如果业务方线程提交的任务过多导致核心线程处理不过来时,将任务暂时放进阻塞队列等待核心线程消费(阻塞队列未满);由于核心线程常驻的原因,当业务方线程提交的任务较少,核心线程消费速度高于业务方生产速度时,核心线程作为消费者会阻塞在阻塞队列的take方法中,避免无谓的浪费cpu资源。由于阻塞队列在内部实现了协调生产者/消费者的机制而不需要外部使用者过多的考虑并发同步问题,极大的降低了生产者/消费者场景下程序的复杂度。
实现阻塞队列
下面我们自己动手一步步的实现阻塞队列。为了加深对阻塞队列工作原理的理解,我们设计几个不同版本、效率由低到高的的阻塞队列。为了降低复杂度,我们的阻塞队列只提供最基础的出队、入队和判空接口。那我们就先实现接口,代码如下:
文中所实现的阻塞队列底层是使用数组承载数据的(ArrayBlockingQueue),内部提供了私有方法enqueue和dequeue来实现原始的内部入队和出队操作。我们先实现一个简单的version1.0版本,最初始的版本中,我们只实现最基本的FIFO队列功能,其put和take方法只是简单的调用了enqueue和dequeue,因此其入队、出队不是阻塞的,也无法保障线程安全。后续我们以v1版本为基础,实现阻塞调用以及线程安全的特性,并且对所实现的阻塞队列性能进行不断的优化。v1版本代码如下:
package com;
/**
* 数组作为底层结构的阻塞队列 v1版本
*/
public class MyArrayBlockingQueueV1<E> implements MyBlockingQueue<E> {
/**
* 队列默认的容量大小
* */
private static final int DEFAULT_CAPACITY = 16;
/**
* 承载队列元素的底层数组
* */
private final Object[] elements;
/**
* 当前头部元素的下标
* */
private int head;
/**
* 下一个元素插入时的下标
* */
private int tail;
/**
* 队列中元素个数
* */
private int count;
//构造方法
public MyArrayBlockingQueueV1() {
// 设置数组大小为默认
this.elements = new Object[DEFAULT_CAPACITY];
// 初始化队列 头部,尾部下标
this.head = 0;
this.tail = 0;
}
public MyArrayBlockingQueueV1(int initCapacity) {
assert initCapacity > 0;
this.elements = new Object[initCapacity];
// 初始化队列 头部,尾部下标
this.head = 0;
this.tail = 0;
}
/**
* 下标取模
* */
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){
enqueue(e);
}
@Override
public E take() {
return dequeue();
}
@Override
public boolean isEmpty() {
return this.count == 0;
}
}
前面提到阻塞调用的出队、入队的功能是阻塞队列区别于普通队列的关键特性。阻塞调用实现的方式有很多,其中最容易理解的一种方式便是无限循环的轮询,直到出队/入队成功(虽然cpu效率很低)。version2.0版本在v1的基础上,使用无限循环加定时休眠的方式简单的实现了同步调用时阻塞的特性。并且在put/take内增加了synchronized块将入队/出队代码包裹起来,阻止多个线程并发的操作队列而产生线程安全问题。
/**
* 数组作为底层结构的阻塞队列 v2版本
*/
public class MyArrayBlockingQueueV2<E> implements MyBlockingQueue<E> {
/**
* 队列默认的容量大小
* */
private static final int DEFAULT_CAPACITY = 16;
/**
* 承载队列元素的底层数组
* */
private final Object[] elements;
/**
* 当前头部元素的下标
* */
private int head;
/**
* 下一个元素插入时的下标
* */
private int tail;
/**
* 队列中元素个数
* */
private int count;
//构造方法
/**
* 默认构造方法
* */
public MyArrayBlockingQueueV2() {
// 设置数组大小为默认
this.elements = new Object[DEFAULT_CAPACITY];
// 初始化队列 头部,尾部下标
this.head = 0;
this.tail = 0;
}
/**
* 默认构造方法
* */
public MyArrayBlockingQueueV2(int initCapacity) {
assert initCapacity > 0;
// 设置数组大小为默认
this.elements = new Object[initCapacity];
// 初始化队列 头部,尾部下标
this.head = 0;
this.tail = 0;
}
/**
* 下标取模
* */
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 {
while (true) {
synchronized (this) {
// 队列未满时执行入队操作
if (count != elements.length) {
// 入队,并返回
enqueue(e);
return;
}
}
// 队列已满,休眠一段时间后重试
Thread.sleep(100L);
}
}
@Override
public E take() throws InterruptedException {
while (true) {
synchronized (this) {
// 队列非空时执行出队操作
if (count != 0) {
// 出队并立即返回
return dequeue();
}
}
// 队列为空的情况下,休眠一段时间后重试
Thread.sleep(100L);
}
}
@Override
public boolean isEmpty() {
return this.count == 0;
}
}
上述代码些缺陷,就是在有大量线程竞争的情况下,v2版本无限循环加休眠的阻塞方式存在两个严重的问题。一个是无限循环轮询的缺陷。
- 线程周期性的休眠/唤醒会造成频繁的发生线程上下文切换,非常浪费cpu资源。
- 线程在尝试操作失败被阻塞时(尝试入队时队列已满、尝试出队时队列为空),如果休眠时间设置的太短,则休眠/唤醒的次数会非常多,cpu性能低下;但如果休眠的时间设置的较长,则会导致被阻塞线程在队列状态发生变化时无法及时的响应。
举个例子:某一生产者线程在入队时发现队列已满,当前线程休眠1s,在0.1s之后一个消费者线程取走了一个元素,而此时休眠的生产者线程还需要白白等待0.9s后才被唤醒并感知到队列未满而接着执行入队操作。综上所述,无限循环加休眠的v2版本阻塞队列其性能极差,需要进一步的优化。这里使用条件变量进行优化。
为了解决上述循环休眠浪费cpu和队列状态发生变化时(已满到未满,已空到未空)被阻塞线程无法及时响应的问题,v3版本引入条件变量对其进行优化。
条件变量由底层的操作系统内核实现的、用于线程间同步的利器。
java将不同操作系统内核提供的条件变量机制抽象封装后,作为可重入锁ReentrantLock的附属给程序员使用。且为了避免lost wakeup问题,在条件变量的实现中增加了校验,要求调用条件变量的signal和await方法时当前线程必须先获得条件变量所附属的锁才行。引入条件变量后,可以令未满足某种条件的线程暂时进入阻塞态,等待在一个条件变量上;当对应条件满足时由其它的线程将等待在条件变量上的线程唤醒,将其从阻塞态再切换回就绪态。
举个例子:当某一生产者线程想要插入新元素但阻塞队列已满时,可以令当前生产者线程等待并阻塞在对应的条件变量中;当后续某一消费者线程执行出队操作使得队列非空后,将等待在条件变量上的生产者线程唤醒,被唤醒的生产者线程便能及时的再次尝试进行入队操作。与之前的v2版本相比,等待在条件变量进入阻塞态的线程不再周期性的被唤醒而占用过多的cpu资源,且在特定条件满足时也能被及时唤醒。引入条件变量后的阻塞队列效率比v2高出许多。其代码如下:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* 数组作为底层结构的阻塞队列 v3版本
*/
public class MyArrayBlockingQueueV3<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 condition;
//构造方法
/**
* 默认构造方法
* */
public MyArrayBlockingQueueV3() {
this(DEFAULT_CAPACITY);
}
/**
* 默认构造方法
* */
public MyArrayBlockingQueueV3(int initCapacity) {
assert initCapacity > 0;
// 设置数组大小为默认
this.elements = new Object[initCapacity];
// 初始化队列 头部,尾部下标
this.head = 0;
this.tail = 0;
this.reentrantLock = new ReentrantLock();
this.condition = 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操作时,如果队列已满则进入条件变量的等待队列,并释放条件变量对应的锁
condition.await();
}
// 走到这里,说明当前队列不满,可以执行入队操作
enqueue(e);
// 唤醒可能等待着的消费者线程
// 由于共用了一个condition,所以不能用signal,否则一旦唤醒的也是生产者线程就会陷入上面的while死循环)
condition.signalAll();
} finally {
// 入队完毕,释放锁
reentrantLock.unlock();
}
}
@Override
public E take() throws InterruptedException {
// 先尝试获得互斥锁,以进入临界区
reentrantLock.lockInterruptibly();
try {
// 因为被生产者唤醒后可能会被其它的消费者消费而使得队列再次为空,需要循环的判断
while(this.count == 0){
condition.await();
}
E headElement = dequeue();
// 唤醒可能等待着的生产者线程
// 由于共用了一个condition,所以不能用signal,否则一旦唤醒的也是消费者线程就会陷入上面的while死循环)
condition.signalAll();
return headElement;
} finally {
// 出队完毕,释放锁
reentrantLock.unlock();
}
}
@Override
public boolean isEmpty() {
return this.count == 0;
}
}
这里用的java.util.concurrent是一个高级的处理并发的包,它提供了大量高级的并发功能,能大大简化多线程程序的编写。虽然Java直接提供了synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁。
v3版本通过引入条件变量解决了v2版本中循环休眠、唤醒效率低下的问题。后续还会不断优化,我也会快速更新,敬请期待。