本篇对应 TAOMP 第 10 章。从队列开始,Java 里就有许多相对的实现了。
这里需要引入池的概念:不需要检测contains
,只有get
和put
。池是一种比集合更加简单的数据结构,就像是背包(bag)一样。
池可以是有界的,也可以是无界的。当我们不希望生产者生产速度过快,我们可以使用有界池。
池的方法可以是完全的(total),或者部分的(partial)。同时,它可能是同步的(synchronous)。所谓完全和部分的,简单来说就是方法有没有可能被阻塞。比如说,Java中BlockingQueue
接口的offer
方法就是完全的,而put
方法则是部分的。不过,什么叫同步呢?
A method is synchronous if it waits for another method to overlap its call interval. For example, in a synchronous pool, a method call that adds an item to the pool is blocked until that item is removed by another method call.
看起来同步和部分很像。同步主要是针对方法和方法之间的关系来看的。
池应该可以支持公平性。这里只考虑最简单的FIFO。这样,这个问题就是一个队列问题。显然,最简单的队列用两个锁控制入队和出队的线程数至多只有一个。
在介绍Java的队列实现之前,可以看一下基本概念:
Java Queues - why “poll” and “offer”?
对队列的操作有几种不同的方法:
Throws ex. | Special v. | Blocks | Times out | |
---|---|---|---|---|
Insert | add(e) | offer(e) | put(e) | offer(e, time, unit) |
Remove | remove() | poll() | take() | poll(time, unit) |
Examine | element() | peek() | N/A | N/A |
可以看到,add
和offer
都是完全的,所以实际上在实现中的时候,add
会调用后者。这个逻辑被封装在了AbstractQueue
里,像LinkedBlockingQueue
都会继承它。
有界部分队列
在Java 中对应的是阻塞队列LinkedBlockingQueue
。它是一个范围可选(optionally-bounded)的阻塞队列。所谓可选,就是你可以传一个capacity,如果不传默认就是Integer.MAX_VALUE
,这相当于无界的。
这里,count 并非是因为需求(我们按照池的要求来的),而是可以避免入队和出队的线程产生冲突。
分析一下LinkedBlockingQueue
的put
:
public void put(E e) throws InterruptedException {
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.
int c = -1;
Node<E> node = new Node<E>(e);
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.
*/
while (count.get() == capacity) {
notFull.await();
}
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}
就像注释写的那样,我们的put
,take
方法只需要和同类的线程竞争(只获取一把锁)。有趣的是,这里有一行notFull.signal()
。入队自己接受这个信号,为什么又要自己发出去呢?原因在于take
的signalNotFull
不是signalAll
的。这里用了一种类似线程协助的方法来发送信号,防止线程因为唤醒丢失而永久阻塞。那什么是唤醒丢失呢?假设生产者消费者队列的消费者A和B都在空队列出队时阻塞。C添加元素唤醒A,但是在A得到锁之前,D添加元素。此时B无法被唤醒,因为队列不为空,而A出队以后队列仍然存在元素,B无法消费。出现这一现象的原因在于,执行顺序是C -> D -> A,发出信号并不意味着立刻获得锁。
话说,现在网上确实有很多并发集合相关的源码分析,在我看来真的是在跃进,或者是为了各种奇怪的KPI。如果分析源码没提到这个问题,要么是水平高的不屑于这个问题,要么我就认为是不走心了。
当然,这里还可以刨根问底一下:为什么不能直接用signalAll
呢?这个其实也不是不可以,但是会有锁的争用。而且,考虑到触发条件是边界,这里触发所有语义上并不合适。
另外在signalNotEmpty
中,signal
由获取锁的代码块包装了起来,因为这里需要的条件是NotEmpty
,对应的锁是takeLock
。但为什么Condition
需要持有锁呢?从直观的代码,也就是 AQS 的实现来看:
public final void signal() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
doSignal(first);
}
如果不是排他锁就会直接抛出异常。我的想法是,这里是为了内存的可见性(不过我搜索了一下,没有找到别人的解答)。
有界队列也可以用数组来实现。这个大家都非常熟悉了,ArrayBlockingQueue
是一个典型的粗粒度锁有界阻塞队列。
无界无锁队列
前面可以看到,BlockingQueue
实际上是生产者消费者队列经典的细粒度锁实现。而我们更希望看到的是一个无锁算法。无锁队列的经典算法是Michael和Scott提出的,对应的实现是ConcurrentLinkedQueue
。显然,它就不是BlockingQueue
了。
显然,一个链表队列的入队需要两步:第一,增加节点;第二,修改 tail
。这两步无法合并。不过,我们可以通过线程协助来完成,这样依然可以保证方法的可线性化。这就是无锁队列的关键思想。这也是并发编程中最常用的思路,我们在前面的链表已经看到这种协作了。和传统的顺序开发逻辑不同,它相当于是在每个线程的执行逻辑中插入了其他线程的逻辑。
我们看一下 TAOMP 里出队的大致逻辑:
public T deq() throws EmptyException {
while (true) {
Node first = head.get();
Node last = tail.get();
Node next = first.next.get();
if (first == head.get()) {// are they consistent?
if (first == last) { // is queue empty or tail falling behind?
if (next == null) { // is queue empty?
throw new EmptyException();
}
// tail is behind, try to advance
tail.compareAndSet(last, next);
} else {
T value = next.value; // read value before dequeuing
if (head.compareAndSet(first, next))
return value;
}
}
}
}
这里head
和tail
都是AtomicReference
。这并不是一个最好的选择,尤其是在循环中。所以在ConcurrentLinkedQueue
里head
和tail
是volatile
的。
可以看到,deq
中插入了enq
第二个CAS。协作enq
是为了防止头尾指针撞在一起,和count 的作用差不多,但是这里比计数器更好。这可以帮助我们理解,为什么ConcurrentLinkedQueue
的size
方法在并发下是不精确的,且是的时间开销。
有个细节需要注意。既然会进行CAS,代码中一开始检查first == head.get()
是必要的吗?其实从逻辑上去掉也是可以的,但是就像双重上锁一样,head.get()
是一种本地自旋的策略。如果加上了first == head.get()
这个比较,这叫做 TTAS 锁;反之为 TAS 锁。TTAS 锁的优势在于,它总是可以从缓存中读取,直到这个原子变量被其他线程修改了。此外,compareAndSet
会让总线上所有CPU的缓存失效,而如果若干线程都这样做,这就会引起总线风暴。之后我也会出一篇更详细的关于TTAS锁的分析。
讲完了思想,看一下Java里真正的实现:ConcurrentLinkedQueue
。这个和前面的还是有很大的差别。比如没有AtomicReference
,所以casHead
等方法都使用了UNSAFE.compareAndSwapObject
。这个方法实际上是针对堆上某个对象的部分内存地址进行CAS。
还是分析出队:
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;
}
}
}
这个写法其实非常tricky。可以捋一下这个if逻辑:
- 如果这个节点的item不为空(排除了哨兵节点和已经被删除的节点)就进行item的CAS,然后更新
head
- 如果循环变量p没有后继,说明到了结尾,直接更新
head
- p后继如果指向自己,此时重新进行循环,重新读取
head
- 循环变量p指向它的后继
第一个CAS操作casItem
设置节点的对象为null。由于item
被设置为volatile
,因此相当于逻辑删除了。casItem
之后,如果它的后继也为null,那就一次性跳过两个节点设置head
,因为此时可能有另一个线程逻辑删除了下一个位置。如果不这样写的话,就产生了线程之间的顺序依赖。最后,如果没有CAS成功(p == h),就不设置head
。
第二个CAS操作在updateHead
里。updateHead
需要两步操作:
final void updateHead(Node<E> h, Node<E> p) {
if (h != p && casHead(h, p))
h.lazySetNext(h);
}
这里的lazySetNext
实现是putOrderedObject
,所以名字包含了lazySet
(在原子并发类有许多例子)。这个方法可以使用更快StoreStore 内存屏障,但是失去了内存可见性。让节点的后继指向自身的原因是让succ
可以返回一个哨兵值。另外,这也是前面if的第3个分支的判断条件,说明此时head
已经被其他线程更新。
继续分析offer
方法。它的if有3个分支:
如果是真正的结尾,就进行CAS。但第二步更新tail
有可能失败;
如果后继指向自身,说明有一个poll
线程把它删除了,此时和poll
一样,需要考虑是否头尾合在一起。但也有可能另一个offer
已经加入新的节点并更新了tail
;
最后一种情况是,此时已经有的线程offer
了另一个节点,但是有可能还没有更新tail
(p == t)。但什么时候p != t && t == (t = tail)
呢?就是前一个if逻辑里,设置为head
的时候。从这里我们也可以看到,一旦设置为head
,且此时如果没有别的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;
}
}
其他方法同理。可以看到ConcurrentLinkedQueue
的线程协助比要前面的示例要巧妙一些,当然也晦涩一些。
对偶数据结构
一看到Dual,就想到拉格朗日……
对偶数据结构指的是方法被分为两步:预留(reservation)和完成(fullfillment)。其典型特点是既会放入一个预留对象,也会放入一个数据对象。这二者只需要节点的一个字段来区分。更简单来说,就是不管出队还是入队,只要不满足条件,都是放入一个元素。注意到,对偶结构中一定只有一种类型的元素在排队。
显然,对偶数据结构意味着阻塞。所以Java的SynchronousQueue
实现了BlockingQueue
。使用这种方式,所有的出入队都变成了排队操作,既保证了公平,又使得所有等待的线程可以在本地缓存的标记上自旋。因此,对于出入队次数比较平衡的场景,对偶数据结构是非常高效的(超过了无锁算法)。
对偶队列来自于论文Scalable synchronous queues。我们还是先看一下简单的实现:
public void enq(T e) {
Node offer = new Node(e, NodeType.ITEM);
while (true) {
Node t = tail.get();
Node h = head.get();
if (h == t || t.type == NodeType.ITEM) {
Node n = t.next.get();
if (t == tail.get()) {
if (n != null) {
tail.compareAndSet(t, n);
} else if (t.next.compareAndSet(n, offer)) {
tail.compareAndSet(t, offer );
while (offer.item.get() == e); // spin
h = head.get();
if (offer == h.next.get()) {
head.compareAndSet(h, offer);
}
return;
}
}
} else {
Node n = h.next.get();
if (t != tail.get() || h != head.get() || n == null) {
continue; // inconsistent snapshot
}
boolean success = n.item.compareAndSet(null, e);
head.compareAndSet(h, n);
if (success) {
return;
}
}
}
}
这里最外层的if-else代表了对偶的两种处理:如果整个队列排的都是数据对象,就加入数据对象offer
,等待offer
的item被设置为null,也就是被处理了;反之,如果整个队列都是预留对象,就正好进行对偶处理。
接下来看看Java中的实现SynchronousQueue
。它和论文有3点不同:
The original algorithms used bit-marked pointers, but the ones here use mode bits in nodes, leading to a number of further adaptations.
SynchronousQueues must block threads waiting to become fulfilled.
Support for cancellation via timeout and interrupts, including cleaning out cancelled nodes/threads from lists to avoid garbage retention and memory depletion.
最大的区别在于,它并没有一个真正的排队空间,而是直接阻塞的。SynchronousQueue
的一个使用场景是Executors.newCachedThreadPool
,你可以看到这个方法将线程的工作队列设置为SynchronousQueue
,这样线程池的大小就是0。
SynchronousQueue
其实应该是一个对偶数据结构,而不仅仅是队列。它的构造函数是这样的:
public SynchronousQueue() {
this(false);
}
public SynchronousQueue(boolean fair) {
transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}
另外,由于这里对偶栈和对偶队列实现非常相似,并且poll
和offer
也是相似的,Java把他们提取了一个公共接口Transferer
,核心在TransferQueue
重载的transfer
方法:
E transfer(E e, boolean timed, long nanos) {
/* Basic algorithm is to loop trying to take either of
* two actions:
*
* 1. If queue apparently empty or holding same-mode nodes,
* try to add node to queue of waiters, wait to be
* fulfilled (or cancelled) and return matching item.
*
* 2. If queue apparently contains waiting items, and this
* call is of complementary mode, try to fulfill by CAS'ing
* item field of waiting node and dequeuing it, and then
* returning matching item.
*/
QNode s = null; // constructed/reused as needed
boolean isData = (e != null);
for (;;) {
QNode t = tail;
QNode h = head;
if (t == null || h == null) // saw uninitialized value
continue; // spin
if (h == t || t.isData == isData) { // empty or same-mode
QNode tn = t.next;
if (t != tail) // inconsistent read
continue;
if (tn != null) { // lagging tail
advanceTail(t, tn);
continue;
}
if (timed && nanos <= 0) // can't wait
return null;
if (s == null)
s = new QNode(e, isData);
if (!t.casNext(null, s)) // failed to link in
continue;
advanceTail(t, s); // swing tail and wait
Object x = awaitFulfill(s, e, timed, nanos);
if (x == s) { // wait was cancelled
clean(t, s);
return null;
}
if (!s.isOffList()) { // not already unlinked
advanceHead(t, s); // unlink if head
if (x != null) // and forget fields
s.item = s;
s.waiter = null;
}
return (x != null) ? (E)x : e;
} else { // complementary-mode
QNode m = h.next; // node to fulfill
if (t != tail || m == null || h != head)
continue; // inconsistent read
Object x = m.item;
if (isData == (x != null) || // m already fulfilled
x == m || // m cancelled
!m.casItem(x, e)) { // lost CAS
advanceHead(h, m); // dequeue and retry
continue;
}
advanceHead(h, m); // successfully fulfilled
LockSupport.unpark(m.waiter);
return (x != null) ? (E)x : e;
}
}
}
整体和前面的思路是一致的,除了增加了超时和打断的机制。另外,它使用的是不可重入锁LockSupport.park
来进行等待,而不是本地自旋。整个逻辑有时间再补一下,主要是它的线程协助用的更加密集。