并发集合类(2):ConcurrentLinkedQueue

2 阅读4分钟

什么是ConcurrentLinkedQueue?

ConcurrentLinkedQueueJava并发包里的一个线程安全无界非阻塞队列,基于链表实现,采用CAS算法保证并发安全。

其特点有如下:

  • 数据结构:单向链表实现链表队列

  • 线程安全:基于CAS实现的线程安全

  • 是否阻塞:基于CAS实现,因此不会阻塞

  • 是否有界:使用链表实现,无界,只受内存容量的限制

为什么要创造ConcurrentLinkedQueue?

ConcurrentLinkedQueue出现之前,我们需要同步队列的时候使用synchronizedArrayList等队列同步配合。但是加锁的消耗太大,在高并发的情况下,就会影响性能,ConcurrentLinkedQueue就是为了取代这类基于锁的同步队列方案,让我们在不阻塞线程的前提下,依然能安全地操作队列。

内部数据结构分析

Node节点

static final class Node<E> {
    volatile E item;
    volatile Node<E> next;


    Node(E item) {
        ITEM.set(this, item);
    }

    /** Constructs a dead dummy node. */
    Node() {}

    void appendRelaxed(Node<E> next) {
        // assert next != null;
        // assert this.next == null;
        NEXT.set(this, next);
    }

    boolean casItem(E cmp, E val) {
        // assert item == cmp || item == null;
        // assert cmp != null;
        // assert val == null;
        return ITEM.compareAndSet(this, cmp, val);
    }
}

这个Node类是ConcurrentLinkeQueue的核心静态内部类。 它只有两个数据:item以及next,并且都使用了volatile进行修饰。

   Node(E item) {
       ITEM.set(this, item);
   }

在有参构造中,使用的代码非常奇怪,不是常规的this.item=item,而是利用VarHandle来设置初值。

原因如下:

  1. itemvolatile字段,直接使用this.item=item会触发volatile写,JVM会强制插入内存屏障(StoreStoreStoreLoad),确保item的立即可见,但是这会带来额外的开销。
  2. 使用VarHandle.set()是一种relaxed write,不强制全局可见性,Node对象即便是在构造之后并不会被其他线程可见也没有关系,因为它并不会立即被其他线程访问,而是要链接到队列之后才必须能够被其他线程访问,同时链接到队列的方法是通过CAS来操作的,CAS可以保证可见性。

因此这是一种依赖CAS搭便车的机制:

  1. 节点对象只有在通过CAS链接到队列之后才会被其他线程感知。
  2. CAS本身就具备volatile语义,因此,在CAS链接到队列之后的任何对item的写入,即便是非volatile写,也会随着CAS的成功而对其他线程可见。
  3. 因为有CAS兜底,因此就完全没有必要插入内存屏障,带来额外的开销。

综上,这是一种无锁并发的优化手段,将对象的可见性推迟到真正需要的时候,从而减少不必要的同步成本。

构造方法

public ConcurrentLinkedQueue() {
    head = tail = new Node<E>();
}

以上是ConcurrentLinkedQueue的无参构造方法,很明显它使用了一个dummy节点,来简化链表的操作算法,并且初始时让头节点和尾节点都指向它。

ConcurrentLinkedQueue操作

offer操作

public boolean offer(E e) {
    final Node<E> newNode = new Node<E>(Objects.requireNonNull(e));

    for (Node<E> t = tail, p = t;;) {
        Node<E> q = p.next;
        if (q == null) {
            // p is last node
            if (NEXT.compareAndSet(p, 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; failure is OK
                    TAIL.weakCompareAndSet(this, t, newNode);
                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
            // Check for tail updates after two hops.
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

offer方法只要不提供NULL,因为CLQ使用链表存储,是无界队列,因此一定返回true

  1. 检查对象是否为NULL,并且包装成节点。
  2. t记录当前的尾节点,并用p记录当前尝试连接的位置,初始是当前的尾节点
    • 如果p的后继节点是NULL,则尝试CAS将节点加入,当节点加入成功后,再判断p是不是之前记录的尾节点,如果不是,则尝试更新尾节点。
    • 如果p的后继节点是p,说明p已经出队了(节点出队时会自引用),此时如果尾节点已经变了,就更新t,并且p重新从t出发,如果尾节点没变,将p从头节点开始。
    • 最后的情况是p的后继不是NULL,但是p也没有出队,此时如果p不是原tail同时tail被更新,更新t,将p跳转到新的tail。否则p前进。

CLQ在多线程的情况下将节点入队时,tail并不总是真正的尾节点,这也是为什么需要p从头节点开始的原因。

poll操作

poll操作弹出第一个元素,如果队列为空则返回null

  1. 整体使用goto语句。
  2. 从头节点head开始,初始状态hp都指向head
    • 利用局部变量保存p的item,当pitem不等于空的时候,使用cas将其置为null,如果p不等于h,说明p已经移动过了,此时更新头节点
    • 如果p的后继q是空,则返回空
    • 如果pq相等,说明p已经被弹出,需要重新循环。

当节点出队的时候,会自引用自身。