目录
1.阻塞队列能做什么
2.阻塞队列里有什么
3.put方法的工作原理
4.take方法的工作原理
5.其他的方法
6.其他的阻塞队列
说到阻塞队列,大家的第一反应都是听说过,但是用的很少。
阻塞队列的Java并发包中的一个重要组件,可以通过线程阻塞的方式实现线程安全的队列功能。
阻塞队列在JDK中应用的也很多,各种线程池的实现就离不开各具特色的阻塞队列。
这次,就从阻塞队列的源码来说一下阻塞队列的工作方式。
首先来简单说一下阻塞队列的功能和它的特点,然后在从源码角度去分析这些特点的实现原理。
与简单的队列比起来,阻塞队列最重要的就是能解决线程安全问题。
使用普通的链表实现的队列,在两个线程同时向队列尾部插入对象时,在操作链表尾指针时可能导致其中一个对象丢失。
如图,使用普通队列时,线程A中的对象A和线程B中的对象B同时向队列尾插入对象。
这时线程A和线程B同时拿到了尾节点对象的引用,然后线程A先把尾节点的next指针指向对象A,线程A继续执行。
然后线程B也把尾节点的next指向对象B,线程B继续执行,这样就导致对象A的丢失。
阻塞队列通过线程控制,让多个线程有序的插入对象,就能避免这个问题。得到以下结果。
上面说到阻塞队列通过控制线程让同一时间只有一个线程可以插入对象(取出对象也是一样,不会发生两个人搬到一块砖的问题),那么它是如何做到的呢?
来看看它的源码吧(本文选择的是LinkedBlockingQueue对象的源码)。
先看一下LinkedBlockingQueue类中有些神马东西。
//删除了部分代码
public class LinkedBlockingQueue extends AbstractQueue
implements BlockingQueue, java.io.Serializable {
//链表的节点类,以内部类形式出现
static class Node {
E item;
Node next;
Node(E x) { item = x; }
}
//LinkedBlockingQueue是一个定长的队列,这个变量表示它的最大长度
private final int capacity;
//count变量表示当前队列中元素的数量,AtomicInteger类提供了int类型的原子操作
private final AtomicInteger count = new AtomicInteger();
//头节点
transient Node head;
//尾节点
private transient Node last;
//重入锁takeLock,在调用take方法时控制线程
private final ReentrantLock takeLock = new ReentrantLock();
//takeLock中的一个条件,用来控制线程
private final Condition notEmpty = takeLock.newCondition();
//重入锁putLock,在调用put时控制线程
private final ReentrantLock putLock = new ReentrantLock();
//putLock中的一个条件,用来控制线程
private final Condition notFull = putLock.newCondition();
...
}
有两个重入锁和两个条件,分别对应装入(put)操作和取出(take)操作,下面就通过分析这两个方法来看看阻塞队列的工作方式吧。
public void put(E e) throws InterruptedException {
int c = -1;
Node node = new Node(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
//给当前线程加一个可中断的重入锁
putLock.lockInterruptibly();
try {
//循环判断,如果当前队列数量已经达到上限,就通过condition条件阻塞等待,直到队列有空余位置
while (count.get() == capacity) {
notFull.await();
}
//装入对象
enqueue(node);
//用原子操作修改count的值
c = count.getAndIncrement();
//判断队列中是否用空余位置,如果有就唤醒其他等待空余位置的线程
if (c + 1 < capacity)
notFull.signal();
} finally {
//释放锁
putLock.unlock();
}
//如果成功装入值,就唤醒等待取值的线程
if (c == 0)
signalNotEmpty();
}
首先当一个线程调用put方法时会进行加锁操作,这个加锁就保证了同一时刻只能有一个线程进行转入操作,避免上面所说的丢对象问题。
然后通过capacity判断列表中是否有空余位置,如果没有,就用Condition类型对象notFull使线程进入一个等待状态。如果有位置就进行装入对象操作,并修改count的值,之后再看看有没有空余位置,如果有就唤醒其他等待位置的线程。
接着在finally块中释放锁。
最后如果转入对象操作成功,那么就通过Condition类型对象NotEmpty来唤醒那些等待取出对象的线程。
总体来说,put方法首先加锁操作让线程有序执行,然后在内部又通过condition条件在不满足条件的情况下阻塞线程,直到条件满足被其他代码唤醒或者被中断。
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
//加一个可中断的重入锁
takeLock.lockInterruptibly();
try {
//如果队列中的可用对象数量为0,就通过Condition类型对象notEmpty阻塞当前线程
while (count.get() == 0) {
notEmpty.await();
}
//有可用对象,取出
x = dequeue();
//使用原子操作更新count值
c = count.getAndDecrement();
//如果还有其他可用对象,就唤醒那些等待获取对象的线程
if (c > 1)
notEmpty.signal();
} finally {
//释放锁
takeLock.unlock();
}
//成功出队一个对象,这时队列中有空余位置了,就唤醒那些等待装入对象的线程
if (c == capacity)
signalNotFull();
return x;
}
take方法代码看起来和put方法很像,也是加锁保证线程同步,然后不满足条件的Condition条件阻塞,条件满足再唤醒线程。
这里有一个需要注意的地方是,count.getAndDecrement()这个方法,是先取得当前值,再进行自增。比如我当前count的值是100,那么这个方法是把count的值变为101并返回100。
相信大家看到这里,就对阻塞队列的工作方式有一个大概的了解了。
其实阻塞队列中还有其他的装入/获取操作的方法。
有push/pull,add/remove。加上put/take一共三对。
这三对方法可以实现装入/获取操作。不同点就在于不满足情况时的处理方法。
put/take方法在不满足条件时会通过Condition类型对象控制线程,在满足条件前阻塞线程,在满足条件后唤醒阻塞的线程。
push/pull方法是在不满足条件时返回特殊值,比方说push方法可以实现对象的装入,装入成功返回true,失败返回false。
add/remove方法则是在不满足条件时抛出异常。
虽然三对功能方法有些许的不同,但是他们在操作队列时都会进行线程同步。大家在实际使用时只需要根据不同的业务场景选择不同的功能方法即可。
BlockingQueue是个接口,JDK提供了很多实现类。本质上都能实现一个线程安全的队列,并且根据底层使用不同的对象容器,不同的内部类对象提供不同的额外功能。本文使用的是LinkedBlockingQueue,底层使用链表作为对象容器,可以充分利用链表插入取出的优秀特性。其他的阻塞队列如下图。
剩下的阻塞队列希望大家也去了解一下,在实际开发过程中选择适合自己的阻塞队列。如果这些你都看不上,自己实现一个阻塞队列也是很酷的,哈哈哈。
最后到这里,本文就结束啦,希望大家能通过这篇博客有所收获。