ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

AQS队列机制深度解析:从Node到CLH变体

AQS队列机制深度解析:从Node到CLH变体 1. 从上一章的尾巴说起state与队列的关系上一章我们把AQS的顶层设计捋了一遍知道了它内部维护了一个volatile int state通过CAS对state做修改来实现锁的语义。但当时留了个问题没展开如果CAS失败线程去哪儿排队队列长什么样节点之间怎么串联这一章专门把这部分啃干净。先说结论AQS内部维护的是一个CLH锁队列的变体不是JDK自带的BlockingQueue那种生产消费队列而是一个专门用来存放“抢锁失败线程”的双向链表。每个抢锁失败的线程都会被包装成一个Node节点挂到这个双向链表尾部然后通过LockSupport.park让自己进入等待状态。当持有锁的线程释放锁时会从队列头部挑一个合适的线程调用LockSupport.unpark唤醒它。这段流程就是AQS队列机制的全部骨架。但骨架之下细节多到能写一本书。我见过不少面试候选人对AQS的认知停留在“有个队列抢不到锁就排队”这个层面一问到Node的状态流转、shouldParkAfterFailedAcquire里为什么那么写、unparkSuccessor为什么要从tail往前遍历就答不上来了。这一章就是把这些细节逐一抠开确保你读完能给别人讲明白面试也能扛住连环追问。先明确一个核心概念AQS的队列不是用来存储数据的它是用来存储线程的。每个节点代表一个线程节点与节点之间通过prev和next指针串联。队列本身不做任何业务逻辑它只干一件事——公平地管理一群“抢不到锁暂时歇着”的线程。这个队列设计上有几个非常关键的特征先列出来后面会逐一展开队列是双向链表但head节点是哨兵节点不绑定实际线程节点入队通过CAS自旋完成保证并发安全节点被唤醒后会自旋检查前驱节点是否为head是才有资格抢锁取消等待的节点会被标记为CANCELLED后续在合适时机被清除唤醒后继节点时从tail往head方向找最靠近head的有效节点。这几个特征单看都简单组合起来就是一套精巧的线程阻塞与唤醒机制。下面我们一个个拆。2. Node节点到底存了什么五个字段一个状态先看Node的数据结构。这段代码在JDK 8到JDK 17里基本没变过属于稳定得不能再稳定的核心类。static final class Node { /** 共享模式节点 */ static final Node SHARED new Node(); /** 独占模式节点 */ static final Node EXCLUSIVE null; /** 节点已取消 */ static final int CANCELLED 1; /** 后继节点需要唤醒 */ static final int SIGNAL -1; /** 节点在条件队列中 */ static final int CONDITION -2; /** 共享模式下无条件传播 */ static final int PROPAGATE -3; volatile int waitStatus; volatile Node prev; volatile Node next; volatile Thread thread; Node nextWaiter; }逐字段说。waitStatus是节点的状态位取值就是上面那四个常量加0。0是初始状态表示当前节点啥事没有正常排队中。CANCELLED表示这个节点的线程已放弃抢锁后续会被清理出队列。SIGNAL表示当前节点的后继节点正在或即将park当前节点在释放锁或取消时需要唤醒后继节点。CONDITION表示节点在条件队列里不在同步队列中。PROPAGATE用在共享锁场景表示唤醒状态需要向后传播。这四个状态里SIGNAL是最核心的它建立了一种“责任链”每个节点在park之前都要确保前驱节点的waitStatus被设置成SIGNAL意思就是“我睡了你走的时候记得叫我”。这个机制保证了锁释放时唤醒操作一定能传递到正确的线程上不会出现线程睡了没人叫的情况。prev和next就是双向链表的前后指针。但AQS的队列和普通双向链表有个区别它的prev指针在取消操作中会被特殊使用这也是后面unparkSuccessor为什么要从tail往前遍历的原因。thread字段存的是当前节点绑定的线程。注意head节点的thread永远是null因为head是哨兵代表“当前持有锁的线程”的抽象位置。nextWaiter在独占模式下恒为null在共享模式下指向下一个共享节点在条件队列里指向下一个等待条件成立的节点。平时看独占锁流程基本可以忽略它。这里有个容易忽略的细节waitStatus初始值是0但节点入队后第一次shouldParkAfterFailedAcquire会尝试把前驱节点的状态改成SIGNAL。如果改成功线程才会真正park。如果前驱节点恰好是CANCELLED状态就跳过它往前找。这个细节在后面的流程里会反复出现。3. 入队逻辑详解CAS自旋与尾指针的博弈先明确一个背景AQS队列在没有任何竞争时head和tail都指向null。第一个线程抢锁失败需要入队时会先执行一次初始化——创建一个空的哨兵节点让head和tail都指向它然后再把当前线程的节点接上去。这也就是enq方法里那个经典的自旋CAS。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; } } } }这个方法有两个细节值得掰扯。第一个细节为什么入队要放在for(;;)自旋里因为compareAndSetTail可能失败。多个线程同时入队时大家都把node.prev指向同一个tail但CAS只允许一个成功。失败的线程下一轮循环重新读取tail再把自己的prev指向新的tail。这个过程中prev指针会被反复修改但没关系因为prev的写入不参与并发安全决策真正的并发安全点是compareAndSetTail这个原子操作。第二个细节t.next node这一步不在CAS保护范围内。也就是说一个线程CAS成功把tail从t改成node之后t.next可能还没来得及赋值另一个线程已经通过tail拿到了新的tail节点。这会不会导致链表断裂不会。因为AQS在遍历队列时从不依赖next指针从前往后找有效节点——这一点在unparkSuccessor里体现得淋漓尽致。AQS的队列遍历逻辑只需要从tail往前通过prev指针回溯就一定能找到所有节点。next指针只是辅助不是遍历的必需品。再来看addWaiter它是入队流程的入口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; }这里做了一次快速入队尝试如果tail不为null就尝试一次性入队。如果这步CAS失败说明此刻有并发竞争才进入enq的自旋流程。这也是AQS性能优化的小心思——大部分场景下入队竞争并不激烈一次CAS就能搞定没必要上来就跑自旋。有个问题值得思考为什么队列要设计成“head是哨兵节点”而不是“head直接指向第一个排队的线程”原因至少有两点。第一如果head直接指向排队线程那head节点自己在排队它被唤醒后需要把自己从队列里摘除这涉及head的变更操作成本高且并发风险大。第二哨兵节点的存在让“抢锁成功”这个操作变得非常简单——只需要把head指向后继节点再把新head的thread置空prev置空即可不需要动链表结构。这两点在acquireQueued里看得非常清楚。4. 获取锁的完整过程acquire、acquireQueued与中断哲学acquire方法是整个AQS获取锁流程的入口总共四行代码但每一行都是精华public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }拆开来说。第一步调用tryAcquire尝试获取锁。tryAcquire是抽象方法由子类实现——ReentrantLock的公平锁、非公平锁Semaphore的共享模式都是对tryAcquire的不同实现。如果tryAcquire返回true说明锁已经拿到整个acquire方法直接返回线程继续往下执行业务代码。如果返回false进入addWaiter把当前线程包装成独占模式节点挂到队尾然后调用acquireQueued进入排队等待。acquireQueued是入队后线程的主循环也是整个AQS最核心的方法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); } }这段代码的逻辑可以概括为每个排队线程都在自旋但自旋不是空转而是由park与unpark驱动的“睡眠-唤醒-检查”循环。线程被唤醒后第一步检查自己的前驱节点是不是head。如果是说明轮到自己抢锁了立刻调用tryAcquire再试一次。为什么这里要“再试一次”因为锁的持有者可能刚好释放了锁当前线程被unpark唤醒就是一个信号——锁可能空出来了。但也有可能锁又被别人抢走了非公平锁场景下新来的线程可能插队那就继续走shouldParkAfterFailedAcquire再睡一轮。如果前驱不是head说明前面还有别的线程在排队自己还不能抢锁直接进入park逻辑。这里引出shouldParkAfterFailedAcquire它的职责是检查前驱节点的状态决定当前线程是否应该立即park还是需要先做点准备工作。private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws pred.waitStatus; if (ws Node.SIGNAL) return true; if (ws 0) { do { node.prev pred pred.prev; } while (pred.waitStatus 0); pred.next node; } else { compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }分三种情况理解。第一种前驱节点的waitStatus是SIGNAL说明前驱承诺“我走的时候会唤醒你”那当前线程可以放心park了返回true。第二种前驱节点的waitStatus大于0也就是CANCELLED说明前驱已经放弃抢锁了不能指望它唤醒自己。这时候当前节点要沿着prev指针往前跳过所有已取消的节点把自己挂到第一个有效节点后面。这段代码就是CLH队列的一个核心操作——清理已取消节点让队列始终保持可用状态。第三种前驱的waitStatus是0或PROPAGATE也就是不大于0且不等于SIGNAL的情况说明前驱还没有设置唤醒承诺。当前线程通过CAS把前驱的状态改为SIGNAL然后返回false下一轮循环里再检查一次。注意这里为什么要多此一举因为如果不提前设置SIGNAL当前线程直接park的话万一在前驱释放锁之后、自己park之前这个时间窗口里前驱没有义务唤醒自己就会造成线程永久沉睡。所以必须先完成SIGNAL的设置确保“有人会叫我”然后再睡。这个设计的本质是在做一件很朴素的约定线程在睡觉之前必须先跟邻居打好招呼保证邻居会叫自己起床。这在并发编程里相当重要是一种极其典型的“happens-before”关系的建立方式。parkAndCheckInterrupt就更简单了两件事调用LockSupport.park挂起当前线程线程被唤醒后检查自己是否被中断过。private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }注意这个Thread.interrupted()会把中断状态复位。这是AQS中断哲学的关键AQS默认不响应中断但会记录中断状态在拿到锁之后通过selfInterrupt把中断状态补上。为什么要这么做因为acquire方法语义上不允许“因为中断就放弃抢锁”但线程的中断状态不能丢需要在真正拿到锁并返回给调用者时补一次中断标记让调用者有机会感知到“我曾经被中断过”。这里跟acquireInterruptibly有个重要区别后者在中断发生时直接抛出InterruptedException不再继续排队。两者一个是“忽略中断但不丢失”一个是“立即响应中断”使用场景完全不同。面试中经常拿这两个方法对比发问本质上考的就是对中断哲学的理解。setHead的代码也很短但很值得细看private void setHead(Node node) { head node; node.thread null; node.prev null; }这就是前面说的“抢锁成功无需改链表结构”的体现直接把head指向当前节点把当前节点的thread置空prev置空。此时当前节点从“排队的节点”变成了“新的哨兵节点”。原本的哨兵节点它的前驱通过p.next null断开引用等待GC回收。5. 释放锁与唤醒后继为什么从tail往前找释放锁的入口是release方法public final boolean release(int arg) { if (tryRelease(arg)) { Node h head; if (h ! null h.waitStatus ! 0) unparkSuccessor(h); return true; } return false; }逻辑分成两步调用tryRelease尝试释放锁——这个由子类实现如果释放成功检查head节点如果head不为null且waitStatus不等于0调用unparkSuccessor唤醒后继线程。这里有个判断为什么要求h.waitStatus ! 0才去唤醒因为如果head的waitStatus是0说明没有后继节点处于SIGNAL状态也就是没有线程在排队等锁或者排队线程还没来得及设置SIGNAL这时候不需要唤醒任何线程。如果head的waitStatus是SIGNAL说明后面至少有一个线程在park等待必须唤醒。unparkSuccessor是AQS里最容易出面试题的方法之一private void unparkSuccessor(Node node) { int ws node.waitStatus; if (ws 0) compareAndSetWaitStatus(node, ws, 0); 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; } if (s ! null) LockSupport.unpark(s.thread); }第一个动作把当前节点head的waitStatus从SIGNAL改回0。为什么因为head马上要变成历史节点了它的“唤醒职责”即将终结把状态归零避免后续误判。第二个动作是重点正常情况下应该唤醒head.next但如果head.next为null或者head.next已经CANCELLED就得想办法找一个有效的后继节点。这里选择了从tail往前遍历。为什么不能从head往后找原因在于AQS入队时t.next node这个赋值的非原子性——当一个线程CAS成功把tail更新为新节点后新节点的prev已经指向旧tail但旧tail的next可能还没来得及指向新节点。如果此刻有线程恰好在这个时间窗口内从head往后遍历可能找不到这个新节点。而如果从tail往前通过prev遍历就永远不会遗漏节点因为prev的赋值发生在CAS之前CAS成功的那一刻prev已经不可变了。这个设计相当精巧。它说明AQS的作者很清楚链表在并发环境下“单向遍历不靠谱、反向遍历才安全”这个核心事实。还有个细节唤醒时只唤醒一个线程不是广播。这正是独占锁的语义——锁同一时刻只允许一个线程持有。被唤醒的线程回到acquireQueued的循环里检查前驱是不是head然后尝试抢锁。如果没抢到比如非公平锁下被新来的线程抢先了继续park等待下一次被唤醒。6. 取消与清理cancelAcquire的内部逻辑cancelAcquire是AQS里最容易被忽略但又极其重要的方法。凡是进入过阻塞队列的线程如果因为中断、超时等原因放弃获取锁都会走这个方法把自己标记为CANCELLED并做清理。private void cancelAcquire(Node node) { if (node null) return; node.thread null; Node pred node.prev; while (pred.waitStatus 0) node.prev pred pred.prev; Node predNext pred.next; node.waitStatus Node.CANCELLED; if (node tail compareAndSetTail(node, pred)) { compareAndSetNext(pred, predNext, null); } else { int ws; if (pred ! head ((ws pred.waitStatus) Node.SIGNAL || (ws 0 compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) pred.thread ! null) { Node next node.next; if (next ! null next.waitStatus 0) compareAndSetNext(pred, predNext, next); } else { unparkSuccessor(node); } node.next node; } }这段代码的分支比较复杂但是核心意图很清晰把当前节点从队列中摘除同时尽量维护队列的连续性。分几个关键点说。第一步把当前节点的thread置空。这一步既是语义需要也是安全考虑——节点取消后不再代表任何线程。第二步跳过所有CANCELLED的前驱节点找到第一个有效节点作为新前驱。这个清理跟shouldParkAfterFailedAcquire里的清理类似都是在维护队列的“可用性”。第三步把当前节点的waitStatus设为CANCELLED。注意这一步必须在处理tail和next之前完成因为后面的分支判断依赖这个状态。第三段的分支逻辑分三种情况。情况一当前节点是tail。如果CAS把tail从当前节点改成前驱成功说明当前节点是队尾后面没有其他节点依赖它直接通过CAS把前驱的next置为null即可。这里用CAS是因为可能存在并发入队的线程正在修改tail。之所以用compareAndSetNext(pred, predNext, null)而不是pred.next null就是为了避免覆盖掉并发入队时刚设置好的next引用。情况二当前节点不是head的后继且前驱状态正常。这种情况下当前节点的前驱有义务在解锁时唤醒当前节点的后继所以需要把前驱的next直接指向当前节点的后继跳过已取消的当前节点。这是典型的链表删除操作。情况三当前节点是head的后继或者前驱状态异常或者前驱线程为null。这种情况下没法安全地把前驱的next指向当前节点的后继干脆调用unparkSuccessor(node)唤醒后继节点让它自己去竞争锁。这里之所以走到这个分支就必须唤醒是因为当前节点取消后它的后继节点已经失去被唤醒的依据——原本当前节点在park前会把前驱设为SIGNAL但现在当前节点取消了需要确保后继节点的“唤醒承诺”不会断掉。最后一行node.next node是个小技巧让已取消节点的next指向自己有助于GC快速回收同时避免后续遍历时循环引用造成的死循环风险。7. 超时获取doAcquireNanos的边界处理前面聊了独占锁的主流程还有一个高频考点是带超时时间的获取tryAcquireNanos。它走后端的doAcquireNanos方法。private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (nanosTimeout 0L) return false; final long deadline System.nanoTime() nanosTimeout; final Node node addWaiter(Node.EXCLUSIVE); boolean failed true; try { for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; failed false; return true; } nanosTimeout deadline - System.nanoTime(); if (nanosTimeout 0L) return false; if (shouldParkAfterFailedAcquire(p, node) nanosTimeout spinForTimeoutThreshold) LockSupport.parkNanos(this, nanosTimeout); if (Thread.interrupted()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }跟acquireQueued相比差异点有三个。第一个差异doAcquireNanos方法声明抛出InterruptedException而acquire不抛。也就是说带超时的获取锁操作是响应中断的一旦线程在等待过程中被中断立即抛出异常退出。第二个差异用System.nanoTime()计算绝对deadline每一轮循环重新计算剩余时间。这里用nanoTime而不是currentTimeMillis是因为nanoTime是单调时钟不受系统时间调整影响超时计算更可靠。第三个差异spinForTimeoutThreshold这个阈值常量默认值是1000纳秒。当剩余超时时间小于这个值时不再调用parkNanos而是直接进入自旋。为什么因为LockSupport.parkNanos的精度不足以支撑纳秒级别的休眠如果剩余时间连1000纳秒都不到了调用park的开销反而比自旋更大甚至可能出现park时间过长导致错过deadline的情况。这个细节在Java并发源码里是经典设计——避免在时间粒度极小时引入不必要的上下文切换开销。还有一个容易踩坑的地方tryAcquireNanos传入的nanosTimeout是相对时间而方法内部转成绝对deadline后每次循环重新计算剩余时间。如果线程在park期间刚好被打断会先响应中断抛出异常而不是继续等到超时。这个行为跟acquireInterruptibly一致面试时可能会跟lockInterruptibly对比着问这里提前说清楚。8. 常见问题与排查技巧实录8.1 线程一直卡在acquireQueued不往下走怎么办这种情况多半是锁没被释放或者释放后唤醒没生效。排查思路先通过jstack看线程栈如果线程停在parkAndCheckInterrupt的LockSupport.park说明它确实在等锁。再看持有锁的线程是谁——通过head节点的thread字段或者直接jstack看哪个线程持有了锁。如果持有锁的线程自己也在等锁那就形成了死锁。在ReentrantLock场景下排查此类问题最有效的方式是开启JVM的-XX:PrintConcurrentLocks它会在线程转储时打印出当前JVM中的所有锁及其持有线程比人肉看代码高效得多。8.2 队列里的CANCELLED节点越来越多影响性能吗如果大量线程在排队过程中超时或被中断队列里会出现不少CANCELLED节点。正常情况下这些节点会在后续的入队、取消操作中逐步被清理不会长期积累。但有一种情况需要警惕如果你的业务代码里频繁出现“创建线程尝试拿锁拿不到就超时退出”的模式最好先想清楚是不是锁竞争本来就太激烈而不是只盯着AQS的清理机制打主意。这时候调整锁粒度或者改用读写锁、分段锁效果往往比在AQS层面想办法好得多。8.3 为什么非公平锁的性能比公平锁好非公平锁在tryAcquire阶段不计入排队顺序直接尝试抢夺。新来的线程可能插队成功这会带来两个效果一是减少了线程park和unpark的次数二是在高竞争场景下让“刚释放锁的线程”更可能立即重新获取锁——线程还在CPU上热乎着呢没必要切换。但代价是队列尾部的线程可能出现饥饿虽然实际概率很低。选公平锁还是非公平锁核心考虑因素是业务对执行顺序的敏感性而不是性能。8.4 条件队列和同步队列的关系总是搞混简要说一下ConditionObject维护的是另一个等待队列。调用await会让当前线程放弃锁进入条件队列调用signal会把条件队列里的节点转移到同步队列让它重新参与锁竞争。也就是说同步队列是“正在等锁”的队列条件队列是“等条件满足”的队列。两者通过transferForSignal这个操作衔接。面试如果问AQS的队列把这两层关系讲清楚比只背Node的几个状态值更有说服力。8.5 如何在线上定位AQS相关的锁竞争问题实践中最常用的工具是jstack的线程转储。当某个线程卡在锁上时转储信息里会显示它在等待一个park对象并且能看出它对应的Node在哪个队列位置。如果配合-XX:PrintConcurrentLocks能看到更完整的锁信息。另外Arthas的thread -b命令可以直接找出当前JVM中阻塞其他线程的“罪魁祸首”比人工翻转储文件快得多。我一般在线上排查锁问题时流程是先Arthas定位阻塞源头线程再jstack确认业务调用链最后结合代码定位锁持有时间过长的原因。8.6 一个容易被忽略的死锁场景tryLock和lock混用有些同学在同一个锁对象上一部分代码用tryLock另一部分用lock还夹杂超时逻辑。这种混用模式很容易在故障时给人造成困惑——因为tryLock拿不到锁不会排队直接返回false而lock会排队阻塞。如果业务代码对tryLock的false分支处理不当比如重试间隔太短可能造成大量线程在短时间内反复尝试把CPU打满。这虽然不是AQS本身的问题但理解了AQS的队列机制后你会明白tryLock走的是非阻塞路径不参与队列排队所以它的竞争行为和lock完全不同不能想当然地认为两者可以随便混用。9. 读完源码之后我建议你做这几件事源码读到这里AQS队列的主干脉络已经通了。我个人认为真正检验自己是否理解AQS的方式不是背方法名而是回答下面几个问题第一如果让我自己实现一个简化版AQS我会怎么设计state、队列和park/unpark的配合第二公平锁和非公平锁在tryAcquire上只差一行代码级别的判断为什么性能差异那么大第三如果去掉shouldParkAfterFailedAcquire的SIGNAL设置会发生什么答案是线程可能永久沉睡因为没人有义务叫醒你。这几个问题都能答上来说明AQS的队列机制你算是真正吃透了。这一章我们把独占模式的核心链路走完了下一章可以继续聊共享模式、Semaphore、CountDownLatch以及ConditionObject的条件队列实现。
返回列表