关于后端:12张图一次性搞懂高性能并发容器ConcurrentLinkedQueue

4次阅读

共计 5459 个字符,预计需要花费 14 分钟才能阅读完成。

12 张图一次性搞懂高性能并发容器 ConcurrentLinkedQueue

前言

上篇文章聊到并发汇合 CopyOnWeiteArrayList 的实现与特点,其不足之处是不适宜写多的场景也不适宜并发量大的场景

本篇文章来聊聊并发场景下高性能的ConcurrentLinkedQueue

浏览本文大略须要 10 分钟

在浏览本文前,须要了解 CAS、volatile 等常识

如果不了解 CAS 能够查看这篇文章 15000 字、6 个代码案例、5 个原理图让你彻底搞懂 Synchronized 的第二大节

如果不了解 volatile 能够查看这篇文章 5 个案例和流程图让你从 0 到 1 搞懂 volatile 关键字

数据结构

ConcurrentLinkedQueue从名称上来看就可能晓得,它反对并发、由链表实现的队列

通过源码,咱们能够看到 ConcurrentLinkedQueue 应用字段记录首尾节点、并且节点的实现是单向链表

并且这些关键字段都被 volatile 润饰,在读场景下应用 volatile 保障可见性,不必“加锁”

还有一些其余字段,比方应用 CAS 的 Unsafe 和一些偏移量信息等,这里就不一一列举

  public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
          implements Queue<E>, java.io.Serializable {    
  
      private static class Node<E> {
          // 记录数据
          volatile E item;
          // 后继节点
          volatile Node<E> next;
      }
      // 首节点
      private transient volatile Node<E> head;
      // 尾节点
      private transient volatile Node<E> tail;
  }   

在初始化时,首尾节点会同时指向一个存储数据为空的节点

      public ConcurrentLinkedQueue() {head = tail = new Node<E>(null);
      }

设计思维

提早更新首尾节点

在查看实现原理前,咱们先来说说 ConcurrentLinkedQueue 的设计思维,否则实现原理可能会看不懂

ConcurrentLinkedQueue写场景中采纳乐观锁的思维,应用 CAS+ 失败重试来保障操作的原子性

为了防止 CAS 开销过大,ConcurrentLinkedQueue采纳提早更新首尾节点的思维,来缩小 CAS 次数

也就是说 ConcurrentLinkedQueue 中的首尾节点并不一定是最新的首尾节点

哨兵节点

ConcurrentLinkedQueue的设计中应用哨兵节点

什么是哨兵节点?

哨兵节点又称虚构节点,哨兵节点常应用在链表这种数据结构中

单向链表中如果要增加或者删除某个节点时,肯定要取得这个节点的前驱节点再去进行操作

当操作的是第一个节点时,如果在第一个节点后面加个虚构节点(哨兵节点),那么就不必非凡解决

换而言之 应用哨兵节点能够缩小代码复杂度,置信刷过链表相干算法的同学深有体会

哨兵节点还可能在只有一个节点时缩小并发抵触

这一特点可能要看完后续实现和流程图能力了解

源码实现

ConcurrentLinkedQueue次要的操作是入队、出队,咱们应用 offerpoll来对其进行剖析

offer

在剖析源码前,先来阐明一些简单变量的作用

t 记录尾节点 tail

p 用于循环遍历的节点,当 p 节点为真正尾节点时才容许增加新节点

q 用于记录 p 的后继节点

在入队时候三种状况:

  1. 当 p 的后继节点为空时(p 为真正尾节点),尝试 CAS 减少新节点,胜利后尝试更新尾节点 tail
  2. 当 p 等于 p 的后继节点时(p 的 next 指向本人,阐明构建成哨兵节点,出队 poll 时可能结构哨兵节点);此时判断尾节点是否被批改过,如果尾节点被批改过就定位到尾节点,如果未被批改过(应用 next 无奈持续遍历),只能定位到头节点
  3. 其余状况时,阐明此时的 p 并不是真正的尾节点,须要定位到真正尾节点;此时如果 p 不是原来的尾节点并且尾节点被批改过,那就定位到尾节点,否则定位到后继节点持续遍历

