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

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

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

前言

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

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

阅读本文大概需要10分钟

在阅读本文前,需要理解CAS、volatile等知识

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

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

数据结构

ConcurrentLinkedQueue从名称上来看就能够知道,它支持并发、由链表实现的队列

image.png

通过源码,我们可以看到**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会有误

image.png

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

  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没画指向关系,也说明为空】

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

image.png

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

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

image.png

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

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

image.png

在第三次入队时,情况与第一次入队相同

image.png

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

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

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

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

image.png

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

image.png

然后将原来的head指向它自己,构建成哨兵节点,方便中间两个不再使用的节点GC

image.png

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

image.png

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

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

image.png

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

此时我们可以发现尾节点tail在哨兵节点上,如果往后遍历是永远无法到达队列的

再进行一次入队操作,发现它满足第二种情况,p的next指向自己,由于未被修改过,p等于头节点,又重新回到队列上

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

image.png

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

在此过程中,哨兵节点可以避免队列中只有一个节点而发生竞争

总结

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

ConcurrentLinkedQueue使用延迟更新首尾节点的思想,大大减少CAS次数,提升并发性能;使用哨兵节点,降低代码复杂度,避免一个节点时的竞争

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

在入队操作的循环期间一般情况下是向后遍历节点,由于出队操作会构建哨兵节点,当判断为哨兵节点(next指向自己)时,根据情况定位到尾节点或头节点(“跳出”)

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

在出队操作的循环期间一般情况下也是向后遍历节点,由于出队会构建哨兵节点,当检测到当前是哨兵节点时,也要跳过本次循环

ConcurrentLinkedQueue基于哨兵节点、延迟CAS更新首尾节点、volatile保证可见性等特点,拥有非常高的性能,相对于CopyOnWriteArrayList来说适用于数据量大、并发高、频繁读写、操作队头、队尾的场景

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

本篇文章被收入专栏 由点到线,由线到面,深入浅出构建Java并发编程知识体系,感兴趣的同学可以持续关注喔

本篇文章笔记以及案例被收入 gitee-StudyJavagithub-StudyJava 感兴趣的同学可以stat下持续关注喔~

案例地址:

Gitee-JavaConcurrentProgramming/src/main/java/F_Collections

Github-JavaConcurrentProgramming/src/main/java/F_Collections

有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

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

相关文章
|
4月前
|
安全 程序员 C++
C++一分钟之-C++中的并发容器
【7月更文挑战第17天】C++11引入并发容器,如`std::shared_mutex`、`std::atomic`和线程安全的集合,以解决多线程中的数据竞争和死锁。常见问题包括原子操作的误用、锁的不当使用和迭代器失效。避免陷阱的关键在于正确使用原子操作、一致的锁管理以及处理迭代器失效。通过示例展示了如何安全地使用这些工具来提升并发编程的安全性和效率。
61 1
|
4月前
|
缓存 安全 Java
Java中的并发容器:ConcurrentHashMap详解
Java中的并发容器:ConcurrentHashMap详解
|
4月前
|
安全 Java 容器
第一篇:并发容器学习开篇介绍
第一篇:并发容器学习开篇介绍
40 4
|
4月前
|
存储 安全 算法
(九)深入并发编程之并发容器:阻塞队列、写时复制容器、锁分段容器原理详谈
相信大家在学习JavaSE时都曾接触过容器这一内容,一般Java中的容器可分为四类:Map、List、Queue以及Set容器,而在使用过程中,对于ArrayList、HashMap等这类容器都是经常使用的,但问题在于这些容器在并发环境下都会存在线程安全问题。
|
6月前
|
安全 Java 容器
Java一分钟之-并发编程:并发容器(ConcurrentHashMap, CopyOnWriteArrayList)
【5月更文挑战第18天】本文探讨了Java并发编程中的`ConcurrentHashMap`和`CopyOnWriteArrayList`,两者为多线程数据共享提供高效、线程安全的解决方案。`ConcurrentHashMap`采用分段锁策略,而`CopyOnWriteArrayList`适合读多写少的场景。注意,`ConcurrentHashMap`的`forEach`需避免手动同步,且并发修改时可能导致`ConcurrentModificationException`。`CopyOnWriteArrayList`在写操作时会复制数组。理解和正确使用这些特性是优化并发性能的关键。
61 1
|
6月前
|
存储 缓存 安全
Golang深入浅出之-Go语言中的并发安全容器:sync.Map与sync.Pool
Go语言中的`sync.Map`和`sync.Pool`是并发安全的容器。`sync.Map`提供并发安全的键值对存储,适合快速读取和少写入的情况。注意不要直接遍历Map,应使用`Range`方法。`sync.Pool`是对象池,用于缓存可重用对象,减少内存分配。使用时需注意对象生命周期管理和容量控制。在多goroutine环境下,这两个容器能提高性能和稳定性,但需根据场景谨慎使用,避免不当操作导致的问题。
192 7
|
5月前
|
安全 Java 大数据
Java性能优化(七)-多线程调优-并发容器的使用
Java性能优化(七)-多线程调优-并发容器的使用
58 0
|
6月前
|
存储 Java 索引
【亮剑】Java中的并发容器ConcurrentHashMap,它在JDK1.5中引入,用于替换HashTable和SynchronizedMap
【4月更文挑战第30天】本文介绍了Java中的并发容器ConcurrentHashMap,它在JDK1.5中引入,用于替换HashTable和SynchronizedMap。文章展示了创建、添加、获取、删除和遍历元素的基本用法。ConcurrentHashMap的内部实现基于分段锁,每个段是一个独立的Hash表,通过分段锁实现并发控制。每个段内部采用数组+链表/红黑树的数据结构,当冲突过多时转为红黑树优化查询。此外,它有扩容机制,当元素超过阈值时,会逐段扩容并翻倍Segment数量,以保持高性能的并发访问。
59 0
|
6月前
|
存储 安全 算法
java多线程之并发容器集合
java多线程之并发容器集合
|
6月前
|
安全 Java API
Java并发 - J.U.C并发容器类 list、set、queue
Queue API 阻塞是通过 condition 来实现的,可参考 Java 并发 - Lock 接口 ArrayBlockingQueue 阻塞 LinkedBlockingQueue 阻塞 ArrayQueue 非阻塞 LinkedQueue 非阻塞