Android 并发编程ConcurrentLinkedQueue

在并发编程中有时候需要使用线程安全的队列。要实现一个线程安全的队列有两种实现方式:一种是使用阻塞算法,另一种是使用非阻塞算法。使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现,而非阻塞的实现方式则可以使用循环CAS的方式来实现。ConcurrentLinkedQueue是使用非阻塞的方式来实现线程安全队列的。

1.ConcurrentLinkedQueue

ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列,采用先进先出的规则对节点进行排序。添加一个元素的时候,它会添加到队列的尾部,获取一个元素时,它会返回队列头部的元素。

ConcurrentLinkedQueue的节点都是Node类型的,源码如下:

private static class Node<E> {

        volatile E item;

        volatile Node<E> next;

        // ........

}

//头节点

private transient volatile Node<E> head;

//尾节点

private transient volatile Node<E> tail;

Node节点主要包含了两个域:一个是数据域item,另一个是next指针,用于指向下一个节点,从而构成链式队列,并且都是用volatile进行修饰的,以保证内存可见性。

ConcurrentLinkedQueue类有两个构造方法:

①默认构造方法,head节点存储的元素为空,tail节点等于head节点

public ConcurrentLinkedQueue() {

    head = tail = new Node<E>(null);

}

②根据其他集合来创建队列

public ConcurrentLinkedQueue(Collection<? extends E> c) {

    Node<E> h = null, t = null;

    // 遍历节点

    for (E e : c) {

        //若节点为null,则直接抛出空指针异常

        checkNotNull(e);

        Node<E> newNode = new Node<E>(e);

        if (h == null)

            h = t = newNode;

        else {

            t.lazySetNext(newNode);

            t = newNode;

        }

    }

    if (h == null)

        h = t = new Node<E>(null);

    head = h;

    tail = t;

}

默认情况下head节点存储的元素为空,tail节点等于head节点。

head = tail = new Node<E>(null);

2.入队操作offer

入队列就是将入队节点添加到队列的尾部。

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16上图所示的元素添加过程如下:

①添加元素1:队列更新head节点的next节点为元素1节点。又因为tail节点默认情况下等于head节点,所以它们的next节点都指向元素1节点。

②添加元素2:队列首先设置元素1节点的next节点为元素2节点,然后更新tail节点指向元素2节点,tail的next指向null。

③添加元素3:设置tail节点的next节点为元素3节点。

④添加元素4:设置元素3的next节点为元素4节点,然后将tail节点指向元素4节点,然后将tail的next指向null。

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

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

来看看ConcurrentLinkedQueue的add入队方法:

public boolean add(E e) {

    return offer(e);

}

public boolean offer(E e) {

    // 创建入队节点,如果e为null,则直接抛出空指针异常

    final Node<E> newNode = newNode( Objects.requireNonNull(e));

    // 循环CAS直到入队成功:不断重试(for只有初始化条件,没有判断条件),直到将node加入队列

    for (Node<E> t = tail, p = t;;) {

        Node<E> q = p.next; // q一直指向p的下一个

       //判断p是不是尾节点,q为null表示p是最后一个元素,尝试加入队列

        if (q == null) {

            //设置p节点的下一个节点为新节点,设置成功则casNext返回true;否则返回false,说明有其他线程更新过尾节点

            if (p.casNext(null, newNode)) {

                // node加入队列之后,tail距离最后一个节点已经相差大于一个了,需要更新tail。如果p != t,则将入队节点设置成tail节点,更新失败了也没关系,因为失败了表示有其他线程成功更新了tail节点

                if (p != t) 

                    // 这儿允许设置tail为最新节点的时候失败,因为添加node的时候是根据p.next是不是为null判断的,所以不用管casTail的返回值

                    casTail(t, newNode); 

                return true;

            }

        } else if (p == q)

            // 多线程操作时候,由于poll时候会把旧的head变为自引用,然后将head的next设置为新的head,所以这里需要重新找新的head,因为新的head后面的节点才是激活的节点

            // 虽然q是p.next,但是因为是多线程,在offer的同时也在poll,如offer的时候正好p被poll了,那么在poll方法中的updateHead方法会将head指向当前的q,而把p.next指向自己,即:p.next == p,这个时候就会造成tail在head的前面,需要重新设置p。如果tail已经改变,将p指向tail,但这个时候tail依然可能在head前面;如果tail没有改变,直接将p指向head

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

        else

            // tail已经不是最后一个节点,将p指向最后一个节点,即更新tail

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

    }

}

