前辈们的经验,学习源码我们从java doc的注释开始,是学习java并发包最好的材料。
BlockingQueue是一个接口,继承Queue,而Queue又继承Collection接口。
BlockingQueue 对插入操作、移除操作、获取元素操作提供了四种不同的方法用于不同的场景中使用:1、抛出异常;2、返回特殊值(null 或 true/false,取决于具体的操作);3、阻塞等待此操作,直到这个操作成功;4、阻塞等待此操作,直到成功或者超时指定时间。总结如下(这个表格多看几遍,多看几遍!):
Throws exception | Special value | Blocks | Times out | |
---|---|---|---|---|
Insert | add(e) | offer(e) | put(e) | offer(e, time, unit) |
Remove | remove() | poll() | take() | poll(time, unit) |
Examine | element() | peek() | not applicable | not applicable |
BlockingQueue不接受null元素,add、put、offer尝试insert null值将会抛出空指针异常。null值在这里通常作为特殊值返回,代表poll失败。所以,如果允许插入null值得话,那么获取得时候就不能很好地用null来判断到底是失败还是获取的值就是null。
阻塞队列可能有容量限制,即具有最大可容纳元素数量。在任何给定时刻,阻塞队列可能具有一个remainingCapacity,即队列中还可以容纳多少元素。当remainingCapacity为0时,无法再添加元素到队列中,必须等待其他线程从队列中取出元素后,才能再次添加元素。如果阻塞队列没有任何内在容量限制,则它将报告一个remainingCapacity为Integer.MAX_VALUE。
阻塞队列的实现主要设计用于生产者-消费者队列,但它们也支持集合接口。因此,例如,可以使用remove(x)方法从队列中删除任意元素。但是,这样的操作通常不是非常高效,并且仅用于偶尔使用,例如在取消排列消息时使用。也就是说,虽然阻塞队列支持Collection接口,但是针对集合的操作并不是阻塞队列的主要设计目的,因此使用时需要注意效率问题。
BlockingQueue的实现都是线程安全的,所有队列的方法都使用内部锁或其他形式的并发控制来实现它们的效果。但是,批量集合操作addAll、containsAll、retainAll和removeAll不一定时原子性的,除非在实现中特别指定。因此,在添加集合c中的一些元素后,addAll©可能会失败(抛出异常)。这就意味着,在使用这些批量集合操作时,需要根据具体实现注意原子性和线程安全问题。
BlockingQueue本质上不支持任何类型的“close”或“shutdown”操作来指示不再添加任何元素,此处应该是对比的线程池的概念。如果需要达到类似线程池的这种概念,往往需要依赖于具体的实现,例如,常见的策略是是生产者insert一个特殊的end-of-stream或者poison对象,当消费者从队列中取出元素时,判断元素是否为特殊对象,如果是,则表示队列已经关闭或停止。此外,还有其他方式来实现关闭或停止操作,具体取决于阻塞队列的实现方式和应用场景。
BlockingQueue作为一种并发集合,在多线程环境中使用时,具有内存一致性效应。具体地说,如果在一个线程中将一个对象放入阻塞队列中,那么在另一个线程中访问或者删除元素时,先前在第一个线程中执行的操作会在后续的访问或删除操作之前发生(即具有happen-before关系)。这种内存一致性效应可以保证多线程环境中的正确性和可靠性。
一个有界的线程安全的队列,底层是用数组实现。是一个FIFO(first in first out)的队列。
学习本章得内容需要了解一下我的另外一篇文章:Condition源码分析,如果你看了这篇文章之后,应该是很容易看懂ArrayBlockingQueue的源码。
ArrayBlockingQueue 共有以下几个属性:
/** The queued items */
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;
/*
* Concurrency control uses the classic two-condition algorithm
* found in any textbook.
* https://stackoverflow.com/questions/15988140/why-concurrency-control-uses-the-classic-two-condition-algorithm#comment22794774_15988140
*/
/** Main lock guarding all access */
final ReentrantLock lock;
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
/**
* Shared state for currently active iterators, or null if there
* are known not to be any. Allows queue operations to update
* iterator state.
*/
transient Itrs itrs = null;
国际惯例,我们先查看类的构造函数,对于ArrayBlockingQueue,我们可以在构造的时候指定以下三个参数:
public ArrayBlockingQueue(int capacity) {
// 使用楼下的方法,false使用的是Non-fair
this(capacity, false);
}
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
// 两个条件,都是基于同一把锁
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
public ArrayBlockingQueue(int capacity, boolean fair,
Collection<? extends E> c) {
this(capacity, fair);
final ReentrantLock lock = this.lock;
lock.lock(); // Lock only for visibility, not mutual exclusion
try {
int i = 0;
try {
for (E e : c) {
checkNotNull(e);
items[i++] = e;
}
} catch (ArrayIndexOutOfBoundsException ex) {
throw new IllegalArgumentException();
}
count = i;
putIndex = (i == capacity) ? 0 : i;
} finally {
lock.unlock();
}
}
构造函数还是非常简单,就不作过多描述,根据Summary of BlockingQueue method的表格,我们选put方法来进行源码分析,因为put基本包含所有的insert操作的方法。
public void put(E e) throws InterruptedException {
// null值检查,如果为空就抛出空指针异常
checkNotNull(e);
final ReentrantLock lock = this.lock;
// 加锁(可抛出中断异常的加锁),await操作需要持有锁
lock.lockInterruptibly();
try {
// 避免虚假唤醒,当前队列容量如果等于当前元素数量,说明装满了,所以notFull条件进入等待。
while (count == items.length)
notFull.await();
// 如果不等于,说明有空闲位置来装元素,就是insert队列。
enqueue(e);
} finally {
// 解锁
lock.unlock();
}
}
private void enqueue(E x) {
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
// 拿到队列的数组
final Object[] items = this.items;
// 入队需要insert元素,当前需要insert到数组的下标为putIndex
items[putIndex] = x;
// 如果下一次insert元素的下标等于队列的容量,就又从0开始放入数据,用这种方式进行循环插入元素。
if (++putIndex == items.length)
putIndex = 0;
// 元素总量
count++;
// 插入了数据,所以队列不会为空了,就可以去唤醒notEmpty条件。
notEmpty.signal();
}
队列有insert元素就会有remove元素,take方法就是用来做这个事情。
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
// 加锁。所有的condition都得在锁内进行操作
lock.lockInterruptibly();
try {
// 如果当前元素总量为0,notEmpty条件等待
while (count == 0)
notEmpty.await();
// 如果不为0,执行出队操作
return dequeue();
} finally {
// 解锁。
lock.unlock();
}
}
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
@SuppressWarnings("unchecked")
// takeIndex是remove元素得下标
E x = (E) items[takeIndex];
// items的takeIndex位置设置为null,也就是移除操作。
items[takeIndex] = null;
// 如果下一次remove元素等于队列的容量,那么就又从0开始移除,当count不为0的时候。
if (++takeIndex == items.length)
takeIndex = 0;
// 总元素-1
count--;
// itrs只有在使用迭代器时才会初始化,如果不使用迭代器,itrs将一直为null。
if (itrs != null)
itrs.elementDequeued();
// 移除了元素之后,notFull条件就可以唤醒了,因为当前肯定不是满的。
notFull.signal();
// 返回移除的元素值
return x;
}
一个基于单向链表的可选边界的阻塞队列,也是first-in-first-out。链表结构通常比基于数组结构的队列具有更高的吞吐量(high throughput),但是在大多数并发应用程序中性能更不可预测,所以再创建线程池的时候,不建议使用LinkedBlockingQueue的无参构造函数来创建任务队列。
看构造方法:
// 无参构造函数,无界
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
//有界,指定容量
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
last = head = new Node<E>(null);
}
看下类的属性:
/** The capacity bound, or Integer.MAX_VALUE if none */
// 容量边界,如果没有指定就是Integer.MAX_VALUE
private final int capacity;
/** Current number of elements */
// 元素当前数量,使用的原子类,核心是CAS
private final AtomicInteger count = new AtomicInteger();
/**
* Head of linked list.(队头)
* Invariant: head.item == null
*/
transient Node<E> head;
/**
* Tail of linked list.(队尾)
* Invariant: last.next == null
*/
private transient Node<E> last;
/** Lock held by take, poll, etc */
// 获取元素方法需要获取的锁
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
// 获取元素需要等待的条件
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
// 插入元素需要获取的锁
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
// 插入元素需要等待的条件
private final Condition notFull = putLock.newCondition();
和ArrayBlockingQueue一样都有两个condition,区别是两个condition来自两把锁。
为什么要用两把锁,和一把锁两个condition比有什么优势?
带着问题,开始分析源码,老规矩从put方法开始:
public void put(E e) throws InterruptedException {
// 不能插入null元素,否则空指针异常
if (e == null) throw new NullPointerException();
// Note: convention in all put/take/etc is to preset local var
// holding count negative to indicate failure unless set.
// 注意:所有put/take/诸如此类方法中的惯例是将本地变量持有的count
// 预设为负数来指示失败,除非已设置
// 如果你纠结这里为什么是-1,可以查看下offer方法,负数返回false。
// 这个仅仅是个标识成功失败的标志。
int c = -1;
// 用Node对象对元素进行包装
Node<E> node = new Node<E>(e);
// 必须获取到putLock才可以进行插入操作
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
/*
* Note that count is used in wait guard even though it is
* not protected by lock. This works because count can
* only decrease at this point (all other puts are shut
* out by lock), and we (or some other waiting put) are
* signalled if it ever changes from capacity. Similarly
* for all other uses of count in other wait guards.
*/
// 如果队列满了,需要等待notFull条件被唤醒
while (count.get() == capacity) {
notFull.await();
}
// 入队
enqueue(node);
// 元素总量+1,不懂为什么要getAndIncrement,而不是直接incrementAndGet
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}
private void enqueue(Node<E> node) {
// assert putLock.isHeldByCurrentThread();
// assert last.next == null;
// 简单的链表操作,node给last的next,把node指定为last
last = last.next = node;
}
private void signalNotEmpty() {
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
// 如果c=0,说明之前所有的获取元素线程都在等待notEmpty条件,所以这里执行一次唤醒操作
notEmpty.signal();
} finally {
takeLock.unlock();
}
}
接下来再看看take方法:
public E take() throws InterruptedException {
// 接收获取的元素
E x;
// 与put方法一致,一个成功失败的标识
int c = -1;
// 队列元素数量
final AtomicInteger count = this.count;
// 获取元素需要takeLock
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
// 如果当前队列元素为0,那么需要等待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;
}
//Removes a node from head of queue
private E dequeue() {
// assert takeLock.isHeldByCurrentThread();
// assert head.item == null;
// head节点和last节点初始化的时候就是Node(null),head.item==null
Node<E> h = head;
// 让头节点的下一个节点为head节点
Node<E> first = h.next;
h.next = h; // help GC,让h的next属性指向自己,也就是断开与后面的连接
head = first;
E x = first.item;
first.item = null;
return x;
}
private void signalNotFull() {
final ReentrantLock putLock = this.putLock;
putLock.lock();
try {
// 如果c等于队列容量,那么说明在这个take发生之前,队列是满的,所以唤醒notFull条件,
// 让所有put的等待线程被唤醒。
notFull.signal();
} finally {
putLock.unlock();
}
}
ArrayBlockingQueue和LinkedBlockingQueue都是Java中的阻塞队列实现。
主要区别如下:
内部数据结构不同:ArrayBlockingQueue使用数组实现,而LinkedBlockingQueue则使用链表实现。
队列的大小不同:ArrayBlockingQueue在创建时需要指定队列的大小,而LinkedBlockingQueue则可以在创建时不指定大小,或者指定一个很大的值来实现无界队列。
在多线程环境下,ArrayBlockingQueue和LinkedBlockingQueue都是线程安全的,但是它们的并发性能略有不同。ArrayBlockingQueue基于单一锁实现,所有的插入和删除操作都是在同一个锁上进行的,因此对于高并发情况下的性能相对较差。而LinkedBlockingQueue则使用了两个锁(一个读锁和一个写锁)来控制队列的插入和删除操作,因此在高并发情况下性能会更好。
总之,如果需要一个具有固定大小的阻塞队列,并且对性能要求不高,可以选择ArrayBlockingQueue;如果需要一个无界阻塞队列,并且对性能要求较高,可以选择LinkedBlockingQueue。