深入理解jdk1-8ConcurrentLinkedQueue源码实现

164 阅读15分钟

在并发编程中,有时候需要使用线程安全的队列。如果要实现一个线程安全的队列有两种方式:一种是使用阻塞算法,另一种是使用非阻塞算法。使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现。非阻塞的实现方式则可以使用循环CAS的方式来实现。而今天要分享的是使用非阻塞的方式实现的线程安全队列ConcurrentLinkedQueue,类名就可以看的出来实现队列的数据结构是链式。

(注:自己阅读源码过程中,涉及到unsafe的方法不懂,想要理解的,可以去看文章,很详细,直接Ctrl+f 去查找): www.jianshu.com/p/1cc04a31f…

ConcurrentLinkedList是一个基于链接节点的无界线程安全队列,采用的是先进先出的规则,对节点进行排序。当我们添加一个元素的时候,会添加到队列尾部,获取元素时,会返回头部的元素。采用CAS算法来实现。先来看下整体的类图:

ConcurrentLinkedQueue是继承了AbstartQueue,实现了Queue接口的。最上层是实现了Iterable,用于迭代元素,属于集合。要想学习该类是源码,需要知道ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和指向下一个节点(next)的引用组成,节点与节点之间就是通过这个next关联起来,从而组成一张链表结构的队列。默认情况下head节点存储的元素为空,tail节点等于head节点。

可以先看用于存放数据的与Node节点元素···

private static class Node<E> {
        volatile E item;   //用于存放node元素
        volatile Node<E> next;   //用于存放下一个节点的node
  
        Node(E item) {
          //使用Unsafe的putObject方法直接操作内存偏移量,存放数据,效率高。
          UNSAFE.putObject(this, itemOffset, item);
        }
        
