当前位置: 首页 > news >正文

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 类 (BaseLinkedQueuePad0BaseLinkedQueuePad1BaseLinkedQueuePad2) 在这两个核心字段之间和之后插入了足够的填充字节。

这样做的结果是,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. 队列确定为空
}
  1. lpConsumerNode(): 普通读,因为只有当前消费者线程会修改它。
  2. lvNext()volatile 读,必须看到生产者线程对 next 指针的更新。
  3. 情况A (最常见)nextNode 不为 null,说明队列中有元素,直接进入第4步。
  4. getSingleConsumerNodeValue(): 这是出队的关键辅助方法。它会:
    • 取出 nextNode 中的值。
    • 将 nextNode 的值设为 null,帮助 GC。
    • 将 currConsumerNode 的 next 指针指向自己 (currConsumerNode.soNext(currConsumerNode))。用于切断已被消费节点与队列的联系,防止内存泄漏。
    • 将 consumerNode 指针前移到 nextNode,使其成为新的哨兵节点。
  5. 情况B (发生竞争)nextNode 为 null,但 consumerNode != producerNode。这说明生产者线程已经创建了新节点并移动了 producerNode 指针,但当前消费者线程还没能看到新节点对 currConsumerNode.next 的链接更新。
  6. spinWaitForNextNode(): 在这种情况下,消费者线程会进入一个忙等待(自旋)循环,不断地 volatile 读 currConsumerNode.lvNext(),直到它不再是 null
  7. 情况CnextNode 为 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 及其子类(如 MpscLinkedQueueSpscLinkedQueue)在设计上就是专门为单消费者(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() 的简化逻辑如下:

  1. final LinkedQueueNode<E> currConsumerNode = lpConsumerNode(); 获取当前的消费者节点(哨兵节点)。lpConsumerNode() 是一个普通读操作。
  2. LinkedQueueNode<E> nextNode = currConsumerNode.lvNext(); 获取真正的队头元素节点。
  3. 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()

  1. C1 执行 lpConsumerNode(),获取到当前的哨兵节点,记为 NodeA
  2. C2 也执行 lpConsumerNode(),它也获取到同一个哨兵节点 NodeA
  3. C1 执行 currConsumerNode.lvNext(),获取到真正的队头 NodeB
  4. C2 也执行 currConsumerNode.lvNext(),它也获取到同一个队头 NodeB
  5. C1 进入 getSingleConsumerNodeValue,从 NodeB 中取出元素值,然后执行 spConsumerNode(NodeB),将 consumerNode 指针更新为 NodeB
  6. 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 可以放弃原子操作?

  1. 无竞争: 在 SPSC 模型中,我们确信只有一个生产者线程会访问和修改 producerNode。因此,根本不存在多个线程同时修改 producerNode 的竞态条件。
  2. 性能收益: 放弃 xchg (一个昂贵的 CAS-like 原子操作) 而改用普通的读写 (lpProducerNode 和 soProducerNode),可以带来巨大的性能提升。普通读写是 CPU 能执行的最快的内存访问指令。

SPSC offer 方法的详细步骤分析

SpscLinkedQueue 的 offer 方法通过两步普通写操作和内存屏障来保证正确性:

  1. LinkedQueueNode<E> oldNode = lpProducerNode();

    • lpProducerNode(): Plain Load,普通加载。因为只有当前线程会写 producerNode,所以它自己读自己写过的值,不需要 volatile 读。
  2. soProducerNode(nextNode);

    • soProducerNode(): Store-Ordered Store,这是一个带 StoreStore 屏障的普通写。它内部调用 UNSAFE.putOrderedObject
    • 作用: 这个写操作将队列的尾指针 producerNode 指向了新创建的 nextNodeStoreStore 屏障确保了在这次写入之前的所有普通写操作(比如 nextNode 内部字段的初始化)都对其他线程可见。
  3. 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) 替换为单生产者场景下成本极低的普通读写加上内存屏障 (lpProducerNodesoProducerNodesoNext),实现了对单生产者入队操作的极致优化。这是典型的用放宽约束(从多生产者到单生产者)来换取性能的设计思想,也是 JCTools 这类高性能库的精髓所在。

http://www.dtcms.com/a/346402.html

相关文章:

  • 洛谷P3370字符串哈希(集合:Hash表)
  • Ubuntu解决makefile交叉编译的问题
  • 提升用户体验的交互设计实战指南:方法、流程与技巧
  • 在通义灵码中配置MCP服务
  • Linux--进程核心概念
  • 基于SamGeo模型和地图客户端的实时图形边界提取
  • 把 AI 变成「会思考的路灯」——基于自学习能耗模型的智慧路灯杆
  • Open3d:点对点ICP配准,点对面ICP配准
  • 105.QML实现现代Neumorphism风格界面01-Button实现
  • 如何提升科研能力:先停止“无效工作”,开始“有效科研”
  • 第二节阶段WinFrom-5:文件操作
  • 车载诊断架构 --- EOL引起关于DTC检测开始条件的思考
  • Linux822 shell:expect 批量
  • 《C++起源与核心:版本演进+命名空间法》
  • 易基因:Nat Commun/IF15.7:多组学研究揭示UHRF2在原始生殖细胞DNA甲基化重编程中的抗性调控机制
  • 光耦合器:电子世界的 “光桥梁“
  • Opnecv详细介绍
  • 量子计算基础
  • C#_组合优于继承的实际应用
  • 音视频处理工作室:实时通信的媒体层设计
  • 容器操作案例
  • C语言——内存函数
  • TTS文字合成语音芯片的使用场景
  • No module named blake2b
  • GaussDB GaussDB 数据库架构师修炼(十八)SQL引擎(1)-SQL执行流程
  • ODDR双边沿数据输出
  • 1小时检测cAMP的武功秘籍
  • AI 绘画争议背后:版权归属、艺术原创性与技术美学的三方博弈
  • Linux系统安装llama-cpp并部署ERNIE-4.5-0.3B
  • Unity--判断一个点是否在扇形区域里面(点乘和叉乘的应用)