尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

AQS CLH队列详解:从入队出队到公平锁的实现原理

AQS CLH队列详解:从入队出队到公平锁的实现原理 如果你问一个 Java 工程师ReentrantLock 和 synchronized 有什么区别十个有八个会答“公平锁与非公平锁、可中断、超时”。但再追问一句公平锁到底在哪里判断了公平很多人都答不出来。答案藏在一个叫 AQS 的核心类里更准确地说藏在它那条 CLH 同步队列里。这篇文章就围绕 AQS 的 CLH 队列把入队、出队、park/unpark 的完整链路以及“公平性”到底是谁给的逐一拆开。1. 为什么 AQS 需要 CLH 队列从“谁能拿到锁”说起1.1 state 与 CASAQS 的原子状态管理AQSAbstractQueuedSynchronizer内部维护了一个volatile int state这就是所有同步器共享的核心状态。你可以把它理解成一把锁的“记账本”ReentrantLock 用 state 记录当前线程重入了几次Semaphore 用 state 记录还剩多少许可CountDownLatch 用 state 记录还差几次 countDown。任何线程想操作这个状态都必须通过 CAS比较并交换保证原子性否则并发环境下两个线程同时修改 state整个同步器就崩了。CAS 擅长解决“短平快”的竞争一个线程进来compareAndSetState(0, 1)成功锁就拿到了。但问题随之而来当竞争变得激烈CAS 频繁失败的时候失败的线程该怎么办继续自旋重试会白白消耗 CPU立刻放弃又没法保证锁的可用性把线程挂起是一个合理方案但挂起之后等待锁释放的线程如何被准确唤醒、唤醒谁就成了新问题。这正是 CLH 同步队列存在的意义。AQS 没有让失败的线程各自为战而是把它们组织成一条 FIFO 队列让锁的释放能够精确通知到“下一个应该获得锁的线程”。这个队列结构就是标题里的 CLH 同步队列。1.2 当 CAS 抢不到时等待线程去哪了如果你读过 ReentrantLock 的源码会发现获取锁的过程分两步先用 CAS 去抢抢不到就进入 AQS 的acquireQueued排队。真正的“排队”动作就是把当前线程包装成一个 Node 节点挂到 AQS 内部维护的双向链表尾部。这条队列有两个明显特征第一它是 FIFO先进队的线程更早被唤醒第二它是双向的每个节点既有 prev 也有 next支持从尾部向前回溯。为什么需要双向因为入队和取消操作存在并发竞态从 tail 往前遍历往往更可靠这个细节在讲唤醒机制时会详细展开。排队本质上解决的是“锁被谁占着”的问题。一个线程拿不到锁它不需要反复询问“锁释放了吗”只需要让前驱节点在释放时通知自己。这样一来锁释放时就能精确地唤醒一个线程而不是让所有等待线程一拥而上、又全部被 CAS 打回造成惊群效应。1.3 原始 CLH 锁一条自旋的链AQS 把它变成了阻塞队列CLH 锁最早由 Craig、Landin 和 Hagersten 三位学者提出用于 NUMA 架构机器上的自旋锁。原始 CLH 锁中每个线程在前驱节点的状态上自旋前驱释放了自己就可以尝试获取。它的优点是锁释放时不需要精确唤醒某个线程局部性极好适合多核 CPU 上的短临界区。AQS 对原始 CLH 队列做了重要改造双向链表结构保留但把“自旋等待”换成了LockSupport.park的阻塞等待同时用 waitStatus 字段记录每个节点的状态。这个改动让 AQS 在大多数场景下能让等待线程不占 CPU这是从自旋锁到阻塞锁的关键跃迁。有一点需要提醒很多人把 AQS 的 CLH 队列和“自旋锁”画等号这是不对的。AQS 只是借鉴了 CLH 的链式等待结构实际等待机制是 park/unpark。真正在自旋的只有队列头附近的极少数节点而且自旋也只是为了补偿“锁快要释放”的窗口期不是长期占用 CPU。2. 节点长什么样waitStatus、前驱后继与“哨兵头节点”的设计考量2.1 Node 的字段布局AQS 的 Node 类是静态内部类每个等待线程对应一个实例。核心字段有四个thread等待线程的引用。waitStatus当前节点的等待状态int 类型。prev、next双向链表的指针。nextWaiter在独占模式下指向一个静态的 SHARED 标记节点用于区分独占还是共享模式在条件队列中它指向下一个等待条件满足的节点。要理解这个结构关键点是一个节点能否被唤醒取决于它的前驱节点而不是它自己。锁释放后唤醒动作沿着 head → next → next 的方向传递就像击鼓传花。这也是为什么 acquireQueued 里判断“前驱是否是 head”如此重要因为只有 head 的下一个节点才具备抢锁资格。2.2 waitStatus 五种状态的语义waitStatus 的取值不是随便定的每种取值决定了节点在某个时刻该干什么。状态值含义CANCELLED1节点因超时或中断被取消不再参与竞争SIGNAL-1后继节点需要当前节点在释放锁后唤醒它CONDITION-2节点在条件队列中等待 signal 或 signalAllPROPAGATE-3共享模式下需要无条件传播唤醒状态00初始状态无特别含义这里最容易误解的就是 SIGNAL。它不是“我发出了一个信号”而是“我承诺在释放锁时去唤醒后继节点”。换句话说waitStatus -1的节点是一个“等待唤醒的承诺者”。这个设计让锁释放时不需要遍历整个队列找该唤醒谁只要检查 head 的 waitStatus 是不是 SIGNAL如果是就唤醒 head 的后继即可。CANCELLED 状态在实战中经常出现。比如线程执行tryLock(1, TimeUnit.SECONDS)超时或者被中断就可能产生 CANCELLED 节点。这些节点本身已经没用了但不会立刻从链表中消失而是在后续遍历中被跳过、被回收。2.3 为什么把 head 设计成哨兵AQS 中的 head 节点永远是已经拿到锁或已经空出的哨兵节点它不代表任何一个正在等待的线程因此 head 的 thread 字段始终为 null。真正排队等待的线程从 head.next 才开始算起。这个设计有三个实打实的好处。第一setHead操作不需要 CAS因为只有当前持有锁的线程自己会更新 head不存在并发竞争。第二锁释放时由调用者线程也就是当前 head去唤醒后继而不是线程自己唤醒自己逻辑上非常干净。第三把 head 独立成哨兵后“出队”变成了一次指针替换而不是从链表中摘除节点代价极低。很多初学者看源码时会对setHead里node.thread null感到困惑为什么刚拿到锁的线程反而不在节点里记录自己了因为节点一旦成为 head它的“排队身份”就结束了真正持锁信息记录在 AQS 子类自己的字段里比如 ReentrantLock 的 exclusiveOwnerThread。head 不需要 thread是为了让下一个拿到锁的线程可以安全地复用这个节点位置。3. 入队这条路从 addWaiter 到 enq 的两次尝试3.1 快速入队为何会失败获取锁失败的线程第一步是调用addWaiter(Node.EXCLUSIVE)把当前线程包装成节点。正常流程是先读一下当前的 tail用 CAS 把新节点挂到 tail 后面如果成功新节点就成了队尾入队完成。private Node addWaiter(Node mode) { Node node new Node(Thread.currentThread(), mode); Node pred tail; if (pred ! null) { node.prev pred; if (compareAndSetTail(pred, node)) { pred.next node; return node; } } enq(node); return node; }fast path 有两个失败场景。第一tail 为 null说明队列还没初始化可能同步器刚建立也可能之前所有等待线程都走完了队列被清空。第二CAS 竞争失败说明同一时刻有多个线程在入队tail 已经被别的线程抢先更新当前线程需要重新读取 tail 再试一次。无论哪种都会进入 enq 方法。有个细节值得注意node.prev pred这行代码在 CAS 之前执行。如果 CAS 失败说明 pred 已经不是真正的 tail 了但没关系后面循环会重新设置 node.prev。如果 CAS 成功之前写入的 pred 恰好就是旧 tail正好满足“前驱是旧队尾”的语义。这种先写 prev、再 CAS 的顺序是为了保证 CAS 成功时 prev 指针一定是对的。3.2 enq 的 for(;;) 自旋与链表初始化enq 的代码是所有 AQS 源码中最经典的循环之一private Node enq(final Node node) { for (;;) { Node t tail; if (t null) { if (compareAndSetHead(new Node())) tail head; } else { node.prev t; if (compareAndSetTail(t, node)) { t.next node; return t; } } } }很多人在第一次看这段代码时容易被if (t null)分支里的compareAndSetHead(new Node())搞蒙入队为什么要先 new 一个哨兵节点因为队列需要一个“已经持有锁”的虚拟头节点。如果 head 和 tail 都指向这个空节点那第一个真正等待的线程就会排在哨兵之后。等它抢到锁setHead会让它变成新的 head哨兵就被顶出去了。再看 else 分支和 addWaiter 的 fast path 一样先设置node.prev t再 CAS tail。如果 CAS 失败for 循环会让 node 重新读一次 tail继续尝试。这个自旋会一直持续到入队成功。因为入队本身只是一个 CAS循环次数通常非常有限很少会成为性能瓶颈。3.3 CAS 尾部入队的真正作用为什么一定要用compareAndSetTail而不是直接把 tail 赋值成新节点原因很简单tail 是 volatile 字段如果多个线程同时执行“把 tail 指向自己的新节点”后写的线程会覆盖先写的线程导致一个节点从队列中凭空消失。CAS 保证了只能有一个线程成功把 tail 从旧值更新到新值失败的线程重读 tail 再试。但这里还有一个潜在问题入队时 CAS 成功只保证了 tail 指针正确并没有保证pred.next node一定已经执行。换句话说在某个瞬间队列从 tail 往前看是完整的但从 head 往后看可能缺了一段。这个不对称性正是后面 unparkSuccessor 要从 tail 往前遍历的根本原因。这里的竞态不是 bug而是入队操作为了减少 CAS 次数故意接受的松弛一致性。4. acquireQueued排队线程的“自旋阻塞”双重博弈4.1 只让老二自旋为什么只有 head 的下一个节点有资格抢锁addWaiter 把节点挂到队尾之后真正的等待逻辑在 acquireQueued 里。简化后的循环逻辑是final boolean acquireQueued(final Node node, int arg) { boolean failed true; try { boolean interrupted false; for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }第一眼看这个循环可能觉得有点奇怪节点都排好队了为什么还要自己循环去 tryAcquire原因在于“队列中的位置决定了资格但资格不等于到手”。只有前驱是 head 的老二才有资格试图拿锁。其他节点即使锁已经释放也只能先 park 等前面的节点走完。用生活类比来说候诊室里只有一个医生导诊员每次只叫排在第二个的人进诊室。这个人如果犹豫了CAS 失败就得退出队伍重排但绝不会让排第五的人越过别人先进去。这个“不许越位”的约束就是 CLH 队列公平性的核心保证之一。4.2 shouldParkAfterFailedAcquire 的三个分支每次 tryAcquire 失败后线程不会立刻 park而是先调用shouldParkAfterFailedAcquire(p, node)判断“现在能不能安心睡”。这个方法会根据前驱节点的 waitStatus 做三种处理。如果前驱的 waitStatus 是 SIGNAL说明前驱已经承诺释放锁时唤醒当前节点当前节点可以放心调用 park 阻塞。如果前驱的 waitStatus 是 CANCELLED大于 0说明前驱已经放弃了不能再指望它。此时当前节点会沿着 prev 指针往前跨越所有已取消节点把自己的 prev 接到一个有效的非取消节点后面类似链表中“跳过死亡节点”的操作。如果前驱的 waitStatus 是 0 或其他状态当前节点会尝试用 CAS 把前驱的 waitStatus 改成 SIGNAL然后返回 false让循环再跑一轮。注意第三个分支它不会直接 park而是先设置前驱为 SIGNAL然后返回 false让当前节点回到 acquireQueued 循环继续 tryAcquire。这个“再试一轮”的窗口期非常关键它可以防止一种竞态锁刚好在设置 SIGNAL 之前释放了如果线程直接 park可能永远不会被唤醒。多试一轮就能抓住锁释放的时机避免死等。4.3 parkAndCheckInterrupt中断只记录不直接响应真正让线程阻塞的代码是private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }这里有一个和直觉相反的细节AQS 会感知到中断但不会在阻塞状态立刻抛异常。如果线程在 park 期间被 interruptpark 方法会直接返回但 acquireQueued 只把中断状态记录在一个局部变量里继续循环直到线程真正拿到锁才把 interrupted 返回给上层处理。为什么不在中断时立刻响应因为 AQS 内部排队必须保证队列结构的一致性。如果线程在排队中途因为中断就擅自退出队列里会留下悬挂节点后续 FIFO 推进就乱了。所以 AQS 的默认策略是中断可以让线程醒过来但队列的出入必须走完完整流程。拿到锁之后再由lockInterruptibly这类方法把 InterruptedException 抛给调用方。理解了这一点你就能明白普通lock()和lockInterruptibly()的差别本质上是“中断是记录下来还是立即抛出去”。5. 出队与唤醒unparkSuccessor 里那个“从尾部往前”的细节5.1 release 的完整链路独占锁释放的入口是 releasepublic final boolean release(int arg) { if (tryRelease(arg)) { Node h head; if (h ! null h.waitStatus ! 0) unparkSuccessor(h); return true; } return false; }tryRelease 由子类实现通常就是把 state 减到 0。释放成功后代码会检查 head如果 head 为 null说明根本没有等待队列如果 head.waitStatus 为 0说明 head 没有承诺唤醒任何后继也就不用做任何事。只有 head.waitStatus 非 0典型情况是 SIGNAL才需要进入 unparkSuccessor。这里有个容易被忽略的细节并不是每次释放锁都会调用 unpark。如果当前没有后继节点需要唤醒就直接跳过了。这个检查看似简单却省掉了大量无意义的 unpark 调用在锁竞争不激烈时尤为重要。5.2 为什么必须从 tail 往前找到第一个没取消的节点unparkSuccessor 的源码中有一段方向反转的遍历Node s node.next; if (s null || s.waitStatus 0) { s null; for (Node t tail; t ! null t ! node; t t.prev) if (t.waitStatus 0) s t; }如果 head 的后继节点为 null 或被取消代码没有顺着 next 往后找而是从 tail 往前回溯。很多初学者不理解为什么要“倒着找”其实原因在于入队流程中的时间差。回顾 enq 的逻辑线程 A 先 CAS 更新 tail 指向新节点 N然后才执行t.next N。如果在这个瞬间锁被释放unparkSuccessor 沿着 head.next 往后找会发现 head.next 还是 null但 N 明明已经在队列里了。反过来N 的 prev 指针在入队时是先于 CAS 写入的所以从 tail 往前找一定能找到 N。这一前一后的不对称性导致 AQS 内部凡是需要向后遍历的地方几乎都优先依赖 prev 链。从 tail 往前找还有一个好处可以跳过那些 next 指针已经断开、但还没被完全清理的 CANCELLED 节点。如果只依赖 next可能走到一个 next 被置空但节点本身还未取消的边界状态导致漏醒。5.3 出队其实是 setHead 完成的next 指针只是帮助 GC严格来说AQS 里没有独立的“出队”方法。节点离开队列的动作被拆成了两步节点抢到锁后先setHead(node)再执行p.next null。setHead 内部做了三件事把 head 指向当前节点把 node.thread 置为 null把 node.prev 置为 null。这样一来当前节点从“等待线程节点”变成了一个干净的哨兵原来的旧 head 不再被任何节点引用p.next null又断开了旧 head 向外的引用GC 就可以回收旧的 head。这种“换头”操作比传统链表的 remove 简单得多因为整个流程中只有当前持锁线程会修改 head 区域没有并发竞争。锁的推进过程因此变得非常轻量旧 head 被丢进垃圾堆新 head 变成之前等待最久的节点队列长度减一等待线程数减一。6. “公平性”从哪来公平锁和非公平锁的源码对比6.1 hasQueuedPredecessors公平锁的入场券检查回到标题的问题公平性到底从哪来答案不在 CLH 队列本身而在子类对 tryAcquire 的实现。以 ReentrantLock 公平锁为例protected final boolean tryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) throw new Error(Maximum lock count exceeded); setState(nextc); return true; } return false; }关键在!hasQueuedPredecessors()。这个方法返回 true说明队列中有比当前线程更早到达的等待者公平锁就不允许插队。public final boolean hasQueuedPredecessors() { Node t tail; Node h head; Node s; return h ! t ((s h.next) null || s.thread ! Thread.currentThread()); }这个实现非常精妙。h ! t表示队列里至少有一个等待线程(s h.next) null专门处理“入队进行中节点还没挂到 head 后面”的竞态此时宁可返回 true让新线程去排队等一轮也不冒险插队s.thread ! Thread.currentThread()表示即使队列里有人等待只要那个等待者就是当前线程自己重入情况就不算插队可以直接尝试获取锁。所以公平锁的“公平”范围非常明确按到达顺序排队前一个没拿到之前后来的不给机会。公平性来自这个“检查队列里是否有人比我先到”的决策点。6.2 nonfairTryAcquire非公平锁的插队逻辑非公平锁的实现叫 nonfairTryAcquirefinal boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // 后面是重入逻辑 }和公平锁对比它缺少 hasQueuedPredecessors 判断。也就是说当锁正好在释放、state 变成 0 的瞬间刚来的线程可以直接 CAS 抢到锁完全无视队列里已经等了很久的线程。这就是“非公平”的根源。更激进的是ReentrantLock 的默认lock()方法在进入 acquire 之前还有一次额外的直接 CAS。也就是说非公平锁在锁空闲时根本不会去看队列先抢再说。这种设计换来了高吞吐量代价是后发可能先至。6.3 FIFO 只保证队列内部不保证全局顺序把公平锁和非公平锁放在一起对照能发现一个经常被误解的点CLH 队列本身只保证“已经进入等待队列的线程”按 FIFO 顺序被唤醒。如果一个线程还没入队或者恰好赶在入队之前它就不受队列约束。换句话说你先调用了 lock不代表你先拿到锁。公平锁保证的是“在锁空闲时不存在未入队的线程插到已入队线程前面”但并不能保证所有线程严格按照调用 lock 的绝对时间排序。因为 hasQueuedPredecessors 只检查当前队列中是否有更早的等待者不检查那些还没入队就已经在 CAS 的线程。用现实中的话来说公平锁维护的是“候诊室里的顺序”但候诊室外面的人可以抢在你之前刷卡进门。真正的公平性需要“锁释放瞬间的状态检查 抢锁动作”共同配合才能实现。6.4 公平锁为什么吞吐量更低公平锁听起来更“正义”但性能往往更差。原因不是 hasQueuedPredecessors 读 head/tail 的开销而是锁释放后的唤醒链路变长了。在公平锁下锁释放后持有者必须唤醒后继线程被唤醒的线程需要经过 park/unpark 的上下文切换才能再次尝试抢锁。这期间锁可能处于空闲状态但没有新线程能补位直到被唤醒的线程真正跑起来。非公平锁则允许新线程在锁释放瞬间直接抢占减少了大量上下文切换所以整体吞吐量更高。另一个原因是公平锁会加剧队尾效应。竞争越激烈队列越长唤醒链上的传递耗时越大。非公平锁则可以通过“插队”让锁更频繁地被临时经过的线程占用虽然对排队的线程不公平但对系统的整体并发吞吐量更友好。我的建议是默认选非公平锁只有当“等待时间波动不能太大”或者业务场景强依赖顺序比如订单流程、任务分发时才考虑公平锁。公平性不是越多越好它是用吞吐量换来的确定性。7. 从 AQS 队列视角看并发问题的排查与调优经验7.1 jstack 看到 BLOCKED/WAITING 时该怎么判断实际排查线上问题经常会在 jstack 里看到大量 WAITING 或 BLOCKED 线程。如果你理解了 CLH 队列就能分得清它们各自的含义状态为java.util.concurrent.locks.LockSupport.park的 WAITING 线程是在 AQS 队列里排队等待唤醒。状态为java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await的 WAITING 线程是在条件队列中等待 signal。状态为 BLOCKED 且堆栈停在synchronized关键字里的线程是 JVM 层面的 monitor 等待和 AQS 无关但往往也是锁竞争的真实瓶颈。不要一看到 WAITING 就以为线程挂了要看它停在哪里。如果大量线程停在 ReentrantLock.lock 的 park 上说明锁的持有时间过长或者并发量暴涨如果大量线程停在 synchronized 上说明代码里有重量级锁的瓶颈此时调 AQS 是没用的只能减小临界区。7.2 队列长度暴涨意味着什么AQS 没有直接暴露队列长度的接口但通过线程转储可以大致估算。如果排队线程总是堆积几十个说明临界区执行时间远大于持锁时间或者锁粒度太粗。这时候优先考虑的应该是缩小锁的范围、减少锁内 IO、用读写锁或分段锁热点而不是把非公平锁换成公平锁。另一个容易被忽略的现象是队列中反复出现 CANCELLED 节点。每次 tryLock 超时或线程中断都可能产生 CANCELLED 节点正常情况会在后续 shouldParkAfterFailedAcquire 中被跳过回收。但如果超时频繁发生队列会反复执行“跨越取消节点”的操作增加无意义的循环。这种情况下如果业务能接受尽量用 tryLock 快速失败而不是中断处理可以让队列更干净。7.3 使用 AQS 同步器时的几个经验准则基于对 CLH 队列的理解我总结几个实战中比较实用的准则不要在自定义同步器里随意使用 LockSupport.park/unpark 而不做许可配对。AQS 内部已经处理了 park 和 unpark 的竞态但如果你自己写要记住 unpark 可以先于 park 执行LockSupport 的许可机制保证先 unpark 也不会丢。自定义基于 AQS 的同步器时必须保证 tryAcquire/tryRelease 的实现足够纯净、快速。它们会在 acquireQueued 循环里被高频调用如果内部做了 IO 或大计算整个队列都会变慢。优先选用现成的同步器。AQS 的设计目标就是让你少写并发代码。理解了 CLH 队列后再看 Semaphore、CountDownLatch、ReentrantReadWriteLock 的源码思路会清晰很多。如果在自定义锁时发现唤醒经常失败第一步去看 tryRelease 是否在所有情况下都返回了 true第二步看释放后 head 的 waitStatus 是否满足唤醒条件第三步再怀疑业务层的中断和超时。大多数问题不是 AQS 的 bug而是对 state 和 release 语义的理解偏差。最后再分享一点个人体会。早年我把 AQS 当黑盒用只知道 ReentrantLock 比 synchronized 灵活但线上每次出现“线程全部卡住”的告警我都只能靠重启解决。后来耐着性子把 addWaiter、acquireQueued、unparkSuccessor 一行行啃下来最大的收获不是背下了源码而是真正理解了“队列 状态 唤醒”这套组合拳如何以极小的代价扩展出无数同步器。之后再遇到并发问题我第一反应不是急着搜工具类而是先想清楚这个场景的排队模型是什么是互斥、共享还是条件等待想清楚再选同步器踩坑就少了一半。
返回列表