第二、三种状况的代码观赏性很好然而可读性不好,能够将总结的状况与源码剖析一起观看,如果还是不了解后续有流程图不便了解

      public boolean offer(E e) {
          // 查看空指针
          checkNotNull(e);
          // 构建新节点
          final Node<E> newNode = new Node<E>(e);
          // 失败重试的循环
          //t: 以后记录的尾节点
          //p: 真正的尾节点
          //q:p 的后继节点
          for (Node<E> t = tail, p = t;;) {
              Node<E> q = p.next;
              // 状况 1:p 的后继节点为空,阐明以后 p 就是真正尾节点
              if (q == null) {
                  // 尝试 CAS 批改 p 的后继节点为新节点
                  // 如果 p 的 next 是 null 则替换成新节点 newNode
                  // 失败则阐明其余线程 cas 增加节点胜利,持续循环;胜利则判断是否更新尾节点 tail
                  if (p.casNext(null, newNode)) {
                      // 如果 p 不等于 t 阐明此时的尾节点不是真正的尾节点
                      // 尝试 CAS:如果以后尾节点是 t,那么就将新节点设置成尾节点
                      if (p != t) 
                          casTail(t, newNode);  
                      return true;
                  }
                  
              }
              // 状况 2:p 等于 p 的后继节点(p 指向本人)else if (p == q)
                  //t:旧的尾节点
                  //(t = tail):新的尾节点
                  //t != (t = tail): 阐明尾节点被批改过,p 等于新的尾节点;未被批改过,p 等于头节点
                  p = (t != (t = tail)) ? t : head;
              
              // 状况 3:此时 p 不是真正尾节点,须要去定位真正尾节点
              else
                  //p!=t:p 不再是原来的尾节点
                  //t != (t = tail): 尾节点被批改过
                  // p 不再是原来的尾节点 并且 尾节点被批改过 就让 p 等于批改过的尾节点;否则让 p 等于它的后继节点 q
                  p = (p != t && t != (t = tail)) ? t : q;
          }
      }

poll

如果了解入队 offer 中的变量,那么出队 poll 也好了解,其中 p 和 q 都是相似的

h 记录头节点 head

p 用于循环遍历的节点,当 p 节点为真正头节点时才容许出队

q 用于记录 p 的后继节点

出队的状况分为四种

  1. 当 p 为真正头节点时,CAS 将数据设置为空,而后判断 head 是否为真正头节点,不是则更新头节点,而后将原来的头节点 next 指向它本人构建成哨兵节点
  2. 当 p 的后继节点为空时,阐明队列为空,尝试 CAS 将头节点批改成 p
  3. 如果 p 的后继节点是它本人,阐明其余线程 poll 出队构建成哨兵节点,跳过本次循环
  4. 其余状况则向后遍历
     public E poll() {
         // 不便退出双重循环
         restartFromHead:
         for (;;) {
             // h 记录头节点
             // p 真正头节点
             // q 为 p 的后继节点
             for (Node<E> h = head, p = h, q;;) {
                 // 获取 p 节点的数据
                 E item = p.item;
                 // 状况 1:// 如果数据不为空 阐明 p 节点为真正头节点
                 // 尝试 CAS 将数据设置为 null, 如果数据为 item 则替换为 null,失败则阐明其余线程以及出队,持续循环
                 if (item != null && p.casItem(item, null)) {
                     // 如果以后头节点不是真正头节点则更新头节点
                     if (p != h) 
                         updateHead(h, ((q = p.next) != null) ? q : p);
                     return item;
                 }
                 // 状况 2:// p 的后继节点为空,阐明以后为空队列,尝试 CAS 将头节点批改为 p(p 此时可能是哨兵节点)else if ((q = p.next) == null) {updateHead(h, p);
                     return null;
                 }
                 // 状况 3:
                 // 如果 p 的后继节点指向 p 本人,阐明其余线程 poll 出队时构建成哨兵节点,跳过本次循环
                 else if (p == q)
                     continue restartFromHead;
                 // 状况 4:
                 // p 定位为后继节点须要遍历
                 else
                     p = q;
             }
         }
     }

在更新头节点办法中,会进行判断

如果以后头节点不是真正头节点,则尝试 CAS 将头节点设置成 p 真正头节点

CAS 胜利后将原来的头节点的 next 指向它本人,构建成哨兵节点

final void updateHead(Node<E> h, Node<E> p) {if (h != p && casHead(h, p))
        h.lazySetNext(h);
}

流程图实现

想要跟着 debug 的同学,须要把 idea 中的这两个设置敞开,否则 debug 会有误

为了更容易的了解,咱们来看一段简略的代码,并附带其实现流程图

    public void testConcurrentLinkedQueue()  {ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

        queue.offer("h1");
        queue.offer("h2");
        queue.offer("h3");

        String p1 = queue.poll();
        String p2 = queue.poll();
        String p3 = queue.poll();
        String p4 = queue.poll();

        queue.offer("h4");
        System.out.println(queue);
    }

