java.util.concurrent.ConcurrentLinkedQueue
一种支持并发的FIFO链式队列,用一种高效的基于 M&S 队列的无锁算法来实现,并且针对 M&S 无锁队列算法的问题进行了优化改进。
ConcurrentLinkedQueue
使用头指针域 head
指向最早加入队列中的元素,尾指针域 tail
指向最近加入队列中的元素,支持 O(1) 时间到达尾节点,只支持弱一致性迭代,因为迭代操作遍历的是 ConcurrentLinkedQueue
在某个时间点的快照,size()
方法的时间复杂度是 O(n),因为需要遍历整个链表来统计元素数量,并且在多线程环境下,size()
的统计值是不精确的。
ConcurrentLinkedQueue
使用的无锁算法,是一种适合垃圾收集系统的算法,因此在垃圾收集系统中,不会出现由于回收结点而产生的 ABA 问题,也就无需采用那些非垃圾收集系统用来解决 ABA 问题的技术(比如引用计数法等)。
M&S 队列是一种易于扩展的高效无锁队列算法,是采用双指针法的链表队列,head
指向队首结点,tail
指向队尾结点,每次入队都需要修改 tail
使其指向队列最后一个结点,每一次出队也需要修改 head
指针,使其指向出队结点的下一个结点,结点的数据结构至少包含两个域,值域和 next
指针域,next
指针域指向队列的下一个结点。该算法的主要问题有两个:
head
和 tail
,并发的入队和出队操作都会使用 CAS 操作修改 head
和 tail
指针,这会导致 CAS 操作的过度使用,带来过多的 CAS 冲突;head
指针,使其向前推进,而 head
指针经过的结点就是已出队的结点,这些已出队的结点会通过 next
指针域链接在一起,并且每一个结点都有被无限期使用的可能性,一旦发生某一个已出队节点被无限期使用的情况,这将会导致两个问题:A. 会导致该结点之后的所有出队结点都是GC可达的,从而无法被回收,导致内存泄漏,并且随着不断入队和出队操作,出队结点链会越来越长,最终发生内存溢出。B. 无法被回收的出队节点都会进入老年代,而新加入的节点会分配在新生代,这些节点通过 next
指针域相连接,会出现旧结点和新结点之间的跨代链接问题,目前的 GC 无法处理这种跨代链接问题,只会不断地重复执行 MajorGC。 对于这两个问题,ConcurrentLinkedQueue
使用了下面的技巧进行改进和优化:
ConcurrentLinkedQueue
允许头指针 head 和尾指针 tail 滞后,ConcurrentLinkedQueue
并不会在每一次出队或入队操作都更新 head
和 tail
指针,而是允许 head
和 tail
指针滞后,当这种滞后超过停滞阈值(slack threshold,这个值为2)时才更新 head
和 tail
指针;这种停滞法是一个非常显著的优化,因为这种方法大大减少对 head
和 tail
进行 CAS 操作的数量,大大降低了 CAS 冲突。head
指针时,ConcurrentLinkedQueue
都会将因为 head
指针前进而变成 head
不可达的结点的 next
指针域指向自身,通过这种方式打断了已出队结点的引用链,让已出队结点能够被 GC 回收。ConcurrentLinkedQueue
以 M&S 队列算法为基础进行了改进,具有以下不变式和可变式。
next
指针域为 null
,通过 tail
指针可以在 O(1) 时间找到队尾结点,加入 tail
指针的目的只是为了优化到达队尾结点的时间,因为从队首结点到达队尾结点的时间复杂度是 O(n);item
域不能为 null
,并且都可以从头指针 head
开始遍历到达,通过 CAS 操作将结点的 item
域改为 null
,会自动将该结点从队列中移除;即使在并发修改 head
指针使其向队尾方向推进时,队列中的结点也都应该是 head
可达的;从 head
指针开始遍历,到达的第一个 item
不为 null
的结点就是队列的第一个结点;head
开始,通过 succ()
方法可以访问到所有存活的元素结点;head
总是不为 null
;tail
开始,通过 succ()
方法总是可以到达队列中的最后一个结点;tail
总是不为null
;head
的滞后特性,因此 head
指向的结点可能是队首结点也可能不是队首结点,不能用 head
直接获取队首结点,需要通过不变式2和3,从 head
开始找到第一个 item
不为 null
结点,才是队首结点。tail
的滞后特性,因此 tail
指向的结点可能是队尾结点也可能不是队尾结点,不能用 tail
直接获取队尾结点,需要通过不变式5,从 tail
开始遍历到最后一个结点。tail
也具有滞后性,所以 tail
指针可能会出现在 head
指针后面,因此 tail
不是 head
可达的,这个不变式说明了,tail
指针并不是一直都比 head
指针离最后一个结点近,也有可能出现这种情况,head
比 tail
更接近队列的最后一个结点。tail
指向结点的 item
域有可能是 null
,next
域有可能指向其自身。不变式和可变式:不变式,就是在任意时刻,都不会发生变化的状态,比如不变式1:只有最后一个结点的
next
指针域为null
,也就说在整个程序运行期间,队列中自始至终都会有一个结点的next
域为null
,并且该结点必定为队列中的最后一个结点,我们假设最后一个结点叫做 Nx,线程 T1 持有了 Nx 结点的引用,根据不变式1,我们知道 Nx 的next
域的值应该等于null
,但在某一个时刻,线程 T1 突然发现 Nx 的next
域不等于null
了,根据不变式1,那么只有一种可能,就是 Nx 结点已经不再是最后一个结点了,已经有其他线程加入了新的结点,T1 线程从而发现了数据冲突。可变式,就是会发生变化的状态,比如可变式2,tail
指向的结点可能是队列中的最后一个结点,也可能不是,如果我们在执行过程中,某一个时刻发现tail
指向的不是最后一个结点,过了一会,发现tail
又指向了最后一个结点,这说明tail
被并发修改了,所以通过可变式我们可以检测到冲突。在无锁同步算法中,不变式和可变式常常被用来做为一致性检查的依据,并以此发现竞争冲突。
我们分析一下 ConcurrentLinkedQueue
的无锁队列算法是如何实现的,对于队列数据结构来说,最重要的当然是入队 offer
和出队 poll
操作,我们重点分析这两个方法的实现,首先我们需要先看看链表结点的数据结构。
源代码 C1 是结点的数据结构,有两个 volatile
域 item
域和 next
指针域,分别用来存储队列元素值和指向队列的下一个结点,Node
类提供了 CAS 操作修改 item
域和 next
域的方法(9行和17行)。同时还提供了 lazySetNext
方法,lazySetNext
方法用于修改结点的 next
域的值。因为 lazySetNext
这个方法的用途比较关键,所以我们先来分析一下这个方法,lazySetNext
使用了 sun.misc.Unsafe
的 putOrderedObject
方法来设置 next
域的值,putOrderedObject
方法有什么作用呢?我们知道 volatile
修饰的共享变量会提供内存可见性,但在某些场景下,我们不需要保持内存可见性,有可能是单纯为了优化性能(因为内存可见性是一种同步机制,保持内存可见性需要消耗额外的性能),也有可能是业务逻辑需要,希望其他线程稍后再看到值变化,这时候就希望对 volatile
类型的变量执行普通变量的写入(也就说只写入CPU缓存,而不立刻同步到主存中,这样,其他线程就无法马上看到变量的修改),putOrderedObject
方法就是用来对对象中的 volatile
类型的域执行非内存可见写入的方法(putOrderedObject
方法就是将新值写入目标变量,但不要求同步到主存)。所以,如果某个线程使用 lazySetNext
方法修改了某个结点的 next
域,那么对于其他正在并发访问该结点的线程来说,并不会看到这种变化,只能看到 next
域的旧值。
关于
volatile
:volatile
是 Java 中的一种轻量级同步机制,用来保证共享变量的内存可见性,被声明为volatile
类型的变量,Java 线程内存模型确保所有线程看到的这个变量的值是一致的,一旦它的值被某个线程修改了,其他线程就能马上读到这个修改的值。具体是如何实现的呢?我们知道,现代的处理器一般都有多级缓存,CPU并不直接与内存进行通信,而是先将系统内存的数据读到内部缓存中,所以当多个线程在共享同一个变量时,该变量就会在CPU缓存中出现多个副本,每一个副本都是该变量在某一个时刻的快照,对于非volatile
类型的变量,CPU在写入时,会先写入缓存,当变量从缓存中弹出时,再回填到主存中,而对于volatile
类型的变量,JVM 在修改变量时,会发送一条 Lock 前缀的指令给CPU,CPU就会将这个变量在缓存中的值写回到主存中,一旦主存中的共享变量值发生了改变,其他处理器因为实现了缓存一致性协议,就会发现自己缓存行对应的内存地址被修改了,就会将该变量所在的缓存行设置为无效,当这些处理器需要对这个共享变量进行访问操作时,就会重新从系统内存中把数据读到处理器缓存中。
在本文只简单介绍下volatile
,想要深入了解的朋友可以网上搜索相关资料。
1 -> private static class Node<E> {
2 -> volatile E item;
3 -> volatile Node<E> next;
4 ->
5 -> Node(E item) {
6 -> UNSAFE.putObject(this, itemOffset, item);
7 -> }
8 ->
9 -> boolean casItem(E cmp, E val) {
10 -> return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
11 -> }
12 ->
13 -> void lazySetNext(Node<E> val) {
14 -> UNSAFE.putOrderedObject(this, nextOffset, val);
15 -> }
16 ->
17 -> boolean casNext(Node<E> cmp, Node<E> val) {
18 -> return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
19 -> }
20 -> ...
21 -> }
源代码 C2 是 ConcurrentLinkedQueue
的入队方法,主要采用了CAS + 双重一致性检查 + 循环重试方式实现,接下来,我们根据源码进行一下具体的分析:
ConcurrentLinkedQueue
不允许插入 null
元素,因为 null
元素会破坏不变式2,按照不变式2,ConcurrentLinkedQueue
会将所有 item
为 null
的元素结点识别为已被移除的结点;因此 null
元素会导致歧义性。tail
指针指向的结点保存到本地变量 t
和 p
,用于后续的一致性检查;由于 tail
指针的滞后特性,因此 tail
指向的可能不是真正的队尾结点,所以 t
和 p
有可能是队尾结点,也有可能不是;t
和 p
的作用是不同的,t
始终保存的是 tail
指针指向的结点在本次循环开始时的快照,而 p
的初始值是 t
,会在循环中不断调整使 p
指向真正的队尾结点,p
结点才是新队列需要加入的位置;p
的后继结点保存在本地变量 q
中,q
是 p
的后继,用于进行后续的一致性检查和插入操作;q
进行一致性检查,根据不变式1,只有当 q==null
成立,才能判断 p
结点在此刻是真正的队尾结点;如果 p
是队尾结点,则进入9行代码开始进行结点插入操作;p
是队尾结点,使用 CAS 操作将 p
结点的的 next
域从 null
改为新插入的结点,如果发生冲突并失败,说明此刻有其他线程也在进行并发的入队操作,进入下一次循环,重新尝试插入;tail
指针,使 tail
指针指向新加入的结点,但并不是每次加入新结点都要推进 tail
指针,只有当 tail
滞后2个或2个以上结点的时候,才修改 tail
指针;p == q
成立,那么说明 p
等于其后继 q
了,说明 p
已经出队了,并且是 head
不可达的,所以 p
结点的 next
指针域指向自身了,出现这种情况是因为发生了可变式3,tail
落后于 head
了,此时需要调整 p
让 p
向最后一个结点推进;在24行,通过p = (t != (t = tail)) ? t : head;
来调整 p
,如果此时tail
刚好被并发更新了,那么就将 p
优先调整为最新的 tail
结点,否则只能将 p
先调整为 head
结点了,因为如果 tail
没有被并发更新,那么 head
比 tail
更接近队列中的最后一个结点;p
经过调整后再进入下一次循环重试;q
不为空,说明 t
和 p
都不是队尾结点,需要更新 p
结点,通过next
指针域推进到真正的队尾结点;在27行的 p = (p != t && t != (t = tail)) ? t : q;
代码中,对 tail
指针也进行了一致性检查,如果发现 tail
指针被并发修改了(说明tail
指针向前推进了,更加接近队尾结点了),就会更新 t
,同时通过 tail
指针加速 p
向前推进。 1 -> public boolean offer(E e) {
2 -> checkNotNull(e);
3 -> final Node<E> newNode = new Node<E>(e);
4 ->
5 -> for (Node<E> t = tail, p = t;;) {
6 -> Node<E> q = p.next;
7 -> if (q == null) {
8 -> // p is last node
9 -> if (p.casNext(null, newNode)) {
10 -> // Successful CAS is the linearization point
11 -> // for e to become an element of this queue,
12 -> // and for newNode to become "live".
13 -> if (p != t) // hop two nodes at a time
14 -> casTail(t, newNode); // Failure is OK.
15 -> return true;
16 -> }
17 -> // Lost CAS race to another thread; re-read next
18 -> }
19 -> else if (p == q)
20 -> // We have fallen off list. If tail is unchanged, it
21 -> // will also be off-list, in which case we need to
22 -> // jump to head, from which all live nodes are always
23 -> // reachable. Else the new tail is a better bet.
24 -> p = (t != (t = tail)) ? t : head;
25 -> else
26 -> // Check for tail updates after two hops.
27 -> p = (p != t && t != (t = tail)) ? t : q;
28 -> }
29 -> }
源代码 C3 是 ConcurrentLinkedQueue 的出队方法,主要采用了CAS + 双重一致性检查 + 双重循环重试方式实现,接下来,我们根据源码进行一下具体的分析:
restartFromHead
,其作用主要就是为了更新本地的 h
指针,并且重置内侧循环,因为 h
指针指向是 head
指针在循环开始的时候指向的结点,一旦发生其他线程并发调用了 updateHead
,会导致 h
指针指向向结点的 next
域指向自身,因此发生无法通过 h
结点继续向前推进的现象,此时就需要重置 h
指针;h
指针保存当前的 head
指针,p
指针是用来指向队列中的第一个结点的,初始值是 head
指针,p
指针需要在循环过程中不断调整,使其向队列中的第一个结点推进。item
保存在本地,用于一致性检查;在内循环中,利用队列的一些不变式可可变式进行一致性检查,这些一致性检查分成了4种情况:
p
结点是队列中的第一个存活结点;首先第7行对 item
进行一致性检查,根据不变式2,如果item != null
成立,说 p
结点是队列中的第一个结点,开始通过 CAS 将 p
结点的 item
更新为 null
(这意味着 p
结点被从队列中移除),如果_CAS_ 失败,说 p
结点已经被其他线程成功的并发消费移除了,p
结点已经不再是队列中的第一个结点了,进入下个分支进行进一步检查;item
域修改成功后,在10 ~ 11行使用 updateHead
更新 head
指针,因为滞后特性,并不是每一次出队都要修改 head
,而是至少出队两个结点之后,才更新 head
指针;updateHead
方法(26行),将 head
指针用 CAS 操作修改为刚刚出队的结点的后继,同时,还需要将将 head
指针之前指向结点的 next
域修改为指向该结点自身来打断已出队结点的引用链帮助 GC 回收已出队结点,为什么不将 next
域设置为 null
而是设置为其自身呢?原因是因为如果将 next
域置为 null
会破坏不变式1,产生歧义;(q = p.next) == null
是进行队列判空,进入当前分支说明 item
已经为 null
,p
结点指向的是已出队的结点,如果 (q = p.next) == null
再成立,根据不变式1,说明 p
是队列的最后一个结点,队列中已经没有存活结点了(没有 item != null
的结点),说明队列已为空,此时,将 p
作为哨兵结点,将 head
指针指向该哨兵结点;p
结点等于其后继 q
,说明 p
是已出队结点,并且 next
域已经指向了其自身,同时 head
指针被其他线程并发的调用 updateHead
方法修改过了,此时需要重置 h
指针进行重试;p
不是队列中的第一个结点,p
沿着其后继查找队列首结点。 1 -> public E poll() {
2 -> restartFromHead:
3 -> for (;;) {
4 -> for (Node<E> h = head, p = h, q;;) {
5 -> E item = p.item;
6 ->
7 -> if (item != null && p.casItem(item, null)) {
8 -> // Successful CAS is the linearization point
9 -> // for item to be removed from this queue.
10 -> if (p != h) // hop two nodes at a time
11 -> updateHead(h, ((q = p.next) != null) ? q : p);
12 -> return item;
13 -> }
14 -> else if ((q = p.next) == null) {
15 -> updateHead(h, p);
16 -> return null;
17 -> }
18 -> else if (p == q)
19 -> continue restartFromHead;
20 -> else
21 -> p = q;
22 -> }
23 -> }
24 -> }
25 ->
26 -> final void updateHead(Node<E> h, Node<E> p) {
27 -> if (h != p && casHead(h, p))
28 -> h.lazySetNext(h);
29 -> }
通过对 offer
方法和 poll
分析,我们看到实现无锁队列的并发入队和出队操作的关键在于三点:
head
指针或 tail
指针进行遍历操作,在遍历过程中,需要根据各种不同的情况,根据具体的不变式和可变式,进行一致性检查,才能成功到达目标结点; ConcurrentLinkedQueue
除了提供基本的入队和出队操作之外,还提供了随机删除方法remove
,队列元素数量统计方法 size
以及迭代器 iterator
,本文不对这些方法的实现进行分析,只是不建议使用 ConcurrentLinkedQueue
的这些功能,原因有两点:1. 这些功能的效率都非常低下,因为需要遍历整个队列,遍历的过程中为了保证数据一致性,还需要进行各种一致性检查,尤其当队列中的元素数量比较多时,而且 size()
方法的返回值还不准确,使用 size() == 0
来判空,就会出现数据不一致问题。2. ConcurrentLinkedQueue
是一种 FIFO 队列,其基本的主要操作入队和出队操作在多线程环境下已经足够高效和安全了,而且 ConcurrentLinkedQueue
比较适合用作生产者消费者模式的高速数据管道,应当主要关注数据流动的速度,而随机的删除方法和迭代遍历等操作基本上是没有必要的。
本文介绍了 ConcurrentLinkedQueue
的实现,ConcurrentLinkedQueue
采用了改进的无锁队列算法,是目前并发性能最好的 FIFO 队列实现(仅针对基本的入队和出队操作)之一,但这也导致 ConcurrentLinkedQueue
其他的一些辅助方法性能不佳,但这些方法对于 FIFO 队列来说,并非必须的操作,应当尽量避免使用。由于 ConcurrentLinkedQueue
是一种无界的队列,因此,如果使用不当,会导致数据堆积在队列中,有可能导致内存溢出问题,因此在使用时,需要谨慎。