Java集合详解:ConcurrentLinkedQueue

1. 简介

  java.util.concurrent.ConcurrentLinkedQueue 一种支持并发的FIFO链式队列,用一种高效的基于 M&S 队列的无锁算法来实现,并且针对 M&S 无锁队列算法的问题进行了优化改进。
  ConcurrentLinkedQueue 使用头指针域 head 指向最早加入队列中的元素,尾指针域 tail 指向最近加入队列中的元素,支持 O(1) 时间到达尾节点,只支持弱一致性迭代,因为迭代操作遍历的是 ConcurrentLinkedQueue 在某个时间点的快照,size() 方法的时间复杂度是 O(n),因为需要遍历整个链表来统计元素数量,并且在多线程环境下,size() 的统计值是不精确的。
  ConcurrentLinkedQueue 使用的无锁算法,是一种适合垃圾收集系统的算法,因此在垃圾收集系统中,不会出现由于回收结点而产生的 ABA 问题,也就无需采用那些非垃圾收集系统用来解决 ABA 问题的技术(比如引用计数法等)。

M&S 队列的主要问题以及ConcurrentLinkedQueue的改进

  M&S 队列是一种易于扩展的高效无锁队列算法,是采用双指针法的链表队列,head 指向队首结点,tail 指向队尾结点,每次入队都需要修改 tail 使其指向队列最后一个结点,每一次出队也需要修改 head 指针,使其指向出队结点的下一个结点,结点的数据结构至少包含两个域,值域和 next 指针域,next 指针域指向队列的下一个结点。该算法的主要问题有两个:

  1. CAS 冲突过多导致的性能问题:由于需要使用 CAS 操作来维护全局的共享变量 headtail,并发的入队和出队操作都会使用 CAS 操作修改 headtail 指针,这会导致 CAS 操作的过度使用,带来过多的 CAS 冲突;
  2. Java 平台下,导致的 GC 问题:由于_M&S_ 队列的出队操作就是通过 CAS 操作修改 head 指针,使其向前推进,而 head 指针经过的结点就是已出队的结点,这些已出队的结点会通过 next 指针域链接在一起,并且每一个结点都有被无限期使用的可能性,一旦发生某一个已出队节点被无限期使用的情况,这将会导致两个问题:A. 会导致该结点之后的所有出队结点都是GC可达的,从而无法被回收,导致内存泄漏,并且随着不断入队和出队操作,出队结点链会越来越长,最终发生内存溢出。B. 无法被回收的出队节点都会进入老年代,而新加入的节点会分配在新生代,这些节点通过 next 指针域相连接,会出现旧结点和新结点之间的跨代链接问题,目前的 GC 无法处理这种跨代链接问题,只会不断地重复执行 MajorGC

  对于这两个问题,ConcurrentLinkedQueue 使用了下面的技巧进行改进和优化:

  1. 对于 CAS 冲突过多的问题, ConcurrentLinkedQueue 允许头指针 head 和尾指针 tail 滞后,ConcurrentLinkedQueue 并不会在每一次出队或入队操作都更新 headtail 指针,而是允许 headtail 指针滞后,当这种滞后超过停滞阈值(slack threshold,这个值为2)时才更新 headtail 指针;这种停滞法是一个非常显著的优化,因为这种方法大大减少对 headtail 进行 CAS 操作的数量,大大降低了 CAS 冲突。
  2. 对于 GC 的问题,在每次更新 head 指针时,ConcurrentLinkedQueue 都会将因为 head 指针前进而变成 head 不可达的结点的 next 指针域指向自身,通过这种方式打断了已出队结点的引用链,让已出队结点能够被 GC 回收。

ConcurrentLinkedQueueM&S 队列算法为基础进行了改进,具有以下不变式和可变式。

相关不变式
  1. 队列中仅仅只有最后一个结点的 next 指针域为 null,通过 tail 指针可以在 O(1) 时间找到队尾结点,加入 tail 指针的目的只是为了优化到达队尾结点的时间,因为从队首结点到达队尾结点的时间复杂度是 O(n)
  2. 队列中的存活元素结点的 item 域不能为 null,并且都可以从头指针 head 开始遍历到达,通过 CAS 操作将结点的 item 域改为 null,会自动将该结点从队列中移除;即使在并发修改 head 指针使其向队尾方向推进时,队列中的结点也都应该是 head 可达的;从 head 指针开始遍历,到达的第一个 item 不为 null 的结点就是队列的第一个结点;
  3. 从头指针 head 开始,通过 succ() 方法可以访问到所有存活的元素结点;
  4. 头指针 head 总是不为 null
  5. 从尾指针 tail 开始,通过 succ() 方法总是可以到达队列中的最后一个结点;
  6. 尾指针 tail 总是不为null