        boolean casItem(E cmp, E val) {
//通过内存偏移地址修改变量值
   /**
      *  compareAndSwapObject(Object o, long offset,Object expected,Object x);
      *     更新变量值为x,如果当前值为expected
      *     o:对象 offset:偏移量 expected:期望值 x:新值
      */         
        return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
        }
        //cas操作修改变量值。
        boolean casNext(Node<E> cmp, Node<E> val) {
            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<E> head;  //头节点

    private transient volatile Node<E> tail;     //尾节点

在初始化构造函数时,默认构造函数如下:

public ConcurrentLinkedQueue() {
      //初始化时首结点与尾节点是一致的,因为没有值。
        head = tail = new Node<E>(null);  
    }

//同时,静态化去初始化跟Node节点一样的一些参数,

另外需要知道设计的几个CAS操作

//更改Node中的数据域item   
boolean casItem(E cmp, E val) {
    return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
//更改Node中的指针域next
void lazySetNext(Node<E> val) {
    UNSAFE.putOrderedObject(this, nextOffset, val);
}
//更改Node中的指针域next
boolean casNext(Node<E> cmp, Node<E> val) {
    return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}

可以看出这些方法实际上是通过调用UNSAFE实例的方法,UNSAFE为sun.misc.Unsafe类.想要了解更多关于Unsafe的可以看我下面分享的这篇文章: www.jianshu.com/p/1cc04a31f…

可以看到ConcurrentLinkedQueue是带Concurrent,所以看源代码,一定不单单考虑单线程的情况,还需要考虑多线程情况下,如何运行,怎么下的。同时,有进队列,也会有出队列,还需要考虑同时进队列和出队列的情况下是如何运行的。 分如下情况: offer为入队列,poll为出队列 1.单线程

  1. 入队列 入队列的主要操作在于操作Tail尾节点的下一个节点,单线程情况下不会与出队列有影响。
  2. 出队列 入队列的主要操作在于操作头节点,单线程情况下不会与入队列有影响。 2.多线程
  3. 多线程入队列 因为入队列,首先需要获取tail节点,然后跟判断tail节点的下一个节点是否为null去做处理,如果下一个节点在还没入队列时,有新的节点插入了,这时候获取到的尾节点为脏数据,需要重新获取尾节点。
  4. 多线程出队列

3.同时入队列 、出队列

  1. 入队列远大于出队列 入队列大于出队列的话,一般两者之间也不会有交集。这个可以按多线程区别讨论。

  2. 出队列大于入队列

    出队列大于入队列的话,也就是队头删的速度要快于队尾添加节点的速度,导致的结果就是队列长度会越来越短,而offer线程和poll线程就会出现“交集”,即那一时刻就可以称之为offer线程和poll线程同时操作的节点为 临界点 ,且在该节点offer线程和poll线程必定相互影响。那么可以根据临界点执行两个操作的发生顺寻分为以下两种:

  3. 执行顺序为offer-->poll-->offer,即表现为当offer线程在Node1后插入Node2时,此时poll线程已经将Node1删除,这种情况很显然需要在offer方法中考虑;

2.执行顺序可能为:poll-->offer-->poll,即表现为当poll线程准备删除的节点为null时(队列为空队列),此时offer线程插入一个节点使得队列变为非空队列.

入队列的源码(offer)

 public boolean add(E e) {
        return offer(e);
    }

public boolean offer(E e) {
      //判空   #单1
        checkNotNull(e);
        //包装成node类,更好的管理和操作队列里面的节点。
        final Node<E> newNode = new Node<E>(e);  #单2
        
        for (Node<E> t = tail, p = t;;) {        #单3   //这里  p = t = tail
//p被认为队列真正的尾节点,tail不一定指向对象真正的尾节点,因为在
//ConcurrentLinkedQueue中tail是被延迟更新的
            Node<E> q = p.next;         #单4
            if (q == null) {                #单5

                if (p.casNext(null, newNode)) {       #单6

                    if (p != t) // hop two nodes at a time
                        casTail(t, newNode);  // Failure is OK.    #单9
                    return true;          #单7
                }
                // Lost CAS race to another thread; re-read next
            }
            else if (p == q)    #多1
    
                p = (t != (t = tail)) ? t : head;
            else       #单8
                // Check for tail updates after two hops.
                p = (p != t && t != (t = tail)) ? t : q;
        }
    }

单线程情况下(可以看我备注的 #单 的代码顺序)第一次入队列 1.先判断入队列数据是否为null 2.包装成node 3.自旋,先获取tail节点 4.这里要注意,初始化时head和tail存入node的值都为null并且相等,如果tail节点的下一个节点为null,则直接用cas操作加入队列,如果成功CAS操作成功。

如图,此时队列的尾节点应该为Node1,而tail指向的节点依然还是Node0,因此可以说明tail是延迟更新的。然后我们接着看,要入队列第二个节点的时候怎么运行,此时#单5 执行tail节点的下一个next节点不会为null,此时会执行#单8 这一条件下的代码:

p = (p != t && t != (t = tail)) ? t : q;

如果这段代码在单线程环境执行时,很显然由于p==t,此时p会被赋值为q,而q等于Node q = p.next,此时找到tail节点了。然后会走到 #单5、 #单6、 #单9这样的顺序去将当前节点设置成队列的尾节点。 这里的casTail失败不需要重试的原因是,offer代码中主要是通过p的next节点q(Node q = p.next)决定后面的逻辑走向的,如果这里casTail设置tail失败即tail还是指向Node0节点的话,无非就是多循环几次通过 #单8 代码定位到队尾节点。

多线程执行角度分析

1.多个线程offer 看p = (p != t && t != (t = tail)) ? t : q;这行代码在单线程中,这段代码永远不会将p赋值为t,那么这么写就不会有任何作用。 其实在多线程环境下这行代码很有意思的。 t != (t = tail)这个操作并非一个原子操作,有这样一种情况:

如图,假设线程A此时读取了变量t,线程B刚好在这个时候offer一个Node后,此时会修改tail指针,那么这个时候线程A再次执行t=tail时t会指向另外一个节点,很显然线程A前后两次读取的变量t指向的节点不相同,即t != (t = tail)为true,并且由于t指向节点的变化p != t也为true,此时该行代码的执行结果为p和t最新的t指针指向了同一个节点,并且此时t也是队列真正的对尾节点。那么,现在已经定位到队列真正的队尾节点,就可以执行offer操作了。

2.offer->poll->offer 先offer的过程中被poll了。

然后来分析下 #多1 这行代码。 p = (t != (t = tail)) ? t : head; 大致可以猜想到应该就是回答一部分线程offer,一部分poll的这种情况。当if (p == q)为true时,说明p指向的节点的next也指向它自己,这种节点称之为哨兵节点,这种节点在队列中存在的价值不大,一般表示为要删除的节点或者是空节点。

入队列的源码poll方法

public E poll() {
    restartFromHead:
    1. for (;;) {
    2.    for (Node<E> h = head, p = h, q;;) {
    3.        E item = p.item;

    4.        if (item != null && p.casItem(item, null)) {
                // Successful CAS is the linearization point
                // for item to be removed from this queue.
    5.            if (p != h) // hop two nodes at a time
    6.                updateHead(h, ((q = p.next) != null) ? q : p);
    7.            return item;
            }
    8.        else if ((q = p.next) == null) {
    9.            updateHead(h, p);
    10.            return null;
            }
    11.        else if (p == q)
    12.            continue restartFromHead;
            else
    13.            p = q;
        }
    }
}

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

void lazySetNext(Node<E> val) {
            UNSAFE.putOrderedObject(this, nextOffset, val);
        }

单线程的角度

先将变量p作为队列要删除真正的队头节点,h(head)指向的节点并不一定是队列的队头节点。看poll出Node1时的情况,由于p=h=head,参照上图,很显然此时p指向的Node1的数据域不为null,在第4行代码中item!=null判断为true后接下来通过casItem将Node1的数据域设置为null。如果CAS设置失败则此次循环结束等待下一次循环进行重试。若第4行执行成功进入到第5行代码,此时p和h都指向Node1,第5行if判断为false,然后直接到第7行return回Node1的数据域1,方法运行结束.
继续从队列中poll,很显然当前h和p指向的Node1的数据域为null,那么第一件事就是要定位准备删除的队头节点(找到数据域不为null的节点)。

定位删除的队头节点

继续看,第三行代码item为null,第4行代码if判断为false,走到第8行代码(q = p.next)if也为false,由于q指向了Node2,在第11行的if判断也为false,因此代码走到了第13行,这个时候p和q共同指向了Node2,也就找到了要删除的真正的队头节点。可以总结出,定位待删除的队头节点的过程为:如果当前节点的数据域为null,很显然该节点不是待删除的节点,就用当前节点的下一个节点去试探。在经过第一次循环后,此时状态图为下图

进行下一次循环,第4行的操作同上述,当前假设第4行中casItem设置成功,由于p已经指向了Node2,而h还依旧指向Node1,此时第5行的if判断为true,然后执行updateHead(h, ((q = p.next) != null) ? q : p),此时q指向的Node3,所有传入updateHead方法的分别是指向Node1的h引用和指向Node3的q引用。updateHead方法的源码看上面

该方法主要是通过casHead将队列的head指向Node3,并且通过 h.lazySetNext将Node1的next域指向它自己。最后在第7行代码中返回Node2的值。此时队列的状态如下图所示

Node1的next域指向它自己,head指向了Node3。如果队列为空队列的话,就会执行到代码的第8行(q = p.next) == null。

单线程执行总结

如果当前head,h和p指向的节点的item为null的话,则说明该节点不是真正的待删除节点,那么应该做的就是寻找item不为null的节点。通过让q指向p的下一个节点(q = p.next)进行试探,若找到则通过updateHead方法更新head指向的节点以及构造哨兵节点(通过updateHead方法的h.lazySetNext(h))

多线程执行情况分析:

else if (p == q)
    continue restartFromHead;

这一部分就是处理多个线程poll的情况,q = p.next也就是说q永远指向的是p的下一个节点,只有p指向的节点在poll的时候转变成了哨兵节点(通过updateHead方法中的h.lazySetNext)。当线程A在判断p==q时,线程B已经将执行完poll方法将p指向的节点转换为哨兵节点并且head指向的节点已经发生了改变,所以就需要从restartFromHead处执行,保证用到的是最新的head。

poll->offer->poll

试想,还有这样一种情况,如果当前队列为空队列,线程A进行poll操作,同时线程B执行offer,然后线程A在执行poll,那么此时线程A返回的是null还是线程B刚插入的最新的那个节点呢?

public static void main(String[] args) {
    Thread thread1 = new Thread(() -> {
        Integer value = queue.poll();
        System.out.println(Thread.currentThread().getName() + " poll 的值为:" + value);
        System.out.println("queue当前是否为空队列:" + queue.isEmpty());
    });
    thread1.start();
    Thread thread2 = new Thread(() -> {
        queue.offer(1);
    });
    thread2.start();
}

输出结果为:

Thread-0 poll 的值为:null
queue当前是否为空队列:false

通过debug控制线程thread1和线程thread2的执行顺序,thread1先执行到第8行代码if ((q = p.next) == null),由于此时队列为空队列if判断为true,进入if块,此时先让thread1暂停,然后thread2进行offer插入值为1的节点后,thread2执行结束。再让thread1执行,这时thread1并没有进行重试,而是代码继续往下走,返回null,尽管此时队列由于thread2已经插入了值为1的新的节点。所以输出结果为thread0 poll的为null,然队列不为空队列。因此,在判断队列是否为空队列的时候是不能通过线程在poll的时候返回为null进行判断的,可以通过isEmpty方法进行判断。

然后我们再回到offer方法中,有一个

11.        else if (p == q)

当执行的顺序是: offer->poll->offer 时,不难想到在线程A执行offer时,线程B执行poll就会存在如下一种情况:

如图,线程A的tail节点存在next节点Node1,因此会通过引用q往前寻找队列真正的队尾节点,当执行到判断if (p == q)时,此时线程B执行poll操作,在对线程B来说,head和p指向Node0,由于Node0的item域为null,同样会往前递进找到队列真正的队头节点Node1,在线程B执行完poll之后,Node0就会转换为哨兵节点,也就意味着队列的head发生了改变,此时队列状态为下图
12.线程B进行poll后队列的状态图.png
此时线程A在执行判断if (p == q)时就为true,会继续执行

p = (t != (t = tail)) ? t : head;,

由于tail指针没有发生改变所以p被赋值为head,重新从head开始完成插入操作。

  1. HOPS的设计

tail更新触发时机:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。 head更新触发时机:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。 并且在更新操作时,源码中会有注释为:hop two nodes at a time。所以这种延迟更新的策略就被叫做HOPS

如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。

一些注意点

虽然ConcurrentLinkedQueue的性能很好,但是在调用size()方法的时候,会遍历一遍集合,对性能损害较大,执行很慢,因此应该尽量的减少使用这个方法,如果判断是否为空,最好用isEmpty()方法。

  ConcurrentLinkedQueue不允许插入null元素,会抛出空指针异常。

  ConcurrentLinkedQueue是无界的,所以使用时,一定要注意内存溢出的问题。即对并发不是很大中等的情况下使用,不然占用内存过多或者溢出,对程序的性能影响很大,甚至是致命的。

应用场景

ConcurrentLinkedQueue 多用于消息队列

待续

部分摘自: www.jianshu.com/p/001c45716… 《java并发编程的艺术》 《Java高并发程序设计》 www.cnblogs.com/sunshine-20…