【申明:如果图中节点 item 没写数据,阐明存储的数据为空;如果节点 next 没画指向关系,也阐明为空】

执行结构时,会初始化首尾节点指向同一个数据为空的节点

在第一次入队时,一进入循环就满足第一种状况,此时的 p 就是真正尾节点,间接 CAS 设置 next 为新节点,但因为 p 与 tail 雷同,就不会更新尾节点 tail

因而首尾节点还是哨兵节点,而哨兵节点的 next 指向新入队的节点

在第二次入队时,因为此时的 p(tail)不是真正尾节点,会来到第三种状况,因为 tail 没被批改过,p 会被改成它的后继节点,持续向后遍历

在第二次循环时,p 就是真正尾节点,于是尝试 CAS 增加新节点,因为此时 p 和尾节点 tail 不同,于是会更新 tail

在第三次入队时,状况与第一次入队雷同

此时队列中存在哨兵节点和 h1、h2、h3 四个节点

在第一次出队时,因为 head 指向的哨兵节点数据域为空,会来到第四种状况,行将 p 改为它的后继节点,持续向后遍历

在第二次循环时,p 为 h1 节点,因为数据不为空,CAS 将数据设置为空

p.casItem(item, null) 将原 h1 节点数据设置为空

此时 head 并不是真正头节点,于是会更新 head

而后将原来的 head 指向它本人,构建成哨兵节点,不便两头两个不再应用的节点 GC

在第二次出队时,满足第一种状况,间接 CAS 将 h2 节点数据设置为空,不会更新头节点

在第三次出队时,也相似与第一次出队,满足第四种状况

在第二次循环时,去 CAS 将数据设置为空,更新头节点,将原来的头节点设置成哨兵节点

在第四次出队时会满足第三种状况,但此时 p 就是首节点,因而不会更新首节点,而后返回 Null

此时咱们能够发现尾节点 tail 在哨兵节点上,如果往后遍历是永远无奈达到队列的

再进行一次入队操作,发现它满足第二种状况,p 的 next 指向本人,因为未被批改过,p 等于头节点,又从新回到队列上

再进入一轮循环,会 CAS 增加 h4 再更新尾节点 tail

至此,该简略示例笼罩大部分入队、出队的流程,再来聊聊哨兵节点

在此过程中,哨兵节点能够防止队列中只有一个节点而产生竞争

总结

ConcurrentLinkedQueue基于单向链表实现,应用 volatile 保障可见性,使得在读场景下不须要应用其余同步机制;应用乐观锁 CAS+ 失败重试保障写场景下操作的原子性

ConcurrentLinkedQueue应用提早更新首尾节点的思维,大大减少 CAS 次数,晋升并发性能;应用哨兵节点,升高代码复杂度,防止一个节点时的竞争

在入队操作时,会在循环中找到真正的尾节点,应用 CAS 增加新节点,再判断是否 CAS 更新尾节点 tail

在入队操作的循环期间个别状况下是向后遍历节点,因为出队操作会构建哨兵节点,当判断为哨兵节点(next 指向本人)时,依据状况定位到尾节点或头节点(“跳出”)

在出队操作时,也是在循环中找到真正的头节点,应用 CAS 将真正头节点的数据设置为空,再判断是否 CAS 更新头节点,而后让旧的头节点 next 指向它本人构建成哨兵节点,不便 GC

在出队操作的循环期间个别状况下也是向后遍历节点,因为出队会构建哨兵节点,当检测到以后是哨兵节点时,也要跳过本次循环

ConcurrentLinkedQueue基于哨兵节点、提早 CAS 更新首尾节点、volatile 保障可见性等特点,领有十分高的性能,绝对于 CopyOnWriteArrayList 来说实用于数据量大、并发高、频繁读写、操作队头、队尾的场景

最初(不要白嫖,一键三连求求拉~)

本篇文章被支出专栏 由点到线,由线到面,深入浅出构建 Java 并发编程常识体系,感兴趣的同学能够继续关注喔

本篇文章笔记以及案例被支出 gitee-StudyJava、github-StudyJava 感兴趣的同学能够 stat 下继续关注喔~

案例地址:

Gitee-JavaConcurrentProgramming/src/main/java/F_Collections

Github-JavaConcurrentProgramming/src/main/java/F_Collections

有什么问题能够在评论区交换,如果感觉菜菜写的不错,能够点赞、关注、珍藏反对一下~

关注菜菜,分享更多干货,公众号:菜菜的后端私房菜

本文由博客一文多发平台 OpenWrite 公布!

正文完
 0