相关可变式
  1. 由于 head 的滞后特性,因此 head 指向的结点可能是队首结点也可能不是队首结点,不能用 head 直接获取队首结点,需要通过不变式2和3,从 head 开始找到第一个 item 不为 null 结点,才是队首结点。
  2. 由于 tail 的滞后特性,因此 tail 指向的结点可能是队尾结点也可能不是队尾结点,不能用 tail 直接获取队尾结点,需要通过不变式5,从 tail 开始遍历到最后一个结点。
  3. 由于 tail 也具有滞后性,所以 tail 指针可能会出现在 head 指针后面,因此 tail 不是 head 可达的,这个不变式说明了,tail 指针并不是一直都比 head 指针离最后一个结点近,也有可能出现这种情况,headtail 更接近队列的最后一个结点。
  4. tail 指向结点的 item 域有可能是 nullnext 域有可能指向其自身。

不变式和可变式:不变式,就是在任意时刻,都不会发生变化的状态,比如不变式1:只有最后一个结点的 next 指针域为 null,也就说在整个程序运行期间,队列中自始至终都会有一个结点的 next 域为 null,并且该结点必定为队列中的最后一个结点,我们假设最后一个结点叫做 Nx,线程 T1 持有了 Nx 结点的引用,根据不变式1,我们知道 Nxnext 域的值应该等于 null,但在某一个时刻,线程 T1 突然发现 Nxnext 域不等于 null 了,根据不变式1,那么只有一种可能,就是 Nx 结点已经不再是最后一个结点了,已经有其他线程加入了新的结点,T1 线程从而发现了数据冲突。可变式,就是会发生变化的状态,比如可变式2,tail 指向的结点可能是队列中的最后一个结点,也可能不是,如果我们在执行过程中,某一个时刻发现 tail 指向的不是最后一个结点,过了一会,发现 tail 又指向了最后一个结点,这说明 tail 被并发修改了,所以通过可变式我们可以检测到冲突。在无锁同步算法中,不变式和可变式常常被用来做为一致性检查的依据,并以此发现竞争冲突。

2. ConcurrentLinkedQueue实现

  我们分析一下 ConcurrentLinkedQueue 的无锁队列算法是如何实现的,对于队列数据结构来说,最重要的当然是入队 offer 和出队 poll 操作,我们重点分析这两个方法的实现,首先我们需要先看看链表结点的数据结构。

链表结点结构

  源代码 C1 是结点的数据结构,有两个 volatileitem 域和 next 指针域,分别用来存储队列元素值和指向队列的下一个结点,Node 类提供了 CAS 操作修改 item 域和 next 域的方法(9行和17行)。同时还提供了 lazySetNext 方法,lazySetNext 方法用于修改结点的 next 域的值。因为 lazySetNext 这个方法的用途比较关键,所以我们先来分析一下这个方法,lazySetNext 使用了 sun.misc.UnsafeputOrderedObject 方法来设置 next 域的值,putOrderedObject 方法有什么作用呢?我们知道 volatile 修饰的共享变量会提供内存可见性,但在某些场景下,我们不需要保持内存可见性,有可能是单纯为了优化性能(因为内存可见性是一种同步机制,保持内存可见性需要消耗额外的性能),也有可能是业务逻辑需要,希望其他线程稍后再看到值变化,这时候就希望对 volatile 类型的变量执行普通变量的写入(也就说只写入CPU缓存,而不立刻同步到主存中,这样,其他线程就无法马上看到变量的修改),putOrderedObject 方法就是用来对对象中的 volatile 类型的域执行非内存可见写入的方法(putOrderedObject 方法就是将新值写入目标变量,但不要求同步到主存)。所以,如果某个线程使用 lazySetNext 方法修改了某个结点的 next 域,那么对于其他正在并发访问该结点的线程来说,并不会看到这种变化,只能看到 next 域的旧值。

   关于 volatilevolatileJava 中的一种轻量级同步机制,用来保证共享变量的内存可见性,被声明为 volatile 类型的变量,Java 线程内存模型确保所有线程看到的这个变量的值是一致的,一旦它的值被某个线程修改了,其他线程就能马上读到这个修改的值。具体是如何实现的呢?我们知道,现代的处理器一般都有多级缓存,CPU并不直接与内存进行通信,而是先将系统内存的数据读到内部缓存中,所以当多个线程在共享同一个变量时,该变量就会在CPU缓存中出现多个副本,每一个副本都是该变量在某一个时刻的快照,对于非 volatile 类型的变量,CPU在写入时,会先写入缓存,当变量从缓存中弹出时,再回填到主存中,而对于 volatile 类型的变量,JVM 在修改变量时,会发送一条 Lock 前缀的指令给CPU,CPU就会将这个变量在缓存中的值写回到主存中,一旦主存中的共享变量值发生了改变,其他处理器因为实现了缓存一致性协议,就会发现自己缓存行对应的内存地址被修改了,就会将该变量所在的缓存行设置为无效,当这些处理器需要对这个共享变量进行访问操作时,就会重新从系统内存中把数据读到处理器缓存中。
   在本文只简单介绍下 volatile,想要深入了解的朋友可以网上搜索相关资料。