在for循环里,一直循环CAS,知道入队成功,所以说ConcurrentLinkedQueue不会阻塞。整个插入过程分为三步:1、根据tail节点定位出尾节点;2、将新节点置为尾节点的下一个节点;3、casTail更新尾节点。

具体分析一下:for初始化p、t都是指向tail,循环过程中一直让p指向最后一个节点,让t指向tail。如下图:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 for循环时,q为p的下一个节点,q用于判断p是不是尾节点,tail节点不一定是尾节点,判断是不是尾节点的依据是该节点的next是不是null。若q为null,则表示p是最后一个元素,满足if条件,尝试加入队列,直接将新节点CAS插入即可。

若q不为null,则表示tail节点此时并不是尾节点,如下图:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 然后会进入else里执行下列语句:

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

首先看括号里面的条件,一共两个条件,首先看第一个p != t,此时p和t相等的,因此第一个条件不满足,无需再看第二个条件,直接把q赋值给p,如下图:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 然后进入下一次for循环里,又把p.next赋值给q,所以此时q又变成了null,如下图:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 此时满足if条件q==null,则执行casNext(p,null,newNode)尝试将newNode插入队尾。插入成功后,如图

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

插入成功后,根据p是否移动了来修改tail的值。此时p != t,因此执行casTail(t,newNode)将tail指向newNode,如图:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 执行完casTail(t,newNode)后直接return跳出了for循环。

但是不要忘记,ConcurrentLinkedQueue是支持多线程并发执行的,比如在下面这个瞬间的时候,

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

有两个线程同时执行到了if(q == null)这条语句,因此这两个线程同时执行csaNext(p,null,newNode)想插入数据,如图:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

但是由于CAS同一时间只允许一个线程操作(lock了),比如线程1抢到了lock并成功将数据插入了,根据上面的分析可以知道,当线程1插入成功后,会用casTail将tail节点指向刚插入的节点,如图:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

而线程2由于没抢到lock,casNext失败。

再一次for循环时,读取p.next赋值给q,因此此时q变成了tail节点,如图:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

这次循环里,由于q不为null,会进入else里面,此时p != t成立,所以看第二个条件t != (t = tail),这条语句先判断t不等于tail(t还在B节点呢,tail在D节点)成立,然后将tail赋值给t,此时t变到了tail的位置,如图

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

因为else里的两个条件都满足,所以会把t赋值给p,也就是将最新的尾节点给了p,这样就完成了每次都重新读取最新的尾节点。因为一个线程CAS插入失败后,他就不知道tail节点到哪里去了(可能已经有多个线程插入成功了),所以此时需要重新去读取最新值。

可能你还是不相信,那么我们debug看看是否真的是如此的

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16 watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

 那么它是如何保证多线程下安全的呢?

从源代码角度来看整个入队过程主要做二件事情:

第一是定位出尾节点。tail节点并不总是尾节点,所以每次入队都必须先通过tail节点来找到尾节点,尾节点可能就是tail节点,也可能是tail节点的next节点。代码中循环体中的第一个if就是判断tail是否有next节点,有则表示next节点可能是尾节点。获取tail节点的next节点需要注意的是p节点等于q节点的情况。

Node<E> q = p.next;

if (q == null) {

     ................//省略很多代码

}else if (p == q)

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

else 

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

虽然q是p.next,但因为是多线程,在offer的同时也在poll,如果offer的时候正好p被poll了,那么在poll方法中的updateHead方法会将head指向当前的q,而把p.next指向自己,即:p.next == p这个时候就会造成tail在head的前面,需要重新设置p如果tail已经改变,将p指向tail,但这个时候tail依然可能在head前面;如果tail没有改变,直接将p指向head。

第二是使用CAS算法将入队节点设置成尾节点的next节点,如不成功则重试。

p.casNext(null, newNode)方法用于将入队节点设置为当前队列尾节点的next节点,q如果是null表示p是当前队列的尾节点,如果不为null表示有其他线程更新了尾节点,则需要重新获取当前队列的尾节点。

大致示意图如下:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

tail节点不一定为尾节点的设计意图?

对于先进先出的队列入队所要做的事情就是将入队节点设置成尾节点,ConcurrentLinkedQueue的代码和逻辑还是稍微有点复杂。那么我用以下方式来实现行不行?

