什么是阻塞队列?
阻塞队列是这样的一种数据结构,它是一个队列(类似于一个List),可以存放0到N个元素。我们可以对这个队列执行插入或弹出元素操作,弹出元素操作就是获取队列中的第一个元素,并且将其从队列中移除;而插入操作就是将元素添加到队列的末尾。当队列中没有元素时,对这个队列的弹出操作将会被阻塞,直到有元素被插入时才会被唤醒;当队列已满时,对这个队列的插入操作就会被阻塞,直到有元素被弹出后才会被唤醒。
在线程池中,往往就会用阻塞队列来保存那些暂时没有空闲线程可以直接执行的任务,等到线程空闲之后再从阻塞队列中弹出任务来执行。一旦队列为空,那么线程就会被阻塞,知道有新任务被插入为止。
一个最简单的版本
先来实现最简单的队列,只满足阻塞这个概念,而不需要考虑线程同步措施。
先看一下他的成员变量吧:
/** 存放元素的数组 */
private final Object[] items;
/** 弹出元素的位置 */
private int takeIndex;
/** 插入元素的位置 */
private int putIndex;
/** 队列中的元素总数 */
private int count;
/**
* 指定队列大小的构造器
*
* @param capacity 队列大小
*/
public BlockingQueue(int capacity) {
if (capacity <= 0)
throw new IllegalArgumentException();
// putIndex, takeIndex和count都会被默认初始化为0
items = new Object[capacity];
}
再看一下put和take方法
/**
* 将指定元素插入队列
*
* @param e 待插入的对象
*/
public void put(Object e) throws InterruptedException {
while (true) {
// 直到队列未满时才执行入队操作并跳出循环
if (count != items.length) {
// 执行入队操作,将对象e实际放入队列中
enqueue(e);
break;
}
// 队列已满的情况下休眠200ms
Thread.sleep(200L);
}
}
/**
* 从队列中弹出一个元素
*
* @return 被弹出的元素
*/
public Object take() throws InterruptedException {
while (true) {
// 直到队列非空时才继续执行后续的出队操作并返回弹出的元素
if (count != 0) {
// 执行出队操作,将队列中的第一个元素弹出
return dequeue();
}
// 队列为空的情况下休眠200ms
Thread.sleep(200L);
}
}
再看一下入队和出队的方法
/**
* 入队操作
*
* @param e 待插入的对象
*/
private void enqueue(Object e) {
// 将对象e放入putIndex指向的位置
items[putIndex] = e;
// putIndex向后移一位,如果已到末尾则返回队列开头(位置0)
if (++putIndex == items.length)
putIndex = 0;
// 增加元素总数
count++;
}
/**
* 出队操作
*
* @return 被弹出的元素
*/
private Object dequeue() {
// 取出takeIndex指向位置中的元素
// 并将该位置清空
Object e = items[takeIndex];
items[takeIndex] = null;
// takeIndex向后移一位,如果已到末尾则返回队列开头(位置0)
if (++takeIndex == items.length)
takeIndex = 0;
// 减少元素总数
count--;
// 返回之前代码中取出的元素e
return e;
}
测验阻塞队列实现
既然已经有了阻塞队列的实现,那么我们就写一个测试程序来测试一下吧。下面是一个对阻塞队列进行并发的插入和弹出操作的测试程序,在这个程序中,会创建2个生产者线程向阻塞队列中插入数字0~19;同时也会创建2个消费者线程从阻塞队列中弹出20个数字,并打印这些数字。而且在程序中也统计了整个程序的耗时,会在所有子线程执行完成之后打印出程序的总耗时。
这里我们期望这个测验程序能够以任意顺序输出0~19这20个数字,然后打印出程序的总耗时,那么实际执行情况会如何呢?
因为put和take没加锁,导致count的计数并不是准确的。
原因就是在我们实现的这个阻塞队列中完全没有线程同步机制,所以同时并发进行的4个线程(2个生产者和2个消费者)会同时执行阻塞队列的put()
和take()
方法。这就可能会导致各种各样并发执行顺序导致的问题,比如两个生产者同时对阻塞队列进行插入操作,有可能就会在putIndex没更新的情况下对同一下标位置又插入了一次数据,导致了数据还没被消费就被覆盖了;而两个消费者也可能会在takeIndex没更新的情况下又获取了一次已经被清空的位置,导致打印出了null
。最后因为这些原因都有可能会导致消费者线程最后还没有弹出20个数字count就已经为0了,这时消费者线程就会一直处于阻塞状态无法退出了。
一个线程安全的版本
在put和take的方法上都添加synchronized关键字
/**
* 将指定元素插入队列
*
* @param e 待插入的对象
*/
public void put(Object e) throws InterruptedException {
while (true) {
synchronized (this) {
// 直到队列未满时才执行入队操作并跳出循环
if (count != items.length) {
// 执行入队操作,将对象e实际放入队列中
enqueue(e);
break;
}
}
// 队列已满的情况下休眠200ms
Thread.sleep(200L);
}
}
/**
* 从队列中弹出一个元素
*
* @return 被弹出的元素
*/
public Object take() throws InterruptedException {
while (true) {
synchronized (this) {
// 直到队列非空时才继续执行后续的出队操作并返回弹出的元素
if (count != 0) {
// 执行出队操作,将队列中的第一个元素弹出
return dequeue();
}
}
// 队列为空的情况下休眠200ms
Thread.sleep(200L);
}
}
结果如下:
一个更快的阻塞队列
让我们先来诊断一下之前的阻塞队列中到底是什么导致了效率的降低,因为put()
和take()
方法是阻塞队列的核心,所以我们自然从这两个方法看起。在这两个方法里,我们都看到了同一段代码Thread.sleep(200L)
,这段代码会让put()
和take()
方法分别在队列已满和队列为空的情况下进入一次固定的200毫秒的休眠,防止线程占用过多的CPU资源。但是如果队列在这200毫秒里发生了变化,那么线程也还是在休眠状态无法马上对变化做出响应。比如如果一个调用put()
方法的线程因为队列已满而进入了200毫秒的休眠,那么即使队列已经被消费者线程清空了,它也仍然会忠实地等到200毫秒之后才会重新尝试向队列中插入元素,中间的这些时间就都被浪费了。
但是如果我们去掉这段休眠的代码,又会导致CPU的使用率过高的问题。那么有没有一种方法可以平衡两者的利弊,同时得到两种情况的好处又没有各自的缺点呢?
使用条件变量优化阻塞唤醒
为了完成上面这个困难的任务,既要马儿跑又要马儿不吃草。那么我们就需要有一种方法,既让线程进入休眠状态不再占用CPU,但是在队列发生改变时又能及时地被唤醒来重试之前的操作了。既然用了对象锁synchronized
,那么我们就找找有没有与之相搭配的同步机制可以实现我们的目标。
在Object
类,也就是所有Java类的基类里,我们找到了三个有意思的方法Object.wait()
、Object.notify()
、Object.notifyAll()
。这三个方法是需要搭配在一起使用的,其功能与操作系统层面的条件变量类似。条件变量是这样的一种线程同步工具:
- 每个条件变量都会有一个对应的互斥锁,要调用条件变量的
wait()
方法,首先需要持有条件变量对应的这个互斥锁。之后,在调用条件变量的wait()
方法时,首先会释放已持有的这个互斥锁,然后当前线程进入休眠状态,等待被Object.notify()
或者Object.notifyAll()
方法唤醒; - 调用
Object.notify()
或者Object.notifyAll()
方法可以唤醒因为Object.wait()
进入休眠状态的线程,区别是Object.notify()
方法只会唤醒一个线程,而Object.notifyAll()
会唤醒所有线程。
因为我们之前的代码中通过synchronized
获取了对应于this引用
的对象锁,所以自然也就要用this.wait()
、this.notify()
、this.notifyAll()
方法来使用与这个对象锁对应的条件变量了。下面是使用条件变量改造后的put()
与take()
方法。还是和之前一样,我们首先以put()
方法为例分析具体的改动。首先,我们去掉了最外层的while循环,然后我们把Thread.sleep
替换为了this.wait()
,以此在队列已满时进入休眠状态,等待队列中的元素被弹出后再继续。在队列满足条件,入队操作成功后,我们通过调用this.notifyAll()
唤醒了可能在等待队列非空条件的调用take()
的线程。take()
方法的实现与put()
也基本类似,只是操作相反。
/**
* 将指定元素插入队列
*
* @param e 待插入的对象
*/
public void put(Object e) throws InterruptedException {
synchronized (this) {
if (count == items.length) {
// 队列已满时进入休眠
this.wait();
}
// 执行入队操作,将对象e实际放入队列中
enqueue(e);
// 唤醒所有休眠等待的进程
this.notifyAll();
}
}
/**
* 从队列中弹出一个元素
*
* @return 被弹出的元素
*/
public Object take() throws InterruptedException {
synchronized (this) {
if (count == 0) {
// 队列为空时进入休眠
this.wait();
}
// 执行出队操作,将队列中的第一个元素弹出
Object e = dequeue();
// 唤醒所有休眠等待的进程
this.notifyAll();
return e;
}
}
结果如下
while循环判断条件是否满足
经过分析,我们看到,在调用this.wait()
后,如果线程被this.notifyAll()
方法唤醒,那么就会直接开始直接入队/出队操作,而不会再次检查count的值是否满足条件。而在我们的程序中,当队列为空时,可能会有很多消费者线程在等待插入元素。此时,如果有一个生产者线程插入了一个元素并调用了this.notifyAll()
,则所有消费者线程都会被唤醒,然后依次执行出队操作,那么第一个消费者线程之后的所有线程拿到的都将是null值。而且同时,在这种情况下,每一个执行完出队操作的消费者线程也同样会调用this.notifyAll()
方法,这样即使队列中已经没有元素了,后续进入等待的消费者线程仍然会被自己的同类所唤醒,消费根本不存在的元素,最终只能返回null
。
所以要解决这个问题,核心就是在线程从this.wait()
中被唤醒时也仍然要重新检查一遍count值是否满足要求,如果count不满足要求,那么当前线程仍然调用this.wait()
回到等待状态当中去继续休眠。而我们是没办法预知程序在第几次判断条件时可以得到满足条件的count值从而继续执行的,所以我们必须让程序循环执行“判断条件 -> 不满足条件继续休眠”这样的流程,直到count满足条件为止。那么我们就可以使用一个while循环来包裹this.wait()
调用和对count的条件判断,以此达到这个目的。
下面是具体的实现代码,我们在其中把count条件(队列未满/非空)作为while条件,然后在count值还不满足要求的情况下调用this.wait()
方法使当前线程进入等待状态继续休眠。
/**
* 将指定元素插入队列
*
* @param e 待插入的对象
*/
public void put(Object e) throws InterruptedException {
synchronized (this) {
while (count == items.length) {
// 队列已满时进入休眠
this.wait();
}
// 执行入队操作,将对象e实际放入队列中
enqueue(e);
// 唤醒所有休眠等待的进程
this.notifyAll();
}
}
/**
* 从队列中弹出一个元素
*
* @return 被弹出的元素
*/
public Object take() throws InterruptedException {
synchronized (this) {
while (count == 0) {
// 队列为空时进入休眠
this.wait();
}
// 执行出队操作,将队列中的第一个元素弹出
Object e = dequeue();
// 唤醒所有休眠等待的进程
this.notifyAll();
return e;
}
}
一个更安全的版本
我们之前的版本中使用这些同步机制:synchronized (this)
、this.wait()
、this.notifyAll()
,这些同步机制都和当前对象this
有关。因为synchronized (obj)
可以使用任意对象对应的对象锁,而Object.wati()
和Object.notifyAll()
方法又都是public方法。也就是说不止在阻塞队列类内部可以使用这个阻塞队列对象的对象锁及其对应的条件变量,在外部的代码中也可以任意地获取阻塞队列对象上的对象锁和对应的条件变量,那么就有可能发生外部代码滥用阻塞队列对象上的对象锁导致阻塞队列性能下降甚至是发生死锁的情况。那我们有没有什么办法可以让阻塞队列在这方面变得更安全呢?
使用显式锁
最直接的方式当然是请出JDK在1.5之后引入的代替synchronized
关键字的显式锁ReentrantLock
类了。ReentrantLock
类是一个可重入互斥锁,互斥指的是和synchronized
一样,同一时间只能有一个线程持有锁,其他获取锁的线程都必须等待持有锁的线程释放该锁。而可重入指的就是同一个线程可以重复获取同一个锁,如果在获取锁时这个锁已经被当前线程所持有了,那么这个获取锁的操作仍然会直接成功。
一般我们使用ReentrantLock
的方法如下:
lock.lock();
try {
做一些操作
}
finally {
lock.unlock();
}
上面的lock
变量就是一个ReentrantLock
类型的对象。在这段代码中,释放锁的操作lock.unlock()
被放在了finally
块中,这是为了保证线程在获取到锁之后,不论出现异常或者什么特殊情况都能保证正确地释放互斥锁。如果不这么做就可能会导致持有锁的线程异常退出后仍然持有该锁,其他需要获取同一个锁的线程就永远运行不了。
那么在我们的阻塞队列中应该如何用ReentrantLock
类来改写呢?
首先,我们显然要为我们的阻塞队列类添加一个实例变量lock
来保存用于在不同线程间实现互斥访问的ReentrantLock
锁。然后我们要将原来的synchronized(this) {...}
格式的代码修改为上面使用ReentrantLock
进行互斥访问保护的实现形式,也就是lock.lock(); try {...} finally {lock.unlock();}
这样的形式。
但是原来与synchronized
所加的对象锁相对应的条件变量使用方法this.wait()
和this.notifyAll()
应该如何修改呢?ReentrantLock
已经为你做好了准备,我们可以直接调用lock.newCondition()
方法来创建一个与互斥锁lock
相对应的条件变量。然后为了在不同线程中都能访问到这个条件变量,我们同样要新增一个实例变量condition
来保存这个新创建的条件变量对象。然后我们原来使用的this.wait()
就需要修改为condition.await()
,而this.notifyAll()
就修改为了condition.signalAll()
。
/** 显式锁 */
private final ReentrantLock lock = new ReentrantLock();
/** 锁对应的条件变量 */
private final Condition condition = lock.newCondition();
/**
* 将指定元素插入队列
*
* @param e 待插入的对象
*/
public void put(Object e) throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == items.length) {
// 队列已满时进入休眠
// 使用与显式锁对应的条件变量
condition.await();
}
// 执行入队操作,将对象e实际放入队列中
enqueue(e);
// 通过条件变量唤醒休眠线程
condition.signalAll();
} finally {
lock.unlock();
}
}
/**
* 从队列中弹出一个元素
*
* @return 被弹出的元素
*/
public Object take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == 0) {
// 队列为空时进入休眠
// 使用与显式锁对应的条件变量
condition.await();
}
// 执行出队操作,将队列中的第一个元素弹出
Object e = dequeue();
// 通过条件变量唤醒休眠线程
condition.signalAll();
return e;
} finally {
lock.unlock();
}
}
到这里,我们就完成了使用显式锁ReentrantLock
所需要做的所有改动了。整个过程中并不涉及任何逻辑的变更,我们只是把synchronized (this) {...}
修改为了lock.lock() try {...} finally {lock.unlock();}
,把this.wait()
修改为了condition.await()
,把this.notifyAll()
修改为了condition.signalAll()
。就这样,我们的锁和条件变量因为是private
字段,所以外部的代码就完全无法访问了,这让我们的阻塞队列变得更加安全,是时候可以提供给其他人使用了。