阻塞队列,也就是
BlockingQueue
。
BlockingQueue
是线程安全的,我们在很多场景下都可以利用线程安全的队列来优雅地解决我们业务自身的线程安全问题。例如生产者消费者模式的核心就是阻塞队列,阻塞队列还有一个很重要的作用就是解耦。
1、关系图
BlockingQueue
下面有 6 种最主要的实现,分别是 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、DelayQueue、PriorityBlockingQueue 和 LinkedTransferQueue
。
非阻塞并发队列的典型例子是 ConcurrentLinkedQueue
,这个类不会让线程阻塞,利用 CAS
保证了线程安全。
2、阻塞队列的特点
take 方法
take
方法的功能是获取并移除队列的头结点,通常在队列里有数据的时候是可以正常移除的。可是一旦执行 take
方法的时候,队列里无数据,则阻塞,直到队列里有数据。一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。
put 方法
与 take
方法同理。
是否有界
无界队列意味着里面可以容纳非常多的元素,例如 LinkedBlockingQueue
的上限是 Integer.MAX_VALUE
,约为 2 的 31 次方,是非常大的一个数,可以近似认为是无限容量,因为我们几乎无法把这个容量装满。
有界队列意味着可以容纳的元素有限,例如 ArrayBlockingQueue
如果容量满了,也不会扩容,所以一旦满了就无法再往里放数据了。
3、常用方法区别
- 抛出异常:
add、remove、element
- 返回结果但不抛出异常:
offer、poll、peek
- 阻塞:
put、take
第一组:add、remove、element
add 方法
add 方法是往队列里添加一个元素,如果队列满了,就会抛出异常来提示队列已满。
java.lang.IllegalStateException: Queue full
remove 方法
remove
方法的作用是删除元素,如果我们删除的队列是空的,那么 remove
方法就会抛出异常。
java.util.NoSuchElementException
element 方法
element
方法是返回队列的头部节点,但是并不删除。如果队列为空,会抛出异常。
java.util.NoSuchElementException
第二组:offer、poll、peek
offer 方法
offer
方法用来插入一个元素,并用返回值来提示插入是否成功。
如果添加成功会返回 true
,而如果队列已经满了,此时继续调用 offer
方法的话,它不会抛出异常,只会返回一个错误提示:false
。
poll 方法
poll
方法和第一组的 remove
方法是对应的,作用也是移除并返回队列的头节点。
但是如果当队列里面是空的,没有任何东西可以移除的时候,便会返回 null
作为提示。如果队列不为空,会把头节点删除并返回。
正因如此,我们是不允许往队列中插入 null
的,否则我们没有办法区分返回的 null
是一个提示还是一个真正的元素。
如果插入 null
会抛出 ava.lang.NullPointerException
。
peek 方法
peek
方法和第一组的 element
方法是对应的,意思是返回队列的头元素但并不删除。
如果队列里面是空的,它便会返回 null
作为提示。如果队列不为空,就返回队列的头元素。
带超时时间的 offer 和 poll
offer(E e, long timeout, TimeUnit unit)
poll(long timeout, TimeUnit unit)
有三个参数,分别是元素、超时时长和时间单位。
这里的超时时间是指等待时间。
第三组:put、take
生产者消费者模式就是使用这一组方法。
put 方法
put
方法的作用是插入元素。
在队列没满的时候是正常的插入,但是如果队列已满就无法继续插入,这时它既不会立刻返回 false
也不会抛出异常,而是让插入的线程陷入阻塞状态,直到队列里有了空闲空间,此时队列就会让之前的线程解除阻塞状态,并把刚才那个元素添加进去。
take 方法
take
方法的作用是获取并移除队列的头结点。
在队列里有数据的时候会正常取出数据并删除;但是如果执行 take
的时候队列里无数据,则阻塞,直到队列里有数据;一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。
4、常见的阻塞队列
ArrayBlockingQueue
ArrayBlockingQueue
是最典型的有界队列,其内部是用数组存储元素的,利用 ReentrantLock
实现线程安全。
构造方法源码:
public ArrayBlockingQueue(int capacity) {
this(capacity, false);
}
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
// ReentrantLock,并且可以指定是否安全
lock = new ReentrantLock(fair);
// 内部使用信号量
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
LinkedBlockingQueue
内部用链表实现的。如果我们不指定它的初始容量,那么它容量默认就为整型的最大值 Integer.MAX_VALUE
。
LinkedBlockingQueue
也被称作无界队列,代表它几乎没有界限。
SynchronousQueue
SynchronousQueue
最大的不同之处在于,它的容量为 0,所以没有一个地方来暂存元素,导致每次取数据都要先阻塞,直到有数据被放入;同理,每次放数据的时候也会阻塞,直到有消费者来取。
因为 SynchronousQueue
不需要去持有元素,它所做的就是直接传递(direct handoff
)。由于每当需要传递的时候,SynchronousQueue
会把元素直接从生产者传给消费者,在此期间并不需要做存储,所以如果运用得当,它的效率是很高的。
一些方法源码:
public int size() {
return 0;
}
public boolean isEmpty() {
return true;
}
public boolean remove(Object o) {
return false;
}
PriorityBlockingQueue
PriorityBlockingQueue
是一个支持优先级的无界阻塞队列。支持自定义比较器。
核心构造方法源码:
public PriorityBlockingQueue(int initialCapacity,
Comparator<? super E> comparator) {
if (initialCapacity < 1)
throw new IllegalArgumentException();
this.lock = new ReentrantLock();
this.notEmpty = lock.newCondition();
this.comparator = comparator;
this.queue = new Object[initialCapacity];
}
DelayQueue
DelayQueue
这个队列比较特殊,具有“延迟”的功能。我们可以设定让队列中的任务延迟多久之后执行,比如 10 秒钟之后执行,这在例如“30 分钟后未付款自动取消订单”等需要延迟执行的场景中被大量使用。
它是无界队列,放入的元素必须实现 Delayed
接口,而 Delayed
接口又继承了 Comparable
接口,所以自然就拥有了比较和排序的能力,代码如下:
public interface Delayed extends Comparable<Delayed> {
/**
* 还剩下多长的延迟时间才会被执行
*/
long getDelay(TimeUnit unit);
}
元素会根据延迟时间的长短被放到队列的不同位置,越靠近队列头代表越早过期。
DelayQueue
内部使用了 PriorityQueue
的能力来进行排序,而不是自己从头编写。
5、阻塞和非阻塞队列的原理
ArrayBlockingQueue 源码分析
重要属性
// 存放元素的数组
final Object[] items;
// 下一次读取操作的位置
int takeIndex;
// 下一次写入操作的位置
int putIndex;
// 队列中的元素数量
int count;
// 这三个变量就是控制并发的
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
put 方法
public void put(E e) throws InterruptedException {
// 检查 e 是否为 null,为 null 则抛出空指针异常
checkNotNull(e);
final ReentrantLock lock = this.lock;
// 加锁,这个加锁方法是可以响应中断的
lock.lockInterruptibly();
try {
// 如果元素个数等于队列容量,说明队列已满,则阻塞
while (count == items.length)
// 说明不满这个条件不满足
notFull.await();
enqueue(e);
} finally {
// 解锁
lock.unlock();
}
}
take 方法
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
// 加锁,这个加锁方法是可以响应中断的
lock.lockInterruptibly();
try {
// 队列中没有元素
while (count == 0)
// 说明不空这个条件不满足
notEmpty.await();
return dequeue();
} finally {
// 解锁
lock.unlock();
}
}
总结
ArrayBlocking
是使用 ReetrantLock + Condition
来保证线程安全。
ConcurrentLinkedQueue 源码分析
ConcurrentLinkedQueue
是使用链表作为其数据结构的,它是一个非阻塞的队列,使用 CAS
来保证线程安全。
offer 方法
public boolean offer(E var1) {
// 检查是否为 null
checkNotNull(var1);
ConcurrentLinkedQueue.Node var2 = new ConcurrentLinkedQueue.Node(var1);
ConcurrentLinkedQueue.Node var3 = this.tail;
ConcurrentLinkedQueue.Node var4 = var3;
do {
while(true) {
ConcurrentLinkedQueue.Node var5 = var4.next;
if (var5 == null) {
break;
}
if (var4 == var5) {
var4 = var3 != (var3 = this.tail) ? var3 : this.head;
} else {
var4 = var4 != var3 && var3 != (var3 = this.tail) ? var3 : var5;
}
}
// casNext(),很明显使用 CAS 来保证线程安全
} while(!var4.casNext((ConcurrentLinkedQueue.Node)null, var2));
if (var4 != var3) {
this.casTail(var3, var2);
}
return true;
}
boolean casNext(ConcurrentLinkedQueue.Node<E> var1, ConcurrentLinkedQueue.Node<E> var2) {
// 底层仍然是 unsaf,本地方法
return UNSAFE.compareAndSwapObject(this, nextOffset, var1, var2);
}
poll 方法
public E poll() {
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
E item = p.item;
if (item != null && p.casItem(item, null)) {
// Successful CAS is the linearization point
// for item to be removed from this queue.
if (p != h) // hop two nodes at a time
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
else if (p == q)
continue restartFromHead;
else
p = q;
}
}
}
总结
非阻塞队列 ConcurrentLinkedQueue
使用 CAS 非阻塞算法 + 不停重试
,来实现线程安全,适合用在不需要阻塞功能,且并发不是特别剧烈的场景。
6、阻塞队列如何选择
线程池对于阻塞队列的选择
FixedThreadPool
FixedThreadPool(SingleThreadExecutor 同理)选取的是 LinkedBlockingQueue。
由于 FixedThreadPool
的线程数是固定的,在任务激增的时候,它无法增加更多的线程来帮忙处理 Task
,所以需要像 LinkedBlockingQueue
这样没有容量上限的 Queue
来存储那些还没处理的 Task
。
CachedThreadPool
CachedThreadPool 选取的是 SynchronousQueue。
对于 CachedThreadPool
而言,为了避免新提交的任务被拒绝,它选择了无限制的 maximumPoolSize
,所以既然它的线程的最大数量是无限的,也就意味着它的线程数不会受到限制,那么它就不需要一个额外的空间来存储那些 Task
,因为每个任务都可以通过新建线程来处理。
ScheduledThreadPool
ScheduledThreadPool(SingleThreadScheduledExecutor同理)选取的是延迟队列。
对于 ScheduledThreadPool
而言,它使用的是 DelayedWorkQueue
。延迟队列的特点是:不是先进先出,而是会按照延迟时间的长短来排序,下一个即将执行的任务会排到队列的最前面。
ScheduledThreadPool
处理的是基于时间而执行的 Task
,而延迟队列有能力把 Task
按照执行时间的先后进行排序,这正是我们所需要的功能。
ArrayBlockingQueue
常用的阻塞队列为 ArrayBlockingQueue
,它经常被用于我们手动创建的线程池中。
这种阻塞队列内部是用数组实现的,在新建对象的时候要求传入容量值,且后期不能扩容,所以 ArrayBlockingQueue
的最大特点就是容量是有限且固定的。
这样一来,使用 ArrayBlockingQueue
且设置了合理大小的最大线程数的线程池,在任务队列放满了以后,如果线程数也已经达到了最大值,那么线程池根据规则就会拒绝新提交的任务,而不会无限增加任务或者线程数导致内存不足,可以非常有效地防止资源耗尽的情况发生。
选择策略
功能
第 1 个需要考虑的就是功能层面,比如是否需要阻塞队列帮我们排序,如优先级排序、延迟执行等。如果有这个需要,我们就必须选择类似于 PriorityBlockingQueue
之类的有排序能力的阻塞队列。
容量
第 2 个需要考虑的是容量,或者说是否有存储的要求,还是只需直接传递。
有的是容量固定的,如 ArrayBlockingQueue
;有的默认是容量无限的,如 LinkedBlockingQueue
;而有的里面没有任何容量,如 SynchronousQueue
;而对于 DelayQueue
而言,它的容量固定就是 Integer.MAX_VALUE
。
能否扩容
第 3 个需要考虑的是能否扩容。因为有时我们并不能在初始的时候很好的准确估计队列的大小,因为业务可能有高峰期、低谷期。
如果一开始就固定一个容量,可能无法应对所有的情况,也是不合适的,有可能需要动态扩容。如果我们需要动态扩容的话,那么就不能选择 ArrayBlockingQueue
,因为它的容量在创建时就确定了,无法扩容。相反,PriorityBlockingQueue
即使在指定了初始容量之后,后续如果有需要,也可以自动扩容。
底层数据结构
第 4 个需要考虑的点就是数据结构。
ArrayBlockingQueue
的数据结构是数组,LinkedBlockingQueue
的内部是用链表实现的,所以这里就需要我们考虑到,ArrayBlockingQueue
没有链表所需要的“节点”,空间利用率更高。所以如果我们对性能有要求可以从内存的结构角度去考虑这个问题。
性能
第 5 点就是从性能的角度去考虑。
比如 LinkedBlockingQueue
由于拥有两把锁,它的操作粒度更细,在并发程度高的时候,相对于只有一把锁的 ArrayBlockingQueue
性能会更好。
SynchronousQueue
性能往往优于其他实现,因为它只需要“直接传递”,而不需要存储的过程。如果我们的场景需要直接传递的话,可以优先考虑 SynchronousQueue
。