目录
1.什么是阻塞队列
阻塞队列是这样的一种数据结构,它是一个队列(类似于一个List),可以存放0到N个元素。我们可以对这个队列执行插入或弹出元素操作,弹出元素操作就是获取队列中的第一个元素,并且将其从队列中移除;而插入操作就是将元素添加到队列的末尾。当队列中没有元素时,对这个队列的弹出操作将会被阻塞,直到有元素被插入时才会被唤醒;当队列已满时,对这个队列的插入操作就会被阻塞,直到有元素被弹出后才会被唤醒。
在线程池中,往往就会用阻塞队列来保存那些暂时没有空闲线程可以直接执行的任务,等到线程空闲之后再从阻塞队列中弹出任务来执行。一旦队列为空,那么线程就会被阻塞,直到有新任务被插入为止。
2.实现一个最简单的阻塞队列
2.1代码实现
我们先来实现一个最简单的队列,在这个队列中我们不会添加任何线程同步措施,而只是实现了最基本的队列与阻塞特性。
当阻塞队列是空的,从队列中获取元素的操作将会被阻塞;
当阻塞队列是满的,往队列里添加元素的操作将会被阻塞;
试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其他的线程往空的队列插入新的元素;
同样,试图从满的阻塞队列中添加新元素的线程同样也会被阻塞,直到其他线程从列中移除一个或者多个元素或者完全清空队列使队列重新变得空闲起来并后续新增。
首先我们定义一个数组elementdata来保存元素,这是一个Object数组,所以可以保存任意类型的对象,在构造器中,会传入一个capacity参数来指定elementdata数组的大小,这也是我们阻塞队列的大小。
而putIndex
和takeIndex就是我们插入和弹出元素的下标位置了,阻塞队列在使用的过程中会不断地被插入和弹出元素,每次弹出的都是队列中的第一个元素,而插入的元素则会被添加到队列的末尾。当下标到达末尾时会被设置为0,从数组的第一个下标位置重新开始向后增长,形成一个不断循环的过程。
那么如果队列中存储的个数超过elementdata数组的长度时,新插入的元素岂不是会覆盖队列开头还没有被弹出的元素了吗?这时我们的最后一个字段count
就能派上用场了,当count
等于elemendata.length时,插入操作就会被阻塞,直到队列中有元素被弹出时为止。
private final Object[] elementdata;
private int putIndex;
private int takeIndex;
private int count;
//指定队列大小的构造器
public BlockingQueue(int capacity){
if (capacity<=0){
throw new RuntimeException("数组容量不能小于等于0");
}
elementdata=new Object[capacity];
}
下面是put()和take()方法的实现,put()
方法向队列末尾添加新元素,而take()
方法从队列中弹出最前面的一个元素,在put()
方法的开头,我们可以看到有一个判断count是否达到了elementdata.length(队列大小)的if语句,如果count不等于elementdata.length,那么就表示队列还没有满,随后就直接调用了enqueue
方法对元素进行了入队。在成功插入元素之后我们就会通过break
语句跳出最外层的无限while循环,从方法中返回。
但是如果这时候队列已满,那么count的值就会等于elementdata.length,这将会导致我们调用Thread.sleep(200L)
使当前线程休眠200毫秒。当线程从休眠中恢复时,又会进入下一次循环,重新判断条件count !=
elementdata.length
。也就是说,如果队列没有弹出元素使我们可以完成插入操作,那么线程就会一直处于“判断 -> 休眠”的循环而无法从put()
方法中返回,也就是进入了“阻塞”状态。
随后的take()
方法也是一样的道理,只有在队列不为空的情况下才能顺利弹出元素完成任务并返回,如果队列一直为空,调用线程就会在循环中一直等待,直到队列中有元素插入为止。
public void put(Object e) throws InterruptedException {
while (true) {
if (count !=elementdata.length) {
enqueue(e);
break;
}
Thread.sleep(200);
}
}
public Object take() throws InterruptedException {
while (true) {
if (count != 0) {
return dequeue();
}
Thread.sleep(200);
}
}
在上面的put()
和take()
方法中分别调用了入队方法enqueue
和出队方法dequeue
我们将这两个方法实现后将完整的代码写出这时候就是一个完整的阻塞队列类BlockingQueue
了
public class BlockingQueue {
private final Object[] elementdata;
private int putIndex;
private int takeIndex;
private int count;
public BlockingQueue(int capacity){
if (capacity<=0){
throw new RuntimeException("数组容量不能小于等于0");
}
elementdata=new Object[capacity];
}
public void put(Object e) throws InterruptedException {
while (true) {
if (count !=elementdata.length) {
enqueue(e);
break;
}
Thread.sleep(200);
}
}
public Object take() throws InterruptedException {
while (true) {
if (count != 0) {
return dequeue();
}
Thread.sleep(200);
}
}
private Object dequeue() {
Object o = elementdata[takeIndex];
elementdata[takeIndex]=null;
takeIndex++;
if (takeIndex>= elementdata.length){
takeIndex=0;
}
count--;
return o;
}
private void enqueue(Object e) {
elementdata[putIndex]=e;
putIndex++;
if (putIndex>= elementdata.length){
putIndex=0;
}
count++;
}
}
2.2测试
既然已经实现了阻塞队列,那么我们就测试一下。下面是一个对阻塞队列进行并发的插入和弹出操作的测试程序,在这个程序中,会创建2个生产者线程向阻塞队列中插入数字0~19;同时也会创建2个消费者线程从阻塞队列中弹出20个数字,并打印这些数字。而且在程序中也统计了整个程序的耗时,会在所有子线程执行完成之后打印出程序的总耗时。
这里我们期望这个测验程序能够以任意顺序输出0~19这20个数字,然后打印出程序的总耗时
public static void main(String[] args) throws Exception {
//大小为2阻塞队列
final BlockingQueue queue=new BlockingQueue(2);
//创建2个线程
final int threads=2;
//每个线程执行10次
final int sum=10;
//线程列表,用于等待所有线程完成
List<Thread> threadList=new ArrayList<>(threads*2);
long starttime=System.currentTimeMillis();
for (int i = 0; i < threads; ++i) {
final int offest=i*sum;
Thread produce=new Thread(()->{
try {
for (int j = 0; j < sum; ++j) {
queue.put(new Integer(offest+j));
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
threadList.add(produce);
produce.start();
}
for (int i = 0; i < threads; ++i) {
Thread consumer = new Thread(() -> {
try {
for (int j = 0; j < sum; ++j) {
Integer element = (Integer) queue.take();
System.out.println(element);
}
} catch (Exception e) {
e.printStackTrace();
}
});
threadList.add(consumer);
consumer.start();
}
for (Thread thread:threadList) {
thread.join();
}
long endtime=System.currentTimeMillis();
System.out.println(String.format("总耗时:",(endtime-starttime)/1e3));
}
运行结果如下
不仅是打印出了很多个null
,而且打印出17行之后就不再打印更多数据,而且程序也就一直没有打印总耗时并结束了,原因就是在我们实现的这个阻塞队列中完全没有线程同步机制,所以同时并发进行的4个线程(2个生产者和2个消费者)会同时执行阻塞队列的put()
和take()
方法。这就可能会导致各种各样并发执行顺序导致的问题,比如两个生产者同时对阻塞队列进行插入操作,有可能就会在putIndex没更新的情况下对同一下标位置又插入了一次数据,导致了数据还没被消费就被覆盖了;而两个消费者也可能会在takeIndex没更新的情况下又获取了一次已经被清空的位置,导致打印出了null
。最后因为这些原因都有可能会导致消费者线程最后还没有弹出20个数字count就已经为0了,这时消费者线程就会一直处于阻塞状态无法退出了。
3.一个线程安全的阻塞队列
使用互斥锁来保护队列操作
过synchronized
,我们可以让一段代码同一时间只能有一个线程进入;如果在同一个对象上通过synchronized
加锁,那么put()
和take()
两个方法可以做到同一时间只能有一个线程调用两个方法中的任意一个。比如如果有一个线程调用了put()
方法插入元素,那么其他线程再调用put()
方法或者take()
就都会被阻塞直到前一个线程完成对put()
方法的调用了。
public void put(Object e) throws InterruptedException {
while (true) {
synchronized (this) {
if (count != elementdata.length) {
enqueue(e);
break;
}
}
Thread.sleep(200);
}
}
public Object take() throws InterruptedException {
while (true) {
synchronized (this) {
if (count != 0) {
return dequeue();
}
}
Thread.sleep(200);
}
}
多次尝试后每次都可以打印出预期的结果,但是这个耗时的确太久了
因为put()
和take()
方法是阻塞队列的核心,所以我们自然从这两个方法看起。在这两个方法里,我们都看到了同一段代码Thread.sleep(200L)
,这段代码会让put()
和take()
方法分别在队列已满和队列为空的情况下进入一次固定的200毫秒的休眠,防止线程占用过多的CPU资源。但是如果队列在这200毫秒里发生了变化,那么线程也还是在休眠状态无法马上对变化做出响应。比如如果一个调用put()
方法的线程因为队列已满而进入了200毫秒的休眠,那么即使队列已经被消费者线程清空了,它也仍然会忠实地等到200毫秒之后才会重新尝试向队列中插入元素,中间的这些时间就都被浪费了。那么有没有什么办法呢?
4.实现一个更快的阻塞队列
使用条件变量优化阻塞唤醒
在Object
类,也就是所有Java类的基类里,我们找到了三个有意思的方法Object.wait()
、Object.notify()
、Object.notifyAll()
。这三个方法是需要搭配在一起使用的,其功能与操作系统层面的条件变量类似。
我们首先以put()
方法为例分析具体的改动。首先,我们去掉了最外层的while循环,然后我们把Thread.sleep
替换为了this.wait()
,以此在队列已满时进入休眠状态,等待队列中的元素被弹出后再继续。在队列满足条件,入队操作成功后,我们通过调用this.notifyAll()
唤醒了可能在等待队列非空条件的调用take()
的线程。
public void put(Object e) throws InterruptedException {
synchronized (this) {
if (count == elementdata.length) {
this.wait();
}
enqueue(e);
this.notifyAll();
}
}
public Object take() throws InterruptedException {
synchronized (this) {
if (count == 0) {
this.wait();
}
Object e = dequeue();
this.notifyAll();
return e;
}
}
虽然我们解决了耗时问题,现在的耗时已经只有0.06s了,但是结果中又出现了大量的null
经过分析,我们可以得出,在调用this.wait()
后,如果线程被this.notifyAll()
方法唤醒,那么就会直接开始直接入队/出队操作,而不会再次检查count的值是否满足条件。而在我们的程序中,当队列为空时,可能会有很多消费者线程在等待插入元素。此时,如果有一个生产者线程插入了一个元素并调用了this.notifyAll()
,则所有消费者线程都会被唤醒,然后依次执行出队操作,那么第一个消费者线程之后的所有线程拿到的都将是null值。而且同时,在这种情况下,每一个执行完出队操作的消费者线程也同样会调用this.notifyAll()
方法,这样即使队列中已经没有元素了,后续进入等待的消费者线程仍然会被自己的同类所唤醒,消费根本不存在的元素,最终只能返回null
。
所以问题出在if()循环语句这里,我们应该使用while循环判断。
public void put(Object e) throws InterruptedException {
synchronized (this) {
while (count == elementdata.length) {
this.wait();
}
enqueue(e);
this.notifyAll();
}
}
public Object take() throws InterruptedException {
synchronized (this) {
while (count == 0) {
this.wait();
}
Object e = dequeue();
this.notifyAll();
return e;
}
}
耗时只有0.05s,而且结果也是正确的 ,我们实现了一个真正可以在程序代码中使用的阻塞队列
5.实现一个更安全的阻塞队列
synchronized (this)
、this.wait()
、this.notifyAll()
,这些同步机制都和当前对象this
有关。因为synchronized (obj)
可以使用任意对象对应的对象锁,而Object.wati()
和Object.notifyAll()
方法又都是public方法。也就是说不止在阻塞队列类内部可以使用这个阻塞队列对象的对象锁及其对应的条件变量,在外部的代码中也可以任意地获取阻塞队列对象上的对象锁和对应的条件变量,那么就有可能发生外部代码滥用阻塞队列对象上的对象锁导致阻塞队列性能下降甚至是发生死锁的情况。那我们有没有什么办法可以让阻塞队列在这方面变得更安全呢?
5.1显式锁
我们可以使用JDK在1.5之后引入的代替synchronized
关键字的显式锁ReentrantLock
类了
ReentrantLock
类是一个可重入互斥锁,互斥指的是和synchronized
一样,同一时间只能有一个线程持有锁,其他获取锁的线程都必须等待持有锁的线程释放该锁。而可重入指的就是同一个线程可以重复获取同一个锁,如果在获取锁时这个锁已经被当前线程所持有了,那么这个获取锁的操作仍然会直接成功。
使用ReentrantLock
的方法
上面的lock
变量就是一个ReentrantLock
类型的对象。在这段代码中,释放锁的操作lock.unlock()
被放在了finally
块中,这是为了保证线程在获取到锁之后,不论出现异常或者什么特殊情况都能保证正确地释放互斥锁。如果不这么做就可能会导致持有锁的线程异常退出后仍然持有该锁,其他需要获取同一个锁的线程就永远运行不了。
首先,我们显然要为我们的阻塞队列类添加一个实例变量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();
public void put(Object e) throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == elementdata.length) {
condition.await();
}
enqueue(e);
condition.signalAll();
}finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == 0) {
condition.await();
}
Object e = dequeue();
condition.signalAll();
return e;
}finally {
lock.unlock();
}
}
到这里,我们就完成了使用显式锁ReentrantLock
所需要做的所有改动了,们的锁和条件变量因为是private
字段,所以外部的代码就完全无法访问了,这让我们的阻塞队列变得更加安全.
但然即使加入了显式锁,我们当前的阻塞队列还可以进行优化,关注下作者阅读下篇文章,让你实现出JDK级别的阻塞队列。