AQS-CLH队列变种

5 阅读6分钟

AQS 内部的等待队列被称为 "CLH 队列的变种"。要理解这个变种的精妙之处,必须先了解原版 CLH 锁是什么,以及 AQS 为了解决阻塞共享问题对它做了什么"手术"。


一、原版 CLH 锁

CLH 锁(Craig, Landin, and Hagersten Lock)是一种自旋锁,因其发明者姓氏得名。它的核心思想是:每个线程在前驱节点的某个标志位上自旋等待,而不是在同一个全局变量上自旋。

1.1 数据结构

原版 CLH 锁是一个隐式的单向链表

    head                          tail
     ↓                             ↓
┌─────────┐    ┌─────────┐    ┌─────────┐
│ Node A  │ →  │ Node B  │ →  │ Node C  │
│ (locked)│    │ (locked)│    │ (locked)│
└─────────┘    └─────────┘    └─────────┘
  • 每个节点只有一个 boolean locked 标志和一个 next 指针(指向后继)。
  • 每个线程持有自己的节点和前驱节点的引用。

1.2 加锁与释放逻辑

加锁

// 伪代码
void lock() {
    Node myNode = new Node(locked = true);
    Node pred = tail.getAndSet(myNode);  // 原子地成为队尾,获取前驱
    if (pred != null) {
        while (pred.locked) { /* 自旋等待前驱释放 */ }
    }
    // 前驱释放了,当前线程持有锁
}

释放

void unlock() {
    myNode.locked = false;  // 只需修改自己的标志,后继会在自旋中看到
}

1.3 优点与局限

优点局限
每个线程在不同内存位置自旋,大幅降低缓存一致性流量(无全局竞争点)只能自旋,无法阻塞。意味着等待期间一直消耗 CPU
出队时无需 CAS,只需修改自身标志单向链表,无法支持唤醒后继节点(因为不知道后继是谁)
FIFO 公平性保证无法处理超时取消中断

二、AQS 的 CLH 变种:关键"手术"清单

AQS 保留了 CLH 锁**"前驱驱动"的调度思想**,但对数据结构做了以下关键改造:

改造点原版 CLHAQS 变种改造目的
链表方向单向 (next 指针)双向 (prev + next)支持从 tail 向前遍历(处理超时取消)
等待方式自旋 (while (pred.locked))阻塞 (LockSupport.park) + 自旋优化节省 CPU 资源
节点状态单一的 boolean locked多状态 waitStatus (SIGNAL/CANCELLED 等)支撑复杂的取消、传播、条件队列语义
头节点真实线程节点虚节点 (Dummy Node)thread == null简化出队逻辑,作为唤醒的"接力棒"
唤醒机制被动观察(自旋读取前驱标志)主动通知 (unparkSuccessor)配合阻塞模式,精准唤醒后继

三、AQS 变种的核心结构

3.1 双向链表

       head (虚节点)                     tail
          ↓                              ↓
┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
│      Node        │ ←→ │      Node        │ ←→ │      Node        │
│ thread = null    │    │ thread = 线程 A  │    │ thread = 线程 B  │
│ waitStatus = -1  │    │ waitStatus = -1  │    │ waitStatus = 0   │
└──────────────────┘    └──────────────────┘    └──────────────────┘
       ↑ prev                              ↑ next
  • prev 指针:支持从 tail 反向遍历,处理取消节点时的安全删除。
  • next 指针:支持正向唤醒后继节点。

3.2 虚头节点 (Dummy Head)

原版 CLH 中,持有锁的线程节点就是 head,释放锁时该节点出队。 AQS 变种中,一旦线程获取锁成功,它就把自己设为一个 thread == null 的虚节点

private void setHead(Node node) {
    head = node;
    node.thread = null;  // 清空线程引用,变成虚节点
    node.prev = null;    // 断开与前驱的连接,帮助 GC
}

好处

  1. 简化出队:持有锁的线程不再被队列引用,GC 可独立回收。
  2. 唤醒接力:虚节点作为"哨兵",它的 next 指向第一个真正等待的线程,释放锁时只需 unpark(head.next)

3.3 waitStatus 状态机

原版 CLH 只有 true/false。AQS 引入了丰富的状态位,这是实现阻塞取消的关键。

状态含义作用
0初始状态节点刚创建入队
SIGNAL (-1)唤醒责任前驱释放锁时必须唤醒我
CANCELLED (1)已取消节点因超时/中断失效,等待被清理
CONDITION (-2)在条件队列中节点当前不在同步队列,而在 Condition 队列
PROPAGATE (-3)共享传播共享模式下,唤醒需要向后链式传播

