ConcurrentLinkedDeque 源码全景:双向无锁队列与并发操作

2 阅读1小时+

ConcurrentLinkedDeque 源码全景:双向无锁队列与并发操作

0 引言:当单向无锁队列不再够用

前文《ConcurrentLinkedQueue 与无锁队列原理》展示了如何在单向链表上通过精巧的 CAS 和 Hop 设计实现高性能的无锁队列。然而,当我们需要在两端同时进行高效的入队和出队——例如工作窃取算法中,线程从本地头部消费,从其他队列尾部窃取——单向队列就力不从心了。ConcurrentLinkedDeque 正是为了满足这种需求而生:它通过双向链表更复杂的 CAS 自旋算法,在完全无锁的情况下,实现了双端并发操作,是 JDK 无锁并发数据结构的巅峰之作。

“如何在双向链表上实现无锁并发?多个线程同时在头部插入和删除,如何保证链表不被破坏?为什么删除一个节点要先‘逻辑删除’再‘物理删除’?skipDeletedPredecessorsskipDeletedSuccessors 是做什么的?ConcurrentLinkedDeque 是如何应用于 ForkJoinPool 的工作窃取机制的?”

这些问题的答案,都隐藏在 ConcurrentLinkedDeque 复杂的 CAS 操作和自旋逻辑中。本文将从双向 Node 结构出发,深入拆解其在头部/尾部进行无锁插入与删除的源码实现,并揭示其在并发编程中的应用价值。

核心要点

  • 双向 Nodevolatile 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. 总论:双向无锁并发设计蓝图

在深入具体的 linkFirstunlink 源码之前,我们必须先在脑海中建立一幅完整的设计蓝图。ConcurrentLinkedDeque 绝非简单地在单向队列上增加一个 prev 指针——它是一套为解决双端无锁并发操作而生的精密算法体系。本章将从“是什么”、“为什么”、“如何做”三个层面,系统勾勒其完整轮廓。后续所有章节,都是对本节总论的逐层展开与求证。

1.1 是什么:定义与语义特性

ConcurrentLinkedDeque<E>java.util.concurrent 包提供的一个基于双向链表、完全无锁(lock-free)、非阻塞的并发双端队列。其核心语义可凝练为五点:

  1. 无界(Unbounded)
    基于链表节点动态创建,队列容量仅受限于堆内存,不会在队列满时阻塞生产者。

  2. 完全无锁与非阻塞(Lock-Free & Non-blocking)
    所有公开方法均不使用任何互斥锁(synchronizedLock)。线程在操作时最多进行自旋重试,永远不会被挂起,保证了极高并发下的低延迟与高吞吐。

  3. 弱一致性(Weakly Consistent)
    size()peek()、迭代器等批量或观察性操作返回的是近似结果,反映某个瞬间或跨瞬间的状态快照。这是高并发性能的必要妥协,也是 JDK 并发容器的常规设计取舍。

  4. 双端操作
    支持在头部(First)和尾部(Last)进行 O(1) 的插入和删除,允许“头部消费、尾部窃取”或“双向存取”等灵活模式。

  5. 自动清理
    通过“逻辑删除”标记和协作式的“物理删除”机制,链表不会积累垃圾节点。任何线程在遍历中遇到 item == null 的节点,都可顺手帮助将其从链表中断开。

1.2 为什么:设计动机与核心价值

单向无锁队列(如 ConcurrentLinkedQueue)在经典的生产者-消费者模型中表现出色——生产者仅在尾部追加,消费者仅在头部提取。然而,当应用需要分离竞争源时,单方向的约束便成为瓶颈。最典型的场景是工作窃取(Work-Stealing)调度(如 ForkJoinPool):

  • 本地线程LIFO 顺序从自己队列的头部消费任务,利用 CPU 缓存局部性。
  • 空闲线程FIFO 顺序从其他队列的尾部窃取任务,保证公平性并避免与本地线程争抢头部。

如果使用单向队列,窃取者和所有者将竞争同一个端(头部),导致激烈的 CAS 冲突。双向队列通过物理隔离操作端(头尾部操作分别作用于 prevnext 链),极大降低了竞争,使得局部调度与全局负载均衡可以并行不悖。这正是 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~ *-- &#34;2&#34; Node~E~ : head / tail
    Node~E~ <-- Node~E~ : prev / next

图1-1:ConcurrentLinkedDeque 类图

  • a) 主旨概括:静态展示了 ConcurrentLinkedDeque 与内部类 Node 的字段、核心方法及其相互关系,是代码结构的精确映射。
  • b) 逐元素分解
    • ConcurrentLinkedDeque:持有两个 volatile 的哨兵引用 headtail,它们指向 Node 实例。公开方法提供了双端操作接口,私有方法(linkFirst, unlink, skipDeleted* 等)封装了无锁算法。
    • Node:三个 volatile 字段(prev, item, next)构成双向链节点。提供了一系列 CAS 和 lazySet 方法,这些方法是所有无锁操作的基础。
  • c) 设计原理映射:组合关系:ConcurrentLinkedDeque 通过 head/tail 持有链表端点,节点之间通过 prev/next 形成自引用结构,构成一个完整的双向链表。所有字段的 volatile 和 CAS 方法均在 Node 层实现,保证了操作的原子性与可见性。
  • d) 工程联系与关键结论这个类图是源码剖析的导航图。后续所有关于 linkFirstunlinkupdateHead 的讨论,本质上都是在解释这些方法如何通过调用 Node 的 CAS 操作,在 head/tail 引导下操纵双向链表。理解这一静态结构,是动态分析无锁算法的先决条件。