C1:ConcurrentLinkedQueue链表结点
 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 ->    }
入队方法的实现

  源代码 C2ConcurrentLinkedQueue 的入队方法,主要采用了CAS + 双重一致性检查 + 循环重试方式实现,接下来,我们根据源码进行一下具体的分析:

  • L2:这一行代码是对入队的元素进行判空,ConcurrentLinkedQueue 不允许插入 null 元素,因为 null 元素会破坏不变式2,按照不变式2,ConcurrentLinkedQueue 会将所有 itemnull 的元素结点识别为已被移除的结点;因此 null 元素会导致歧义性。
  • L5:第5行代码是一个无限循环,每次循环中都会尝试将新的元素结点插入到队尾,插入成功则跳出循环,不成功则开始进行下一次尝试;同时将 tail 指针指向的结点保存到本地变量 tp,用于后续的一致性检查;由于 tail 指针的滞后特性,因此 tail 指向的可能不是真正的队尾结点,所以 tp 有可能是队尾结点,也有可能不是;tp 的作用是不同的,t 始终保存的是 tail 指针指向的结点在本次循环开始时的快照,而 p 的初始值是 t,会在循环中不断调整使 p 指向真正的队尾结点,p 结点才是新队列需要加入的位置;
  • L6:第6行代码将 p 的后继结点保存在本地变量 q 中,qp 的后继,用于进行后续的一致性检查和插入操作;
  • L7:第7行对后继结点 q 进行一致性检查,根据不变式1,只有当 q==null 成立,才能判断 p 结点在此刻是真正的队尾结点;如果 p 是队尾结点,则进入9行代码开始进行结点插入操作;
  • L9p 是队尾结点,使用 CAS 操作将 p 结点的的 next 域从 null 改为新插入的结点,如果发生冲突并失败,说明此刻有其他线程也在进行并发的入队操作,进入下一次循环,重新尝试插入;
  • L13 ~ 14:说明新的结点已经成功加入队列,需要通过 CAS 操作推进 tail 指针,使 tail 指针指向新加入的结点,但并不是每次加入新结点都要推进 tail 指针,只有当 tail 滞后2个或2个以上结点的时候,才修改 tail 指针;
  • L19 ~ 24:在第19行,如果p == q 成立,那么说明 p 等于其后继 q 了,说明 p 已经出队了,并且是 head 不可达的,所以 p 结点的 next 指针域指向自身了,出现这种情况是因为发生了可变式3,tail 落后于 head 了,此时需要调整 pp 向最后一个结点推进;在24行,通过p = (t != (t = tail)) ? t : head;来调整 p,如果此时tail 刚好被并发更新了,那么就将 p 优先调整为最新的 tail 结点,否则只能将 p 先调整为 head 结点了,因为如果 tail 没有被并发更新,那么 headtail 更接近队列中的最后一个结点;p 经过调整后再进入下一次循环重试;
  • L25 ~ 27:如果后继 q 不为空,说明 tp 都不是队尾结点,需要更新 p 结点,通过next 指针域推进到真正的队尾结点;在27行的 p = (p != t && t != (t = tail)) ? t : q; 代码中,对 tail 指针也进行了一致性检查,如果发现 tail 指针被并发修改了(说明tail 指针向前推进了,更加接近队尾结点了),就会更新 t,同时通过 tail 指针加速 p 向前推进。