四、核心操作的变种实现

4.1 入队:从单向原子改为双向原子

原版 CLH(单向,只需 CAS 改 tail):

Node pred = tail.getAndSet(myNode);

AQS 变种(双向,需处理 prevnext 两个指针):

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) {
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;                     // ① 先连 prev
            if (compareAndSetTail(t, node)) {  // ② CAS 发布 tail
                t.next = node;                 // ③ 最后连 next(滞后)
                return t;
            }
        }
    }
}

关键:步骤③的滞后性导致正向遍历(next)可能暂时断裂,但反向遍历(prev)永远安全。因此 unparkSuccessor 必须从 tail 向前查找。

4.2 等待方式:从自旋改为阻塞 + 自旋混合

原版 CLH

while (pred.locked) { /* 空转,烧 CPU */ }

AQS 变种acquireQueued):

for (;;) {
    if (p == head && tryAcquire(arg)) {
        setHead(node);   // 成功,设为虚头节点
        return;
    }
    // 失败:先设置前驱为 SIGNAL,然后 park 阻塞
    if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
        interrupted = true;
}
  • 自旋优化:只有前驱是 head 时才尝试抢锁(因为很可能马上轮到自己),避免无效自旋。
  • 阻塞触发:确认前驱不是 head 且已将前驱状态设为 SIGNAL 后,调用 LockSupport.park 挂起。

4.3 释放与唤醒:从被动观察到主动通知

原版 CLH

myNode.locked = false;  // 后继自己会看到

AQS 变种

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);  // 主动唤醒后继节点
        return true;
    }
    return false;
}
  • 原版 CLH 后继一直在自旋,所以修改标志后它会立即感知。
  • AQS 后继可能已阻塞,必须通过 LockSupport.unpark 主动唤醒。

4.4 取消操作:从无法取消到惰性删除

原版 CLH:线程一旦排队,无法中途退出(除非一直自旋直到拿到锁)。

AQS 变种:通过 CANCELLED 状态实现安全的节点删除:

private void cancelAcquire(Node node) {
    node.waitStatus = Node.CANCELLED;  // 标记为取消
    
    // 如果 node 是 tail,尝试 CAS 移除自己
    // 否则,让前驱的 next 跳过自己,连接到一个有效的后继
    Node pred = node.prev;
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;  // 跳过前驱中的取消节点
    
    Node predNext = pred.next;
    node.waitStatus = Node.CANCELLED;
    
    // 如果 node 是 tail,CAS 更新 tail 为 pred
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    } else {
        // 非 tail:如果前驱是 SIGNAL 且不是 head,让前驱 next 跨过自己
        if (pred != head && pred.waitStatus == Node.SIGNAL) {
            Node next = node.next;
            if (next != null && next.waitStatus <= 0)
                compareAndSetNext(pred, predNext, next);
        }
        unparkSuccessor(node);  // 唤醒后继,让它帮忙清理
    }
}

五、共享模式下的额外变种:传播唤醒

原版 CLH 仅支持独占锁。AQS 为了支持 SemaphoreCountDownLatch,在共享模式中加入了传播唤醒机制

当共享节点获取成功且发现仍有剩余资源 (tryAcquireShared 返回 > 0) 时:

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;
    setHead(node);
    if (propagate > 0 || h == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();  // 继续唤醒下一个共享节点
    }
}

这是原版 CLH 完全没有的能力——一次释放可以批量唤醒多个等待者


六、CLH 变种与原版对比总结

维度原版 CLH 锁AQS CLH 变种
数据结构单向隐式链表双向显式链表 (prev + next)
节点状态boolean lockedint waitStatus (SIGNAL/CANCELLED/等)
等待方式自旋 (CPU 忙等)阻塞 + 自旋优化 (park + 有限自旋)
唤醒机制被动观察前驱状态主动 unpark 后继
取消支持不支持支持 (CANCELLED + 惰性删除)
共享模式不支持支持传播唤醒
头节点真实线程节点虚节点 (Dummy Node)
公平性FIFO 公平支持公平/非公平(由钩子方法控制)

一句话总结:AQS 的 CLH 变种是将原版 CLH 锁**从"自旋锁"升级为"阻塞同步框架"**的产物,它保留了 FIFO 公平性和前驱驱动的核心思想,同时引入了双向指针、多状态机、虚节点和主动唤醒,以支撑 JUC 包中各种复杂同步器的需求。