ConcurrentLinkedQueue原理
ConcurrentLinkedQueue是Queue的一个线程安全实现。
它是一个基于链接节点的无界线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序。队列的头部 是队列中时间最长的元素。
队列的尾部 是队列中时间最短的元素。新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。
当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。
我来分析设计一个线程安全的队列哪几种方法。
第一种:使用synchronized同步队列,就像Vector或者Collections.synchronizedList/Collection那样。
显然这不是一个好的并发队列,这会导致吞吐量急剧下降。
第二种:使用Lock。一种好的实现方式是使用ReentrantReadWriteLock来代替ReentrantLock提高读取的吞吐量。
但是显然 ReentrantReadWriteLock的实现更为复杂,而且更容易导致出现问题,
另外也不是一种通用的实现方式,因为 ReentrantReadWriteLock适合哪种读取量远远大于写入量的场合。
当然了ReentrantLock是一种很好的实现,结合 Condition能够很方便的实现阻塞功能,
这在后面介绍BlockingQueue的时候会具体分析。
第三种:使用CAS操作。尽管Lock的实现也用到了CAS操作,但是毕竟是间接操作,而且会导致线程挂起。
一个好的并发队列就是采用某种非阻塞算法来取得最大的吞吐量。
ConcurrentLinkedQueue采用的就是第三种策略。
它采用了参考资料1(http://www.cs.rochester.edu/u/scott/papers/1996_PODC_queues.pdf) 中的算法。
要使用非阻塞算法来完成队列操作,那么就需要一种“循环尝试”的动作,就是循环操作队列,直到成功为止,失败就会再次尝试。
针对各种功能深入分析。
先介绍下ConcurrentLinkedQueue的数据结构。
ConcurrentLinkedQueue只有头结点、尾节点两个元素,而对于一个节点Node而言除了保存队列元素item外,还有一个指向下一个节点的引用next。
看起来整个数据结构还是比较简单的。但是也有几点是需要说明:
1. 所有结构(head/tail/item/next)都是volatile类型。 这是因为ConcurrentLinkedQueue是非阻塞的,
所以只有volatile才能使变量的写操作对后续读操作是可见的(这个是有 happens-before法则保证的)。同样也不会导致指令的重排序。
2. 所有结构的操作都带有原子操作,这是由AtomicReferenceFieldUpdater保证的,
这在原子操作中介绍过。它能保证需要的时候对变量的修改操作是原子的。
3. 由于队列中任何一个节点(Node)只有下一个节点的引用,所以这个队列是单向的,根据FIFO特性,也就是说出队列在头部(head),入队列在尾部(tail)。
头部保存有进入队列最长时间的元素,尾部是最近进入的元素。
4. 没有对队列长度进行计数,所以队列的长度是无限的,同时获取队列的长度的时间不是固定的,这需要遍历整个队列,并且这个计数也可能是不精确的。
5. 初始情况下队列头和队列尾都指向一个空节点,但是非null,这是为了方便操作,不需要每次去判断head/tail是否为空。但是head却不作为存取元素的节点,
tail在不等于head情况下保存一个节点元素。也就是说head.item这个应该一直是空,但是tail.item却不一定是空(如果 head!=tail,那么tail.item!=null)。
对于第5点,可以从ConcurrentLinkedQueue的初始化中看到。这种头结点也叫“伪节点”,也就是说它不是真正的节点,只是一标识,就像c中的字符数组后面的\0以后,只是用来标识结束,并不是真正字符数组的一部分。
private transient volatile Node<E> head = new Node<E>(null, null);
private transient volatile Node<E> tail = head;
有了上述5点再来解释相关API操作就容易多了。
在上一节中列出了add/offer/remove/poll/element/peek等价方法的区别,所以这里就不再重复了。
清单1 入队列操作
public boolean offer(E e) { if (e == null) throw new NullPointerException(); Node<E> n = new Node<E>(e, null); for (;;) { Node<E> t = tail; Node<E> s = t.getNext(); if (t == tail) { if (s == null) { if (t.casNext(s, n)) { casTail(t, n); return true; } } else { casTail(t, s); } } } }
清单1 描述的是入队列的过程。整个过程是这样的。
1. 获取尾节点t,以及尾节点的下一个节点s。如果尾节点没有被别人修改,也就是t==tail,进行2,否则进行1。
2. 如果s不为空,也就是说此时尾节点后面还有元素,那么就需要把尾节点往后移,进行1。否则进行3。
3. 修改尾节点的下一个节点为新节点,如果成功就修改尾节点,返回true。否则进行1。
从操作3中可以看到是先修改尾节点的下一个节点,然后才修改尾节点位置的,所以这才有操作2中为什么获取到的尾节点的下一个节点不为空的原因。
特别需要说明的是,对尾节点的tail的操作需要换成临时变量t和s,一方面是为了去掉volatile变量的可变性,另一方面是为了减少volatile的性能影响。
清单2 描述的出队列的过程,这个过程和入队列相似,有点意思。
头结点是为了标识队列起始,也为了减少空指针的比较,所以头结点总是一个item为null的非null节点。
也就是说head!=null并且 head.item==null总是成立。所以实际上获取的是head.next,
一旦将头结点head设置为head.next成功就将新head的 item设置为null。至于以前就的头结点h,h.item=null并且h.next为新的head,
但是由于没有对h的引用,所以最终会被GC回收。这就是整个出队列的过程。
清单2 出队列操作
public E poll() { for (;;) { Node<E> h = head; Node<E> t = tail; Node<E> first = h.getNext(); if (h == head) { if (h == t) { if (first == null) return null; else casTail(t, first); } else if (casHead(h, first)) { E item = first.getItem(); if (item != null) { first.setItem(null); return item; } // else skip over deleted item, continue loop, } } } }
另外对于清单3 描述的获取队列大小的过程,由于没有一个计数器来对队列大小计数,所以获取队列的大小只能通过从头到尾完整的遍历队列,显然这个代价是很大的。所以通常情况下ConcurrentLinkedQueue需要和一个AtomicInteger搭配才能获取队列大小。后面介绍的BlockingQueue正是使用了这种思想。
清单3 遍历队列大小
public int size() { int count = 0; for (Node<E> p = first(); p != null; p = p.getNext()) { if (p.getItem() != null) { // Collections.size() spec says to max out if (++count == Integer.MAX_VALUE) break; } } return count; }
ConcurrentHashMap,它是一个以Concurrent开头的并发集合类,其原理是通过增加锁和细化锁的粒度来提高并发度。
而ConcurrentLinkedQueue这个类采用了另一种提高并发度的方式:非阻塞算法(Non-blocking),第一次实现了无锁的并发。
谈到这里,先要介绍一下非阻塞算法。其实非阻塞算法并不是什么神秘高深的东西,它需要有一套硬件和指令的配合(似乎目前大多数pc都能支持),
主要解决的问题是:在许多时候,一个线程A持有其他线程B,C,D所需要的资源,但线程A遭遇网络阻塞,或数据库连接阻塞,或页面阻塞等。
这时B,C,D就必须等待 A执行结束才能继续向前推进。这种情况在队列、堆栈等数据结构中也会经常出现,典型的如将一个队列中的数据取出来需要锁整个队列,
也就是说,在对队列的操作中,各个线程实际上是串行的,中间还需要加上线程上下文切换的开销。如何在取队列中元素时进一步提高并发度(就像ConcurrentHashMap一样只锁部分)。
ConcurrentHashMap是固定的16个段,并且每个段的操作是独立的,所以每个段使用了一把锁,关于这点也是考虑到一些开销和安全的问题,
而队列中元素则是可以动态增长的,因为要涉及到队列指针的问题,不是锁单独一个元素就能够保证其原子性的。这时传说中的非阻塞算法就是比较好的选择了。
非阻塞算法
在《Concurrency in practice》中对两个概念nonblocking和lock-free进行了解释。nonblocking定义为:任何线程失败或挂起不影响其他线程的失败或挂起;
而lock-free定义为:在执行的的每一步,都有线程能够向前推进。而一个基于CAS(compareAndSet)且构造正确的算法一定是nonblocking和lock-free的。
对于java中的非阻塞算法,核心原理是采用硬件级的指令来保证CAS的原子性,不同于lock这样的悲观锁定,非阻塞算法是乐观的,
它基于某些算法步骤是不安全的,在每次进行CAS时可能成功,也可能失败,失败则再取新值重新CAS,
这样不用每次使用lock以保证得到锁的线程必须成功。
一个比较好的例子是Java 理论与实践: 非阻塞算法简介中的Nonblocking stack,这里采用的是Treiber 的非阻塞算法。
这个例子比较容易,之后有一个对ConcurrentLinkedQueue的put方法介绍的例子,这里又是采用的Michael-Scott算法。
开发非阻塞算法是一项非常有挑战的任务,对一个算法中的每一步都需要证明不会产生冲突和死锁。
当然,也遵循一些规律,首先无论是否在多线程的多步执行中必须使得数据结构总是在一致的状态。
即一个线程不能打断另一个线程的原子操作。其次,假设一个线程执行更新,另一个线程等待更新,
如果前一个线程更新失败,则后一个线程会浪费等待时间,并且在等待中没有任何向前推进。
解决的办法是细化原子操作的粒度,并且后一个线程使用快照。
@ThreadSafe public class LinkedQueue <E> { private static class Node <E> { final E item; final AtomicReference<Node<E>> next; public Node(E item, Node<E> next) { this.item = item; this.next = new AtomicReference<Node<E>>(next); } } private final Node<E> dummy = new Node<E>(null, null); private final AtomicReference<Node<E>> head = new AtomicReference<Node<E>>(dummy); private final AtomicReference<Node<E>> tail = new AtomicReference<Node<E>>(dummy); public boolean put(E item) { Node<E> newNode = new Node<E>(item, null); while (true) { Node<E> curTail = tail.get(); Node<E> tailNext = curTail.next.get(); if (curTail == tail.get()) { if (tailNext != null) { tail.compareAndSet(curTail, tailNext); } else { if (curTail.next.compareAndSet(null, newNode)) { tail.compareAndSet(curTail, newNode); return true; } } } } } }
注意1:这里的 compareAndSet是AtomicReference的方法。
全名为java.util.concurrent.atomic.AtomicReference<V>
在文档对其描述如下
compareAndSet
public final boolean compareAndSet(V expect,
V update)如果当前值 == 预期值,则以原子方式将该值设置为给定的更新值。
参数:
expect - 预期值
update - 新值
返回:
如果成功,则返回 true。返回 false 指示实际值与预期值不相等。
注意2:java.util.concurrent.atomic 包中提供了原子变量的 9 种风格( AtomicInteger; AtomicLong; AtomicReference; AtomicBoolean;
原子整型;长型;引用;及原子标记引用和戳记引用类的数组形式,其原子地更新一对值)。
原子变量类可以认为是 volatile 变量的泛化,它扩展了可变变量的概念,来支持原子条件的比较并设置更新。
注意3:关于ConcurrentLinkedQueue的API介绍可参考《ConcurrentLinkedQueue》
注意4:关于非阻塞算法简介的介绍可参考《Java非阻塞算法简介》和《流行的原子》

浙公网安备 33010602011771号