C2:ConcurrentLinkedQueue的入队方法
 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 + 双重一致性检查 + 双重循环重试方式实现,接下来,我们根据源码进行一下具体的分析:

  • L3:第3行是无限循环 restartFromHead,其作用主要就是为了更新本地的 h 指针,并且重置内侧循环,因为 h 指针指向是 head 指针在循环开始的时候指向的结点,一旦发生其他线程并发调用了 updateHead,会导致 h 指针指向向结点的 next 域指向自身,因此发生无法通过 h 结点继续向前推进的现象,此时就需要重置 h 指针;
  • L4:第4行开始内侧的循环,循环尝试进行出队操作,同时,使用本地的 h 指针保存当前的 head 指针,p 指针是用来指向队列中的第一个结点的,初始值是 head 指针,p 指针需要在循环过程中不断调整,使其向队列中的第一个结点推进。
  • L5:第5行将 item 保存在本地,用于一致性检查;

在内循环中,利用队列的一些不变式可可变式进行一致性检查,这些一致性检查分成了4种情况:

  • L7 ~ 11:对应第一种情况,p 结点是队列中的第一个存活结点;首先第7行对 item 进行一致性检查,根据不变式2,如果item != null 成立,说 p 结点是队列中的第一个结点,开始通过 CASp 结点的 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,产生歧义;
  • L14 ~ 15:对应第二种情况,队列是否为空;(q = p.next) == null 是进行队列判空,进入当前分支说明 item 已经为 nullp 结点指向的是已出队的结点,如果 (q = p.next) == null 再成立,根据不变式1,说明 p 是队列的最后一个结点,队列中已经没有存活结点了(没有 item != null 的结点),说明队列已为空,此时,将 p 作为哨兵结点,将 head 指针指向该哨兵结点;
  • L18 ~ 19:对应第三种情况,p 结点等于其后继 q,说明 p 是已出队结点,并且 next 域已经指向了其自身,同时 head 指针被其他线程并发的调用 updateHead 方法修改过了,此时需要重置 h 指针进行重试;
  • L20 ~ 21:对应第四种情况,p 不是队列中的第一个结点,p 沿着其后继查找队列首结点。
C3:ConcurrentLinkedQueue的出队方法
 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 分析,我们看到实现无锁队列的并发入队和出队操作的关键在于三点:

  1. 在入队和出队操作之前,都需要先找到目标位置(队首元素和队末元素),对目标位置的查找需要通过 head 指针或 tail 指针进行遍历操作,在遍历过程中,需要根据各种不同的情况,根据具体的不变式和可变式,进行一致性检查,才能成功到达目标结点;
  2. 找到目标结点之后,需要至少通过一次一致性检查以及一次 CAS 操作来修改目标变量,而 CAS 操作实质上就是原子化的一致性检查 + 写入的复合操作。
  3. 在尝试的过程中,一旦发生一致性检查不通过,则需要进入下一次循环进行重新尝试,尝试之前需要先将共享变量同步到本地。
ConcurrentLinkedQueue中的其他方法

  ConcurrentLinkedQueue 除了提供基本的入队和出队操作之外,还提供了随机删除方法remove,队列元素数量统计方法 size 以及迭代器 iterator,本文不对这些方法的实现进行分析,只是不建议使用 ConcurrentLinkedQueue 的这些功能,原因有两点:1. 这些功能的效率都非常低下,因为需要遍历整个队列,遍历的过程中为了保证数据一致性,还需要进行各种一致性检查,尤其当队列中的元素数量比较多时,而且 size() 方法的返回值还不准确,使用 size() == 0 来判空,就会出现数据不一致问题。2. ConcurrentLinkedQueue是一种 FIFO 队列,其基本的主要操作入队和出队操作在多线程环境下已经足够高效和安全了,而且 ConcurrentLinkedQueue 比较适合用作生产者消费者模式的高速数据管道,应当主要关注数据流动的速度,而随机的删除方法和迭代遍历等操作基本上是没有必要的。

3. 总结

  本文介绍了 ConcurrentLinkedQueue 的实现,ConcurrentLinkedQueue 采用了改进的无锁队列算法,是目前并发性能最好的 FIFO 队列实现(仅针对基本的入队和出队操作)之一,但这也导致 ConcurrentLinkedQueue 其他的一些辅助方法性能不佳,但这些方法对于 FIFO 队列来说,并非必须的操作,应当尽量避免使用。由于 ConcurrentLinkedQueue 是一种无界的队列,因此,如果使用不当,会导致数据堆积在队列中,有可能导致内存溢出问题,因此在使用时,需要谨慎。

你可能感兴趣的:(Java集合,java,数据结构)