JCTools 并发无锁链表队列 LinkedQueue
BaseLinkedQueue
这个抽象类是 JCTools 中所有基于链表的、无锁并发队列的基石。它本身不是一个可以直接使用的队列,但它包含了实现高性能队列所需的核心结构、关键算法和并发控制思想。理解它,就能掌握 JCTools 队列设计的精髓。
首先,我们来看 BaseLinkedQueue
的继承链:
BaseLinkedQueue
-> BaseLinkedQueuePad2
-> BaseLinkedQueueConsumerNodeRef
-> BaseLinkedQueuePad1
-> BaseLinkedQueueProducerNodeRef
-> BaseLinkedQueuePad0
这个继承结构是为了解决一个高性能并发编程中的关键问题:伪共享 (False Sharing)。
CPU 为了提高性能,不是一个字节一个字节地从主内存读取数据,而是一块一块地读取,这一块数据被称为一个缓存行 (Cache Line)(在现代 CPU 上通常是 64 字节)。当一个 CPU 核心修改了其缓存中的某个数据时,为了保证数据一致性,其他 CPU 核心中包含该数据所在缓存行的缓存副本就会被标记为“无效”。
伪共享就发生在这里:如果两个不同的变量,被两个不同的线程频繁修改,而它们又恰好位于同一个缓存行中,那么一个线程对变量 A 的修改,会导致另一个线程的整个缓存行失效,迫使其重新从主内存加载数据,即使它只关心变量 B。这种不必要的缓存失效和重载,会极大地拖慢程序性能。
JCTools 通过在核心字段前后填充大量的无用字节(byte b000, b001...
)来解决这个问题。
// ...
abstract class BaseLinkedQueuePad0<E> extends AbstractQueue<E> implements MessagePassingQueue<E>
{// 这里填充了大量的 byte 字段,总计约 120 字节byte b000,b001,b002,b003,b004,b005,b006,b007;// 8b// ... more padding ...
}// $gen:ordered-fields
abstract class BaseLinkedQueueProducerNodeRef<E> extends BaseLinkedQueuePad0<E>
{// ...private volatile LinkedQueueNode<E> producerNode; // <--- 核心字段1
}abstract class BaseLinkedQueuePad1<E> extends BaseLinkedQueueProducerNodeRef<E>
{// 这里再次填充了 128 字节byte b000,b001,b002,b003,b004,b005,b006,b007;// 8b// ... more padding ...
}//$gen:ordered-fields
abstract class BaseLinkedQueueConsumerNodeRef<E> extends BaseLinkedQueuePad1<E>
{// ...private LinkedQueueNode<E> consumerNode; // <--- 核心字段2
}
// ...
BaseLinkedQueueProducerNodeRef
只包含生产者线程频繁修改的producerNode
(队尾指针)。BaseLinkedQueueConsumerNodeRef
只包含消费者线程频繁修改的consumerNode
(队头指针)。Pad
类 (BaseLinkedQueuePad0
,BaseLinkedQueuePad1
,BaseLinkedQueuePad2
) 在这两个核心字段之间和之后插入了足够的填充字节。
这样做的结果是,producerNode
和 consumerNode
被强制隔离到不同的缓存行中。生产者线程修改队尾时,不会影响消费者线程的缓存;反之亦然。这种设计以微小的空间开销,换取了极致的并发性能。
核心字段与队列结构
private volatile LinkedQueueNode<E> producerNode;
:指向队列的尾节点。生产者(Producer)线程只与它交互。volatile
保证了多生产者场景下的可见性。private LinkedQueueNode<E> consumerNode;
:指向队列的头节点。在 SPSC/MPSC(单消费者)模型中,只有消费者线程会修改它,所以不需要volatile
(通过Unsafe
的volatile
读写来保证跨线程可见性)。
这个队列是一个由 LinkedQueueNode
组成的单向链表。其结构有一个关键特点:consumerNode
指向的是一个哨兵节点 (Sentinel Node) 或称哑节点 (Dummy Node)。这个节点的值始终为 null
,真正的队头元素是 consumerNode.lvNext()
指向的节点。
[Node A] val=null [Node B] val="Item 1" [Node C] val="Item 2"
consumerNode ---^ ^ ^--- producerNode| |next ----------------------> next ---> null
使用哨兵节点的好处是简化了入队和出队操作的逻辑,避免了对空队列和只有一个元素的队列做大量的边界条件判断。
isEmpty()
public boolean isEmpty()
{LinkedQueueNode<E> consumerNode = lvConsumerNode();LinkedQueueNode<E> producerNode = lvProducerNode();return consumerNode == producerNode;
}
逻辑非常简单:当头指针和尾指针指向同一个(哨兵)节点时,队列为空。这里使用 lvConsumerNode()
和 lvProducerNode()
进行 volatile
读,确保能看到其他线程最新的修改。
poll()
(单消费者)
这是出队操作的核心,我们分步来看:
public E poll()
{final LinkedQueueNode<E> currConsumerNode = lpConsumerNode(); // 1. 获取当前哨兵节点LinkedQueueNode<E> nextNode = currConsumerNode.lvNext(); // 2. 获取真正的队头节点if (nextNode != null) // 3. 如果队头存在{return getSingleConsumerNodeValue(currConsumerNode, nextNode); // 4. 处理并返回}else if (currConsumerNode != lvProducerNode()) // 5. 队头不存在,但队列不为空{nextNode = spinWaitForNextNode(currConsumerNode); // 6. 自旋等待 next 指针可见return getSingleConsumerNodeValue(currConsumerNode, nextNode);}return null; // 7. 队列确定为空
}
lpConsumerNode()
: 普通读,因为只有当前消费者线程会修改它。lvNext()
:volatile
读,必须看到生产者线程对next
指针的更新。- 情况A (最常见):
nextNode
不为null
,说明队列中有元素,直接进入第4步。 getSingleConsumerNodeValue()
: 这是出队的关键辅助方法。它会:- 取出
nextNode
中的值。 - 将
nextNode
的值设为null
,帮助 GC。 - 将
currConsumerNode
的next
指针指向自己 (currConsumerNode.soNext(currConsumerNode)
)。用于切断已被消费节点与队列的联系,防止内存泄漏。 - 将
consumerNode
指针前移到nextNode
,使其成为新的哨兵节点。
- 取出
- 情况B (发生竞争):
nextNode
为null
,但consumerNode != producerNode
。这说明生产者线程已经创建了新节点并移动了producerNode
指针,但当前消费者线程还没能看到新节点对currConsumerNode.next
的链接更新。 spinWaitForNextNode()
: 在这种情况下,消费者线程会进入一个忙等待(自旋)循环,不断地volatile
读currConsumerNode.lvNext()
,直到它不再是null
。- 情况C:
nextNode
为null
且consumerNode == producerNode
,说明队列真的空了,返回null
。
relaxedPoll()
与 poll()
的区别在于,它在上述的“情况B”中不会自旋等待,而是直接返回 null
。这使得 relaxedPoll
是一个非阻塞的方法,适用于不希望线程被暂停的场景。
代码生成与模板化设计
在工程中看到的大量 Pad
类、atomic
包、unpadded
包下的相似文件,都揭示了 JCTools 的一个核心工程实践:代码生成。
BaseLinkedQueue.java
是一个模板源文件。- 构建脚本(如
JavaParsingAtomicLinkedQueueGenerator
)会读取这个模板。 - 根据不同的配置,脚本会自动生成多个变体:
atomic
包: 将基于Unsafe
的操作替换为基于AtomicReferenceFieldUpdater
的操作,以实现更好的跨平台兼容性(尽管性能稍低)。unpadded
包: 移除所有的缓存行填充,用于那些对内存占用极其敏感,且伪共享不是主要矛盾的场景。Unsafe
版本 (当前文件): 性能最高的版本,直接使用sun.misc.Unsafe
。
这种模板化设计极大地减少了代码的重复和维护成本,保证了不同队列变体在逻辑上的一致性。
总结
BaseLinkedQueue
是一个为极致性能而设计的并发队列基类。它通过精巧的缓存行填充继承结构来消除伪共享,利用哨兵节点简化算法,并通过 Unsafe
和精心设计的内存序来保证无锁操作的正确性。它不仅是一个高性能组件,更是学习现代并发编程底层技术的绝佳范例。
为什么只是为了单消费者设计
BaseLinkedQueue
及其子类(如 MpscLinkedQueue
, SpscLinkedQueue
)在设计上就是专门为单消费者(Single Consumer)场景优化的,绝对不能用于多消费者(Multi-Consumer)场景。
JCTools 的作者在代码中明确地标注了这一点。在 BaseLinkedQueue
的类注释中写道:
//...
/*** A base data structure for concurrent linked queues. For convenience also pulled in common single consumer* methods since at this time there's no plan to implement MC.*/
abstract class BaseLinkedQueue<E> extends BaseLinkedQueuePad2<E>
//...
这里的 “pulled in common single consumer methods” 和 “no plan to implement MC” (MC: Multi-Consumer) 已经清楚地说明了这个基类是为单消费者设计的。
在 poll()
和 peek()
方法的 Javadoc 中也反复强调:
//.../*** {@inheritDoc} <br>* <p>* IMPLEMENTATION NOTES:<br>* Poll is allowed from a SINGLE thread.<br>* ...*/@Overridepublic E poll()
//...
“Poll is allowed from a SINGLE thread.” 这句话是决定性的。
核心技术原因:消费端指针更新的非原子性
这是问题的根本所在。让我们深入分析 poll()
方法的执行过程,看看多消费者同时调用会发生什么。
poll()
的简化逻辑如下:
final LinkedQueueNode<E> currConsumerNode = lpConsumerNode();
获取当前的消费者节点(哨兵节点)。lpConsumerNode()
是一个普通读操作。LinkedQueueNode<E> nextNode = currConsumerNode.lvNext();
获取真正的队头元素节点。return getSingleConsumerNodeValue(currConsumerNode, nextNode);
如果nextNode
存在,就调用这个辅助方法来完成消费。
现在,我们来看 getSingleConsumerNodeValue
的关键操作:
//...protected E getSingleConsumerNodeValue(LinkedQueueNode<E> currConsumerNode, LinkedQueueNode<E> nextNode){// ... 获取值 ...final E nextValue = nextNode.getAndNullValue();// ...currConsumerNode.soNext(currConsumerNode);spConsumerNode(nextNode); // 关键步骤:更新 consumerNode 指针return nextValue;}
//...
这里的 spConsumerNode(nextNode)
最终执行的是 consumerNode = nextNode;
,这是一个普通写操作。
多消费者下的致命竞态条件 (Race Condition)
假设有两个消费者线程 C1 和 C2 同时执行 poll()
:
- C1 执行
lpConsumerNode()
,获取到当前的哨兵节点,记为NodeA
。 - C2 也执行
lpConsumerNode()
,它也获取到同一个哨兵节点NodeA
。 - C1 执行
currConsumerNode.lvNext()
,获取到真正的队头NodeB
。 - C2 也执行
currConsumerNode.lvNext()
,它也获取到同一个队头NodeB
。 - C1 进入
getSingleConsumerNodeValue
,从NodeB
中取出元素值,然后执行spConsumerNode(NodeB)
,将consumerNode
指针更新为NodeB
。 - C2 也进入
getSingleConsumerNodeValue
,它也尝试从NodeB
中取出元素值(此时可能已经为 null),然后它也执行spConsumerNode(NodeB)
,再次将consumerNode
指针更新为NodeB
。
灾难性后果:
- 元素被重复消费:C1 和 C2 消费了同一个元素
NodeB
。 - 队列状态不一致:
consumerNode
指针被错误地移动,或者说,它的移动没有正确地反映出两个元素被消费的事实。
为何如此设计?为了极致的性能!
既然多消费者不安全,为什么不使用原子操作(如 CAS)来更新 consumerNode
呢?
答案是:性能。
这是一个非常重要的设计权衡。在单消费者场景下,我们确信只有一个线程会更新 consumerNode
,因此完全不需要任何同步或原子操作。一个普通的读(lpConsumerNode
)和一个普通的写(spConsumerNode
)是 CPU 能执行的最快的内存操作。
如果为了支持多消费者,poll
方法必须改成类似这样的 CAS 循环:
// 这是一个伪代码,说明多消费者需要做什么
public E multiConsumerPoll() {while(true) {LinkedQueueNode<E> oldConsumerNode = lvConsumerNode(); // volatile 读LinkedQueueNode<E> nextNode = oldConsumerNode.lvNext();if (nextNode == null) {return null; // 队列空}// 尝试原子地将 consumerNode 从 old 更新为 nextif (casConsumerNode(oldConsumerNode, nextNode)) {// 成功!只有我这个线程成功了return nextNode.getValue();}// CAS 失败,说明有其他消费者线程抢先了,循环重试}
}
compareAndSwap
(CAS) 操作虽然是无锁的,但相比普通的读写操作,它的开销要大得多。
JCTools 的设计哲学是为特定场景提供极致优化的数据结构。对于生产者-消费者模型中非常常见的 SPSC(单产单消)和 MPSC(多产单消)场景,BaseLinkedQueue
通过放弃多消费者支持,换来了消费端无与伦比的性能。
总结
BaseLinkedQueue
不是一个通用的并发队列,它是一个特化的、为单消费者场景打造的高性能基类。
- 原因:其消费端的指针
consumerNode
的更新是通过非原子的普通读写操作完成的,这在多线程环境下会产生严重的竞态条件,导致数据被重复消费和队列状态损坏。 - 目的:这种设计是为了避免在消费端使用昂贵的原子操作(如 CAS),从而在确定的单消费者场景下,将出队(
poll
)操作的性能压榨到极致。
SpscLinkedQueue 和 MpscLinkedQueue
SpscLinkedQueue
是 JCTools 中最基础也是性能最高的队列之一。它的命名已经表明了其适用场景:SPSC,即 Single Producer, Single Consumer(单生产者,单消费者)。我们之前已经分析过 BaseLinkedQueue
是如何为单消费者优化的,现在我们来看 SpscLinkedQueue
是如何在上层为单生产者进行优化的。
核心优化点:放弃原子操作,使用普通读写 + 内存屏障
与 MpscLinkedQueue
(多生产者,单消费者)相比,SpscLinkedQueue
的核心区别和优化点在于其 offer
方法的实现。
我们先来看 SpscLinkedQueue
的 offer
方法:
// ... existing code ...@Overridepublic boolean offer(final E e){if (null == e){throw new NullPointerException();}// 1. 创建新节点final LinkedQueueNode<E> nextNode = newNode(e);// 2. 普通读,获取旧的尾节点LinkedQueueNode<E> oldNode = lpProducerNode();// 3. 带 StoreStore 屏障的普通写,更新尾节点指针soProducerNode(nextNode);// 4. 带 StoreStore 屏障的普通写,链接新节点oldNode.soNext(nextNode);return true;}
// ... existing code ...
再来看 MpscLinkedQueue
中对应的入队操作,它使用了一个关键的原子操作 xchgProducerNode
:
// ... existing code ...@Override@SuppressWarnings("unchecked")public boolean offer(final E e){if (null == e){throw new NullPointerException();}final LinkedQueueNode<E> nextNode = newNode(e);// 关键区别:使用原子交换操作final LinkedQueueNode<E> prevProducerNode = xchgProducerNode(nextNode);// Link node atomic...prevProducerNode.soNext(nextNode);return true;}
// ... existing code ...
xchgProducerNode
内部实现是 UNSAFE.getAndSetObject
,这是一个原子的“交换并返回旧值”操作。
private LinkedQueueNode<E> xchgProducerNode(LinkedQueueNode<E> newVal){// jdk8+ 优化if (UnsafeAccess.SUPPORTS_GET_AND_SET_REF){return (LinkedQueueNode<E>) UNSAFE.getAndSetObject(this, P_NODE_OFFSET, newVal);}else{LinkedQueueNode<E> oldVal;do{oldVal = lvProducerNode();}while (!UNSAFE.compareAndSwapObject(this, P_NODE_OFFSET, oldVal, newVal));return oldVal;}}
为什么 SPSC 可以放弃原子操作?
- 无竞争: 在 SPSC 模型中,我们确信只有一个生产者线程会访问和修改
producerNode
。因此,根本不存在多个线程同时修改producerNode
的竞态条件。 - 性能收益: 放弃
xchg
(一个昂贵的 CAS-like 原子操作) 而改用普通的读写 (lpProducerNode
和soProducerNode
),可以带来巨大的性能提升。普通读写是 CPU 能执行的最快的内存访问指令。
SPSC offer
方法的详细步骤分析
SpscLinkedQueue
的 offer
方法通过两步普通写操作和内存屏障来保证正确性:
LinkedQueueNode<E> oldNode = lpProducerNode();
lpProducerNode()
: Plain Load,普通加载。因为只有当前线程会写producerNode
,所以它自己读自己写过的值,不需要volatile
读。
soProducerNode(nextNode);
soProducerNode()
: Store-Ordered Store,这是一个带StoreStore
屏障的普通写。它内部调用UNSAFE.putOrderedObject
。- 作用: 这个写操作将队列的尾指针
producerNode
指向了新创建的nextNode
。StoreStore
屏障确保了在这次写入之前的所有普通写操作(比如nextNode
内部字段的初始化)都对其他线程可见。
oldNode.soNext(nextNode);
soNext()
: 同样是带StoreStore
屏障的普通写。- 作用: 这个写操作将旧的尾节点(
oldNode
)的next
指针指向了新的尾节点(nextNode
),从而将新节点正式链接到队列的尾部。
内存序的重要性:两步写入的顺序
这两步写入的顺序至关重要: 先更新 producerNode
,再链接 oldNode.next
。
// 步骤 2: soProducerNode(nextNode);
// 队列状态: producerNode 指向了 nextNode,但链表还没连上
// consumer 此时可能看到 producerNode 已经前移,但 oldNode.next 还是 null// 步骤 4: oldNode.soNext(nextNode);
// 队列状态: 链表连接完成,队列恢复一致状态
StoreStore
屏障:soProducerNode
和soNext
中的StoreStore
屏障保证了这两步写入不会被 CPU 或编译器重排序。即soProducerNode
的写入结果一定会先于soNext
的写入结果被其他线程观察到。- “气泡” (Bubble): 注释中提到了一个有趣的现象,如果在
soProducerNode
之后和oldNode.soNext
之前,生产者线程被中断,那么消费者线程可能会观察到一个短暂的“不一致”状态:它看到了新的producerNode
,但无法通过oldNode.next
找到它。这就是所谓的“气泡”。但由于消费者端的poll
方法中有自旋等待nextNode
的逻辑 (spinWaitForNextNode
),所以这个“气泡”是无害的,消费者会等待生产者完成链接操作。
与 MPSC 的对比总结
特性 | SpscLinkedQueue (单生产者优化) | MpscLinkedQueue (多生产者) |
---|---|---|
核心操作 | 普通读 (lp) + 有序写 (so) | 原子交换 (xchg) |
性能 | 极高。避免了昂贵的原子操作。 | 高。但原子操作在高争用下有开销。 |
线程安全 | 仅限单生产者。多生产者调用会破坏队列结构。 | 支持多生产者。通过原子操作保证线程安全。 |
实现复杂度 | 相对简单,依赖内存屏障保证顺序。 | 依赖硬件原子指令,逻辑更直接。 |
结论
SpscLinkedQueue
通过将多生产者场景下必需的原子操作 (xchg
) 替换为单生产者场景下成本极低的普通读写加上内存屏障 (lpProducerNode
, soProducerNode
, soNext
),实现了对单生产者入队操作的极致优化。这是典型的用放宽约束(从多生产者到单生产者)来换取性能的设计思想,也是 JCTools 这类高性能库的精髓所在。