多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析

  一.背景

  要实现对队列的安全访问,有两种方式:阻塞算法和非阻塞算法。阻塞算法的实现是使用一把锁(出队和入队同一把锁ArrayBlockingQueue)和两把锁(出队和入队各一把锁LinkedBlockingQueue)来实现;非阻塞算法使用自旋+CAS实现。

   阻塞,顾名思义:当我们的生产者向队列中生产数据时,若队列已满,那么生产线程会暂停下来,直到队列中有可以存放数据的地方,才会继续工作;而当我们的消费者向队列中获取数据时,若队列为空,则消费者线程会暂停下来,直到容器中有元素出现,才能进行获取操作。
  那么对于非阻塞来说,非阻塞队列的执行并不会被阻塞,无论是消费者的出队,还是生产者的入队。同时对于入队和出队,使用了向后推进策略(重新寻找头或尾节点)保证并发的数据一致性。

  今天来探究下使用非阻塞算法来实现的线程安全队列ConcurrentLinkedQueue,它是一个基于链接节点的无界线程安全队列,采用先进先出的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部,当我们获取一个元素时,它会返回队列头部的元素。它采用了“wait-free”算法(即CAS算法)来实现。即当入队时,插入的元素依次向后延伸,形成链表;而出队时,则从链表的第一个元素开始获取,依次递增。

  ConcurrentLinkedQueue的类图结构:

