AQS 的 CLH 队列到底排的是什么:一次线程池“假死“,和 ReentrantLock 加锁链路的 3 个细节 引子我们配置中心客户端有个定时刷新任务每秒钟跑一次先用ReentrantLock抢到锁再去拉远端配置。某天配置中心网络抖动拉取卡了将近 60 秒。也就是这 60 秒里那把锁一直没被释放。结果线程池里所有要等这把锁的任务全在排队活跃线程被占满任务积压了几万条——监控上 CPU 很低、活跃线程数也正常但业务就是不动典型的假死。这次事故逼我把ReentrantLock底层的 AQSAbstractQueuedSynchronizer认真读了一遍。读完才发现我们踩的坑本质上是不理解队列里排的到底是什么、以及锁一直不释放时队列会怎么膨胀。这篇文章就把 AQS 的核心讲清楚。问题为什么 ReentrantLock 比 synchronized 更可控synchronized加锁失败就两条路自旋或者挂起你控制不了。而ReentrantLock是基于 AQS 实现的它把加锁失败之后怎么办这件事拆成了可插拔的策略你可以tryLock()立即返回、可以tryLock(3, SECONDS)带超时、可以lockInterruptibly()响应中断、还能选公平或非公平。这些能力都来自 AQS 这套框架。AQS 只干两件最核心的事用一个volatile int state表示同步状态对锁来说 0 是空闲、0 是已被占用且记录重入次数。维护一条双向的 FIFO 等待队列CLH 队列的变种没抢到锁的线程被包装成Node挂到队列里排队。理解这两点AQS 家族ReentrantLock、Semaphore、CountDownLatch、ThreadPoolExecutor的 Worker 也借了类似思路就一通百通了。原理一自己写一把锁看清 AQS 的骨架最直观的方式是继承AbstractQueuedSynchronizer写一个最简独占锁import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class MutexLock { // 私有内部类继承 AQS只实现独占模式 private static class Sync extends AbstractQueuedSynchronizer { Override protected boolean tryAcquire(int acquires) { // state 从 0 变 1 表示加锁成功用 CAS 保证原子 if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; // 已被占用交给 AQS 入队排队 } Override protected boolean tryRelease(int releases) { if (getState() 0) throw new IllegalMonitorStateException(); setExclusiveOwnerThread(null); setState(0); // 释放就是把 state 归零 return true; } Override protected boolean isHeldExclusively() { return getState() 1; } } private final Sync sync new Sync(); public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } public boolean tryLock() { return sync.tryAcquire(1); } }逐行解释第 5 行extends AbstractQueuedSynchronizer是标准写法把锁的逻辑委托给内部Sync业务类只暴露lock/unlock。第 8 行tryAcquire是 AQS 留给子类的真正抢锁钩子。这里只用compareAndSetState(0, 1)抢一次抢到就把持锁线程记下来。抢不到返回 falseAQS 会自动把当前线程封装成 Node 塞进队列——排队逻辑你不用写。第 16 行tryRelease把state归零并清空持锁线程。注意它没用 CAS释放锁的一定是持锁线程自己不存在竞争直接写就行。第 27 行sync.acquire(1)是 AQS 的模板方法内部先调你的tryAcquire失败了就入队、阻塞、等被唤醒后再次尝试。这一行背后藏了整条 CLH 队列的入队、自旋、挂起、唤醒逻辑。你看自定义一把锁只需要写抢和放两个判断排队、唤醒全交给 AQS。这就是模板方法模式的威力。原理二ReentrantLock 非公平锁的加锁链路ReentrantLock默认是非公平锁它的tryAcquire核心逻辑长这样简化自 JDK 源码// ReentrantLock.NonfairSync / Sync 的核心已简化 final boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { // 关键点①state 为 0 时直接 CAS 抢不关心队列里是否有人在等 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { // 关键点②是当前线程重入state 累加这就是可重入的实现 int nextc c acquires; if (nextc 0) throw new Error(Maximum lock count exceeded); setState(nextc); return true; } return false; // 关键点③抢不到返回 falseAQS 把它丢进队列排队 }逐行解释关键点①这就是非公平四个字的全部含义——锁一释放新来的线程不用管队列里排队的那些先 CAS 抢一把抢到了就走。吞吐高但排队的线程可能被插队饿着。关键点②重入靠的是当前线程是不是持锁线程是的话只把state 1解锁时也要对应-1减到 0 才真正释放。这也是为什么lock()几次就要unlock()几次少一次就死锁。关键点③返回false后AQS 的acquire会把线程挂到队列队尾进入WAITING状态等持锁线程release时从队头唤醒下一个。公平锁的区别仅在c 0那一步多了一个hasQueuedPredecessors()判断队列里有人比我早来我就老老实实排队不去抢。性能低一点但没人会饿死。原理三CountDownLatch 用的是共享模式AQS 除了独占ReentrantLock还有共享模式Semaphore、CountDownLatch。共享模式的state不是 0/1而是一个计数器多个线程可以同时通过。看CountDownLatch的典型用法public class BatchLoader { public void loadAll(ListString urls) throws InterruptedException { int n urls.size(); CountDownLatch latch new CountDownLatch(n); // ① 计数器初始 任务数 ExecutorService pool Executors.newFixedThreadPool(8); for (String url : urls) { pool.submit(() - { try { fetch(url); } finally { latch.countDown(); // ② 每个任务完成计数器 -1 } }); } latch.await(); // ③ 主线程阻塞直到计数器归 0 pool.shutdown(); System.out.println(全部加载完成); } }逐行解释第 4 行new CountDownLatch(n)把 AQS 的state初始化成任务数量n。第 10 行latch.countDown()每调用一次AQS 的state就用 CAS 减 1减到 0 的那一刻AQS 会唤醒所有在await()上等待的线程这是共享模式的特点一个事件放行一批线程。第 13 行latch.await()让主线程在 AQS 队列里挂起直到state归 0 被整体唤醒。注意countDown()必须放在finally里否则某个任务抛异常没countDown主线程就永远await不下去——这也是我们线上踩过的坑。回到那次假死现在能解释清楚事故了定时任务lock()拿锁后去拉配置HTTP 没设超时卡了 60 秒这把锁 60 秒没释放。这 60 秒里线程池里后续的任务调用lock()全部tryAcquire失败被 AQS一个接一个塞进 CLH 队列挂起。队列越来越长线程被占满新任务进不来旧的又跑不动——假死就这么来的。两个修复点对应 AQS 的两个能力// 修复 1用带超时的 tryLock而不是无脑 lock() if (lock.tryLock(3, TimeUnit.SECONDS)) { try { refreshConfig(); // 这里如果卡住最多 3 秒就让出锁 } finally { lock.unlock(); } } else { log.warn(3 秒内没拿到锁跳过本次刷新); } // 修复 2HTTP 客户端必须设超时根因 RequestConfig cfg RequestConfig.custom() .setConnectTimeout(2000) .setSocketTimeout(2000) .build();tryLock(3, SECONDS)用的是 AQS 的可超时能力3 秒内没抢到就直接返回 false线程不会被无限期挂起队列也就不会无限膨胀。再配合 HTTP 超时把持锁时间这个根因也治了。我的取舍判断公平还是非公平默认用非公平ReentrantLock()无参构造就是非公平。除非你真的观察到某线程长期拿不到锁被饿死这种证据否则别为了看起来公平去牺牲吞吐量。公平锁每次都要走队列检查性能差一截。tryLock 还是 lock凡是持锁的代码块里会调用外部服务、DB、RPC一律用tryLock(timeout)并在finally里unlock。把拿不到锁当作正常分支处理而不是赌它一定拿得到。这次假死就是赌输了。CountDownLatch 还是 CyclicBarrierCountDownLatch 是一个等多个倒计时到 0 就一次性放行不能重置适合等所有子任务完成。如果需要反复凑齐一批人再跑可重置用CyclicBarrier。别混用。总结AQS 就靠state 一条 CLH 双向队列撑起了大半个 JUC。ReentrantLock的可控来自它把抢锁失败后的行为做成了钩子是否插队、是否可中断、是否可超时而CountDownLatch借的是 AQS 的共享模式一个事件唤醒一批线程。理解队列里排的是抢锁失败的线程你就知道为什么锁长时间不释放会把线程池拖垮——因为队列会无限长。下次再遇到线程池假死先jstack看是不是一堆线程卡在LockSupport.park()上等 AQS 队列往往一抓一个准。思考题你们的代码里有没有lock()之后在临界区里调用了外部 HTTP/RPC/DB 却没设超时的试着把其中一个改成tryLock(超时)并在finally里unlock()再压一次看看线程池的堆积情况。把你的改造前后的线程 dump 对照发出来我们一起看 CLH 队列的变化。