ConcurrentLinkedDeque 源码全景:双向无锁队列与并发操作
0 引言:当单向无锁队列不再够用
前文《ConcurrentLinkedQueue 与无锁队列原理》展示了如何在单向链表上通过精巧的 CAS 和 Hop 设计实现高性能的无锁队列。然而,当我们需要在两端同时进行高效的入队和出队——例如工作窃取算法中,线程从本地头部消费,从其他队列尾部窃取——单向队列就力不从心了。ConcurrentLinkedDeque 正是为了满足这种需求而生:它通过双向链表和更复杂的 CAS 自旋算法,在完全无锁的情况下,实现了双端并发操作,是 JDK 无锁并发数据结构的巅峰之作。
“如何在双向链表上实现无锁并发?多个线程同时在头部插入和删除,如何保证链表不被破坏?为什么删除一个节点要先‘逻辑删除’再‘物理删除’?skipDeletedPredecessors 和 skipDeletedSuccessors 是做什么的?ConcurrentLinkedDeque 是如何应用于 ForkJoinPool 的工作窃取机制的?”
这些问题的答案,都隐藏在 ConcurrentLinkedDeque 复杂的 CAS 操作和自旋逻辑中。本文将从双向 Node 结构出发,深入拆解其在头部/尾部进行无锁插入与删除的源码实现,并揭示其在并发编程中的应用价值。
核心要点
- 双向 Node:
volatile prev/next/item,逻辑删除与物理删除分离 - 并发插入:
linkFirst/linkLast的 CAS 自旋与哨兵更新 - 并发删除:
unlinkFirst/unlinkLast的冲突处理与辅助清理 - 节点断开:
unlink方法如何短路双向链表 - Hop 设计:
head/tail延迟更新以减少竞争 - 工程应用:
ForkJoinPool工作窃取的双端队列基础
文章组织架构
flowchart TB
A["1. 总论:双向无锁并发设计蓝图"] --> B["2. 双向 Node 结构:volatile 三剑客与逻辑删除"]
B --> C["3. 头部/尾部并发插入:linkFirst 与 linkLast 的 CAS 自旋"]
C --> D["4. 头部/尾部并发删除:unlinkFirst/unlinkLast 的复杂自旋"]
D --> E["5. 双向断开与辅助清理:unlink 方法与跳跃清理"]
E --> F["6. Hop 设计:head/tail 延迟更新的精妙平衡"]
F --> G["7. 与 ConcurrentLinkedQueue 的对比及工作窃取应用"]
G --> H["8. 面试高频专题"]
classDef process fill:#f1f5f9,stroke:#334155,color:#1e293b
class A,B,C,D,E,F,G,H process
分层说明:第1章建立设计蓝图;第2-6章是全文核心,逐步拆解结构、插入、删除、清理和哨兵更新;第7章进行对比并回归工程应用;第8章提供面试高频题的系统解答。关键结论:ConcurrentLinkedDeque 是 JDK 无锁并发容器的集大成者。它通过在双向链表上实施精细的 CAS 操作、逻辑删除与物理删除分离、以及延迟更新哨兵的 Hop 设计,实现了在极端并发场景下的高性能双端操作,是理解无锁并发编程和 ForkJoinPool 工作窃取算法的基石。
1. 总论:双向无锁并发设计蓝图
在深入具体的 linkFirst 或 unlink 源码之前,我们必须先在脑海中建立一幅完整的设计蓝图。ConcurrentLinkedDeque 绝非简单地在单向队列上增加一个 prev 指针——它是一套为解决双端无锁并发操作而生的精密算法体系。本章将从“是什么”、“为什么”、“如何做”三个层面,系统勾勒其完整轮廓。后续所有章节,都是对本节总论的逐层展开与求证。
1.1 是什么:定义与语义特性
ConcurrentLinkedDeque<E> 是 java.util.concurrent 包提供的一个基于双向链表、完全无锁(lock-free)、非阻塞的并发双端队列。其核心语义可凝练为五点:
-
无界(Unbounded)
基于链表节点动态创建,队列容量仅受限于堆内存,不会在队列满时阻塞生产者。 -
完全无锁与非阻塞(Lock-Free & Non-blocking)
所有公开方法均不使用任何互斥锁(synchronized或Lock)。线程在操作时最多进行自旋重试,永远不会被挂起,保证了极高并发下的低延迟与高吞吐。 -
弱一致性(Weakly Consistent)
size()、peek()、迭代器等批量或观察性操作返回的是近似结果,反映某个瞬间或跨瞬间的状态快照。这是高并发性能的必要妥协,也是 JDK 并发容器的常规设计取舍。 -
双端操作
支持在头部(First)和尾部(Last)进行 O(1) 的插入和删除,允许“头部消费、尾部窃取”或“双向存取”等灵活模式。 -
自动清理
通过“逻辑删除”标记和协作式的“物理删除”机制,链表不会积累垃圾节点。任何线程在遍历中遇到item == null的节点,都可顺手帮助将其从链表中断开。
1.2 为什么:设计动机与核心价值
单向无锁队列(如 ConcurrentLinkedQueue)在经典的生产者-消费者模型中表现出色——生产者仅在尾部追加,消费者仅在头部提取。然而,当应用需要分离竞争源时,单方向的约束便成为瓶颈。最典型的场景是工作窃取(Work-Stealing)调度(如 ForkJoinPool):
- 本地线程 以 LIFO 顺序从自己队列的头部消费任务,利用 CPU 缓存局部性。
- 空闲线程 以 FIFO 顺序从其他队列的尾部窃取任务,保证公平性并避免与本地线程争抢头部。
如果使用单向队列,窃取者和所有者将竞争同一个端(头部),导致激烈的 CAS 冲突。双向队列通过物理隔离操作端(头尾部操作分别作用于 prev 和 next 链),极大降低了竞争,使得局部调度与全局负载均衡可以并行不悖。这正是 ConcurrentLinkedDeque 存在的核心价值。
1.3 如何做:架构全景与无锁算法地图
整个队列的设计由两个核心类构成:ConcurrentLinkedDeque<E> 本身,以及静态内部类 Node<E>。它们协同工作,形成一套完整的无锁并发体系。
1.3.1 静态结构:核心类与关系
classDiagram
class ConcurrentLinkedDeque~E~ {
-volatile Node~E~ head
-volatile Node~E~ tail
+addFirst(E e) void
+addLast(E e) void
+pollFirst() E
+pollLast() E
+remove(Object o) boolean
-linkFirst(E e) void
-linkLast(E e) void
-unlinkFirst(Node~E~ first, Node~E~ next) void
-unlinkLast(Node~E~ last, Node~E~ prev) void
-unlink(Node~E~ x) void
-updateHead() void
-updateTail() void
-skipDeletedPredecessors(Node~E~ start) Node~E~
-skipDeletedSuccessors(Node~E~ start) Node~E~
}
class Node~E~ {
-volatile E item
-volatile Node~E~ prev
-volatile Node~E~ next
+Node()
+Node(E item)
+casItem(E cmp, E val) boolean
+casPrev(Node~E~ cmp, Node~E~ val) boolean
+casNext(Node~E~ cmp, Node~E~ val) boolean
+lazySetNext(Node~E~ val) void
+lazySetPrev(Node~E~ val) void
}
ConcurrentLinkedDeque~E~ *-- "2" Node~E~ : head / tail
Node~E~ <-- Node~E~ : prev / next
图1-1:ConcurrentLinkedDeque 类图
- a) 主旨概括:静态展示了
ConcurrentLinkedDeque与内部类Node的字段、核心方法及其相互关系,是代码结构的精确映射。 - b) 逐元素分解:
ConcurrentLinkedDeque:持有两个volatile的哨兵引用head和tail,它们指向Node实例。公开方法提供了双端操作接口,私有方法(linkFirst,unlink,skipDeleted*等)封装了无锁算法。Node:三个volatile字段(prev,item,next)构成双向链节点。提供了一系列 CAS 和lazySet方法,这些方法是所有无锁操作的基础。
- c) 设计原理映射:组合关系:
ConcurrentLinkedDeque通过head/tail持有链表端点,节点之间通过prev/next形成自引用结构,构成一个完整的双向链表。所有字段的volatile和 CAS 方法均在Node层实现,保证了操作的原子性与可见性。 - d) 工程联系与关键结论:这个类图是源码剖析的导航图。后续所有关于
linkFirst、unlink、updateHead的讨论,本质上都是在解释这些方法如何通过调用Node的 CAS 操作,在head/tail引导下操纵双向链表。理解这一静态结构,是动态分析无锁算法的先决条件。
1.3.2 动态结构:运行时的典型状态
flowchart LR
subgraph 队列动态结构
direction LR
H["head<br>(可能滞后)"] --> A
A["Node A<br>item=有效数据<br>prev=null"]
B["Node B<br>item=有效数据"]
C["Node C<br>item=null<br>(已逻辑删除)"]
D["Node D<br>item=有效数据<br>next=null"]
T["tail<br>(可能滞后)"]
A <--> B
B <--> C
C <--> D
T -..-> C
end
图1-2:ConcurrentLinkedDeque 运行时状态
- a) 主旨概括:展示了一个运行时队列的双向链表结构,包含滞后哨兵、有效节点、已逻辑删除节点以及它们之间的
prev/next双向指针关系。 - b) 逐元素分解:
head引用指向节点 A(真头),tail引用则滞后地指向节点 C(已逻辑删除)。- 节点 A 的
prev为null,item有效;节点 D 的next为null,item有效,是真正的尾节点。 - 节点 C 的
item已为null,表示它已被逻辑删除,但其prev(指向 B)和next(指向 D)仍然保留,等待物理断开。
- c) 设计原理映射:滞后哨兵是 Hop 设计的直观体现;
item == null节点是逻辑删除与物理删除分离策略的中间产物;所有指针的修改都依赖volatile保证可见性。 - d) 工程联系与关键结论:这张图是理解后续所有插入/删除算法的起点。任何线程在遍历时,都必须具备从这种“非精确哨兵 + 混合有效/无效节点”的结构中,快速定位真实头尾的能力——这正是
skipDeleted*方法和哨兵更新逻辑存在的意义。
1.3.3 核心流程与无锁算法总览
所有操作都围绕 CAS 自旋展开,可按类别归纳为下表:
| 操作类别 | 核心方法 | 算法要点 |
|---|---|---|
| 头部/尾部插入 | linkFirst, linkLast | ①从哨兵出发遍历,定位当前真实边界节点;②用 lazySet 建立新节点到边界节点的单向连接;③一次关键 CAS 原子地将边界节点的 prev/next 从 null 改为新节点;④尝试更新 head/tail(允许失败) |
| 头部/尾部删除 | unlinkFirst, unlinkLast | ①CAS 将目标节点的 item 置 null(逻辑删除);②将目标节点的边界指针自指(标记已删除);③CAS 断开后继/前驱与目标的连接;④更新哨兵 |
| 中间节点删除 | unlink(Node) | ①节点已逻辑删除;②通过 skipDeletedPredecessors/Successors 找到有效邻居;③两次 CAS 将有效前驱与后继直接相连,短路掉目标 |
| 辅助清理 | skipDeletedPredecessors, skipDeletedSuccessors | 在遍历或操作中遇到 item == null 的节点时,主动跳过并帮助完成物理断开,维持链表“清洁” |
所有方法遵循统一原则:每次 CAS 必须校验预期值,失败即自旋重试;每次操作前必须先跳过已逻辑删除的节点。这种“乐观尝试—失败帮助—重试”的协作模式,使多个并发操作能安全交织。
1.3.4 并发操作时序示例
以两个线程在头部并发插入和删除为例:
sequenceDiagram
participant T1 as 线程1 (pollFirst)
participant T2 as 线程2 (addFirst)
participant Deque as ConcurrentLinkedDeque
T2->>Deque: linkFirst(X)
activate T2
T2->>Deque: 定位 first = A
T2->>Deque: CAS A.prev null→X
Note over Deque: T2 成功插入 X 为头
deactivate T2
T1->>Deque: pollFirst() 定位 first = X
activate T1
T1->>Deque: CAS X.item 有效值→null (逻辑删除)
Note over Deque: T1 成功占有删除权
T1->>Deque: unlinkFirst: CAS X.prev→X (自指标记)
T1->>Deque: CAS A.prev X→null (断开)
Note over Deque: 物理删除完成,A 重新成为头
deactivate T1
图1-3:并发头部插入与删除时序图
- a) 主旨概括:展示两个线程在头部并发插入和删除的完整时序,揭示 CAS 原子性如何保证无锁下的状态一致。
- b) 逐元素分解:线程2将节点X插入为头,紧接着线程1将其删除。整个过程没有互斥,仅靠 CAS 的原子性和重试保证链表状态一致。自指标记(
X.prev = X)使任何并发遍历能立即识别并跳过已删除节点。 - c) 设计原理映射:逻辑删除(
item = null)确立删除的线性化点,自指标记作为并发栅栏防止误连,CAS 的条件校验保证修改只在预期状态下生效。 - d) 工程联系与关键结论:这张时序图是无锁并发“乐观尝试—失败重试”哲学的具象化。插入与删除在无锁环境下的安全交织,证明了 CAS 作为原子原语的强大表达力。
1.4 设计精髓:三大无锁策略
贯穿整个 ConcurrentLinkedDeque 实现的,是三个深刻的设计思想:
-
Hop 设计(哨兵延迟更新)
head和tail不总是精确指向边界,允许滞后数个节点。这样连续多次插入或删除可共享一次哨兵更新,避免热点字段上的激烈 CAS 竞争,是高吞吐量的关键。 -
逻辑删除与物理删除分离
删除操作首先将itemCAS 为null,这是线性化点(linearization point),宣告元素逻辑失效。物理指针断开可由任意线程在后续完成,甚至由多个线程协作。这种分离保证了删除语义立即生效,且不阻塞其他操作。 -
协作式清理(Helping)
线程在遍历链表时遇到item == null的垃圾节点,会主动调用skipDeleted*或unlink帮助清理。链表因此具有“自愈”能力,整体健康度由所有线程共同维护,无需专职清洁线程。
1.5 适用场景概览
- 首选场景:需要双端操作的高并发环境,如工作窃取调度器(
ForkJoinPool)、支持“放回头部”的异步任务系统、灵活存取的无界缓冲区。 - 可用场景:普通 FIFO/LIFO 场景也可用,但若明确只需单向操作,
ConcurrentLinkedQueue或LinkedBlockingDeque可能在复杂度和常数开销上更有优势。 - 不宜场景:要求强一致性迭代或快照(需加锁包装)、需要有界阻塞(应使用
LinkedBlockingDeque)。
关键结论:ConcurrentLinkedDeque 的架构本质是在双向链表上以 CAS 为原子原语,通过逻辑删除与物理删除分离、哨兵延迟更新、协作式清理三大策略,实现高性能、无锁的双端并发操作。理解了这张蓝图,后续对每个方法的源码剖析将不再是孤立的细节,而是对蓝图中某个模块的精确验证。
2. 双向 Node 结构:volatile 三剑客与逻辑删除
总论已经阐明:ConcurrentLinkedDeque 的一切无锁并发操作,都建立在精心设计的 Node<E> 结构之上。它是构成双向链表的原子单元,也是所有 CAS 操作的直接作用对象。本章将深入拆解 Node 的三个 volatile 字段、配套的 CAS 与延迟写方法,并揭示“逻辑删除”这一核心约定的设计原理。
2.1 Node 的静态定义与字段语义
JDK 8 中 ConcurrentLinkedDeque.Node<E> 的源码(精简后)如下:
static final class Node<E> {
volatile Node<E> prev;
volatile E item;
volatile Node<E> next;
// 空构造,用于哨兵节点
Node() {}
// 带 item 的构造,通过 UNSAFE 直接写 volatile 字段
Node(E item) {
UNSAFE.putObject(this, itemOffset, item);
}
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
void lazySetNext(Node<E> val) {
UNSAFE.putOrderedObject(this, nextOffset, val);
}
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
void lazySetPrev(Node<E> val) {
UNSAFE.putOrderedObject(this, prevOffset, val);
}
boolean casPrev(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, prevOffset, cmp, val);
}
// 字段偏移量(通过 static 块计算,略)
}
三个 volatile 字段构成“三剑客”:
| 字段 | 类型 | 语义 |
|---|---|---|
prev | Node<E> | 指向前驱节点。若为 null,则本节点可能是头节点(或未连接);若指向自身(this),表示该节点已从头部/尾部物理删除并标记。 |
item | E | 存储的元素。为 null 即表示该节点已被逻辑删除,不再代表有效元素。 |
next | Node<E> | 指向后继节点。若为 null,则本节点可能是尾节点(或未连接);若指向自身,语义类似 prev 自指。 |
三个字段均被声明为 volatile,保证:
- 可见性:任一 CPU 核心对这些字段的写操作,会立刻被其他核心看到(通过内存屏障)。
- 禁止重排序:对
prev、item、next的读写顺序不会被编译器或 CPU 随意重排,这在无锁算法中至关重要。
所有 CAS 方法均基于 Unsafe 提供的原子指令。此外,Node 还提供了 lazySetNext 和 lazySetPrev,它们使用 putOrderedObject,即低成本的延迟写:不保证立即被其他线程看见,但最终一定可见,且不引入全屏障。这在某些“先建立单向链、后 CAS 抢占”的流程中能显著提升性能。
2.2 逻辑删除:item 置 null 的两阶段策略
在 ConcurrentLinkedDeque 中,删除一个节点的第一步,永远是将它的 item 字段通过 CAS 原子地置为 null。这个操作称为 逻辑删除。一旦某个线程成功将节点 x 的 item 从有效值改为 null,其他所有线程在遍历到 x 时,会立即识别出“该节点已无效”,并跳过它。
删除操作的完整生命周期分为两阶段:
- 逻辑删除:
itemCAS →null。这是删除的线性化点(linearization point)——对于外界观察者,该元素在这一刻被移除。 - 物理删除:通过
unlink、unlinkFirst、unlinkLast等方法,修改prev/next指针,将节点从链表中彻底断开。
为什么必须分两步?
因为在无锁环境下,没有互斥锁可以让我们原子地同时修改三个节点的指针(前驱、待删节点、后继)。先做逻辑删除,相当于向所有并发线程“广播”该节点已失效;之后物理断开可以在任意时间、由任意线程代为完成,甚至由多个线程协作推进。这种分离是保证无阻塞删除的基石。
2.3 哨兵节点:head 与 tail 的滞后角色
ConcurrentLinkedDeque 内部持有两个 volatile 哨兵引用:
private transient volatile Node<E> head;
private transient volatile Node<E> tail;
它们并不总是指向链表真正的头节点或尾节点,这是刻意为之的“Hop 设计”。head 指向的节点可能其 prev 并不为 null(即它不是真正的头),tail 指向的节点可能其 next 并不为 null。那么,真正的边界节点如何定位?——遍历时主动沿着 prev 或 next 前进,直到找到 prev == null 且 item != null 的节点(真头),或 next == null 且 item != null 的节点(真尾)。这个过程在 linkFirst、pollFirst 等方法中都有体现。
哨兵滞后的根本目的是减少对 head/tail 的 CAS 竞争。如果每次插入删除都必须即时更新哨兵,这两个字段将成为整个队列的瓶颈。允许滞后,使得连续操作可以共享一次哨兵推进,极大提升吞吐量。
2.4 双向链表结构图:直观呈现 volatile 三剑客
下面这张图展示了一个 ConcurrentLinkedDeque 运行中的典型双向链表形态,涵盖有效节点、已逻辑删除节点,以及滞后的 head/tail:
flowchart LR
subgraph 双向链表结构
direction LR
H["head<br>(滞后)"] --> A
A["Node A<br>item=数据<br>prev=null"]
B["Node B<br>item=数据"]
C["Node C<br>item=null<br>(逻辑删除)"]
D["Node D<br>item=数据<br>next=null"]
T["tail<br>(滞后)"]
A <--> B
B <--> C
C <--> D
T -..-> C
end
图 2-1:ConcurrentLinkedDeque 双向链表结构图
- a) 主旨概括:此图完整展示了一个运行时队列的双向链表结构,包含滞后哨兵、有效节点、已逻辑删除节点以及它们之间的
prev/next双向指针关系。 - b) 逐元素分解:
head引用指向节点 A(真头),tail引用则滞后地指向节点 C(已逻辑删除)。- 节点 A 的
prev为null,item有效;节点 D 的next为null,item有效,是真正的尾节点。 - 节点 C 的
item已为null,表示它已被逻辑删除,但其prev(指向 B)和next(指向 D)仍然保留,等待物理断开。 - 箭头
<-->表示双向链接,虚线表示哨兵引用允许不精确。
- c) 设计原理映射:滞后哨兵是 Hop 设计的直观体现;
item == null节点是逻辑删除与物理删除分离策略的中间产物;所有指针的修改都依赖volatile保证可见性。 - d) 工程联系与关键结论:这张图是理解后续所有插入/删除算法的起点。任何线程在遍历时,都必须具备从这种“非精确哨兵 + 混合有效/无效节点”的结构中,快速定位真实头尾的能力——这正是
skipDeleted*方法和哨兵更新逻辑存在的意义。
2.5 从 Node 到算法:本章在全文中的位置
理解了 Node 的三个 volatile 字段和逻辑删除约定,就等于拿到了打开 ConcurrentLinkedDeque 大门的钥匙。接下来的章节将逐一展开:
- 第3章:如何在这样的双向链表上,通过 CAS 自旋实现安全的头部/尾部并发插入(
linkFirst/linkLast)。 - 第4章:如何处理复杂的并发删除(
unlinkFirst/unlinkLast),以及自指标记与并发冲突化解。 - 第5章:如何对中间节点进行物理断开(
unlink),以及skipDeletedPredecessors/skipDeletedSuccessors的跳跃清理机制。 - 第6章:Hop 设计的具体实现——
updateHead/updateTail的延迟更新策略。
关键结论:Node 是整个 ConcurrentLinkedDeque 的基因。三个 volatile 字段提供了无锁算法所需的最小可见性保障;item == null 的逻辑删除约定,使得删除操作可以安全地分步完成;而 CAS 与 lazySet 的区分使用,则展示了 Doug Lea 对性能的极致追求。掌握这些微观结构,是驾驭宏观算法的先决条件。
3. 头部/尾部并发插入:linkFirst 与 linkLast 的 CAS 自旋
总论已经勾勒出无锁算法的蓝图,第2章拆解了 Node 的 volatile 三剑客。本章将深入第一个核心操作:如何在双向链表的头部和尾部,通过有限的 CAS 操作实现无锁并发插入。我们将逐行拆解 linkFirst 和 linkLast 的源码,揭示其精密的 CAS 自旋、单向连接预热、以及哨兵延迟更新策略。
3.1 linkFirst:抢占链表的头部
linkFirst(E e) 是头部插入的核心方法,它负责将一个新节点安插为当前链表的头节点。由于没有锁保护,多线程可能同时向头部插入,该方法必须仅依赖 CAS 自旋来确保正确性。
3.1.1 源码全景与逐步拆解
以下是 JDK 8 中 linkFirst 的简化源码(保留了核心逻辑):
private void linkFirst(E e) {
final Node<E> newNode = new Node<E>(Objects.requireNonNull(e));
restartFromHead:
for (;;) {
// 步骤1:从 head 出发,沿 prev 寻找真正的 first 节点
for (Node<E> p = head;;) {
Node<E> q;
// 步骤2:跳过所有 item == null 的逻辑删除节点
while ((q = p.prev) != null && (q.item == null)) {
p = q;
}
// 此时 p.prev == null 或 p.prev.item != null
Node<E> first = p; // first 是当前的真正头节点
// 步骤3:用 lazySet 建立新节点 → first 的单向连接
newNode.lazySetNext(first);
// 步骤4:【关键 CAS】原子地将 first.prev 从 null 改为 newNode
if (first.casPrev(null, newNode)) {
// 步骤5:CAS 成功,新节点正式成为头节点
// 如果 first 在此期间发生了变化,需要重试
if (p != first)
continue restartFromHead;
// 步骤6:尝试更新 head 哨兵(允许滞后)
updateHead();
return;
}
// CAS 失败,说明有其他线程抢先修改了 first.prev
// 内层循环继续,重新寻找 first 并重试
}
}
}
逐步解读:
步骤1:定位真正的头节点。
head 哨兵可能滞后(指向的不是真正的头),因此不能直接使用 head 作为 first。代码从 head 出发,沿着 prev 指针向前搜索。真正的头节点满足:它的 prev == null(没有前驱),且它的 item != null(未被逻辑删除)。
步骤2:跳过已逻辑删除的节点。
在向前搜索的过程中,遇到的每个节点的 item 都会被检查。如果 item == null(表示该节点已被逻辑删除),线程会继续向前,将这些“死节点”跳过。这个过程中,线程实际上在帮助清理——它不会在已删除的节点上插入,也避免了新节点被连接到垃圾节点之前。
步骤3:lazySetNext 建立单向连接。
在抢占 first.prev 之前,新节点先用 lazySetNext(first) 将其 next 指针指向 first。注意这里用的是 lazySet(即 putOrderedObject)而非 CAS。为什么?
- 此时新节点尚未被任何线程通过
prev链发现(它还没有连接入链),不会有并发线程通过新节点的next遍历。 - 因此不需要立即可见和全内存屏障,
lazySet的最终可见性已经足够,且性能远高于 CAS。 - 真正的“宣告插入”的原子操作在下一步——CAS 修改
first.prev。
步骤4:关键 CAS ——抢占 first.prev。
first.casPrev(null, newNode) 是整个 linkFirst 的唯一竞争点。它原子地检查 first.prev 是否为 null,如果是,则将其改为 newNode。这是只有一个线程能成功的操作。成功的线程便完成了“新节点成为头”的原子宣告。
步骤5:CAS 成功后的验证与重试。
CAS 成功后,代码检查 p != first,即确认 first 在此期间没有发生变化。如果变了,说明并发操作导致了不一致,需要跳转到外层的 restartFromHead 标签重新开始整个流程。
步骤6:延迟更新 head 哨兵。
updateHead() 会尝试将 head 指针向前移动到新的头节点(newNode),但这个 CAS 允许失败。如果失败,说明其他线程已经帮忙更新了,或者滞后尚可接受。这正是 Hop 设计的体现。
CAS 失败的路径:
如果 first.casPrev(null, newNode) 失败,说明 first.prev 已不再是 null。这通常意味着两个情况:
- 其他线程抢先完成了
linkFirst:该线程已将其新节点设为first的前驱。此时本线程的first已不再是头,内层循环会继续向前找到新的first并重试。 - 其他线程正在删除
first:first的prev被unlinkFirst改为自指。此时本线程会跳过该节点,寻找更前面的有效头节点。
3.1.2 并发插入的冲突与化解
假设线程 A 和线程 B 同时调用 linkFirst,而当前链表的真正头节点是 H:
- 两者都定位到
first = H(假设没有滞后问题)。 - 两者都用
lazySetNext将各自的新节点(nA、nB)的next指向H。 - 两者都尝试
H.casPrev(null, nA)和H.casPrev(null, nB)。 - 硬件保证只有一个 CAS 能成功。假设线程 A 成功——
H.prev变为nA。 - 线程 B 的 CAS 失败,因为此时
H.prev是nA而非null。 - 线程 B 沿着
H.prev继续向前走,发现nA的item非空且nA.prev == null,于是first变为nA。 - 线程 B 重试,将
nB的next改为nA,然后 CASnA.prev从null改为nB。
最终结果:链表头为 nB ← nA ← H ← ...,两个插入都成功,顺序反映了 CAS 的获胜顺序。整个过程无锁无阻塞,仅靠 CAS 失败重试便化解了冲突。
3.1.3 linkFirst 流程可视化
flowchart TD
A["开始 linkFirst(E e)"] --> B["从 head 出发<br>沿 prev 向前遍历"]
B --> C{"当前节点的<br>prev 是否为 null?"}
C -- 否 --> D{"prev 节点的<br>item 是否为 null?"}
D -- 是 --> E["跳过该节点<br>继续向前"]
E --> C
D -- 否 --> F["定位成功:<br>当前节点为 first"]
C -- 是 --> F
F --> G["创建新节点 newNode<br>lazySetNext(first)"]
G --> H{"CAS first.prev<br>null → newNode"}
H -- 成功 --> I["插入成功<br>新节点成为头"]
H -- 失败 --> J["CAS 失败:<br>first.prev 已被修改"]
J --> C
I --> K["调用 updateHead<br>(尝试更新哨兵,允许失败)"]
K --> L["结束"]
图 3-1:linkFirst 插入流程图
- a) 主旨概括:此图完整展示了
linkFirst从定位头节点、跳过逻辑删除节点、到关键 CAS 抢占及哨兵更新的全流程。 - b) 逐元素分解:
- 步骤 B-E:定位
first的循环,包含对逻辑删除节点(item == null)的主动跳过。 - 步骤 G:
lazySetNext建立单向连接,是低成本的预热操作。 - 步骤 H:关键 CAS——原子地将
first.prev从null改为newNode,是插入的线性化点。 - 步骤 J → C:CAS 失败后的重试回路,体现了无锁算法的“乐观尝试-失败重试”模式。
- 步骤 K:
updateHead尝试推进哨兵,允许失败,是 Hop 设计的具体实现。
- 步骤 B-E:定位
- c) 设计原理映射:整个流程仅有一个关键 CAS 竞争点,极大降低了同步开销。
lazySet的使用展示了在非竞争路径上减少内存屏障的优化技巧。哨兵延迟更新使连续插入共享head的 CAS。 - d) 工程联系与关键结论:
linkFirst用一次 CAS 即完成了双向链表的头部插入,其核心在于精确的“寻找 first → 预热 next → CAS 抢占 prev”三步曲。这是无锁数据结构中“用最小原子操作完成复杂结构修改”的典范。
3.2 linkLast:对称的尾部插入
linkLast(E e) 是 linkFirst 的镜像对称。其源码逻辑几乎相同,不同之处仅在于方向:
private void linkLast(E e) {
final Node<E> newNode = new Node<E>(Objects.requireNonNull(e));
restartFromTail:
for (;;) {
// 从 tail 出发,沿 next 向后寻找真正的 last 节点
for (Node<E> p = tail;;) {
Node<E> q;
// 跳过 item == null 的逻辑删除节点
while ((q = p.next) != null && (q.item == null)) {
p = q;
}
Node<E> last = p; // 当前真正的尾节点
// lazySet 建立 newNode 的 prev 指向 last
newNode.lazySetPrev(last);
// 关键 CAS:原子地将 last.next 从 null 改为 newNode
if (last.casNext(null, newNode)) {
if (p != last)
continue restartFromTail;
// 尝试更新 tail 哨兵
updateTail();
return;
}
// CAS 失败,重试
}
}
}
与 linkFirst 的对称映射:
| 步骤 | linkFirst | linkLast |
|---|---|---|
| 出发哨兵 | head | tail |
| 搜索方向 | 沿 prev 向前 | 沿 next 向后 |
| 边界条件 | prev == null | next == null |
| 预热连接 | newNode.lazySetNext(first) | newNode.lazySetPrev(last) |
| 关键 CAS | first.casPrev(null, newNode) | last.casNext(null, newNode) |
| 哨兵更新 | updateHead() | updateTail() |
并发冲突处理与 linkFirst 完全对称:多个线程同时 linkLast 时,竞争 last.next,只有一个成功;失败者会沿 next 继续寻找新的 last 并重试。
3.3 设计精髓:最少 CAS 与分离竞争
回顾 linkFirst 和 linkLast 的实现,可以总结出三大设计精髓:
1. 一次关键 CAS 完成插入。
双向链表看似需要同时修改两个指针(新节点的 prev/next 和邻居节点的对应指针),但 linkFirst 只用了一次 CAS(first.casPrev)就完成了原子插入。原因在于:新节点的 prev 默认为 null(头节点特征),next 仅通过 lazySet 单向预热,不需要同步。整个插入的线性化点就落在 first.prev 的 CAS 上。
2. lazySet 在非竞争路径上削减开销。
新节点的 next(或 linkLast 中 prev)在插入前对其他线程不可达,不存在并发访问,因此使用 lazySet 而非 CAS,避免了不必要的全内存屏障,这是 Doug Lea 的经典优化。
3. 哨兵更新与插入解耦。
updateHead/updateTail 在插入成功后被调用,但允许失败。这种分离使得连续插入操作的吞吐量不受哨兵更新延迟的影响,贯彻了 Hop 设计的核心理念。
3.4 本章小结与下一章预告
本章深入拆解了 ConcurrentLinkedDeque 的头部与尾部并发插入算法。linkFirst 和 linkLast 通过“定位边界 → 预热单向连接 → 一次关键 CAS 抢占 → 延迟更新哨兵”的四步曲,在双向链表上实现了高性能的无锁插入。多线程并发插入时的冲突,仅表现为 CAS 失败后的自旋重试,不存在阻塞。
然而,插入只是故事的一半。接下来的章节将面对更复杂的挑战:头部与尾部的并发删除(unlinkFirst/unlinkLast)——当一个线程正在删除头节点时,另一个线程可能正在向头部插入,或者同时删除相邻节点。ConcurrentLinkedDeque 如何通过自指标记、逻辑删除与物理删除分离、以及精巧的 CAS 条件来化解这些冲突?这些将在第4章中揭晓。
4. 头部/尾部并发删除:unlinkFirst 与 unlinkLast 的复杂自旋
如果说 linkFirst/linkLast 是“在边界上安插新成员”,那么 unlinkFirst/unlinkLast 就是“在边界上拆除旧成员”——并且是在其他线程可能同时插入、删除甚至遍历的混乱中完成拆除。删除操作涉及更多的 CAS 步骤、更复杂的冲突处理,以及一种独特的“自指”标记。本章将深入拆解这两个方法的源码,揭示并发删除的完整画卷。
4.1 删除的两阶段:从逻辑到物理
在拆解源码之前,必须明确删除由两个线程分阶段完成,这种分离是并发删除正确性的基石。
-
逻辑删除:发生在
pollFirst/pollLast等公开方法中。线程通过 CAS 将目标节点的item从有效值改为null。这一步是删除的线性化点,标志着元素从语义上已从队列中移除。只有成功执行此 CAS 的线程才“拥有”该节点的删除权。 -
物理删除:由
unlinkFirst/unlinkLast(或unlink)完成。线程修改prev/next指针,将节点从链表中断开。物理删除可以由逻辑删除的线程立即执行,也可以被后续的其他线程(在遍历或操作中)帮忙完成。
本章讨论的 unlinkFirst 和 unlinkLast 承担的是物理删除的职责。
4.2 unlinkFirst:拆除头节点
unlinkFirst(Node<E> first, Node<E> next) 的调用前提是:first 节点已被逻辑删除(item == null),且 next 是 first 的有效后继(item != null,或为尾部)。方法的任务是将 first 从链表中解链。
4.2.1 源码全景与逐步拆解
// 简化源码(基于 JDK 8)
private void unlinkFirst(Node<E> first, Node<E> next) {
// 步骤1:将 first 的 prev 指向自己,标记“已从头部删除”
first.casPrev(null, first);
// 步骤2:CAS 将 next.prev 从 first 改为 null,切断连接
if (next.casPrev(first, null)) {
// 步骤3:成功断开,尝试更新 head 哨兵
updateHead();
}
// 如果步骤2 CAS 失败,说明 next.prev 已被其他线程修改
// 该方法不重试,留给后续操作处理
}
逐步解读:
步骤1:自指标记 —— first.casPrev(null, first)。
被删除的 first 原本其 prev 为 null(头节点特征)。现在将其 prev 通过 CAS 改为指向自己(first)。这产生了一个显式的“已删除”标记。为什么需要这样做?
- 正常的头节点
prev == null,而已删除的头节点如果保留prev == null,遍历线程无法区分“这是一个原始的头”还是“这是一个已断开但未被清理的节点”。 - 将其改为指向自己,所有遍历到
first的线程看到prev == this,就能立即知道该节点已被删除,应该跳过。 - 同时,这个自指也是一个屏障:任何试图在
first之前插入新节点的linkFirst线程,在执行first.casPrev(null, newNode)时会失败(因为prev已不是null),从而避免新节点被连接到已删除的节点之前。
步骤2:切断后继的连接 —— next.casPrev(first, null)。
这一步将 next 的 prev 从 first 改为 null,使 next 正式成为新的头节点。CAS 的条件是 next.prev 必须为 first。如果在此期间,另一个线程修改了 next.prev(例如在 first 和 next 之间插入了一个新节点),这个 CAS 就会失败。
CAS 失败的并发语义:失败意味着 next.prev 已经不是 first 了。最可能的情况是另一个线程在 first 和 next 之间插入了一个新节点 X。此时 next.prev 应为 X。本方法不进行重试,而是将清理工作留给后续的遍历或操作线程(它们调用 skipDeletedSuccessors 等方法时会自然处理)。这种“快速失败、协作清理”的策略,避免了在 unlinkFirst 中陷入复杂的自旋。
步骤3:延迟更新 head 哨兵。
与 linkFirst 类似,成功后调用 updateHead() 尝试将 head 前移指向 next(新头),但允许失败。
4.2.2 unlinkFirst 删除流程可视化
flowchart TD
A["开始 unlinkFirst<br>(first.item == null, next 为有效后继)"] --> B["步骤1:CAS 将 first.prev<br>从 null 改为 first (自指标记)"]
B --> C{"步骤2:CAS 将 next.prev<br>从 first 改为 null"}
C -- 成功 --> D["断开成功<br>next 成为新的头节点"]
C -- 失败 --> E["CAS 失败:<br>next.prev 已被其他线程修改<br>(如并发插入)"]
D --> F["步骤3:调用 updateHead<br>(尝试推进 head,允许滞后)"]
E --> G["不重试,留给后续<br>遍历/操作帮助清理"]
F --> H["结束"]
G --> H
图 4-1:unlinkFirst 删除流程图
- a) 主旨概括:完整展示
unlinkFirst通过自指标记和一次关键 CAS 完成头节点物理删除的流程,包含失败时的协作清理策略。 - b) 逐元素分解:
- 步骤1:
prev自指是显式的删除标记,同时阻止并发插入误连到已删节点。 - 步骤2:核心断开操作,CAS 条件确保不会误删中间插入的新节点。
- 步骤3:哨兵延迟更新,Hop 设计。
- 路径 E→G:CAS 失败时不阻塞重试,而是依赖协作清理,体现无锁设计中的“快速失败”哲学。
- 步骤1:
- c) 设计原理映射:自指标记将节点状态编码进指针本身,是并发数据结构中常见的“状态压缩”技巧。关键 CAS 的条件性验证避免了对并发插入的误伤。快速失败与协作清理的结合保证了系统的整体推进能力。
- d) 工程联系与关键结论:
unlinkFirst的自指设计是并发删除的精髓——它用一个指针的更改同时完成了“标记死亡”和“阻止误连”两件事。理解这一点,是理解整个双端无锁删除的基础。
4.3 unlinkLast:对称的尾部删除
unlinkLast(Node<E> last, Node<E> prev) 是 unlinkFirst 的镜像。源码对称如下:
private void unlinkLast(Node<E> last, Node<E> prev) {
// 步骤1:将 last 的 next 指向自己,标记“已从尾部删除”
last.casNext(null, last);
// 步骤2:CAS 将 prev.next 从 last 改为 null
if (prev.casNext(last, null)) {
// 成功,更新 tail 哨兵
updateTail();
}
// 失败则不重试
}
对称映射:
| 步骤 | unlinkFirst | unlinkLast |
|---|---|---|
| 自指标记 | first.casPrev(null, first) | last.casNext(null, last) |
| 切断邻居 | next.casPrev(first, null) | prev.casNext(last, null) |
| 哨兵更新 | updateHead() | updateTail() |
4.4 并发删除冲突:当插入与删除同时发生
unlinkFirst 和 unlinkLast 的设计体现了对并发插入的精细考量。下面以头部并发删除与头部并发插入的冲突为典型场景,详细拆解其交互过程。
4.4.1 场景:删除 first 时,新节点插入到 first 之前
假设初始链表为 A (first) ↔ B (next),线程 T1 准备删除 A(已将 A.item CAS 为 null),线程 T2 正尝试 linkFirst(C) 插入新节点 C。
时序展开:
| 时刻 | 线程 T1 (unlinkFirst) | 线程 T2 (linkFirst) |
|---|---|---|
| t1 | A.casPrev(null, A) 成功,A.prev = A(自指) | 定位 first = A |
| t2 | 执行 newNode.lazySetNext(A) | |
| t3 | B.casPrev(A, null) 尝试中…… | 执行 A.casPrev(null, C) |
| t4 | CAS 失败! 因为 A.prev 现在是 A 而非 null | |
| t5 | B.casPrev(A, null) 成功,B 成为新头 | T2 重试:从 A 沿 prev 向前…… |
| t6 | 发现 A.prev == A(自指),识别 A 已被删除,跳过 A | |
| t7 | 继续向前,找到 B 为 first(B.prev == null 且 item != null) | |
| t8 | 重试 CAS B.casPrev(null, C),成功插入 C 为 B 的前驱 |
结果:C 被正确插入为 B 的前驱,A 被成功删除。链表变为 C ↔ B。
关键机制:T1 在 t1 时刻的自指标记提前阻止了 T2 的 CAS 误连。T2 的 CAS 失败后,在自旋中识别出 A 的死亡状态,自动跳过并重试到有效节点 B。整个过程没有互斥,仅通过 CAS 的条件和自指标记便完成了冲突化解。
4.4.2 并发删除冲突处理示意图
下图可视化这一冲突过程:
flowchart TD
subgraph 初始状态
direction LR
A1["A<br>item=null<br>prev=null"] <--> B1["B<br>item=数据"]
end
subgraph 时间线
T1_1["线程T1: A.casPrev(null, A) 成功<br>A.prev = A (自指)"] --> T2_1["线程T2: linkFirst(C)<br>定位 first = A<br>尝试 A.casPrev(null, C)"]
T2_1 --> T2_FAIL{"CAS 失败!<br>A.prev 现在是 A 而非 null"}
T2_FAIL --> T2_RETRY["线程T2 重试:从 A 向前遍历<br>发现 A.prev == A (自指),跳过 A<br>定位新 first = B"]
T1_1 --> T1_2["线程T1: B.casPrev(A, null) 成功<br>B 成为新头"]
T2_RETRY --> T2_SUCC["线程T2: B.casPrev(null, C) 成功<br>C 插入为 B 的前驱"]
end
subgraph 最终状态
direction LR
C2["C<br>item=数据<br>prev=null"] <--> B2["B<br>item=数据"]
end
图 4-2:并发删除冲突处理示意图
- a) 主旨概括:展示了
unlinkFirst与linkFirst并发操作同一节点时的冲突和自动化解过程,核心在于自指标记对插入操作的阻挡。 - b) 逐元素分解:
- 初始状态:A 已被逻辑删除,等待物理断开。
- 冲突点:T2 试图在 A 前插入 C,但 T1 的自指使 T2 的 CAS 失败。
- 化解:T2 识别自指标记,跳过 A,重试到有效节点 B。
- 最终状态:A 被成功移除,C 正确插入。
- c) 设计原理映射:自指标记充当了“并发栅栏”——它在物理删除尚未完成时,就阻止了新节点向已删节点的错误连接。这是“先标记死亡,再清理连接”策略的并发安全保障。
- d) 工程联系与关键结论:并发删除的安全不依赖锁,而依赖于“状态标记 + CAS 条件校验”的组合。自指标记是 ConcurrentLinkedDeque 区别于 ConcurrentLinkedQueue 的关键创新之一,它使得双端无锁删除在与插入的激烈竞争中依然保持稳健。
4.5 辅助清理:为何 CAS 失败后不重试?
你可能注意到,unlinkFirst 在步骤2的 CAS 失败后,并不进行自旋重试。这与 linkFirst 的循环重试策略形成鲜明对比。原因何在?
- 删除是协作式的。节点一旦被逻辑删除(
item == null),其物理清理是所有线程的共同责任。如果unlinkFirst遇到冲突而放弃,后续任何遍历到该区域的线程,在调用skipDeletedPredecessors或skipDeletedSuccessors时,会自动跳过该垃圾节点,并可能帮助完成物理断开。 - 避免重复竞争。如果
unlinkFirst也进行自旋,那么高度竞争的场景下,删除线程和插入线程可能陷入反复的 CAS 对抗,浪费 CPU 资源。将部分清理责任分散给遍历线程,是更优的整体吞吐设计。
这种“快速失败,协作清理”的策略,正是无锁数据结构中典型的 Helping 机制。每个线程既为自己工作,也为整个数据结构的健康贡献力量。
4.6 本章小结与下一章预告
本章深入拆解了 ConcurrentLinkedDeque 的头部与尾部并发删除算法。unlinkFirst/unlinkLast 通过自指标记明确宣告节点死亡,通过一次条件 CAS 断开邻居连接,并通过快速失败与协作清理化解并发冲突。自指标记是整个删除机制的精髓,它同时完成了状态标记和并发栅栏的双重功能。
至此,我们已经掌握了边界操作的完整逻辑。然而,队列中间的节点也可能被删除(如 remove(Object))。这时,需要 unlink(Node<E> x) 方法来完成中间节点的物理断开——它如何找到有效的前驱和后继?skipDeletedPredecessors 和 skipDeletedSuccessors 是如何在垃圾节点丛中开辟出一条清洁路径的?这些将在第5章中揭晓。
5. 双向断开与辅助清理:unlink 方法与跳跃清理
前两章已经覆盖了边界操作:在头部和尾部插入(linkFirst/linkLast),以及从头部和尾部删除(unlinkFirst/unlinkLast)。然而,ConcurrentLinkedDeque 还支持删除队列中间的某个节点——例如通过 remove(Object) 或 removeFirstOccurrence(Object) 方法。当目标节点既不是头也不是尾时,它的物理删除就落在 unlink(Node<E> x) 方法身上。
此外,删除后的“垃圾节点”如何被彻底清理?在遍历过程中遇到 item == null 的节点如何处理?这些问题的答案都隐藏在 skipDeletedPredecessors 和 skipDeletedSuccessors 这两个至关重要的辅助方法中。本章将深入拆解这些机制,揭示 ConcurrentLinkedDeque 如何维持链表的“清洁”。
5.1 unlink:短路中间节点的双向指针
unlink(Node<E> x) 的任务是:将已被逻辑删除的节点 x 从双向链表中短路掉,即让 x.prev 和 x.next 直接相连。但在并发环境下,直接使用记录的前驱和后继是危险的——它们可能也已被逻辑删除。因此,unlink 必须配合跳跃清理来定位有效邻居。
5.1.1 源码全景与逐步拆解
// 简化源码(基于 JDK 8)
void unlink(Node<E> x) {
final Node<E> prev = x.prev;
final Node<E> next = x.next;
// 情况1:x 是头节点
if (prev == null) {
unlinkFirst(x, next);
}
// 情况2:x 是尾节点
else if (next == null) {
unlinkLast(x, prev);
}
// 情况3:x 是中间节点
else {
// 步骤A:找到有效的、未被删除的前驱节点
Node<E> activePred = skipDeletedPredecessors(prev);
// 步骤B:找到有效的、未被删除的后继节点
Node<E> activeSucc = skipDeletedSuccessors(next);
if (activePred != null && activeSucc != null) {
// 步骤C:CAS 将有效前驱的 next 从 x(或原值)改为有效后继
activePred.casNext(x, activeSucc);
// 步骤D:CAS 将有效后继的 prev 从 x(或原值)改为有效前驱
activeSucc.casPrev(x, activePred);
}
// 如果 activePred 或 activeSucc 为 null,说明遇到了哨兵边界,
// 或者 x 已不再是真正的中间节点,留给其他操作处理
}
}
逐步解读:
边界判断:方法首先检查 x.prev 和 x.next 是否为 null。如果是,说明 x 实际上是边界节点,应转交给 unlinkFirst 或 unlinkLast 处理。这两个方法我们在第4章已经详细剖析。
步骤A & B:定位有效邻居。
这是 unlink 的核心所在。x.prev 和 x.next 中记录的引用,可能指向的节点早已被其他线程逻辑删除(item == null)。如果直接用这些“死节点”来短路,链表会连接无效节点,形成垃圾链。因此,必须调用 skipDeletedPredecessors(prev) 和 skipDeletedSuccessors(next) 来找到第一个 item 不为 null 的有效前驱和有效后继。
步骤C & D:两次 CAS 完成短路。
activePred.casNext(x, activeSucc) 将有效前驱的 next 指针从 x 改为有效后继。activeSucc.casPrev(x, activePred) 将有效后继的 prev 指针从 x 改为有效前驱。这两个 CAS 的顺序和设计蕴含了精妙的并发语义:
- 为什么用 CAS 而不是直接赋值? 因为
activePred和activeSucc的指针可能在此期间被其他线程修改(例如activePred被另一个删除操作处理,或在其后插入了新节点)。CAS 确保只在我们期望的旧值(x)未被改变时才执行修改,否则安全放弃。 - 两个 CAS 不要求原子性。 它们可以分开成功或失败。如果
casNext成功但casPrev失败,那么x的next链已经断开,但prev链仍可能暂时引用x。不过这是安全的,因为x已经被逻辑删除(item == null),遍历线程在通过prev链到达x时会检查item并跳过,且后续的辅助清理(如skipDeletedPredecessors)最终会修复prev链的不一致。这种“最终一致性”保证了无锁环境下的正确性。
如果 CAS 失败了呢? 方法并不重试。原因与 unlinkFirst 相同:物理删除是协作式的。如果本次 unlink 因为并发冲突而未能完全断开,后续其他线程的遍历(如迭代器、size() 或下一次 remove)将遇到 x 并再次尝试清理。
5.1.2 中间节点 unlink 流程图
flowchart TD
A["开始 unlink(x)<br>(x.item == null)"] --> B{"x.prev == null?"}
B -- 是 --> C["调用 unlinkFirst(x, x.next)"]
B -- 否 --> D{"x.next == null?"}
D -- 是 --> E["调用 unlinkLast(x, x.prev)"]
D -- 否 --> F["定位 activePred =<br>skipDeletedPredecessors(x.prev)"]
F --> G["定位 activeSucc =<br>skipDeletedSuccessors(x.next)"]
G --> H{"activePred 和 activeSucc<br>均非 null?"}
H -- 否 --> I["放弃,留待后续清理"]
H -- 是 --> J["CAS: activePred.next<br>x → activeSucc"]
J --> K["CAS: activeSucc.prev<br>x → activePred"]
K --> L["短路完成<br>x 被物理断开"]
I --> M["结束"]
C --> M
E --> M
L --> M
图 5-1:中间节点 unlink 流程图
- a) 主旨概括:完整展示
unlink方法根据节点位置分发处理,并通过跳跃方法定位有效邻居后执行短路连接的流程。 - b) 逐元素分解:
- 前置判断:依据
prev/next是否为null分派给边界删除方法。 - 核心步骤 F、G:
skipDeletedPredecessors和skipDeletedSuccessors负责从记录的邻居出发,跳过沿途所有item == null的垃圾节点,找到有效邻居。 - 步骤 J、K:两次独立的 CAS 分别修改前驱的
next和后继的prev,将x从链中挤出。 - 路径 H→I:如果找不到有效邻居(通常因为并发状态下链表变化),放弃本次清理,交给后续线程。
- 前置判断:依据
- c) 设计原理映射:两阶段跳跃清理 + 两次独立 CAS 的模式,是逻辑删除与物理删除分离策略的延续。它允许在保持数据结构安全的前提下,将清理开销分散到各个操作中。
- d) 工程联系与关键结论:
unlink的“先跳跃找到有效邻居,再 CAS 短路”是中间节点并发删除的经典范式。它展示了无锁数据结构如何通过协作清理来维持长期稳定,而不是依赖单一的集中式修复。
5.2 skipDeletedPredecessors 与 skipDeletedSuccessors:垃圾节点的清道夫
这两个方法是辅助清理的核心,它们被广泛调用:不仅在 unlink 中,也在 linkFirst、linkLast、pollFirst、pollLast、迭代器遍历等几乎任何需要定位有效节点的地方。
5.2.1 skipDeletedPredecessors 源码
// 沿 prev 链向前跳,直到找到 item != null 或遇到边界
private Node<E> skipDeletedPredecessors(Node<E> start) {
Node<E> p = start;
while (p != null && p.item == null) {
p = p.prev;
}
return p;
}
逻辑极简,但意味深远:从给定的 start 节点出发,沿 prev 链向前遍历。遇到 item == null 的节点就跳过。循环终止于 p == null(到达链表头部之外),或 p.item != null(找到了一个有效的节点)。
5.2.2 skipDeletedSuccessors 源码
// 沿 next 链向后跳,直到找到 item != null 或遇到边界
private Node<E> skipDeletedSuccessors(Node<E> start) {
Node<E> p = start;
while (p != null && p.item == null) {
p = p.next;
}
return p;
}
对称地沿着 next 链向后跳跃。
5.2.3 跳跃清理的并发安全性
这两个方法看似简单,但它们运行在完全并发的环境下,必须面对以下问题:
-
节点在跳跃过程中被物理删除怎么办?
假设p当前指向一个item == null的节点,它正要执行p = p.prev。如果此时p被unlink物理断开(p.prev和p.next被修改),p.prev是否可能指向一个无效节点?答案是可能的,但无害。因为即使p被物理删除,其prev字段仍然保留着指向其前驱的引用(除非被显式修改为自指或清理)。在跳跃中,最坏情况是跳到一个也已被逻辑删除的节点,那么循环会继续。不会形成无限循环,因为链表没有环路,且边界条件(null)始终存在。 -
跳跃过程中新插入的节点会干扰吗?
不会。跳跃只关心item == null与否。新插入的节点item非null,因此它们是“有效”的。如果跳跃穿过了一个新插入的节点,那么我们就找到了一个更近的有效邻居,这反而提高了物理删除的质量。 -
跳跃是否可能永远找不到有效节点?
不会。因为队列至少有一个哨兵节点(或真正的头/尾),其item要么是数据(非null),要么是自指标记。但在skipDeletedPredecessors和skipDeletedSuccessors的应用场景中,它们总是从某个节点的prev或next出发,最终一定能到达一个item非空的真实有效节点或到达null(链表之外)。例如在unlink中,如果activePred最终为null,方法会放弃清理。
5.3 辅助清理的生态:协作式自愈
skipDeletedPredecessors 和 skipDeletedSuccessors 代表了一种协作式自愈哲学。在 ConcurrentLinkedDeque 中,没有一个专职的“垃圾回收线程”。相反,每个线程在遍历链表时,只要遇到 item == null 的节点,就会在自身操作中跳过它,并顺手帮助物理断开。例如:
- 迭代器遍历:
nextNode()方法会调用skipDeletedSuccessors跳过垃圾。 size()计算:遍历计数时自动跳过item == null的节点。peekFirst()/peekLast():在定位有效头/尾时,也使用类似的跳跃逻辑。unlink和边界删除:如我们所见,使用跳跃找到有效邻居。
这种设计的优势:
- 负载分散:清理开销不集中于单个线程,而是均摊到所有访问者。
- 无阻塞:清理不需要全局锁或停止所有操作。
- 自适应性:当垃圾节点较多时,任何访问者都能帮助减少它们;当没有垃圾时,跳跃几乎零开销。
5.4 设计精髓:短路、跳跃与最终一致性
回顾本章内容,可以总结出 ConcurrentLinkedDeque 中间节点删除与清理的三大设计精髓:
-
短路优先于彻底清理。
unlink并不试图原子地修复所有指针,而是让有效前驱和后继直接相连(短路)。至于x本身留下的悬空指针,则由后续的跳跃操作逐步清理。这种“先短路、后清理”的策略,将一次复杂的多指针原子修改简化为两次独立的 CAS,极大降低了实现复杂度。 -
跳跃解耦物理删除与逻辑删除。
skipDeletedPredecessors/Successors让所有遍历操作都能适应“item 已 null 但指针仍在”的中间状态。它们是逻辑删除与物理删除分离策略得以成立的桥梁。 -
协作清理实现最终一致性。
链表结构不需要在删除瞬间就变得完美。它允许短暂的不一致(如prev链和next链不完全对称),但通过各线程的协作,最终会达到一个清洁、一致的状态。这是最终一致性在数据结构内部的体现。
5.5 本章小结与下一章预告
本章深入拆解了 unlink 方法通过 skipDeletedPredecessors/skipDeletedSuccessors 跳跃垃圾节点、完成中间节点短路连接的过程,并揭示了 ConcurrentLinkedDeque 协作式清理的全貌。辅助清理方法看似简单,却是逻辑删除与物理删除分离策略的黏合剂,它们使队列具备了“自愈”能力。
至此,我们已经掌握了插入、删除和清理的完整算法。下一个问题自然浮现:head 和 tail 的延迟更新(Hop 设计)究竟是如何实现的?updateHead 和 updateTail 方法内部做了什么,使得它们在允许失败的前提下,依然能保证队列的整体性能? 这些将在第6章中揭晓。
6. Hop 设计:head/tail 延迟更新的精妙平衡
在前面的章节中,我们已经在 linkFirst、unlinkFirst 等方法的最后一步看到了 updateHead() 或 updateTail() 的调用。它们总是轻描淡写地出现,而且允许失败。总论中曾提到,head 和 tail 并不总是指向真正的头/尾节点,而是允许滞后——这就是经典的 Hop 设计。本章将正面回应这个设计:updateHead 和 updateTail 的内部机制是什么?滞后距离如何控制?为什么“不精确”的哨兵反而能带来性能提升?
6.1 为什么需要滞后:热点竞争的必然代价
想象一下,如果每次头部插入或删除都必须立即更新 head 引用,那么当有 N 个线程同时操作头部时,head 字段会成为整个队列最激烈的 CAS 竞争热点。线程们将反复在 head 上执行 CAS,大量操作失败重试,导致 CPU 空转和总线流量飙升。
Hop 设计的解决方案是:将哨兵更新与数据操作解耦。插入或删除操作在修改链表结构后,只需“尝试”更新哨兵,但允许更新滞后。真正的遍历操作(如 peek、poll)通过沿 prev/next 链自行定位边界,代价仅仅是多走一两个节点,却换来了热点竞争的急剧减少。
6.2 updateHead:有条件地推进 head 哨兵
updateHead() 在 linkFirst、unlinkFirst 以及 unlink 等操作成功后被调用,目的是将 head 引用前移到更接近真正头节点的位置。其简化源码如下:
private void updateHead() {
// 从当前 head 开始,沿 prev 向前找到真正的第一个有效节点
Node<E> h, p;
restartFromHead:
for (;;) {
h = head;
p = h;
// 向前跳过所有 item == null 的逻辑删除节点
while ((p = p.prev) != null && p.item == null)
;
// 此时 p 是 head 沿 prev 链找到的第一个 item != null 的节点
// 或者 p.prev == null(到达真正的头)
// 关键条件:只有当 head 需要移动至少两个节点时才执行 CAS
if (h != p && h != p.prev) {
// CAS 尝试将 head 从 h 更新为 p
if (UNSAFE.compareAndSwapObject(this, headOffset, h, p))
return;
// CAS 失败,说明其他线程已经更新了 head,重新开始
} else {
// 不需要更新(head 已经足够接近),直接返回
return;
}
}
}
逐步解读:
定位目标 p:方法从当前的 head 引用出发,沿 prev 链向前遍历,跳过所有 item == null 的已逻辑删除节点,直到找到一个 item != null 的有效节点(或到达真正的头)。这个 p 就是期望将 head 更新到的位置。
Hop 条件:h != p && h != p.prev。这是整个 Hop 设计的核心。它要求:
head当前指向的节点h必须不同于目标节点p(h != p),这意味着有更新空间;- 并且
h不能恰好是p的直接后继(h != p.prev)。换句话说,只有当head与真正的头节点至少相隔两个节点时,才触发 CAS 更新。
为什么是“至少隔两个节点”?
这正是 Hop 设计中“hop”的含义——跳跃。如果 head 仅滞后一个节点(即 h == p.prev),则遍历的额外成本几乎为零(只需一次额外的 prev 解引用),但更新 head 仍然会引发一次 CAS 竞争。因此,为了避免“为了节省一次指针追踪而付出 CAS 代价”的过度优化,设计选择了当滞后累积到两个节点以上时才执行更新。这正是 Hop = 2 的策略。
CAS 原子更新:当条件满足时,使用 UNSAFE.compareAndSwapObject 尝试将 head 从 h 改为 p。如果 CAS 成功,哨兵被推进;如果失败,说明其他线程已经更新了 head,方法重试整个过程。
直接返回:如果条件不满足(head 已经足够接近),或者 CAS 失败后重试发现条件不再满足,方法直接返回。这种“允许失败”的设计使得 updateHead 成为无阻塞的辅助操作,不会成为性能瓶颈。
6.3 updateTail:对称的尾部哨兵更新
updateTail() 是 updateHead() 的完全对称实现:
private void updateTail() {
Node<E> t, p;
restartFromTail:
for (;;) {
t = tail;
p = t;
// 沿 next 向后跳过所有 item == null 的节点
while ((p = p.next) != null && p.item == null)
;
// p 是 tail 沿 next 链找到的第一个有效尾节点
// Hop 条件:tail 与真实尾节点至少相隔两个节点才更新
if (t != p && t != p.next) {
if (UNSAFE.compareAndSwapObject(this, tailOffset, t, p))
return;
} else {
return;
}
}
}
逻辑完全对称:从 tail 出发沿 next 向后找有效尾节点,仅当 tail 滞后至少两个节点时才尝试 CAS 推进。
6.4 Hop 设计的可视化:条件性更新的决策流程
flowchart TD
A["调用 updateHead"] --> B["从 head 出发沿 prev 向前<br>跳过 item == null 的节点<br>找到目标 p"]
B --> C{"h != p 且<br>h != p.prev ?"}
C -- 否 --> D["head 已足够接近<br>无需更新,直接返回"]
C -- 是 --> E["CAS 将 head 从 h 更新为 p"]
E -- 成功 --> F["head 推进成功"]
E -- 失败 --> G["其他线程已更新 head<br>重试"]
G --> B
D --> H["结束"]
F --> H
图 6-1:updateHead 决策流程图
- a) 主旨概括:展示
updateHead如何通过“滞后距离 ≥ 2”的条件,只在值得的时候才发起 CAS 更新,避免过度竞争。 - b) 逐元素分解:
- 步骤 B:遍历
prev链,跳过逻辑删除节点,找到目标节点p。 - 步骤 C:Hop 条件的判断,
h != p && h != p.prev保证滞后距离至少为 2。 - 步骤 E:CAS 竞争点,允许失败,失败后重试。
- 路径 D:当滞后距离不够大时,放弃更新,节省一次 CAS。
- 步骤 B:遍历
- c) 设计原理映射:Hop 条件体现了对 CAS 成本与遍历成本的精算权衡。在并发数据结构中,并非所有“不精确”都需要立即修正,修正本身也有代价。
- d) 工程联系与关键结论:Hop 设计的本质是“用廉价的指针追踪替代昂贵的 CAS 竞争”。
head/tail滞后 2 个节点的经验值,使得队列在极端并发下保持高吞吐,同时遍历开销几乎可忽略。
6.5 设计精髓:Hop 设计的性能权衡
-
减少 CAS 竞争。通过聚合多次操作的哨兵更新,
head/tail上的 CAS 频率显著降低。连续的插入或删除操作可以共享一次哨兵的推进。 -
遍历开销极低。即使
head滞后,peek或poll也只需要额外追踪一两次prev/next指针。这种开销远小于一次失败的 CAS 带来的流水线冲刷和总线流量。 -
自适应。滞后距离由
updateHead/updateTail的条件动态控制。当并发度低时,滞后很快被修正;当竞争激烈时,滞后自然积累,从而自动减少 CAS 尝试频率。 -
协作性。哨兵更新不仅仅由插入/删除线程发起,遍历线程(如
peek)也可能在发现滞后时帮忙推进。这种协作机制保证了滞后不会无限累积。
6.6 本章小结与下一章预告
本章揭开了 Hop 设计的面纱:updateHead 和 updateTail 通过“滞后距离 ≥ 2 时才 CAS”的策略,将哨兵更新从热点竞争中解放出来,用极小的遍历代价换取了整体吞吐量的大幅提升。这是 ConcurrentLinkedDeque(以及它的单向兄弟 ConcurrentLinkedQueue)高性能的关键技术之一。
至此,我们已经完成了对 ConcurrentLinkedDeque 内部所有核心算法的全景解析:Node 结构、插入、删除、清理、哨兵更新。接下来,我们将从内部走向外部,将 ConcurrentLinkedDeque 与其单向兄弟 ConcurrentLinkedQueue 进行全面对比,并深入探讨它在 ForkJoinPool 工作窃取调度中的关键应用——这将是理解“为什么需要双向无锁队列”的最终答案。
7. 与 ConcurrentLinkedQueue 的对比及工作窃取应用
前六章已经完整拆解了 ConcurrentLinkedDeque 的内部算法。现在,我们将视角拉远,将其与单向无锁队列 ConcurrentLinkedQueue 并置,从结构、算法复杂度、竞争特性和适用场景进行系统对比,并最终深入其最重要的工程应用——ForkJoinPool 的工作窃取调度。这是“为什么需要双向无锁队列”的终极答案。
7.1 技术差异全景:单向 vs 双向
7.1.1 多维对比表
| 维度 | ConcurrentLinkedQueue | ConcurrentLinkedDeque |
|---|---|---|
| 底层结构 | 单向链表(仅 next 指针) | 双向链表(prev + next) |
| 节点复杂度 | 简单:item + next | 复杂:prev + item + next |
| 操作支持 | 仅 FIFO:offer(尾部), poll(头部) | 双端:addFirst/Last, pollFirst/Last |
| 删除粒度 | 仅头部删除,中间删除通过标记 next | 头、尾、中间任意节点删除 |
| 并发控制核心 | next CAS + Hop 设计 | prev/next CAS + 自指标记 + 跳跃清理 |
| 删除标记 | 无显式自指标记 | prev/next 自指标记已删除边界节点 |
| 哨兵滞后策略 | head/tail 允许滞后(Hop=2) | 同样 Hop 设计,但双向需维护两端哨兵 |
| 辅助清理 | 仅遍历时跳过 item==null | skipDeletedPredecessors/Successors 主动清理 |
| 适用场景 | 简单生产者-消费者(FIFO) | 工作窃取、双端操作、灵活调度 |
| 常数开销 | 更低(每次操作 CAS 竞争更少) | 稍高(双向维护成本) |
7.1.2 结构可视化对比
flowchart LR
subgraph ConcurrentLinkedQueue
direction LR
QH["head"] --> Q1["Node 1"] --> Q2["Node 2"] --> QT["tail"]
end
subgraph ConcurrentLinkedDeque
direction LR
DH["head"] <--> D1["Node 1"] <--> D2["Node 2"] <--> DT["tail"]
end
图 7-1:ConcurrentLinkedDeque vs ConcurrentLinkedQueue 结构对比图
- a) 主旨概括:直观展示单向链表队列与双向链表队列在结构上的根本差异——前者只有单向
next链路,后者具备对称的prev/next双向链路。 - b) 逐元素分解:
ConcurrentLinkedQueue每个节点仅持有next指针,形成单一方向的链。操作被强制为“尾入头出”。ConcurrentLinkedDeque每个节点同时持有prev和next,链表可双向遍历,操作可在两端自由进行。
- c) 设计原理映射:双向链表带来的灵活性以更复杂的并发控制为代价。
ConcurrentLinkedDeque必须处理双向指针的一致性、自指标记以及协作清理,这些都是单向队列所不需面对的。 - d) 工程联系与关键结论:选择哪个队列,取决于是否需要双端操作。若只需 FIFO 管道,
ConcurrentLinkedQueue更轻量高效;若需要工作窃取或双端灵活存取,ConcurrentLinkedDeque是唯一解。两者的差异体现了数据结构能力与实现复杂度的正比关系。
7.2 工程应用:ForkJoinPool 的工作窃取调度
ConcurrentLinkedDeque 最闪亮的应用舞台,是 ForkJoinPool 内部的工作队列。虽然 JDK 8 的 ForkJoinPool 实现中使用了特殊的数组双端队列(WorkQueue),但其算法设计深受 ConcurrentLinkedDeque 的无锁双端思想影响。我们以 ConcurrentLinkedDeque 为底层来阐释工作窃取调度的核心原理。
7.2.1 工作窃取的调度模型
ForkJoinPool 采用**工作窃取(Work-Stealing)**调度策略,每个工作线程维护一个双端队列用于存放待执行的任务。调度规则如下:
- 本地线程操作自己队列的头部:以 LIFO(后进先出)顺序消费任务,利用 CPU 缓存的热数据,提升局部性。
- 空闲线程从其他队列的尾部窃取任务:以 FIFO(先进先出)顺序窃取“最老”的任务,促进负载均衡,并避免与忙碌线程在头部发生直接竞争。
这种“头部消费、尾部窃取”的模式是双端队列的绝配。ConcurrentLinkedDeque 在头部和尾部均提供 O(1) 的无锁插入/删除,使得本地操作和远程窃取可以分别在链表的两端进行,将竞争空间天然隔离。
7.2.2 工作窃取流程可视化
sequenceDiagram
participant W1 as 工作线程1 (忙)
participant W1Q as 线程1本地双端队列
participant W2 as 工作线程2 (空闲)
participant W2Q as 线程2本地双端队列
Note over W1: 生成新任务 TaskA, TaskB
W1->>W1Q: addFirst(TaskB) 头部插入
W1->>W1Q: addFirst(TaskA) 头部插入
Note over W1Q: 队列状态: [TaskA, TaskB]
W1->>W1Q: pollFirst() 头部取任务
W1Q-->>W1: TaskA
Note over W1: 执行 TaskA,执行中...
Note over W2: W2 空闲,尝试窃取
W2->>W1Q: pollLast() 尾部窃取
W1Q-->>W2: TaskB
Note over W2: 窃取到 TaskB,开始执行
Note over W1: 继续从头部取任务 (队列可能已空)
W1->>W1Q: pollFirst()
W1Q-->>W1: null (无任务)
Note over W1: 可能尝试窃取其他队列
图 7-2:工作窃取时序图
- a) 主旨概括:展示
ForkJoinPool中本地线程在头部(LIFO)消费、空闲线程在尾部(FIFO)窃取的完整时序,揭示双端队列对竞争分离的关键作用。 - b) 逐元素分解:
- 线程1在头部插入 TaskA 和 TaskB,并立即从头部消费 TaskA,体现 LIFO 和缓存局部性。
- 线程2空闲时,从线程1队列的尾部窃取 TaskB,这是最老的任务,且操作在尾部,不与头部消费冲突。
- 线程1消费完头部后可能再去窃取其他队列,形成全局负载均衡。
- c) 设计原理映射:双端队列的“头部取、尾部窃”模式,将本地操作与远程窃取的操作端物理隔离,避免了对同一端点的 CAS 竞争。
ConcurrentLinkedDeque在两端均提供高效的无锁操作,是实现此模型的理想底层。 - d) 工程联系与关键结论:工作窃取是双端队列的杀手级应用。
ConcurrentLinkedDeque的双向无锁设计,使ForkJoinPool能够在大量短任务场景下保持极高的吞吐量和低延迟,这是单向队列无法企及的。
7.2.3 为何不能用 ConcurrentLinkedQueue 替代?
ConcurrentLinkedQueue 只支持“尾部入、头部出”的单一 FIFO 模式。如果强制用它实现工作窃取:
- 本地消费:只能从头部取,失去 LIFO 的缓存友好性。
- 窃取:也只能从头部取,与本地线程直接竞争同一个热点,导致剧烈的 CAS 冲突和性能退化。
- 没有双端:无法实现“头部消费、尾部窃取”的竞争隔离,工作窃取的优势荡然无存。
因此,ConcurrentLinkedDeque 在这种场景下是不可替代的。它的设计并非为了取代单向队列,而是在单向队列力不能及的双端领域,提供同等水准的无锁高性能。
7.3 本章小结与全文回顾
本章将 ConcurrentLinkedDeque 与其单向兄弟进行全面对比,明确了各自的适用边界,并深入解析了它在 ForkJoinPool 工作窃取中的核心作用。“头部取、尾部窃”的模式,是对 ConcurrentLinkedDeque 双端无锁能力的最佳诠释。
至此,我们已经完成了从 Node 结构、插入算法、删除算法、辅助清理、Hop 设计到工程应用的完整旅程。ConcurrentLinkedDeque 通过逻辑删除与物理删除分离、自指标记、跳跃清理、哨兵延迟更新等精巧技术,在双向链表这一复杂结构上实现了完全无锁的并发操作。它是 JDK 无锁并发容器设计的集大成者,也是理解无锁编程思想和算法工程的绝佳范本。
8. 面试高频专题
8.1 ConcurrentLinkedDeque 的 Node 结构是怎样的?为什么要将 item 字段设计为 volatile?
① 一句话回答
Node 由 volatile 修饰的 prev、item、next 三个字段构成;item 为 volatile 是为了保证逻辑删除(CAS 置 null)对其他线程的立即可见,从而确立无锁删除的线性化点。
② 详细解释
Node 是双向链表的基本单元。三个字段全部 volatile,保证跨核心的可见性和有序性。item 字段的核心使命是承载逻辑删除标志:当执行 poll 或 remove 时,线程通过 casItem(item, null) 将 item 置为 null。若 item 不是 volatile,该修改可能被延迟甚至对其他 CPU 不可见,导致遍历线程仍看到过期值,破坏并发正确性。此外,底层 CAS 操作依赖 volatile 的内存语义来保障原子变量的读写语义。
③ 多角度追问
- 追问1:为什么
prev和next也必须volatile?
答:插入和删除依赖 CAS 修改prev/next,若它们非volatile,其他线程可能看不到指针更新,导致遍历断裂或死循环。 - 追问2:构造器中为何用
UNSAFE.putObject而非直接赋值?
答:防止指令重排序导致构造逸出。putObject提供volatile写语义,确保item初始化在对象引用发布前对其他线程可见。 - 追问3:
lazySetNext使用putOrderedObject有何深意?
答:在非竞争路径(如预热连接)上使用延迟写,避免全内存屏障,显著提升性能而不牺牲最终可见性。
④ 加分回答
volatile 三字段的组合使 ConcurrentLinkedDeque 具备“无锁的线程安全”。这是 Java Memory Model 中 final 字段安全发布思想的延伸。Doug Lea 大量使用 putOrderedObject 进行优化,将内存屏障开销降至最低,是高性能无锁数据结构的标志性手法。
8.2 什么是 ConcurrentLinkedDeque 中的“逻辑删除”和“物理删除”?为什么要分两步?
① 一句话回答
逻辑删除是将 item CAS 为 null 使节点失效,物理删除是通过 CAS 修改指针将其从链表中移除;分两步是因为无锁环境下无法原子地同时修改多个指针。
② 详细解释
在锁保护下可一次性修改前驱、后驱和待删节点的指针。但无锁环境只能通过 CAS 逐一修改,若试图一步完成,中间状态将暴露给其他线程,可能破坏链表。因此先逻辑删除(item = null),向所有线程宣告节点失效,确保任何遍历都会跳过它;物理删除则可在之后由任意线程安全完成,甚至由遍历线程辅助执行(调用 unlink)。
③ 多角度追问
- 追问1:逻辑删除后、物理删除前,遍历操作会出错吗?
答:不会。遍历方法(如succ/pred)会检查item并主动跳过null节点,还会顺手帮助unlink清理。 - 追问2:垃圾节点会无限堆积吗?
答:辅助清理机制会及时清理,正常负载下不会堆积。极端情况下可能短暂存在,但协作清理最终会清除。 - 追问3:是否所有无锁队列都采用这种分离?
答:是普遍模式。ConcurrentLinkedQueue也将item置null作为逻辑删除,之后再物理断开next链。
④ 加分回答
逻辑删除与物理删除分离本质上是乐观的、可线性化的删除语义。逻辑删除的 CAS 就是线性化点,保证 poll 的正确性。物理断开不影响已完成的删除语义,这使迭代器可以是弱一致性的——允许看到逻辑删除后元素消失,符合并发预期。
8.3 linkFirst 方法在并发环境下是如何保证插入成功的?当多个线程同时在头部插入时会发生什么?
① 一句话回答
linkFirst 通过一次关键 CAS(first.casPrev(null, newNode))原子抢占头节点,多线程插入时只有一个 CAS 成功,失败者自旋重试。
② 详细解释
linkFirst 先沿 prev 从 head 找到真正 first,用 lazySetNext 建立新节点到 first 的单向连接,最后 CAS 将 first.prev 从 null 改为新节点。这是原子操作,唯一胜利者完成插入。失败线程发现 first.prev 已非 null,会重新循环定位新 first(可能已被其他线程更新)并重试。整个过程无阻塞。
③ 多角度追问
- 追问1:为什么先用
lazySetNext而不直接用 CAS?
答:此时新节点尚未接入链表,无并发访问,只需最终可见,lazySet减少屏障开销。 - 追问2:若 CAS 前
first被逻辑删除了怎么办?
答:定位first时会跳过item == null的节点,确保不在已删除节点上插入。 - 追问3:ABA 问题是否可能发生?
答:因节点动态创建且不会被重用(Java GC 环境),配合item和prev的协同变化,ABA 风险极低,实际不构成问题。
④ 加分回答
linkFirst 的单 CAS 抢占是 lock-free 算法 的典型代表。它证明在双向链表上,插入仅需修改一个指针(first.prev)的原子操作即可完成,而不需同时修改新节点的 prev 和 next,深刻利用了链表的不变量。
8.4 unlinkFirst 方法在处理删除时,为什么要把被删节点的 prev 指针指向自己?
① 一句话回答
自指是一种显式的“已从头部删除”标记,用来通知并发线程该节点已失效,并阻止新插入操作误连到已删节点。
② 详细解释
正常头节点 prev 为 null。若删除后保留 null,遍历线程无法区分“原始头”和“已断开头”。改为指向自己后,任何看到 prev == this 的线程立即知道节点已删除,可安全跳过。更重要的是,并发 linkFirst 若试图在此节点前插入,其 CAS 期望 first.prev == null 会失败,从而避免新节点连接到已删除节点之前。自指同时完成了状态标记和并发栅栏双重功能。
③ 多角度追问
- 追问1:所有无锁链表删除都会自指吗?
答:常见于双端队列。ConcurrentLinkedQueue不需要,因为它是单向,无前驱指针。 - 追问2:若只将
prev设为null会怎样?
答:并发遍历可能从该节点继续向前搜索prev,到达一个本应孤立的区域,导致混乱。 - 追问3:自指是否导致内存泄漏?
答:不会。节点仍有来自后继的引用,当后继也被删除后,整个链不可达,GC 可正确处理自引用。
④ 加分回答
自指是一种状态压缩技巧,将“是否已删除”这一布尔信息编码进指针本身,利用引用空间的特殊值。类似 AQS 中状态值的多种含义,是无锁编程中避免额外 CAS 字段的常用手法。
8.5 skipDeletedPredecessors 和 skipDeletedSuccessors 方法的作用是什么?它们解决了什么问题?
① 一句话回答
它们沿 prev/next 链跳过所有 item == null 的逻辑删除节点,找到第一个有效节点,解决了延迟物理删除导致的“死节点”干扰操作和遍历的问题。
② 详细解释
当线程需要物理删除节点 x 时,其记录的前驱 pred 和后继 succ 可能已被逻辑删除。若直接连接 pred 和 succ,会将无效节点作为合法边界,链表无法彻底清理。skipDeletedPredecessors 从给定前驱出发沿 prev 遍历直到 item != null 或边界,skipDeletedSuccessors 沿 next 对称处理。这样物理删除总是跨越所有垃圾节点,实现链表的压缩清理。
③ 多角度追问
- 追问1:如果所有节点都被逻辑删除,会无限循环吗?
答:不会,边界条件prev == null或next == null或自指节点会终止循环。 - 追问2:它们被哪些方法调用?
答:unlink、unlinkFirst、unlinkLast、迭代器、size()等任何需要定位有效邻居的地方。 - 追问3:清理是强制的吗?
答:是协作式的。线程执行自己任务时“顺手”清理,不阻塞,体现了无锁数据结构的 helping 机制。
④ 加分回答
这两个方法是懒惰删除得以成立的配套机制。它们实现了分层迭代:应用层(item 有效性)和结构层(指针连续性)分离,通过及时跳过无效节点,维护了一个在语义上连续、结构上干净的链表视图。
8.6 ConcurrentLinkedDeque 的 head 和 tail 指针为什么允许滞后?这种设计有什么好处?
① 一句话回答
允许滞后是为了减少对 head/tail 这两个极热点字段的 CAS 竞争,从而提升高并发下的吞吐量。
② 详细解释
若每次插入/删除都立即更新哨兵,这些字段将成为瓶颈,大量线程在 CAS 上竞争,导致频繁失败和空转。通过延迟更新(Hop 设计),连续操作可共享一次哨兵更新,甚至遍历操作也能帮助推进。滞后导致遍历多走一两个节点,开销极小,而避免了激烈 CAS 带来的流水线停顿和总线同步开销,整体吞吐量大幅提升。
③ 多角度追问
- 追问1:滞后会不会使
size()计算变慢?
答:size()原本就需遍历,且是弱一致性,滞后只带来少量额外节点,影响微小。 - 追问2:如何防止滞后无限累积?
答:每次操作都可能调用updateHead/updateTail尝试推进,遍历也会触发更新,形成负反馈调节。 - 追问3:滞后与弱一致性的关系?
答:滞后正是弱一致性的来源之一:head/tail反映的不是精确边界,但任何操作都会基于最新节点引用,最终达到正确状态。
④ 加分回答
Hop 设计是分散热点的典型技术,类似 LongAdder 的分段累加。它通过容许短暂的不精确,换取长时间的高吞吐,是并发数据结构设计中寻找并消解热点的首要策略。
8.7 ConcurrentLinkedDeque 和 ConcurrentLinkedQueue 在设计和使用场景上有什么本质区别?
① 一句话回答
本质区别在于底层链表是双向还是单向,ConcurrentLinkedQueue 只支持 FIFO(尾入头出),ConcurrentLinkedDeque 支持双端任意操作,适用于工作窃取等场景。
② 详细解释
ConcurrentLinkedQueue 仅维护单向 next 链,offer 固定在尾部,poll 固定在头部,算法相对简单,常数开销更低。ConcurrentLinkedDeque 维护双向链表,额外提供 addFirst/pollLast 等操作,内部需处理 prev CAS、自指标记和跳跃清理,复杂度大增。性能上,单向队列在纯 FIFO 场景更快;但在 ForkJoinPool 中,需本地线程头部 LIFO 消费、窃取者尾部 FIFO 消费,ConcurrentLinkedDeque 是唯一解。
③ 多角度追问
- 追问1:能用两个
ConcurrentLinkedQueue模拟双端队列吗?
答:不能。无法在保证原子性和性能的同时在两个队列间迁移元素。 - 追问2:两者是否都使用 Hop 设计?
答:是,都使用,但Deque的实现因双向而更复杂。 - 追问3:如果只需单向操作,用
Deque会有性能损失吗?
答:常数开销略高,但不显著。不过明确单向场景下Queue是更合适选择。
④ 加分回答
ConcurrentLinkedDeque 是 ConcurrentLinkedQueue 在设计哲学上的递进——在保持无锁和高性能的同时,将操作能力推向一般化。它是 JDK 并发容器中无锁数据结构的巅峰,理解它就理解了如何用 CAS 构建任意复杂度的并发结构。
8.8 为什么 ForkJoinPool 的工作窃取算法要使用双端队列?ConcurrentLinkedDeque 的“头部取、尾部窃”模式是如何减少竞争的?
① 一句话回答
双端队列允许本地线程在头部 LIFO 消费(高缓存局部性),窃取线程在尾部 FIFO 消费,操作端物理隔离,极大降低竞争。
② 详细解释
ForkJoinPool 中每个工作线程绑定一个双端队列。本地线程生成的任务在头部插入(addFirst),也从头部取出(pollFirst),保持栈式执行顺序和数据局部性。空闲线程从其他队列尾部窃取(pollLast),取最老任务,促进均衡且避免与忙碌线程争抢头部。由于头部操作主要修改 prev 链,尾部操作主要修改 next 链,涉及的节点和指针区域不同,CAS 竞争大大减少。若用单向队列,窃取者和所有者都竞争同一个头,冲突激烈。
③ 多角度追问
- 追问1:为什么本地线程不也从尾部取?
答:那会失去 LIFO 的缓存友好性,且大任务可能快速耗尽,不公平。 - 追问2:
ForkJoinPool的WorkQueue真的是ConcurrentLinkedDeque实例吗?
答:JDK 8 内部实现是特殊优化的数组双端队列,但其并发控制算法深受ConcurrentLinkedDeque无锁设计影响,思想一脉相承。 - 追问3:窃取频率很高时性能如何?
答:即使高频窃取,操作分处两端,CAS 竞争主要在哨兵上,因 Hop 设计仍保持出色性能。
④ 加分回答
工作窃取调度是双端队列的杀手级应用,完美诠释了“分离资源访问端以消除竞争”的并发设计原则。ConcurrentLinkedDeque 在结构上为这种分离提供了硬件级别的支持(CAS 作用在不同字段),这也是 Doug Lea 将其思想深植于 ForkJoinPool 核心的原因。
8.9 在 ConcurrentLinkedDeque 中遍历元素时,如果遇到 item 为 null 的节点,迭代器会怎么处理?
① 一句话回答
迭代器会跳过 item == null 的节点,并可能调用 unlink 帮助物理删除,确保遍历结果的正确性和链表的清洁。
② 详细解释
ConcurrentLinkedDeque 的迭代器是弱一致性的。其 next() 方法内部调用 nextNode(),会持续推进指针,并使用 skipDeletedSuccessors(或类似逻辑)跳过所有 item == null 的节点。同时,迭代器会尝试对遇到的无效节点进行 unlink 操作,主动清理链表。这样,被逻辑删除的元素对迭代器不可见,链表也得到了维护。
③ 多角度追问
- 追问1:弱一致性迭代器是否会漏掉新插入的元素?
答:可能漏掉也可能不,取决于插入发生的时机,这正是弱一致性的体现。 - 追问2:迭代过程中链表结构变化会导致死循环吗?
答:不会。迭代器始终跟随最新的next指针,且对删除节点有明确的跳过和清理机制。 - 追问3:为何不设计强一致性迭代器?
答:强一致性需要全局锁或版本号,破坏无锁性能,违背设计初衷。
④ 加分回答
“遍历即清理”模式是无锁数据结构的标配,类似垃圾收集中的“标记-清扫”协作。它体现了健壮性与性能的统一:允许数据结构暂时有脏节点,但任何持续访问都能帮助打扫干净。
8.10 ConcurrentLinkedDeque 的 remove(Object) 方法是如何定位并删除队列中间的某个元素的?它会阻塞吗?
① 一句话回答
remove 通过线性遍历找到第一个 equals 的节点,然后 CAS 将其 item 置 null 完成逻辑删除,再调用 unlink 物理断开;整个过程无锁无阻塞。
② 详细解释
remove 从头(或尾)开始沿链表遍历。在并发环境中,它不保证删除的是严格全局第一个相等的元素,但保证至少删除一个(如果存在)。定位到目标节点 x 后,尝试 x.casItem(item, null),成功则调用 unlink(x) 短路指针。若 CAS 失败(被其他线程抢先删除),可能重试寻找下一个目标。由于完全无锁,线程永远不会阻塞,最坏情况是返回 false。
③ 多角度追问
- 追问1:如果
equals方法有副作用且并发调用呢?
答:可能被触发多次,但删除操作本身原子的,结果符合并发预期。 - 追问2:遍历如何避免在垃圾节点上浪费时间?
答:通过succ/pred的跳跃机制跳过item == null的节点。 - 追问3:删除中间节点会导致链表断裂吗?
答:不会。unlink使用skipDeleted*保证有效邻居连接,即使某次 CAS 失败,协作清理最终修复。
④ 加分回答
remove 的遍历和 CAS 删除是典型的 lock-free search-and-delete 模式。其原子性仅限于 item 字段,操作成功与否对调用方清晰。因不阻塞,特别适合事件驱动或低延迟系统。
8.11 系统设计题:高效异步任务调度器
题目:设计一个高效的异步任务调度器,核心是一个全局的任务队列。调度器线程池中的线程向队列提交任务(在尾部),所有工作线程从队列获取任务执行(从头部)。当某个工作线程因任务A阻塞等待外部响应时,它应能把任务A暂时放回队列头部,让其他线程优先执行,避免阻塞。请基于 ConcurrentLinkedDeque 实现该方案:画出任务提交、消费和“放回”的时序图;核心代码骨架;分析高并发下头部和尾部操作是否会产生严重竞争;如果用 ConcurrentLinkedQueue 能否实现类似效果?如果不能,原因是什么?
① 一句话回答
使用 ConcurrentLinkedDeque,调度器 offerLast 提交任务,工作线程 pollFirst 获取任务;阻塞时通过 offerFirst 将任务放回头部,利用双向操作实现无锁的优先级倒置缓解。
② 详细解释与方案设计
架构组件:
- TaskWrapper:包装任务,携带重试/放回逻辑。
- GlobalDeque:全局
ConcurrentLinkedDeque<TaskWrapper>。 - Scheduler:调度线程从外部接收任务,执行
addLast。 - Worker:工作线程循环,
pollFirst获取任务执行。
任务放回:任务因等待外部响应而需要暂放回时,调用 deque.offerFirst(taskWrapper) 插回头部,使其获得高优先级,被其他工作线程立即消费。
无锁优势:addLast(提交)操作尾部,pollFirst(消费)操作头部,两者物理隔离,互不竞争。放回操作 offerFirst 与 pollFirst 竞争头部,但与 addLast 无冲突。整体竞争可控。
核心代码骨架:
import java.util.concurrent.*;
public class StealableScheduler {
private final ConcurrentLinkedDeque<TaskWrapper> deque = new ConcurrentLinkedDeque<>();
private final ExecutorService workers = Executors.newCachedThreadPool();
// 任务包装
static class TaskWrapper implements Callable<Void> {
private final Callable<Void> task;
private final Object responseLock;
public TaskWrapper(Callable<Void> task, Object responseLock) {
this.task = task; this.responseLock = responseLock;
}
public Void call() throws Exception {
return task.call();
}
}
// 提交:尾部入队
public void submit(TaskWrapper task) {
deque.offerLast(task);
}
// 工作线程主循环
public void startWorker() {
while (!Thread.currentThread().isInterrupted()) {
TaskWrapper task = deque.pollFirst(); // 头部取
if (task == null) {
// 尝试窃取其他队列或短暂让出CPU
Thread.yield();
continue;
}
try {
task.call();
} catch (NeedRescheduleException e) {
// 阻塞需要重试:放回头部
deque.offerFirst(task);
}
}
}
}
时序图:
sequenceDiagram
participant S as Scheduler
participant D as ConcurrentLinkedDeque
participant W1 as Worker1
participant W2 as Worker2
S->>D: offerLast(TaskA) 尾部提交
W1->>D: pollFirst() 获取 TaskA
activate W1
W1-->>W1: 执行 TaskA,遇到阻塞响应
W1->>D: offerFirst(TaskA) 放回头部
deactivate W1
W2->>D: pollFirst() 成功获取 TaskA
activate W2
W2-->>W2: 执行 TaskA
deactivate W2
竞争分析:高并发下,offerLast 主要竞争 tail,pollFirst 和 offerFirst 竞争 head。head 端的竞争无法避免,但因是无锁 CAS 自旋,性能依然很高。由于 head 和 tail 操作分布在链表两端,不会相互干扰,全局吞吐量有保障。
ConcurrentLinkedQueue 能否替代?
不能。ConcurrentLinkedQueue 只支持“尾入头出”,没有 offerFirst 操作。如果要放回头部,只能通过遍历找到位置插入,无法保证 O(1) 和无锁,或者使用另一个队列转移,会破坏原子性和顺序。它没有双向结构,无法实现高效的“放回头部”需求。
③ 多角度追问
- 追问1:如果大量任务频繁放回,是否会形成活锁?
答:可能。需结合调度策略,如限制放回次数或降低放回优先级,超过阈值则改为offerLast尾插入。 - 追问2:如何避免同一个任务被多个线程反复放回和取出?
答:任务内部维护状态机,记录重试次数,超过阈值则改为尾插入,进入普通队列。 - 追问3:如果任务放回时队列为空,如何确保线程不空转?
答:可配合轻量等待,如pollFirst返回null时短暂Thread.yield()或使用ForkJoinPool类似的管理机制。
④ 加分回答
该设计本质上是局部优先级继承:阻塞任务通过重新插回头部获得事实上的高优先级,缓解了“头阻塞”问题。ConcurrentLinkedDeque 天然支持双向操作,使得这一模式无需任何锁即可实现,这是其超越 ConcurrentLinkedQueue 的关键价值。在演员模型或事件循环系统中,这种放回机制常被称为“无锁重入调度”,是构建高性能异步系统的重要技术。
关键结论:ConcurrentLinkedDeque 是 JDK 无锁并发容器的集大成者。它通过在双向链表上实施精细的 CAS 操作、逻辑删除与物理删除分离、以及延迟更新哨兵的 Hop 设计,实现了在极端并发场景下的高性能双端操作,是理解无锁并发编程和 ForkJoinPool 工作窃取算法的基石。