多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析_第1张图片

   从类图中可以看到,ConcurrentLinkedQueue由head和tail节点组成,每个节点Node由节点元素item和指向下一个节点的引用next组成,节点与节点之间通过next关联起来组成一张链表结构的队列。

  二.源码解析

  1. 构造方法

        private static class Node {
            volatile E item;//元素
            volatile Node next;//下一节点
    
            Node(E item) {//添加元素
                UNSAFE.putObject(this, itemOffset, item);
            }
    
            boolean casItem(E cmp, E val) {//cas修改元素
                return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
            }
    
            void lazySetNext(Node val) {//添加节点
                UNSAFE.putOrderedObject(this, nextOffset, val);
            }
    
            boolean casNext(Node cmp, Node val) {//cas修改节点
                return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
            }
    
            private static final sun.misc.Unsafe UNSAFE;
            private static final long itemOffset;
            private static final long nextOffset;
    
            static {
                try {
                    UNSAFE = sun.misc.Unsafe.getUnsafe();
                    Class k = Node.class;
                    //获得元素的偏移位置
                    itemOffset = UNSAFE.objectFieldOffset
                        (k.getDeclaredField("item"));
                    //获得下一节点的偏移位置
                    nextOffset = UNSAFE.objectFieldOffset
                        (k.getDeclaredField("next"));
                } catch (Exception e) {
                    throw new Error(e);
                }
            }
        }
        //头节点
        private transient volatile Node head;
        //尾节点
        private transient volatile Node tail;
        public ConcurrentLinkedQueue() {
            //默认情况下head节点存储的元素为空,tail节点等于head节点。
            head = tail = new Node(null);
        }
        public ConcurrentLinkedQueue(Collectionextends E> c) {
            Node h = null, t = null;
            //遍历集合
            for (E e : c) {
                checkNotNull(e);//检查是否为空,如果为空抛出空指针异常
                //创建节点和将元素存储到节点中
                Node newNode = new Node(e);
                if (h == null)//头节点为空
                    h = t = newNode;//头和尾节点是创建的节点
                else {
                    t.lazySetNext(newNode);//添加节点
                    t = newNode;//修改尾节点的标识
                }
            }
            //如果集合没有元素,设置队列的头尾节点为空
            if (h == null)
                h = t = new Node(null);
            head = h;//更新队列的头节点标识
            tail = t;//更新队列的尾节点标识
        }
        private static void checkNotNull(Object v) {
            if (v == null)
                throw new NullPointerException();
        }    
  2. 入队add:多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析_第2张图片

    • 入队操作主要做两件事情,第一是将入队节点设置成当前队列尾节点的下一个节点;第二是更新tail节点,如果tail节点的next节点不为空,则将入队节点设置成tail节点,如果tail节点的next节点为空,则将入队节点设置成tail的next节点,所以tail节点不总是尾节点

    • 上面的分析让我们从单线程入队的角度来理解入队过程,但是多个线程同时进行入队情况就变得更加复杂,因为可能会出现其他线程插队的情况。如果有一个线程正在入队,那么它必须先获取尾节点,然后设置尾节点的下一个节点为入队节点,但这时可能有另外一个线程插队了,那么队列的尾节点就会发生变化,这时当前线程要暂停入队操作,然后重新获取尾节点。

    • 源码解析:从下面可以看出,入队永远是返回true,所以不要通过返回值判断是否入队成功

      public boolean add(E e) {
              return offer(e);
          }
          public boolean offer(E e) {
              checkNotNull(e);//检查是否为空
              //创建入队节点,将元素添加到节点中
              final Node newNode = new Node(e);
              //自旋队列CAS直到入队成功
              // 1、根据tail节点定位出尾节点(last node);2、将新节点置为尾节点的下一个节点;3、casTail更新尾节点
              for (Node t = tail, p = t;;) {
                  //p是尾节点,q得到尾节点的next
                  Node q = p.next;
                  //如果q为空
                  if (q == null) {
                      //p是last node,将尾节点的next修改为创建的节点
                      if (p.casNext(null, newNode)) {
                          //p在遍历后会变化,因此需要判断,如果不相等即p != t = tail,表示t(= tail)不是尾节点,则将入队节点设置成tail节点,更新失败了也没关系,因为失败了表示有其他线程成功更新了tail节点
                          if (p != t)
                              casTail(t, newNode);//入队节点更新为尾节点,允许失败,因此t= tail并不总是尾节点
                          return true;//结束
                      }
                  }
                  //重新获取head节点:多线程操作时,轮询后p有可能等于q,此时,就需要对p重新赋值
                  //(多线程自引用的情况,只有offer()和poll()交替执行时会出现)
                  else if (p == q)
                      //因为并发下可能tail被改了,如果被改了,则使用新的t,否则跳转到head,从链表头重新轮询,因为从head开始所有的节点都可达
                      p = (t != (t = tail)) ? t : head;//运行到这里再继续自旋遍历
                  else
                      /**
                       * 寻找尾节点,同样,当t不等于p时,说明p在上面被重新赋值了,并且tail也被别的线程改了,则使用新的tail,否则循环检查p的下个节点
                       *  (多offer()情况下会出现)
                       * p=condition?result1:result2
                       *  满足result1的场景为 :
                       *      获取尾节点tail的快照已经过时了(其他线程更新了新的尾节点tail),直接跳转到当前获得的最新尾节点的地方
                       *  满足result2的场景为:
                       *      多线程同时操作offer(),执行p.casNext(null, newNode)CAS成功后,未更新尾节点(未执行casTail(t, newNode)方法:两种原因 1是未满足前置条件if判断 2是CAS更新失败),直接找next节点
                       */
                      p = (p != t && t != (t = tail)) ? t : q;//运行到这里再继续自旋遍历
              }
          }
    1. debug断点测试案例:多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析_第3张图片

      public static void main(String[] args) throws IndexOutOfBoundsException {
              ConcurrentLinkedQueue c = new ConcurrentLinkedQueue();
              new Thread(()->{
                  int i;
                  for(i=0;i<10;){
                      c.offer(i++);
                      Object poll = c.poll();//注释或取消进行测试
                      System.out.println(Thread.currentThread().getName()+":"+poll);
                  }
              }).start();
              new Thread(()->{
                  int i;
                  for(i=200;i<210;){
                      c.offer(i++);
                      Object poll = c.poll();//注释或取消进行测试
                      System.out.println(Thread.currentThread().getName()+":"+poll);
                  }
              }).start();
           }
    2. tail多线程的更新情况:通过p和t是否相等来判断

      多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析_第4张图片

  3. 出队poll:多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析_第5张图片

    • 从上图可知,并不是每次出队时都更新head节点,当head节点里有元素时,直接弹出head节点里的元素,而不会更新head节点。只有当head节点里没有元素时,出队操作才会更新head节点。采用这种方式也是为了减少使用CAS更新head节点的消耗,从而提高出队效率。
    • 源码解析:

      public E poll() {
              restartFromHead:
              //自旋
              for (;;) {
                  //获得头节点
                  for (Node h = head, p = h, q;;) {
                      E item = p.item;//获得头节点元素
                      //如果头节点元素不为null并且cas删除头节点元素成功
                      if (item != null && p.casItem(item, null)) {
                          //p被修改了
                          if (p != h) // hop two nodes at a time
                              // 如果p 的next 属性不是null ,将 p 作为头节点,而 q 将会消失
                              updateHead(h, ((q = p.next) != null) ? q : p);
                          return item;
                      }
                      //如果头节点的元素为空或头节点发生了变化,这说明头节点已经被另外一个线程修改了。
                      // 那么获取p节点的下一个节点,如果p节点的下一节点为null,则表明队列已经空了
                      // 如果 p(head) 的 next 节点 q 也是null,则表示没有数据了,返回null,则将 head 设置为null
                      // 注意:updateHead 方法最后还会将原有的 head 作为自己 next 节点,方便offer 连接。
                      else if ((q = p.next) == null) {
                          updateHead(h, p);
                          return null;
                      }
                      //如果 p == q,说明别的线程取出了 head,并将 head 更新了。就需要重新开始获取head节点
                      else if (p == q)
                          continue restartFromHead;
                      // 如果下一个元素不为空,则将头节点的下一个节点设置成头节点
                      else
                          p = q;
                  }
              }
          }
          final void updateHead(Node h, Node p) {
              if (h != p && casHead(h, p))
                  // 将旧的头结点h的next域指向为h
                  h.lazySetNext(h);
          }
  4. 入队和出队操作中,都有p == q的情况,在下面这种情况中:多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析_第6张图片

    • 在弹出一个节点之后,tail节点有一条指向自己的虚线,这是什么意思呢?在poll()方法中,移除元素之后,会调用updateHead方法,其中有h.lazySetNext(h),可以看到,在更新完head之后,会将旧的头结点h的next域指向为h,上图中所示的虚线也就表示这个节点的自引用
    • 如果这时,再有一个线程来添加元素,通过tail获取的next节点则仍然是它本身,这就出现了p == q的情况,出现该种情况之后,则会触发执行head的更新,将p节点重新指向为head,所有“活着”的节点(指未删除节点),都能从head通过遍历可达,这样就能通过head成功获取到尾节点,然后添加元素了。

  5. 获取首部元素peek:多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析_第7张图片

    • 从图中可以看到,peek操作会改变head指向,执行peek()方法后head会指向第一个具有非空元素的节点。
    • 源码解析:
      // 获取链表的首部元素(只读取而不移除)
          public E peek() {
              restartFromHead:
              //自旋
              for (;;) {
                  for (Node h = head, p = h, q;;) {
                      //获得头节点元素
                      E item = p.item;
                      //头节点元素不为空或头节点下一节点为空(表示链表只有一个节点)
                      if (item != null || (q = p.next) == null) {
                          updateHead(h, p);//更新头节点标识
                          return item;
                      }
                      /如果 p == q,说明别的线程取出了 head,并将 head 更新了。就需要重新开始获取head节点
                      else if (p == q)
                          continue restartFromHead;
                      // 如果下一个元素不为空,则将头节点的下一个节点设置成头节点
                      else
                          p = q;
                  }
              }
          }
  6. 判断队列是否为空isEmpty:

        public boolean isEmpty() {
                return first() == null;
        }
        Node first() {
            restartFromHead:
            for (;;) {
                for (Node h = head, p = h, q;;) {
                    //头节点是否有元素
                    boolean hasItem = (p.item != null);
                    //头节点有元素或当前链表只有一个节点
                    if (hasItem || (q = p.next) == null) {
                        updateHead(h, p);
                        return hasItem ? p : null;//头节点有值返回节点,否则返回null
                    }
                    else if (p == q)
                        continue restartFromHead;
                    else
                        p = q;
                }
            }
        }
  7. 获取个数size:在并发环境中,其结果可能不精确,因为整个过程都没有加锁,所以从调用size方法到返回结果期间有可能增删元素,导致统计的元素个数不精确。【在队列元素很多的时候,size()方法十分消耗性能和时间,只是单纯的判断队列为空使用isEmpty()即可!!!】

        public int size() {
            int count = 0;
            // first()获取第一个具有非空元素的节点,若不存在,返回null
            // succ(p)方法获取p的后继节点,若p == p的后继节点,则返回head
            for (Node p = first(); p != null; p = succ(p))
                //节点有元素数量+1
                if (p.item != null)
                    if (++count == Integer.MAX_VALUE)
                        break;
            return count;
        }
        //取下一节点
        final Node succ(Node p) {
            Node next = p.next;
            //若p == p的后继节点(自引用情况下会出现),则返回head
            return (p == next) ? head : next;
        }
  8. 判断元素是否包含contains:该方法和size方法类似,有可能返回错误结果,比如调用该方法时,元素还在队列里面,但是遍历过程中,该元素被删除了,那么就会返回false。

        public boolean contains(Object o) {
            if (o == null) return false;
            for (Node p = first(); p != null; p = succ(p)) {
                E item = p.item;
                // 若找到匹配节点,则返回true
                if (item != null && o.equals(item))
                    return true;
            }
            return false;
        }
  9. 删除元素remove:

        public boolean remove(Object o) {
            //删除的元素不能为null,
            if (o != null) {
                Node next, pred = null;
                //遍历,开始获得头节点,
                for (Node p = first(); p != null; pred = p, p = next) {
                    boolean removed = false;//删除的标识
                    E item = p.item;//节点元素
                    if (item != null) {
                        //节点的元素不等于要删除的元素,获取下一节点进行遍历循环操作
                        if (!o.equals(item)) {
                            next = succ(p);//将当前遍历的节点移到下一节点
                            continue;
                        }
                        //节点元素等于删除元素,CAS将节点元素置为null
                        removed = p.casItem(item, null);
                    }
                    next = succ(p);//获取删除节点的下一节点,
                    //有前节点和后置节点
                    if (pred != null && next != null) // unlink
                        pred.casNext(p, next);//删除当前节点,即当前节点移除出队列
                    if (removed)//元素删除了返回true
                        return true;
                }
            }
            return false;
        }

  三.总结

  • 使用 CAS 原子指令来处理对数据的并发访问,这是非阻塞算法得以实现的基础。
  • head/tail 并非总是指向队列的头 / 尾节点,也就是说允许队列处于不一致状态。 这个特性把入队 / 出队时,原本需要一起原子化执行的两个步骤分离开来,从而缩小了入队 / 出队时需要原子化更新值的范围到唯一变量。这是非阻塞算法得以实现的关键。
  • 由于队列有时会处于不一致状态。为此,ConcurrentLinkedQueue 使用三个不变式来维护非阻塞算法的正确性。
  • 以批处理方式来更新 head/tail,从整体上减少入队 / 出队操作的开销。
  • 为了有利于垃圾收集,队列使用特有的 head 更新机制;为了确保从已删除节点向后遍历,可到达所有的非删除节点,队列使用了特有的向后推进策略。

  四.参考

  • https://blog.csdn.net/qq_38293564/article/details/80798310
  • https://www.ibm.com/developerworks/cn/java/j-lo-concurrent/index.html

你可能感兴趣的:(多线程高并发编程(11) -- 非阻塞算法实现ConcurrentLinkedQueue源码分析)