1.3.2 动态结构:运行时的典型状态
flowchart LR
    subgraph 队列动态结构
        direction LR
        H[&#34;head<br>(可能滞后)&#34;] --> A
        A[&#34;Node A<br>item=有效数据<br>prev=null&#34;] 
        B[&#34;Node B<br>item=有效数据&#34;]
        C[&#34;Node C<br>item=null<br>(已逻辑删除)&#34;]
        D[&#34;Node D<br>item=有效数据<br>next=null&#34;]
        T[&#34;tail<br>(可能滞后)&#34;]

        A <--> B
        B <--> C
        C <--> D
        T -..-> C
    end

图1-2:ConcurrentLinkedDeque 运行时状态

  • a) 主旨概括:展示了一个运行时队列的双向链表结构,包含滞后哨兵、有效节点、已逻辑删除节点以及它们之间的 prev/next 双向指针关系。
  • b) 逐元素分解
    • head 引用指向节点 A(真头),tail 引用则滞后地指向节点 C(已逻辑删除)。
    • 节点 A 的 prevnullitem 有效;节点 D 的 nextnullitem 有效,是真正的尾节点。
    • 节点 C 的 item 已为 null,表示它已被逻辑删除,但其 prev(指向 B)和 next(指向 D)仍然保留,等待物理断开。
  • c) 设计原理映射:滞后哨兵是 Hop 设计的直观体现;item == null 节点是逻辑删除与物理删除分离策略的中间产物;所有指针的修改都依赖 volatile 保证可见性。
  • d) 工程联系与关键结论这张图是理解后续所有插入/删除算法的起点。任何线程在遍历时,都必须具备从这种“非精确哨兵 + 混合有效/无效节点”的结构中,快速定位真实头尾的能力——这正是 skipDeleted* 方法和哨兵更新逻辑存在的意义。
1.3.3 核心流程与无锁算法总览

所有操作都围绕 CAS 自旋展开,可按类别归纳为下表:

操作类别核心方法算法要点
头部/尾部插入linkFirst, linkLast①从哨兵出发遍历,定位当前真实边界节点;②用 lazySet 建立新节点到边界节点的单向连接;③一次关键 CAS 原子地将边界节点的 prev/nextnull 改为新节点;④尝试更新 head/tail(允许失败)
头部/尾部删除unlinkFirst, unlinkLast①CAS 将目标节点的 itemnull(逻辑删除);②将目标节点的边界指针自指(标记已删除);③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 实现的,是三个深刻的设计思想:

  1. Hop 设计(哨兵延迟更新)
    headtail 不总是精确指向边界,允许滞后数个节点。这样连续多次插入或删除可共享一次哨兵更新,避免热点字段上的激烈 CAS 竞争,是高吞吐量的关键。

  2. 逻辑删除与物理删除分离
    删除操作首先将 item CAS 为 null,这是线性化点(linearization point),宣告元素逻辑失效。物理指针断开可由任意线程在后续完成,甚至由多个线程协作。这种分离保证了删除语义立即生效,且不阻塞其他操作。

  3. 协作式清理(Helping)
    线程在遍历链表时遇到 item == null 的垃圾节点,会主动调用 skipDeleted*unlink 帮助清理。链表因此具有“自愈”能力,整体健康度由所有线程共同维护,无需专职清洁线程。

1.5 适用场景概览

  • 首选场景:需要双端操作的高并发环境,如工作窃取调度器(ForkJoinPool)、支持“放回头部”的异步任务系统、灵活存取的无界缓冲区。
  • 可用场景:普通 FIFO/LIFO 场景也可用,但若明确只需单向操作,ConcurrentLinkedQueueLinkedBlockingDeque 可能在复杂度和常数开销上更有优势。
  • 不宜场景:要求强一致性迭代或快照(需加锁包装)、需要有界阻塞(应使用 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 字段构成“三剑客”

字段类型语义
prevNode<E>指向前驱节点。若为 null,则本节点可能是头节点(或未连接);若指向自身(this),表示该节点已从头部/尾部物理删除并标记。
itemE存储的元素。null 即表示该节点已被逻辑删除,不再代表有效元素。
nextNode<E>指向后继节点。若为 null,则本节点可能是尾节点(或未连接);若指向自身,语义类似 prev 自指。

三个字段均被声明为 volatile,保证:

  • 可见性:任一 CPU 核心对这些字段的写操作,会立刻被其他核心看到(通过内存屏障)。
  • 禁止重排序:对 previtemnext 的读写顺序不会被编译器或 CPU 随意重排,这在无锁算法中至关重要。

所有 CAS 方法均基于 Unsafe 提供的原子指令。此外,Node 还提供了 lazySetNextlazySetPrev,它们使用 putOrderedObject,即低成本的延迟写:不保证立即被其他线程看见,但最终一定可见,且不引入全屏障。这在某些“先建立单向链、后 CAS 抢占”的流程中能显著提升性能。

2.2 逻辑删除:item 置 null 的两阶段策略

ConcurrentLinkedDeque 中,删除一个节点的第一步,永远是将它的 item 字段通过 CAS 原子地置为 null。这个操作称为 逻辑删除。一旦某个线程成功将节点 xitem 从有效值改为 null,其他所有线程在遍历到 x 时,会立即识别出“该节点已无效”,并跳过它。

删除操作的完整生命周期分为两阶段:

  1. 逻辑删除item CAS → null。这是删除的线性化点(linearization point)——对于外界观察者,该元素在这一刻被移除。
  2. 物理删除:通过 unlinkunlinkFirstunlinkLast 等方法,修改 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。那么,真正的边界节点如何定位?——遍历时主动沿着 prevnext 前进,直到找到 prev == nullitem != null 的节点(真头),或 next == nullitem != null 的节点(真尾)。这个过程在 linkFirstpollFirst 等方法中都有体现。

哨兵滞后的根本目的是减少对 head/tail 的 CAS 竞争。如果每次插入删除都必须即时更新哨兵,这两个字段将成为整个队列的瓶颈。允许滞后,使得连续操作可以共享一次哨兵推进,极大提升吞吐量。

2.4 双向链表结构图:直观呈现 volatile 三剑客

下面这张图展示了一个 ConcurrentLinkedDeque 运行中的典型双向链表形态,涵盖有效节点、已逻辑删除节点,以及滞后的 head/tail

flowchart LR
    subgraph 双向链表结构
        direction LR
        H[&#34;head<br>(滞后)&#34;] --> A
        A[&#34;Node A<br>item=数据<br>prev=null&#34;] 
        B[&#34;Node B<br>item=数据&#34;]
        C[&#34;Node C<br>item=null<br>(逻辑删除)&#34;]
        D[&#34;Node D<br>item=数据<br>next=null&#34;]
        T[&#34;tail<br>(滞后)&#34;]

        A <--> B
        B <--> C
        C <--> D
        T -..-> C
    end

图 2-1:ConcurrentLinkedDeque 双向链表结构图

  • a) 主旨概括:此图完整展示了一个运行时队列的双向链表结构,包含滞后哨兵、有效节点、已逻辑删除节点以及它们之间的 prev/next 双向指针关系。
  • b) 逐元素分解
    • head 引用指向节点 A(真头),tail 引用则滞后地指向节点 C(已逻辑删除)。
    • 节点 A 的 prevnullitem 有效;节点 D 的 nextnullitem 有效,是真正的尾节点。
    • 节点 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章拆解了 Nodevolatile 三剑客。本章将深入第一个核心操作:如何在双向链表的头部和尾部,通过有限的 CAS 操作实现无锁并发插入。我们将逐行拆解 linkFirstlinkLast 的源码,揭示其精密的 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 并重试。
  • 其他线程正在删除 firstfirstprevunlinkFirst 改为自指。此时本线程会跳过该节点,寻找更前面的有效头节点。
3.1.2 并发插入的冲突与化解

假设线程 A 和线程 B 同时调用 linkFirst,而当前链表的真正头节点是 H

  1. 两者都定位到 first = H(假设没有滞后问题)。
  2. 两者都用 lazySetNext 将各自的新节点(nAnB)的 next 指向 H
  3. 两者都尝试 H.casPrev(null, nA)H.casPrev(null, nB)
  4. 硬件保证只有一个 CAS 能成功。假设线程 A 成功——H.prev 变为 nA
  5. 线程 B 的 CAS 失败,因为此时 H.prevnA 而非 null
  6. 线程 B 沿着 H.prev 继续向前走,发现 nAitem 非空且 nA.prev == null,于是 first 变为 nA
  7. 线程 B 重试,将 nBnext 改为 nA,然后 CAS nA.prevnull 改为 nB

最终结果:链表头为 nB ← nA ← H ← ...,两个插入都成功,顺序反映了 CAS 的获胜顺序。整个过程无锁无阻塞,仅靠 CAS 失败重试便化解了冲突。

3.1.3 linkFirst 流程可视化
flowchart TD
    A[&#34;开始 linkFirst(E e)&#34;] --> B[&#34;从 head 出发<br>沿 prev 向前遍历&#34;]
    B --> C{&#34;当前节点的<br>prev 是否为 null?&#34;}
    C -- 否 --> D{&#34;prev 节点的<br>item 是否为 null?&#34;}
    D -- 是 --> E[&#34;跳过该节点<br>继续向前&#34;]
    E --> C
    D -- 否 --> F[&#34;定位成功:<br>当前节点为 first&#34;]
    C -- 是 --> F
    F --> G[&#34;创建新节点 newNode<br>lazySetNext(first)&#34;]
    G --> H{&#34;CAS first.prev<br>null → newNode&#34;}
    H -- 成功 --> I[&#34;插入成功<br>新节点成为头&#34;]
    H -- 失败 --> J[&#34;CAS 失败:<br>first.prev 已被修改&#34;]
    J --> C
    I --> K[&#34;调用 updateHead<br>(尝试更新哨兵,允许失败)&#34;]
    K --> L[&#34;结束&#34;]

图 3-1:linkFirst 插入流程图

  • a) 主旨概括:此图完整展示了 linkFirst 从定位头节点、跳过逻辑删除节点、到关键 CAS 抢占及哨兵更新的全流程。
  • b) 逐元素分解
    • 步骤 B-E:定位 first 的循环,包含对逻辑删除节点(item == null)的主动跳过。
    • 步骤 G:lazySetNext 建立单向连接,是低成本的预热操作。
    • 步骤 H:关键 CAS——原子地将 first.prevnull 改为 newNode,是插入的线性化点。
    • 步骤 J → C:CAS 失败后的重试回路,体现了无锁算法的“乐观尝试-失败重试”模式。
    • 步骤 K:updateHead 尝试推进哨兵,允许失败,是 Hop 设计的具体实现。
  • 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 的对称映射

步骤linkFirstlinkLast
出发哨兵headtail
搜索方向沿 prev 向前沿 next 向后
边界条件prev == nullnext == null
预热连接newNode.lazySetNext(first)newNode.lazySetPrev(last)
关键 CASfirst.casPrev(null, newNode)last.casNext(null, newNode)
哨兵更新updateHead()updateTail()

并发冲突处理linkFirst 完全对称:多个线程同时 linkLast 时,竞争 last.next,只有一个成功;失败者会沿 next 继续寻找新的 last 并重试。

3.3 设计精髓:最少 CAS 与分离竞争

回顾 linkFirstlinkLast 的实现,可以总结出三大设计精髓:

1. 一次关键 CAS 完成插入。
双向链表看似需要同时修改两个指针(新节点的 prev/next 和邻居节点的对应指针),但 linkFirst 只用了一次 CAS(first.casPrev)就完成了原子插入。原因在于:新节点的 prev 默认为 null(头节点特征),next 仅通过 lazySet 单向预热,不需要同步。整个插入的线性化点就落在 first.prev 的 CAS 上。

2. lazySet 在非竞争路径上削减开销。
新节点的 next(或 linkLastprev)在插入前对其他线程不可达,不存在并发访问,因此使用 lazySet 而非 CAS,避免了不必要的全内存屏障,这是 Doug Lea 的经典优化。

3. 哨兵更新与插入解耦。
updateHead/updateTail 在插入成功后被调用,但允许失败。这种分离使得连续插入操作的吞吐量不受哨兵更新延迟的影响,贯彻了 Hop 设计的核心理念。

3.4 本章小结与下一章预告

本章深入拆解了 ConcurrentLinkedDeque 的头部与尾部并发插入算法。linkFirstlinkLast 通过“定位边界 → 预热单向连接 → 一次关键 CAS 抢占 → 延迟更新哨兵”的四步曲,在双向链表上实现了高性能的无锁插入。多线程并发插入时的冲突,仅表现为 CAS 失败后的自旋重试,不存在阻塞。

然而,插入只是故事的一半。接下来的章节将面对更复杂的挑战:头部与尾部的并发删除(unlinkFirst/unlinkLast——当一个线程正在删除头节点时,另一个线程可能正在向头部插入,或者同时删除相邻节点。ConcurrentLinkedDeque 如何通过自指标记、逻辑删除与物理删除分离、以及精巧的 CAS 条件来化解这些冲突?这些将在第4章中揭晓。


4. 头部/尾部并发删除:unlinkFirst 与 unlinkLast 的复杂自旋

如果说 linkFirst/linkLast 是“在边界上安插新成员”,那么 unlinkFirst/unlinkLast 就是“在边界上拆除旧成员”——并且是在其他线程可能同时插入、删除甚至遍历的混乱中完成拆除。删除操作涉及更多的 CAS 步骤、更复杂的冲突处理,以及一种独特的“自指”标记。本章将深入拆解这两个方法的源码,揭示并发删除的完整画卷。

4.1 删除的两阶段:从逻辑到物理

在拆解源码之前,必须明确删除由两个线程分阶段完成,这种分离是并发删除正确性的基石。

  1. 逻辑删除:发生在 pollFirst/pollLast 等公开方法中。线程通过 CAS 将目标节点的 item 从有效值改为 null这一步是删除的线性化点,标志着元素从语义上已从队列中移除。只有成功执行此 CAS 的线程才“拥有”该节点的删除权。

  2. 物理删除:由 unlinkFirst/unlinkLast(或 unlink)完成。线程修改 prev/next 指针,将节点从链表中断开。物理删除可以由逻辑删除的线程立即执行,也可以被后续的其他线程(在遍历或操作中)帮忙完成。

本章讨论的 unlinkFirstunlinkLast 承担的是物理删除的职责。

4.2 unlinkFirst:拆除头节点

unlinkFirst(Node<E> first, Node<E> next) 的调用前提是:first 节点已被逻辑删除(item == null),且 nextfirst 的有效后继(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 原本其 prevnull(头节点特征)。现在将其 prev 通过 CAS 改为指向自己(first)。这产生了一个显式的“已删除”标记。为什么需要这样做?

  • 正常的头节点 prev == null,而已删除的头节点如果保留 prev == null,遍历线程无法区分“这是一个原始的头”还是“这是一个已断开但未被清理的节点”。
  • 将其改为指向自己,所有遍历到 first 的线程看到 prev == this,就能立即知道该节点已被删除,应该跳过。
  • 同时,这个自指也是一个屏障:任何试图在 first 之前插入新节点的 linkFirst 线程,在执行 first.casPrev(null, newNode) 时会失败(因为 prev 已不是 null),从而避免新节点被连接到已删除的节点之前。

步骤2:切断后继的连接 —— next.casPrev(first, null)。
这一步将 nextprevfirst 改为 null,使 next 正式成为新的头节点。CAS 的条件是 next.prev 必须为 first。如果在此期间,另一个线程修改了 next.prev(例如在 firstnext 之间插入了一个新节点),这个 CAS 就会失败。

CAS 失败的并发语义:失败意味着 next.prev 已经不是 first 了。最可能的情况是另一个线程在 firstnext 之间插入了一个新节点 X。此时 next.prev 应为 X。本方法不进行重试,而是将清理工作留给后续的遍历或操作线程(它们调用 skipDeletedSuccessors 等方法时会自然处理)。这种“快速失败、协作清理”的策略,避免了在 unlinkFirst 中陷入复杂的自旋。

步骤3:延迟更新 head 哨兵。
linkFirst 类似,成功后调用 updateHead() 尝试将 head 前移指向 next(新头),但允许失败。

4.2.2 unlinkFirst 删除流程可视化
flowchart TD
    A[&#34;开始 unlinkFirst<br>(first.item == null, next 为有效后继)&#34;] --> B[&#34;步骤1:CAS 将 first.prev<br>从 null 改为 first (自指标记)&#34;]
    B --> C{&#34;步骤2:CAS 将 next.prev<br>从 first 改为 null&#34;}
    C -- 成功 --> D[&#34;断开成功<br>next 成为新的头节点&#34;]
    C -- 失败 --> E[&#34;CAS 失败:<br>next.prev 已被其他线程修改<br>(如并发插入)&#34;]
    D --> F[&#34;步骤3:调用 updateHead<br>(尝试推进 head,允许滞后)&#34;]
    E --> G[&#34;不重试,留给后续<br>遍历/操作帮助清理&#34;]
    F --> H[&#34;结束&#34;]
    G --> H

图 4-1:unlinkFirst 删除流程图

  • a) 主旨概括:完整展示 unlinkFirst 通过自指标记和一次关键 CAS 完成头节点物理删除的流程,包含失败时的协作清理策略。
  • b) 逐元素分解
    • 步骤1:prev 自指是显式的删除标记,同时阻止并发插入误连到已删节点。
    • 步骤2:核心断开操作,CAS 条件确保不会误删中间插入的新节点。
    • 步骤3:哨兵延迟更新,Hop 设计。
    • 路径 E→G:CAS 失败时不阻塞重试,而是依赖协作清理,体现无锁设计中的“快速失败”哲学。
  • 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();
    }
    // 失败则不重试
}

对称映射

步骤unlinkFirstunlinkLast
自指标记first.casPrev(null, first)last.casNext(null, last)
切断邻居next.casPrev(first, null)prev.casNext(last, null)
哨兵更新updateHead()updateTail()

4.4 并发删除冲突:当插入与删除同时发生

unlinkFirstunlinkLast 的设计体现了对并发插入的精细考量。下面以头部并发删除与头部并发插入的冲突为典型场景,详细拆解其交互过程。

4.4.1 场景:删除 first 时,新节点插入到 first 之前

假设初始链表为 A (first) ↔ B (next),线程 T1 准备删除 A(已将 A.item CAS 为 null),线程 T2 正尝试 linkFirst(C) 插入新节点 C。

时序展开:

时刻线程 T1 (unlinkFirst)线程 T2 (linkFirst)
t1A.casPrev(null, A) 成功,A.prev = A(自指)定位 first = A
t2执行 newNode.lazySetNext(A)
t3B.casPrev(A, null) 尝试中……执行 A.casPrev(null, C)
t4CAS 失败! 因为 A.prev 现在是 A 而非 null
t5B.casPrev(A, null) 成功,B 成为新头T2 重试:从 A 沿 prev 向前……
t6发现 A.prev == A(自指),识别 A 已被删除,跳过 A
t7继续向前,找到 B 为 firstB.prev == nullitem != 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[&#34;A<br>item=null<br>prev=null&#34;] <--> B1[&#34;B<br>item=数据&#34;]
    end

    subgraph 时间线
        T1_1[&#34;线程T1: A.casPrev(null, A) 成功<br>A.prev = A (自指)&#34;] --> T2_1[&#34;线程T2: linkFirst(C)<br>定位 first = A<br>尝试 A.casPrev(null, C)&#34;]
        T2_1 --> T2_FAIL{&#34;CAS 失败!<br>A.prev 现在是 A 而非 null&#34;}
        T2_FAIL --> T2_RETRY[&#34;线程T2 重试:从 A 向前遍历<br>发现 A.prev == A (自指),跳过 A<br>定位新 first = B&#34;]
        T1_1 --> T1_2[&#34;线程T1: B.casPrev(A, null) 成功<br>B 成为新头&#34;]
        T2_RETRY --> T2_SUCC[&#34;线程T2: B.casPrev(null, C) 成功<br>C 插入为 B 的前驱&#34;]
    end

    subgraph 最终状态
        direction LR
        C2[&#34;C<br>item=数据<br>prev=null&#34;] <--> B2[&#34;B<br>item=数据&#34;]
    end

图 4-2:并发删除冲突处理示意图

  • a) 主旨概括:展示了 unlinkFirstlinkFirst 并发操作同一节点时的冲突和自动化解过程,核心在于自指标记对插入操作的阻挡。
  • 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 遇到冲突而放弃,后续任何遍历到该区域的线程,在调用 skipDeletedPredecessorsskipDeletedSuccessors 时,会自动跳过该垃圾节点,并可能帮助完成物理断开。
  • 避免重复竞争。如果 unlinkFirst 也进行自旋,那么高度竞争的场景下,删除线程和插入线程可能陷入反复的 CAS 对抗,浪费 CPU 资源。将部分清理责任分散给遍历线程,是更优的整体吞吐设计。

这种“快速失败,协作清理”的策略,正是无锁数据结构中典型的 Helping 机制。每个线程既为自己工作,也为整个数据结构的健康贡献力量。

4.6 本章小结与下一章预告

本章深入拆解了 ConcurrentLinkedDeque 的头部与尾部并发删除算法。unlinkFirst/unlinkLast 通过自指标记明确宣告节点死亡,通过一次条件 CAS 断开邻居连接,并通过快速失败与协作清理化解并发冲突。自指标记是整个删除机制的精髓,它同时完成了状态标记和并发栅栏的双重功能。

至此,我们已经掌握了边界操作的完整逻辑。然而,队列中间的节点也可能被删除(如 remove(Object))。这时,需要 unlink(Node<E> x) 方法来完成中间节点的物理断开——它如何找到有效的前驱和后继?skipDeletedPredecessorsskipDeletedSuccessors 是如何在垃圾节点丛中开辟出一条清洁路径的?这些将在第5章中揭晓。


5. 双向断开与辅助清理:unlink 方法与跳跃清理

前两章已经覆盖了边界操作:在头部和尾部插入(linkFirst/linkLast),以及从头部和尾部删除(unlinkFirst/unlinkLast)。然而,ConcurrentLinkedDeque 还支持删除队列中间的某个节点——例如通过 remove(Object)removeFirstOccurrence(Object) 方法。当目标节点既不是头也不是尾时,它的物理删除就落在 unlink(Node<E> x) 方法身上。

此外,删除后的“垃圾节点”如何被彻底清理?在遍历过程中遇到 item == null 的节点如何处理?这些问题的答案都隐藏在 skipDeletedPredecessorsskipDeletedSuccessors 这两个至关重要的辅助方法中。本章将深入拆解这些机制,揭示 ConcurrentLinkedDeque 如何维持链表的“清洁”。

5.1 unlink:短路中间节点的双向指针

unlink(Node<E> x) 的任务是:将已被逻辑删除的节点 x 从双向链表中短路掉,即让 x.prevx.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.prevx.next 是否为 null。如果是,说明 x 实际上是边界节点,应转交给 unlinkFirstunlinkLast 处理。这两个方法我们在第4章已经详细剖析。

步骤A & B:定位有效邻居。
这是 unlink 的核心所在。x.prevx.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 而不是直接赋值? 因为 activePredactiveSucc 的指针可能在此期间被其他线程修改(例如 activePred 被另一个删除操作处理,或在其后插入了新节点)。CAS 确保只在我们期望的旧值(x)未被改变时才执行修改,否则安全放弃。
  • 两个 CAS 不要求原子性。 它们可以分开成功或失败。如果 casNext 成功但 casPrev 失败,那么 xnext 链已经断开,但 prev 链仍可能暂时引用 x。不过这是安全的,因为 x 已经被逻辑删除(item == null),遍历线程在通过 prev 链到达 x 时会检查 item 并跳过,且后续的辅助清理(如 skipDeletedPredecessors)最终会修复 prev 链的不一致。这种“最终一致性”保证了无锁环境下的正确性。

如果 CAS 失败了呢? 方法并不重试。原因与 unlinkFirst 相同:物理删除是协作式的。如果本次 unlink 因为并发冲突而未能完全断开,后续其他线程的遍历(如迭代器、size() 或下一次 remove)将遇到 x 并再次尝试清理。

5.1.2 中间节点 unlink 流程图
flowchart TD
    A[&#34;开始 unlink(x)<br>(x.item == null)&#34;] --> B{&#34;x.prev == null?&#34;}
    B -- 是 --> C[&#34;调用 unlinkFirst(x, x.next)&#34;]
    B -- 否 --> D{&#34;x.next == null?&#34;}
    D -- 是 --> E[&#34;调用 unlinkLast(x, x.prev)&#34;]
    D -- 否 --> F[&#34;定位 activePred =<br>skipDeletedPredecessors(x.prev)&#34;]
    F --> G[&#34;定位 activeSucc =<br>skipDeletedSuccessors(x.next)&#34;]
    G --> H{&#34;activePred 和 activeSucc<br>均非 null?&#34;}
    H -- 否 --> I[&#34;放弃,留待后续清理&#34;]
    H -- 是 --> J[&#34;CAS: activePred.next<br>x → activeSucc&#34;]
    J --> K[&#34;CAS: activeSucc.prev<br>x → activePred&#34;]
    K --> L[&#34;短路完成<br>x 被物理断开&#34;]
    I --> M[&#34;结束&#34;]
    C --> M
    E --> M
    L --> M

图 5-1:中间节点 unlink 流程图

  • a) 主旨概括:完整展示 unlink 方法根据节点位置分发处理,并通过跳跃方法定位有效邻居后执行短路连接的流程。
  • b) 逐元素分解
    • 前置判断:依据 prev/next 是否为 null 分派给边界删除方法。
    • 核心步骤 F、G:skipDeletedPredecessorsskipDeletedSuccessors 负责从记录的邻居出发,跳过沿途所有 item == null 的垃圾节点,找到有效邻居。
    • 步骤 J、K:两次独立的 CAS 分别修改前驱的 next 和后继的 prev,将 x 从链中挤出。
    • 路径 H→I:如果找不到有效邻居(通常因为并发状态下链表变化),放弃本次清理,交给后续线程。
  • c) 设计原理映射:两阶段跳跃清理 + 两次独立 CAS 的模式,是逻辑删除与物理删除分离策略的延续。它允许在保持数据结构安全的前提下,将清理开销分散到各个操作中。
  • d) 工程联系与关键结论unlink 的“先跳跃找到有效邻居,再 CAS 短路”是中间节点并发删除的经典范式。它展示了无锁数据结构如何通过协作清理来维持长期稳定,而不是依赖单一的集中式修复。

5.2 skipDeletedPredecessors 与 skipDeletedSuccessors:垃圾节点的清道夫

这两个方法是辅助清理的核心,它们被广泛调用:不仅在 unlink 中,也在 linkFirstlinkLastpollFirstpollLast、迭代器遍历等几乎任何需要定位有效节点的地方。

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 跳跃清理的并发安全性

这两个方法看似简单,但它们运行在完全并发的环境下,必须面对以下问题:

  1. 节点在跳跃过程中被物理删除怎么办?
    假设 p 当前指向一个 item == null 的节点,它正要执行 p = p.prev。如果此时 punlink 物理断开(p.prevp.next 被修改),p.prev 是否可能指向一个无效节点?答案是可能的,但无害。因为即使 p 被物理删除,其 prev 字段仍然保留着指向其前驱的引用(除非被显式修改为自指或清理)。在跳跃中,最坏情况是跳到一个也已被逻辑删除的节点,那么循环会继续。不会形成无限循环,因为链表没有环路,且边界条件(null)始终存在。

  2. 跳跃过程中新插入的节点会干扰吗?
    不会。跳跃只关心 item == null 与否。新插入的节点 itemnull,因此它们是“有效”的。如果跳跃穿过了一个新插入的节点,那么我们就找到了一个更近的有效邻居,这反而提高了物理删除的质量。

  3. 跳跃是否可能永远找不到有效节点?
    不会。因为队列至少有一个哨兵节点(或真正的头/尾),其 item 要么是数据(非 null),要么是自指标记。但在 skipDeletedPredecessorsskipDeletedSuccessors 的应用场景中,它们总是从某个节点的 prevnext 出发,最终一定能到达一个 item 非空的真实有效节点或到达 null(链表之外)。例如在 unlink 中,如果 activePred 最终为 null,方法会放弃清理。

5.3 辅助清理的生态:协作式自愈

skipDeletedPredecessorsskipDeletedSuccessors 代表了一种协作式自愈哲学。在 ConcurrentLinkedDeque 中,没有一个专职的“垃圾回收线程”。相反,每个线程在遍历链表时,只要遇到 item == null 的节点,就会在自身操作中跳过它,并顺手帮助物理断开。例如:

  • 迭代器遍历nextNode() 方法会调用 skipDeletedSuccessors 跳过垃圾。
  • size() 计算:遍历计数时自动跳过 item == null 的节点。
  • peekFirst()/peekLast():在定位有效头/尾时,也使用类似的跳跃逻辑。
  • unlink 和边界删除:如我们所见,使用跳跃找到有效邻居。

这种设计的优势:

  • 负载分散:清理开销不集中于单个线程,而是均摊到所有访问者。
  • 无阻塞:清理不需要全局锁或停止所有操作。
  • 自适应性:当垃圾节点较多时,任何访问者都能帮助减少它们;当没有垃圾时,跳跃几乎零开销。

5.4 设计精髓:短路、跳跃与最终一致性

回顾本章内容,可以总结出 ConcurrentLinkedDeque 中间节点删除与清理的三大设计精髓:

  1. 短路优先于彻底清理。
    unlink 并不试图原子地修复所有指针,而是让有效前驱和后继直接相连(短路)。至于 x 本身留下的悬空指针,则由后续的跳跃操作逐步清理。这种“先短路、后清理”的策略,将一次复杂的多指针原子修改简化为两次独立的 CAS,极大降低了实现复杂度。

  2. 跳跃解耦物理删除与逻辑删除。
    skipDeletedPredecessors/Successors 让所有遍历操作都能适应“item 已 null 但指针仍在”的中间状态。它们是逻辑删除与物理删除分离策略得以成立的桥梁

  3. 协作清理实现最终一致性。
    链表结构不需要在删除瞬间就变得完美。它允许短暂的不一致(如 prev 链和 next 链不完全对称),但通过各线程的协作,最终会达到一个清洁、一致的状态。这是最终一致性在数据结构内部的体现。

5.5 本章小结与下一章预告

本章深入拆解了 unlink 方法通过 skipDeletedPredecessors/skipDeletedSuccessors 跳跃垃圾节点、完成中间节点短路连接的过程,并揭示了 ConcurrentLinkedDeque 协作式清理的全貌。辅助清理方法看似简单,却是逻辑删除与物理删除分离策略的黏合剂,它们使队列具备了“自愈”能力。

至此,我们已经掌握了插入、删除和清理的完整算法。下一个问题自然浮现:headtail 的延迟更新(Hop 设计)究竟是如何实现的?updateHeadupdateTail 方法内部做了什么,使得它们在允许失败的前提下,依然能保证队列的整体性能? 这些将在第6章中揭晓。


6. Hop 设计:head/tail 延迟更新的精妙平衡

在前面的章节中,我们已经在 linkFirstunlinkFirst 等方法的最后一步看到了 updateHead()updateTail() 的调用。它们总是轻描淡写地出现,而且允许失败。总论中曾提到,headtail 并不总是指向真正的头/尾节点,而是允许滞后——这就是经典的 Hop 设计。本章将正面回应这个设计:updateHeadupdateTail 的内部机制是什么?滞后距离如何控制?为什么“不精确”的哨兵反而能带来性能提升?

6.1 为什么需要滞后:热点竞争的必然代价

想象一下,如果每次头部插入或删除都必须立即更新 head 引用,那么当有 N 个线程同时操作头部时,head 字段会成为整个队列最激烈的 CAS 竞争热点。线程们将反复在 head 上执行 CAS,大量操作失败重试,导致 CPU 空转和总线流量飙升。

Hop 设计的解决方案是:将哨兵更新与数据操作解耦。插入或删除操作在修改链表结构后,只需“尝试”更新哨兵,但允许更新滞后。真正的遍历操作(如 peekpoll)通过沿 prev/next 链自行定位边界,代价仅仅是多走一两个节点,却换来了热点竞争的急剧减少。

6.2 updateHead:有条件地推进 head 哨兵

updateHead()linkFirstunlinkFirst 以及 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 必须不同于目标节点 ph != p),这意味着有更新空间;
  • 并且 h 不能恰好是 p 的直接后继(h != p.prev)。换句话说,只有当 head 与真正的头节点至少相隔两个节点时,才触发 CAS 更新。

为什么是“至少隔两个节点”?
这正是 Hop 设计中“hop”的含义——跳跃。如果 head 仅滞后一个节点(即 h == p.prev),则遍历的额外成本几乎为零(只需一次额外的 prev 解引用),但更新 head 仍然会引发一次 CAS 竞争。因此,为了避免“为了节省一次指针追踪而付出 CAS 代价”的过度优化,设计选择了当滞后累积到两个节点以上时才执行更新。这正是 Hop = 2 的策略。

CAS 原子更新:当条件满足时,使用 UNSAFE.compareAndSwapObject 尝试将 headh 改为 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[&#34;调用 updateHead&#34;] --> B[&#34;从 head 出发沿 prev 向前<br>跳过 item == null 的节点<br>找到目标 p&#34;]
    B --> C{&#34;h != p 且<br>h != p.prev ?&#34;}
    C -- 否 --> D[&#34;head 已足够接近<br>无需更新,直接返回&#34;]
    C -- 是 --> E[&#34;CAS 将 head 从 h 更新为 p&#34;]
    E -- 成功 --> F[&#34;head 推进成功&#34;]
    E -- 失败 --> G[&#34;其他线程已更新 head<br>重试&#34;]
    G --> B
    D --> H[&#34;结束&#34;]
    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。
  • c) 设计原理映射:Hop 条件体现了对 CAS 成本与遍历成本的精算权衡。在并发数据结构中,并非所有“不精确”都需要立即修正,修正本身也有代价。
  • d) 工程联系与关键结论Hop 设计的本质是“用廉价的指针追踪替代昂贵的 CAS 竞争”。head/tail 滞后 2 个节点的经验值,使得队列在极端并发下保持高吞吐,同时遍历开销几乎可忽略。

6.5 设计精髓:Hop 设计的性能权衡

  1. 减少 CAS 竞争。通过聚合多次操作的哨兵更新,head/tail 上的 CAS 频率显著降低。连续的插入或删除操作可以共享一次哨兵的推进。

  2. 遍历开销极低。即使 head 滞后,peekpoll 也只需要额外追踪一两次 prev/next 指针。这种开销远小于一次失败的 CAS 带来的流水线冲刷和总线流量。

  3. 自适应。滞后距离由 updateHead/updateTail 的条件动态控制。当并发度低时,滞后很快被修正;当竞争激烈时,滞后自然积累,从而自动减少 CAS 尝试频率。

  4. 协作性。哨兵更新不仅仅由插入/删除线程发起,遍历线程(如 peek)也可能在发现滞后时帮忙推进。这种协作机制保证了滞后不会无限累积。

6.6 本章小结与下一章预告

本章揭开了 Hop 设计的面纱:updateHeadupdateTail 通过“滞后距离 ≥ 2 时才 CAS”的策略,将哨兵更新从热点竞争中解放出来,用极小的遍历代价换取了整体吞吐量的大幅提升。这是 ConcurrentLinkedDeque(以及它的单向兄弟 ConcurrentLinkedQueue)高性能的关键技术之一。

至此,我们已经完成了对 ConcurrentLinkedDeque 内部所有核心算法的全景解析:Node 结构、插入、删除、清理、哨兵更新。接下来,我们将从内部走向外部,将 ConcurrentLinkedDeque 与其单向兄弟 ConcurrentLinkedQueue 进行全面对比,并深入探讨它在 ForkJoinPool 工作窃取调度中的关键应用——这将是理解“为什么需要双向无锁队列”的最终答案。


7. 与 ConcurrentLinkedQueue 的对比及工作窃取应用

前六章已经完整拆解了 ConcurrentLinkedDeque 的内部算法。现在,我们将视角拉远,将其与单向无锁队列 ConcurrentLinkedQueue 并置,从结构、算法复杂度、竞争特性和适用场景进行系统对比,并最终深入其最重要的工程应用——ForkJoinPool 的工作窃取调度。这是“为什么需要双向无锁队列”的终极答案。

7.1 技术差异全景:单向 vs 双向

7.1.1 多维对比表
维度ConcurrentLinkedQueueConcurrentLinkedDeque
底层结构单向链表(仅 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==nullskipDeletedPredecessors/Successors 主动清理
适用场景简单生产者-消费者(FIFO)工作窃取、双端操作、灵活调度
常数开销更低(每次操作 CAS 竞争更少)稍高(双向维护成本)
7.1.2 结构可视化对比
flowchart LR
    subgraph ConcurrentLinkedQueue
        direction LR
        QH[&#34;head&#34;] --> Q1[&#34;Node 1&#34;] --> Q2[&#34;Node 2&#34;] --> QT[&#34;tail&#34;]
    end
    subgraph ConcurrentLinkedDeque
        direction LR
        DH[&#34;head&#34;] <--> D1[&#34;Node 1&#34;] <--> D2[&#34;Node 2&#34;] <--> DT[&#34;tail&#34;]
    end

图 7-1:ConcurrentLinkedDeque vs ConcurrentLinkedQueue 结构对比图

  • a) 主旨概括:直观展示单向链表队列与双向链表队列在结构上的根本差异——前者只有单向 next 链路,后者具备对称的 prev/next 双向链路。
  • b) 逐元素分解
    • ConcurrentLinkedQueue 每个节点仅持有 next 指针,形成单一方向的链。操作被强制为“尾入头出”。
    • ConcurrentLinkedDeque 每个节点同时持有 prevnext,链表可双向遍历,操作可在两端自由进行。
  • 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?

① 一句话回答
Nodevolatile 修饰的 previtemnext 三个字段构成;itemvolatile 是为了保证逻辑删除(CAS 置 null)对其他线程的立即可见,从而确立无锁删除的线性化点。

② 详细解释
Node 是双向链表的基本单元。三个字段全部 volatile,保证跨核心的可见性和有序性。item 字段的核心使命是承载逻辑删除标志:当执行 pollremove 时,线程通过 casItem(item, null)item 置为 null。若 item 不是 volatile,该修改可能被延迟甚至对其他 CPU 不可见,导致遍历线程仍看到过期值,破坏并发正确性。此外,底层 CAS 操作依赖 volatile 的内存语义来保障原子变量的读写语义。

③ 多角度追问

  • 追问1:为什么 prevnext 也必须 volatile
    答:插入和删除依赖 CAS 修改 prev/next,若它们非 volatile,其他线程可能看不到指针更新,导致遍历断裂或死循环。
  • 追问2:构造器中为何用 UNSAFE.putObject 而非直接赋值?
    答:防止指令重排序导致构造逸出。putObject 提供 volatile 写语义,确保 item 初始化在对象引用发布前对其他线程可见。
  • 追问3lazySetNext 使用 putOrderedObject 有何深意?
    答:在非竞争路径(如预热连接)上使用延迟写,避免全内存屏障,显著提升性能而不牺牲最终可见性。

④ 加分回答
volatile 三字段的组合使 ConcurrentLinkedDeque 具备“无锁的线程安全”。这是 Java Memory Modelfinal 字段安全发布思想的延伸。Doug Lea 大量使用 putOrderedObject 进行优化,将内存屏障开销降至最低,是高性能无锁数据结构的标志性手法。


8.2 什么是 ConcurrentLinkedDeque 中的“逻辑删除”和“物理删除”?为什么要分两步?

① 一句话回答
逻辑删除是将 item CAS 为 null 使节点失效,物理删除是通过 CAS 修改指针将其从链表中移除;分两步是因为无锁环境下无法原子地同时修改多个指针。

② 详细解释
在锁保护下可一次性修改前驱、后驱和待删节点的指针。但无锁环境只能通过 CAS 逐一修改,若试图一步完成,中间状态将暴露给其他线程,可能破坏链表。因此先逻辑删除(item = null),向所有线程宣告节点失效,确保任何遍历都会跳过它;物理删除则可在之后由任意线程安全完成,甚至由遍历线程辅助执行(调用 unlink)。

③ 多角度追问

  • 追问1:逻辑删除后、物理删除前,遍历操作会出错吗?
    答:不会。遍历方法(如 succ/pred)会检查 item 并主动跳过 null 节点,还会顺手帮助 unlink 清理。
  • 追问2:垃圾节点会无限堆积吗?
    答:辅助清理机制会及时清理,正常负载下不会堆积。极端情况下可能短暂存在,但协作清理最终会清除。
  • 追问3:是否所有无锁队列都采用这种分离?
    答:是普遍模式。ConcurrentLinkedQueue 也将 itemnull 作为逻辑删除,之后再物理断开 next 链。

④ 加分回答
逻辑删除与物理删除分离本质上是乐观的、可线性化的删除语义。逻辑删除的 CAS 就是线性化点,保证 poll 的正确性。物理断开不影响已完成的删除语义,这使迭代器可以是弱一致性的——允许看到逻辑删除后元素消失,符合并发预期。


8.3 linkFirst 方法在并发环境下是如何保证插入成功的?当多个线程同时在头部插入时会发生什么?

① 一句话回答
linkFirst 通过一次关键 CAS(first.casPrev(null, newNode))原子抢占头节点,多线程插入时只有一个 CAS 成功,失败者自旋重试。

② 详细解释
linkFirst 先沿 prevhead 找到真正 first,用 lazySetNext 建立新节点到 first 的单向连接,最后 CAS 将 first.prevnull 改为新节点。这是原子操作,唯一胜利者完成插入。失败线程发现 first.prev 已非 null,会重新循环定位新 first(可能已被其他线程更新)并重试。整个过程无阻塞。

③ 多角度追问

  • 追问1:为什么先用 lazySetNext 而不直接用 CAS?
    答:此时新节点尚未接入链表,无并发访问,只需最终可见,lazySet 减少屏障开销。
  • 追问2:若 CAS 前 first 被逻辑删除了怎么办?
    答:定位 first 时会跳过 item == null 的节点,确保不在已删除节点上插入。
  • 追问3:ABA 问题是否可能发生?
    答:因节点动态创建且不会被重用(Java GC 环境),配合 itemprev 的协同变化,ABA 风险极低,实际不构成问题。

④ 加分回答
linkFirst 的单 CAS 抢占是 lock-free 算法 的典型代表。它证明在双向链表上,插入仅需修改一个指针(first.prev)的原子操作即可完成,而不需同时修改新节点的 prevnext,深刻利用了链表的不变量。


8.4 unlinkFirst 方法在处理删除时,为什么要把被删节点的 prev 指针指向自己?

① 一句话回答
自指是一种显式的“已从头部删除”标记,用来通知并发线程该节点已失效,并阻止新插入操作误连到已删节点。

② 详细解释
正常头节点 prevnull。若删除后保留 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 可能已被逻辑删除。若直接连接 predsucc,会将无效节点作为合法边界,链表无法彻底清理。skipDeletedPredecessors 从给定前驱出发沿 prev 遍历直到 item != null 或边界,skipDeletedSuccessors 沿 next 对称处理。这样物理删除总是跨越所有垃圾节点,实现链表的压缩清理。

③ 多角度追问

  • 追问1:如果所有节点都被逻辑删除,会无限循环吗?
    答:不会,边界条件 prev == nullnext == null 或自指节点会终止循环。
  • 追问2:它们被哪些方法调用?
    答:unlinkunlinkFirstunlinkLast、迭代器、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 是更合适选择。

④ 加分回答
ConcurrentLinkedDequeConcurrentLinkedQueue 在设计哲学上的递进——在保持无锁和高性能的同时,将操作能力推向一般化。它是 JDK 并发容器中无锁数据结构的巅峰,理解它就理解了如何用 CAS 构建任意复杂度的并发结构。


8.8 为什么 ForkJoinPool 的工作窃取算法要使用双端队列?ConcurrentLinkedDeque 的“头部取、尾部窃”模式是如何减少竞争的?

① 一句话回答
双端队列允许本地线程在头部 LIFO 消费(高缓存局部性),窃取线程在尾部 FIFO 消费,操作端物理隔离,极大降低竞争。

② 详细解释
ForkJoinPool 中每个工作线程绑定一个双端队列。本地线程生成的任务在头部插入(addFirst),也从头部取出(pollFirst),保持栈式执行顺序和数据局部性。空闲线程从其他队列尾部窃取(pollLast),取最老任务,促进均衡且避免与忙碌线程争抢头部。由于头部操作主要修改 prev 链,尾部操作主要修改 next 链,涉及的节点和指针区域不同,CAS 竞争大大减少。若用单向队列,窃取者和所有者都竞争同一个头,冲突激烈。

③ 多角度追问

  • 追问1:为什么本地线程不也从尾部取?
    答:那会失去 LIFO 的缓存友好性,且大任务可能快速耗尽,不公平。
  • 追问2ForkJoinPoolWorkQueue 真的是 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 将其 itemnull 完成逻辑删除,再调用 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(消费)操作头部,两者物理隔离,互不竞争。放回操作 offerFirstpollFirst 竞争头部,但与 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 主要竞争 tailpollFirstofferFirst 竞争 headhead 端的竞争无法避免,但因是无锁 CAS 自旋,性能依然很高。由于 headtail 操作分布在链表两端,不会相互干扰,全局吞吐量有保障。

ConcurrentLinkedQueue 能否替代?
不能。ConcurrentLinkedQueue 只支持“尾入头出”,没有 offerFirst 操作。如果要放回头部,只能通过遍历找到位置插入,无法保证 O(1) 和无锁,或者使用另一个队列转移,会破坏原子性和顺序。它没有双向结构,无法实现高效的“放回头部”需求。

③ 多角度追问
  • 追问1:如果大量任务频繁放回,是否会形成活锁?
    答:可能。需结合调度策略,如限制放回次数或降低放回优先级,超过阈值则改为 offerLast 尾插入。
  • 追问2:如何避免同一个任务被多个线程反复放回和取出?
    答:任务内部维护状态机,记录重试次数,超过阈值则改为尾插入,进入普通队列。
  • 追问3:如果任务放回时队列为空,如何确保线程不空转?
    答:可配合轻量等待,如 pollFirst 返回 null 时短暂 Thread.yield() 或使用 ForkJoinPool 类似的管理机制。
④ 加分回答

该设计本质上是局部优先级继承:阻塞任务通过重新插回头部获得事实上的高优先级,缓解了“头阻塞”问题。ConcurrentLinkedDeque 天然支持双向操作,使得这一模式无需任何锁即可实现,这是其超越 ConcurrentLinkedQueue 的关键价值。在演员模型或事件循环系统中,这种放回机制常被称为“无锁重入调度”,是构建高性能异步系统的重要技术。


关键结论ConcurrentLinkedDeque 是 JDK 无锁并发容器的集大成者。它通过在双向链表上实施精细的 CAS 操作、逻辑删除与物理删除分离、以及延迟更新哨兵的 Hop 设计,实现了在极端并发场景下的高性能双端操作,是理解无锁并发编程和 ForkJoinPool 工作窃取算法的基石。