目录
概念
阻塞队列,也就是 BlockingQueue接口,继承了 Queue 接口,是队列的一种。Queue 和 BlockingQueue 都是在 Java 5 中加入的。BlockingQueue 是线程安全的,在很多场景下都可以利用线程安全的队列来优雅地解决我们业务自身的线程安全问题。比如说,使用生产者/消费者模式的时候,我们生产者只需要往队列里添加元素,而消费者只需要从队列里取出它们就可以了。
继承关系
常见阻塞队列的继承关系如下
核心方法
阻塞队列最大的特点是阻塞,核心方法有
抛出异常:add、remove、element (记为are)
返回结果但不抛出异常:offer、poll、peek (记为opp)
阻塞:put、take
第一组 add remove element
- add方式是往队列里面添加元素,如果队里队列满了,会抛出异常如下图所示
- remove方法是删除元素,如果队列里面的不够删除了则抛出异常,如下图所示
- element方法
element 方法是返回队列的头部节点,但是并不删除。和 remove 方法一样,如果用这个方法去操作一个空队列,想获取队列的头结点,可是由于队列是空的,我们什么都获取不到,会抛出和前面 remove 方法一样的异常:NoSuchElementException。示例代码如下:
第二组offer poll peek
想比于第一组方法,第二组方法当发现队列满了无法添加,或者队列为空无法删除的时候会给一个提示,而不是抛出一个异常。
- offer 方法
offer 方法用来插入一个元素,并用返回值来提示插入是否成功。如果添加成功会返回 true,而如果队列已经满了,此时继续调用 offer 方法的话,它不会抛出异常,只会返回一个错误提示:false。示例代码如下
- poll 方法
poll 方法和第一组的 remove 方法是对应的,作用也是移除并返回队列的头节点。但是如果当队列里面是空的,没有任何东西可以移除的时候,便会返回 null 作为提示。正因如此,是不允许往队列中插入 null 的,否则没有办法区分返回的 null 是一个提示还是一个真正的元素。示例代码如下:
- peek
peek 方法和第一组的 element 方法是对应的,意思是返回队列的头元素但并不删除。如果队列里面是空的,它便会返回 null 作为提示。示例代码如下:
带超时时间的 offer 和 poll
offer 和 poll 都有带超时时间的重载方法。
offer(E e, long timeout, TimeUnit unit)
它有三个参数,分别是元素、超时时长和时间单位。通常情况下,这个方法会插入成功并返回 true;如果队列满了导致插入不成功,在调用带超时时间重载方法的 offer 的时候,则会等待指定的超时时间,如果时间到了依然没有插入成功,就会返回 false。
poll(long timeout, TimeUnit unit)
带时间参数的 poll 方法和 offer 类似:如果能够移除,便会立刻返回这个节点的内容;如果队列是空的就会进行等待,等待时间正是我们指定的时间,直到超时时间到了,如果队列里依然没有元素可供移除,便会返回 null 作为提示。
第三组 put take
- put 方法的作用是插入元素
通常在队列没满的时候是正常的插入,但是如果队列已满就无法继续插入,这时它既不会立刻返回 false 也不会抛出异常,而是让插入的线程陷入阻塞状态,直到队列里有了空闲空间,此时队列就会让之前的线程解除阻塞状态,并把刚才那个元素添加进去。
- take 方法
take 方法的作用是获取并移除队列的头结点。通常在队列里有数据的时候会正常取出数据并删除;但是如果执行 take 的时候队列里无数据,则阻塞,直到队列里有数据;一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。
总结如下:
常见的阻塞/非阻塞队列
ArrayBlockingQueue
典型的有界队列,初始时就要指定大小,内部用数组存储元素,利用ReentrantLock实现线程安全。初始创建指定容量后,不能再扩容。ArrayBlockingQueue采用全部锁,锁的粒度较大。
-- 核心变量
final Object[] items;
/** items index for next take, poll, peek or remove */
int takeIndex;
/** items index for next put, offer, or add */
int putIndex;
/** Number of elements in the queue */
int count;
-- 构造方法
ArrayBlockingQueue(int capacity, boolean fair)
构造函数中第二个参数用来标识是否公平,这里的是否公平性体现在如offer方法中,
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lock(); // 是否能提前插队
try {
if (count == items.length)
return false;
else {
enqueue(e);
return true;
}
} finally {
lock.unlock();
}
}
线程1执行offer方法获取锁后,线程2执行offer尝试获取锁被阻塞,放到等待队列中,线程3过来执行offer方法时,是否能直接尝试抢占锁。
LinkedBlockingQueue
如名字所示,这是一个内部使用单向链表实现的BlockingQueue,如果不指定它的初始容量,则默认容量就是整形的最大值Integer.MAX_VALUE,由于容量非常大,LinkedBlockingQueue 也被称作无界队列。LinkedBlockingQueue中包含两个Node分别用来存放首位元素,另外还有两个ReentrantLock的实例,分别用来控制元素入队和出队的原子性,takeLock来控制同时只有一个线程可以获取锁从队列头获取元素,putLock控制同时只能有一个线程可以获取锁,在队列尾部添加元素。notEmpty和notFull是条件变量,他们内部都有一个条件队列来存放进队出队时被阻塞的线程。
-- 存放首元素
transient Node<E> head;
-- 存放尾元素
private transient Node<E> last;
-- 取数据锁 take(阻塞) poll(非阻塞)
private final ReentrantLock takeLock = new ReentrantLock();
-- 放数据锁 put(阻塞) offer(非阻塞)
private final ReentrantLock putLock = new ReentrantLock();
-- 存数据
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
//队列满了
while (count.get() == capacity) {
//等待不满的时候执行再往下执行
notFull.await();
}
//将节点加入队列
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity)
//如果队列不满了,唤醒之前"等待不满状态"的线程继续执行
notFull.signal();
} finally {
putLock.unlock();
}
// c初始值为-1,c=0说明队列有新元素加入了,只有从空到有一个元素加入时,才去唤醒因为“饥饿”阻塞的线程,目的是为了避免重复唤醒(ps:只有队列为空时,才有线程饥饿产生,存数据线程才有必要去通知)
if (c == 0){
signalNotEmpty();
}
-- 原理同上
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
SynchronousQueue
如图所示,SynchronousQueue 最大的不同之处在于,它的容量为 0,所以没有一个地方来暂存元素,导致每次取数据都要先阻塞,直到有数据被放入;同理,每次放数据的时候也会阻塞,直到有消费者来取。需要注意的是,SynchronousQueue 的容量不是 1 而是 0,因为 SynchronousQueue 不需要去持有元素,它所做的就是直接传递(direct handoff)。由于每当需要传递的时候,SynchronousQueue 会把元素直接从生产者传给消费者,在此期间并不需要做存储,所以如果运用得当,它的效率是很高的。
SynchronousQueue 的 peek 方法永远返回 null,代码如下:
public E peek() {
return null;
}
直接 return 0,同理,isEmpty 方法始终返回 true:
public boolean isEmpty() {
return true;
}
PriorityBlockingQueue
PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。同时,插入队列的对象必须是可比较大小的,也就是 Comparable 的,否则会抛出 ClassCastException 异常。
它的 take 方法在队列为空的时候会阻塞,但是正因为它是无界队列,而且会自动扩容,所以它的队列永远不会满,所以它的 put 方法永远不会阻塞,添加操作始终都会成功,也正因为如此,它的成员变量里只有一个 Condition:
private final Condition notEmpty;
这和之前的 ArrayBlockingQueue 拥有两个 Condition(分别是 notEmpty 和 notFull)形成了鲜明的对比,我们的 PriorityBlockingQueue 不需要 notFull,因为它永远都不会满,真是“有空间就可以任性”。
DelayQueue
DelayQueue 这个队列比较特殊,是一个无界阻塞延迟队列,具有“延迟”的功能。我们可以设定让队列中的任务延迟多久之后执行,比如 10 秒钟之后执行,这在例如“30 分钟后未付款自动取消订单”等需要延迟执行的场景中被大量使用。它是无界队列,放入的元素必须实现 Delayed 接口,而 Delayed 接口又继承了 Comparable 接口,所以自然就拥有了比较和排序的能力,代码如下:
public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}
可以看出这个 Delayed 接口继承自 Comparable,里面有一个需要实现的方法,就是 getDelay。这里的 getDelay 方法返回的是“还剩下多长的延迟时间才会被执行”,如果返回 0 或者负数则代表任务已过期。元素会根据延迟时间的长短被放到队列的不同位置,越靠近队列头代表越早过期,DelayQueue 内部使用了 PriorityQueue 的能力来进行排序。说白了就是在每次往优先级队列中添加元素,然后以元素的delay/过期值作为排序的因素,以此来达到先过期的元素会拍在队首,每次从队列里取出来都是最先要过期的元素
ConcurrentLinkedQueue
从文章开头的UML图可以看到,ConcurrentLinkedQueue并没有直接继承BlockingQueue,而是实现的Queue接口,少了take和put这两个阻塞方法,如名称所示,ConcurrentLinkedQueue内部也是由单向链表的方式实现,默认存在一个哨兵节点,它是线程安全的无界非阻塞队列,与BlockingQueue不同的是,ConcurrentLinkedQueue通过CAS方式实现线程安全。
阻塞及非阻塞并发原理
以 ArrayBlockingQueue 为例,首先分析 BlockingQueue 即阻塞队列的线程安全原理。
ArrayBlockingQueue源码分析
首先看一下 ArrayBlockingQueue 的源码,ArrayBlockingQueue 有以下几个重要的属性:
// 用于存放元素的数组
final Object[] items;
// 下一次读取操作的位置
int takeIndex;
// 下一次写入操作的位置
int putIndex;
// 队列中的元素数量
int count;
第一个就是最核心的、用于存储元素的 Object 类型的数组;然后它还会有两个位置变量,分别是 takeIndex 和 putIndex,这两个变量就是用来标明下一次读取和写入位置的;另外还有一个 count 用来计数,它所记录的就是队列中的元素个数。
另外,看下面这三个变量:
// 以下3个是控制并发用的工具
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
这三个变量也非常关键,第一个就是一个 ReentrantLock,而下面两个 Condition 分别是由 ReentrantLock 产生出来的,这三个变量就是我们实现线程安全最核心的工具。
ArrayBlockingQueue 实现并发同步的原理就是利用 ReentrantLock 和它的两个 Condition,读操作和写操作都需要先获取到 ReentrantLock 独占锁才能进行下一步操作。进行读操作时如果队列为空,线程就会进入到读线程专属的 notEmpty 的 Condition 的队列中去排队,等待写线程写入新的元素;同理,如果队列已满,这个时候写操作的线程会进入到写线程专属的 notFull 队列中去排队,等待读线程将队列元素移除并腾出空间。
分析一下最重要的 put 方法:
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
在 put 方法中,首先用 checkNotNull 方法去检查插入的元素是不是 null。如果不是 null,我们会用 ReentrantLock 上锁,并且上锁方法是 lock.lockInterruptibly()。try 中会有一个 while 循环,它会检查当前队列是不是已经满了,也就是 count 是否等于数组的长度。如果等于就代表已经满了,于是我们便会进行等待,直到有空余的时候,才会执行下一步操作,调用 enqueue 方法让元素进入队列,最后用 unlock 方法解锁。
ConcurrentLinkedQueue源码分析
看完阻塞队列之后,我们就来看看非阻塞队列 ConcurrentLinkedQueue。顾名思义,ConcurrentLinkedQueue 是使用链表作为其数据结构的,我们来看一下关键方法 offer 的源码:
public boolean offer(E e) {
checkNotNull(e);
final Node<E> newNode = new Node<E>(e);
for (Node<E> t = tail, p = t;;) {
Node<E> q = p.next;
if (q == null) {
// p is last node
if (p.casNext(null, newNode)) {
// Successful CAS is the linearization point
// for e to become an element of this queue,
// and for newNode to become "live".
if (p != t) // hop two nodes at a time
casTail(t, newNode); // Failure is OK.
return true;
}
// Lost CAS race to another thread; re-read next
}
else if (p == q)
// We have fallen off list. If tail is unchanged, it
// will also be off-list, in which case we need to
// jump to head, from which all live nodes are always
// reachable. Else the new tail is a better bet.
p = (t != (t = tail)) ? t : head;
else
// Check for tail updates after two hops.
p = (p != t && t != (t = tail)) ? t : q;
}
}
放到整体的代码结构上,可以看到它整个是一个大的 for 循环,而且是一个非常明显的死循环。在这个循环中有一个非常亮眼的 p.casNext 方法,这个方法正是利用了 CAS 来操作的,而且这个死循环去配合 CAS 也就是典型的乐观锁的思想。我们就来看一下 p.casNext 方法的具体实现,其方法代码如下:
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
可以看出这里运用了 UNSAFE.compareAndSwapObject 方法来完成 CAS 操作,而 compareAndSwapObject 是一个 native 方法,最终会利用 CPU 的 CAS 指令保证其不可中断。
可以看出,非阻塞队列 ConcurrentLinkedQueue 使用 CAS 非阻塞算法 + 不停重试,来实现线程安全,适合用在不需要阻塞功能,且并发不是特别剧烈的场景。
应用场景
- 功能
第 1 个需要考虑的就是功能层面,比如是否需要阻塞队列帮我们排序,如优先级排序、延迟执行等。如果有这个需要,我们就必须选择类似于 PriorityBlockingQueue 之类的有排序能力的阻塞队列。
- 容量
第 2 个需要考虑的是容量,或者说是否有存储的要求,还是只需要“直接传递”。在考虑这一点的时候,我们知道前面介绍的那几种阻塞队列,有的是容量固定的,如 ArrayBlockingQueue;有的默认是容量无限的,如 LinkedBlockingQueue;而有的里面没有任何容量,如 SynchronousQueue;而对于 DelayQueue 而言,它的容量固定就是 Integer.MAX_VALUE。所以不同阻塞队列的容量是千差万别的,我们需要根据任务数量来推算出合适的容量,从而去选取合适的 BlockingQueue。
- 能否扩容
第 3 个需要考虑的是能否扩容。因为有时我们并不能在初始的时候很好的准确估计队列的大小,因为业务可能有高峰期、低谷期。
如果一开始就固定一个容量,可能无法应对所有的情况,也是不合适的,有可能需要动态扩容。如果我们需要动态扩容的话,那么就不能选择 ArrayBlockingQueue ,因为它的容量在创建时就确定了,无法扩容。相反,PriorityBlockingQueue 即使在指定了初始容量之后,后续如果有需要,也可以自动扩容。所以我们可以根据是否需要扩容来选取合适的队列。
-
内存结构
第 4 个需要考虑的点就是内存结构。在上一课时我们分析过 ArrayBlockingQueue 的源码,看到了它的内部结构是“数组”的形式。和它不同的是,LinkedBlockingQueue 的内部是用链表实现的,所以这里就需要我们考虑到,ArrayBlockingQueue 没有链表所需要的“节点”,空间利用率更高。所以如果我们对性能有要求可以从内存的结构角度去考虑这个问题。
-
性能
第 5 点就是从性能的角度去考虑。比如 LinkedBlockingQueue 由于拥有两把锁,它的操作粒度更细,在并发程度高的时候,相对于只有一把锁的 ArrayBlockingQueue 性能会更好。
另外,SynchronousQueue 性能往往优于其他实现,因为它只需要“直接传递”,而不需要存储的过程。如果我们的场景需要直接传递的话,可以优先考虑 SynchronousQueue。
最后是各个队列属性的总结:
名称 | 底层数据结构 | 是否阻塞 | 内部锁实现 | 是否有界 | 是否需要初始化长度 | 默认长度 | 最大长度 |
ArrayBlockingQueue | 数组 | put/take方法阻塞 | lock | 有界且初始化后不能扩容 | 需要 | 无 | -- |
LinkedBlockingQueue | 单向链表 | put/take方法阻塞 | lock | 可看成无界队列 | 不需要 | Integer.MAX_VALUE | Integer.MAX_VALUE |
SynchronousQueue | --- | put/take方法阻塞 | lock | 队列长度为0 | 不需要 | 0 | 0 |
PriorityBlockingQueue | 数组(按照树形结构存放) | put/take方法阻塞 | lock | 无界队列 offer超过容量会引发扩容 | 不需要 | 11 | Integer.MAX_VALUE - 8 |
DelayQueue | 数组 | put/take方法阻塞 | lock | 无界队列量/offer超过容量会引发扩容 | 不能初始化 | | Integer.MAX_VALUE |
ConcurrentLinkedQueue | 单向链表 | 无put/take方法 | cas | 无界队列 | 可初始化 | 0 | Integer.MAX_VALUE |
参考资料:《Java并发78讲》
《Java并发编程之美》