public boolean offer(E e) {

    checkNotNull(e);

    final Node<E> newNode = new Node<E>(e);

    for (;;) {

        Node<E> t = tail;

        if (t.casNext(null ,newNode) && casTail(t, newNode)) {

            return true;

        }

    }

}

让tail节点永远作为队列的尾节点,这样实现代码量非常少,而且逻辑非常清楚和易懂。但是这么做有个缺点就是每次都需要使用循环CAS更新tail节点。如果能减少CAS更新tail节点的次数,就能提高入队的效率。

再简单点解释:比如有一个队列,tail节点为尾节点,其next指向null。现在有成千上万个线程要插入队列,假设线程A先获得了lock,然后用CAS机制把tail的next指向了线程A想要修改的值nodeA,修改完成后unlock,注意此时tail并没有变化,依然为nodeA前一个节点呢。如果我们想要让tail永远为尾节点,线程A修改完值只是第一步,它第二步还应该修改tail节点为自己的节点,但是一旦线程A unlock了,那么成千上万个线程就来争夺lock了,线程A也重新加入争夺lock的大军当中,此时线程A极大可能拿不到lock,而其他线程就算拿到锁也没办法修改,因为此时tail的next不是null(而是nodeA),这就对CPU性能完成了无用的消耗。所以为了优化,才设计为线程A只修改值就退出,此时其他线程抢到lock后,首先判断tail的next是不是null,如果是,直接用CAS将数据插入队列,否则要先移动tail为尾节点,让tail的next指向null,然后再进行CAS将数据插入队列。这样极大的节省了CPU的性能消耗。

在JDK 1.7的实现中,doug lea使用hops变量来控制并减少tail节点的更新频率,并不是每次节点入队后都将 tail节点更新成尾节点,而是当tail节点和尾节点的距离大于等于常量HOPS的值(默认等于1)时才更新tail节点,tail和尾节点的距离越长使用CAS更新tail节点的次数就会越少,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间就越长,因为循环体需要多循环一次来定位出尾节点,但是这样仍然能提高入队的效率,因为从本质上来看它通过增加对volatile变量的读操作来减少了对volatile变量的写操作,而对volatile变量的写操作开销要远远大于读操作,所以入队效率会有所提升。

在JDK 1.8的实现中,tail的更新时机是通过p和t是否相等来判断的,其实现结果和JDK 1.7相同,即当tail节点和尾节点的距离大于等于1时,更新tail。

ConcurrentLinkedQueue的入队操作整体逻辑如下图所示:

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_20,color_FFFFFF,t_70,g_se,x_16

3.poll()方法原理

出队列的就是从队列里返回一个节点元素,并清空该节点对元素的引用。下面让我们通过每个节点出队的快照来观察下head节点的变化。

watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5a2f6Iqz6Iqz,size_19,color_FFFFFF,t_70,g_se,x_16

 从上图可知,并不是每次出队时都更新head节点,当head节点里有元素时,直接弹出head节点里的元素,而不会更新head节点。只有当head节点里没有元素时,出队操作才会更新head节点。这种做法也是通过hops来减少使用CAS更新head节点的消耗,从而提高出队效率。让我们再通过源码来深入分析下出队过程。

public E poll() {

    // 如果出现p被删除的情况需要从head重新开始

    restartFromHead:

    for (;;) {

        for (Node<E> h = head, p = h, q;;) {

            E item = p.item;

            if (item != null && p.casItem(item, null)) {

                // Successful CAS is the linearization point

                // for item to be removed from this queue.

                if (p != h) // hop two nodes at a time

                    updateHead(h, ((q = p.next) != null) ? q : p);

                return item;

            }

           else if ((q = p.next) == null) {

                // 队列为空

                updateHead(h, p);

                return null;

            }

            else if (p == q)

                // 当一个线程在poll的时候,另一个线程已经把当前的p从队列中删除——将p.next = p,p已经被移除不能继续,需要重新开始

                continue restartFromHead;

            else

                p = q;

        }

    }

}

final void updateHead(Node<E> h, Node<E> p) {

    if (h != p && casHead(h, p))

        h.lazySetNext(h);

}

首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用CAS的方式将头节点的引用设置成null,如果CAS成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。

4.HOPS的设计

通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:

①tail更新触发时机:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。

②head更新触发时机:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。

猜你喜欢

转载自blog.csdn.net/zenmela2011/article/details/123765442