ConcurrentLinkedQueue 源码浅析
队列是一种常见的数据结构,主要特点是 FIFO,Java 为其定义了接口类:Queue,并提供了丰富的实现,有底层基于数组的[有界]队列,也有基于节点链接的无界队列,有阻塞队列,有非阻塞队列,还有并发安全的队列。
常见的队列实现的两种方式:数组、节点链接。
Java 对队列的基本实现在包:java.util 中,对并发安全实现主要存在于 Java 的 JUC 包下。
在使用 Java 的线程池工具:ThreadPoolExecutor,其使用阻塞队列来缓存任务,因为阻塞队列具备通知唤醒的功能,能够在任务添加或消耗时进行线程通知,同时保证了线程并发安全;而 JUC 中还有另外一种 Queue,是并发安全的非阻塞队列,那就是:ConcurrentLinkedQueue 。
适用场景
ConcurrentLinkedQueue 是 java 提供的一个无界非阻塞的 FIFO 队列,具备并发安全特性。适用于多线程共享访问相同的集合,要求多线程主动获取而不是线程阻塞等待通知;并且于队列的大小要求无限制,这常常是使用节点链接的形式来实现。不过,无界的场景也可能会导致内存占用过大。
底层实现(本文基于 jdk15 源码)
从名称我们可以看出来 ConcurrentLinedQueue 是基于节点链接的形式。节点的定义如下:
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) {
NEXT.set(this, next);
}
boolean casItem(E cmp, E val) {
return ITEM.compareAndSet(this, cmp, val);
}
}
内部属性只有 item、next,是一种常见的队列节点结构,使用链接形式。
ConcurrentLinkedQueue 的算法实现基于 Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms( Maged M. Michael , Michael L. Scott),是此论文中非阻塞算法的一种修改实现。
在并发过程中,不使用任何锁如:synchronized、Lock 等,而是纯粹通过 CAS 完成。同时,修改的部分主要有:基于 JVM 的回收环境,让元素能够被自动回收,不会存在 ABA 的循环引用导致回收不了的问题;还有,提供了 remove 操作,支持内部元素删除。
添加元素 add(E)
按照前述节点链接结构,节点通过 next 链接到下一节点,而新节点的 next 总是链接到 null 中。我们可以这样实现,每次添加到队列时,从 head 开始遍历到最后一个节点,并通过 CAS(next,E),这样,我们一定程度上完成了并发的安全性(这里尚未考虑删除情况)。
boolean add(E e){
E t = head; E next;
while(t != null){
if(t.next != null){
t = t.next; // 推进到下一节点
}else if(CAS(t.next,null,e)){ // CAS更新,失败则从头开始
return true;
}else{
t=head;
}
}
因为队列是无界的,所以此方法将会一直重试直到添加成功,并永远返回 true。这种实现虽然无锁,不过明显效率低下,所以我们在实现节点链接时,常常会引入 head、tail 指针来辅助推进,避免遍历的情况。同样地,ConcurrentLinkedQueue 使用了这种优化手段。在初始化时,队列就默认初始化了 head指针 和 tail指针,为 dummy 节点。
public ConcurrentLinkedQueue() {
head = tail = new Node<E>();
}
但是,next引用 与 tail指针 是两个不同的属性,而 CAS 在同一个时刻只能更新一个变量,如果想要确保 next引用 与 tail指针 的更新具备原子性,又回到了需要用到锁的境地。ConcurrentLinkedQueue 是如何解决这个问题的?
答案是:延缓更新。在 ConcurrentLinkedQueue 的注释文档中,Doug Lea 提及 LinkedTransferQueue 也同样使用了这种做法,称为:slack threshold。在每一次插入操作之后,tail指针 没有与 next节点 一起做原子性的更新操作。具体我们看下代码,在这里,我们模拟四个并发插入的线程:
/**
* 队列为无界,所以方法永远不返回 false <br/>
*/
public boolean offer(E e) {
final Node<E> newNode = new Node<E>(Objects.requireNonNull(e));
// p、q 前后节点
for (Node<E> t = tail, p = t;;) {
Node<E> q = p.next;
if (q == null) {
// 并发 CAS 控制入口
// p is last node; p 总是最后的节点(即tail)
if (NEXT.compareAndSet(p, null, newNode)) {
// 一次跳俩或多节点,允许失败。如果在 if 此处阻塞,将会出现 tail 滞后于 head 的情况 ✌
// (不一定是只有最后更新的 next 节点的线程才可以更新成功,也就是说更新后的 tail 不一定是最新最准确的)
// 如果不准确,后续的线程依然可以通过 next 往下推进
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 ...
}
}
① 当线程一添加元素时,head=tail=new Node(),将 p(=>pointer:指针) 赋值为 tail,将 q 赋值为 p 的下一个节点,即 p=tail.next=null,所以进入后线程一能直接 CAS 插入节点,此时 p=t=tail,所以第一个更新的线程满足 p!=t,不会更新 tail指针。
② 并发的另外三个线程因为 CAS失败,重新进入循环。此时 q = tail.next != null,进入第二个分支判断 p==q,这是插入时针对并发删除的分支处理。在没有删除操作时,p 总是不等于 q,因为 p = tail; q = p.next = tail.next
else if (p == q)
// 此处是与删除操作的多线程处理
// 前后节点相等,说明该节点的已经被执行过移除操作
// 节点移除后,如果此时 tail 未变更(可能阻塞了),也组织不了它被移除的命运。
// 此时,我们要做的是将指针跳到 head,一边它能够从头遍历。否则更新 tail 更好。
// 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;
目前在单纯的并发插入操作中,q 总是 p 的下一个节点(next),可能为新节点&