Java并发——非阻塞队列ConcurrentLinkedQueue

·  阅读 1545

简述

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

ConcurrentLinkedQueue是无界线程安全队列(FIFO),基于CAS来实现。

ConcurrentLinkedQueue

ConcurrentLinkedQueue由head节点和tail节点来管理队列

属性

ConcurrentLinkedQueue


    private transient volatile Node head;
    private transient volatile Node tail;
复制代码

重要内部类

Node节点有两个属性,item存储节点元素,next指针指向下一个节点的引用,从而组成链表结构的队列。两个属性都用volatile修饰,为了保证内存可见性。


    private static class Node {
        volatile E item;
        volatile Node next;

        /**
         * Constructs a new node.  Uses relaxed write because item can
         * only be seen after publication via casNext.
         */
        Node(E item) {
            UNSAFE.putObject(this, itemOffset, item);
        }
        ...
    }    
复制代码

构造方法


    /**
     * 默认无参构造
     * tail节点等于head节点,
     */
    public ConcurrentLinkedQueue() {
        head = tail = new Node(null);
    }
    
    /**
     * 指定集合,以集合的迭代器的遍历顺序添加
     */
    public ConcurrentLinkedQueue(Collection c) {
        Node h = null, t = null;
        // foreach遍历
        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;
    }
复制代码

offer方法


    public boolean offer(E e) {
        // 判空
        checkNotNull(e);
        final Node newNode = new Node(e);
        for (Node t = tail, p = t;;) {
            Node q = p.next;
            // 若p是尾节点
            if (q == null) {
                // CAS将新节点newNode置为当前队列尾节点p的next节点
                if (p.casNext(null, newNode)) {
                    // Successful CAS is the linearization point
                    // for e to become an element of this queue,
                    // and for newNode to become "live".
                    if (p != t) // hop two nodes at a time
                        casTail(t, newNode);  // Failure is OK.
                    return true;
                }
                // Lost CAS race to another thread; re-read next
            }
            else if (p == q)
                // We have fallen off list.  If tail is unchanged, it
                // will also be off-list, in which case we need to
                // jump to head, from which all live nodes are always
                // reachable.  Else the new tail is a better bet.
                p = (t != (t = tail)) ? t : head;
            else
                // p有next节点,表示p的next节点是尾节点,则需要重新更新p后将它指向next节点
                p = (p != t && t != (t = tail)) ? t : q;
        }
    }
复制代码

根据源码单线程角度来理解此方法:
默认构造函数:

添加节点A:
走if分支,CAS将节点A置为当前队列尾节点p的next节点(若cas失败再次重试),由于p=t所以没执行casTail方法

我们可以看到tail节点可能不是尾节点

添加节点B:
第一次循环:t、p节点:node节点,q节点:A节点(图中),走else分支


     p = (p != t && t != (t = tail)) ? t : q;
复制代码

由于p=t,所以p赋为q(图中A节点)

第二次循环:t节点:node节点,p:A节点,q为null,进入第一个分支,cas将p的next指向新节点,p不等于t,通过casTail()方法,将新节点设置为队列的队尾节点(若casTail()失败等待下次添加)

offer主要做两件事:
1.将入队节点设置成当前队列尾节点的下一个节点
2.更新tail节点,如果tail节点的next节 点不为空,则将入队节点设置成tail节点,如果tail节点的next节点为空,则将入队节点设置成 tail的next节点,所以tail节点不总是尾节点

多线程角度理解:
如果有一个线程正在 入队,那么它先获取到尾节点,然后设置尾节点的下一个节点为入队节点,但这时可能有另 外一个线程插队了,那么队列的尾节点就会发生变化,这时当前线程要暂停入队操作,然后重 新获取尾节点。

当线程A获取到tail节点。这时线程B插队先行完成offer操作,修改了tail节点,再执行
t != (t = tail),线程A前后两次读取的变量t指向的节点明显不相同,那么p将会重新指向尾节点

类似于这个demo:


public class Test {

    private Integer age;

    public void setAge(Integer age) {
        this.age = age;
    }

    public Integer getAge() {
        return age;
    }

    public static void main(String[] args) throws InterruptedException {
        final Test test = new Test();
        Integer age = test.getAge();
        new Thread(new Runnable() {
            @Override
            public void run() {
                test.setAge(100);
            }
        }).start();
        Thread.sleep(1000);
        System.out.println(age != (age = test.getAge()) ? test.getAge() : 0); //100
    }
}
复制代码

poll方法


    public E poll() {
        //设置起始点 
        restartFromHead:
        for (;;) {
            for (Node h = head, p = h, q;;) {
                // 获取p节点的值
                E item = p.item;
                // 如果p节点的值不为空,CAS将p节点的值置为null
                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
                        // //如果p节点不是head节点则更新head节点,也可以理解为删除该结点后检查head是否与头结点相差两个结点,如果是则更新head节点
                        updateHead(h, ((q = p.next) != null) ? q : p);
                    return item;
                }
                //如果p节点的下一个节点为null,则说明这个队列为空,更新head结点
                else if ((q = p.next) == null) {
                    updateHead(h, p);
                    return null;
                }
                //结点出队失败,重新跳到restartFromHead来进行出队
                else if (p == q)
                    continue restartFromHead;
                else
                    p = q;
            }
        }
    }
    
    final void updateHead(Node h, Node p) {
        // cas更新head节点
        if (h != p && casHead(h, p))
            // //将旧的头结点指向自身以实现删除
            h.lazySetNext(h);
    }
复制代码

当前ConcurrentLinkedQueue:

第一次调用:由于head的item为空,走elas分支,p指向p的next节点(图中节点A),节点A的item不为null,cas将其item置为null(若cas失败再次重试),h仍然指向head节点,所以p != h,cas更新head节点:

第二次调用:直接进入if分支,但因为p=h,所以不更新head节点,head节点值为null

当head节点里有元素时,直接弹出head节点里的元素,而不会更新head节点。
只有当head节点里没有元素时,出队操作才会更新head节点,head节点不一定是头节点

HOPS

HOPS即跃数,通过解析offer以及poll方法可以看到tail、head节点并不一定是头、尾结点。

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

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

那么为什么这样设计?
我们以下方式实现offer方法:


    public boolean offer(E e) {
        if (e == null) {
            throw new NullPointerException();
        }
        Node n = new Node(e);
        for (; ; ) {
            Node t = tail;
            if (t.casNext(null, n) && casTail(t, n)) {
                return true;
            }
        }
    }
复制代码

让tail节点永远作为队列的尾节点,这样实现代码量非常少,而且逻辑清晰和易懂。但这样做有一个缺点,如果同时有大量的入队操作,每次都要CAS更新tail,汇总起来对性能也会是大大的损耗。如果能减少CAS更新tail节点的次数,就能提高入队的效率,所以才控制并减少tail节点的更新频率。但是又不能把HOPS值(默认等于1)设太大,因为太大的话就会需要多次循环才能定位出尾结点,但总体来说读的操作效率要远远高于写的性能。

感谢

《java并发编程的艺术》
https://juejin.cn/post/6844903602427805704#heading-6

分类:
后端
收藏成功!
已添加到「」, 点击更改