
先说结论如果这辈子只想读一个Java并发源码我建议你读ReentrantLock和它背后的AQS。ReentrantLock是Java并发包中实现最完整、注释最清晰、使用场景最典型的锁实现之一AQSAbstractQueuedSynchronizer则是几乎所有JUC同步器——Semaphore、CountDownLatch、ReentrantReadWriteLock、ThreadPoolExecutor的worker甚至未来的虚拟线程同步原语——的公共底座。把这两个类读透整个java.util.concurrent的骨架就摊在你面前了。这篇文章不打算按源码顺序逐行翻译而是用“抢座位”这个场景把AQS的队列机制讲明白锁就是座位没抢到的人就得去排队排队排到哪、什么时候轮到你、有人插队怎么处理、中途不想等了能不能走人——这些问题恰恰就是AQS源码里addWaiter、acquireQueued、parkAndCheckInterrupt、ConditionObject在回答的问题。下面我从头到尾拆一遍文末附上我实际阅读源码时踩过的几个理解误区希望能帮你少绕弯。1. 为什么AQS队列可以理解成“抢座位”1.1 先把“抢座位”的比喻立起来想象这样一个场景自习室里只有一个靠窗座位大家都想坐。谁来坐先到先得是一种方式但如果有个人力气大、腿脚快即使晚到也能抢在别人前头坐下这就是另一种方式。Java里的锁就是那个座位而AQS维护的CLH变体队列就是用来管理“没抢到座位的人按什么规则等”的那张排队表。具体来说有座位锁空闲直接坐下——对应CAS无锁拿锁没座位锁被占用先去排队——对应addWaiter入队排到队首且座位空出来了——对应acquireQueued里被唤醒后抢锁中途不想等了或者等待时被中断——对应lockInterruptibly和中断恢复逻辑。这个比喻最大的好处是它把“队列”从抽象的数据结构变成了你每天都会遇到的场景队列不是用来“传数据”的它是用来“记录等待者顺序”的。AQS里的那个双向链表每个节点里存的并不是业务数据而是一个个被阻塞的线程以及它们的状态。1.2 AQS到底做了什么关键决策AQS的核心设计是一个模板方法模式加一个状态变量。父类AbstractQueuedSynchronizer只负责两件事维护一个volatile int state以及维护一个基于CLH改进的双向等待队列。至于“什么条件下算拿到锁”“拿到锁后如何处理”全部交给子类去实现tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared这几个钩子方法。这种设计的妙处在于不同同步器之间几乎不需要重复实现排队、唤醒、中断处理这些复杂机制一个框架就能复用到各种场景。比如ReentrantLock的非公平锁tryAcquire里直接CAS抢state公平锁tryAcquire里先检查队列中是否有等待者有这个字面意思就是“如果该释放了我来接”SemaphoretryAcquireShared尝试扣减stateCountDownLatchtryReleaseShared里的state减到0时所有等待线程同时被唤醒。AQS不需要关心子类锁的“业务语义”它只像一个严格的门卫你告诉我“这个人有没有资格进门”我来负责“让他排队叫号催他”。所以读AQS源码时不要盯着子类看先把AQS的队列状态模型吃透也就是我下面要拆的State、WaitStatus、Node、head/tail那套东西。2. 核心源码拆解从lock()到排队到底经历了什么2.1 lock()入口先试试“抢”抢不到才去排以ReentrantLock最常用的非公平锁为例调用lock()时实际执行的是final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }compareAndSetState(0, 1)的意思是如果当前state是0就原子地改成1同时把“占用线程”记录为当前线程。这一步是彻头彻尾的“抢座位”——不管队列里有没有人排队我直接尝试坐下。如果抢成功了整个lock()结束线程继续执行临界区代码。抢不到就进入acquire(1)走排队流程。这里有一个新读者容易忽略的点非公平锁的“非公平”就体现在这个入口的抢先CAS上。明明有人在排队新到的线程还是可以先试一次抢锁抢不到才老老实实去队尾。这种设计不是bug而是性能取舍这个后面专门展开。而公平锁的lock()则不同它直接走acquire(1)里面的tryAcquire会先调用hasQueuedPredecessors()检查队列里是否有排在自己前面的线程有就直接返回false老老实实入队连试都不试。所以公平锁的入口根本不给你“插队”的机会。2.2 抢不到之后addWaiter是怎么把线程塞进队列的当acquire发现tryAcquire返回false就执行Node node addWaiter(Node.EXCLUSIVE);addWaiter做的事很简单把当前线程包装成一个Node节点放到双向队列的尾部。但这个“简单”背后有两个关键实现细节。第一入队用的是CAS 自旋而不是synchronized或Lock。代码是这样的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; }队列为空或者CAS尾插失败都会走enq方法。enq内部用for(;;)自旋不断尝试“把节点CAS到队尾”直到成功为止。为什么不用锁来保护入队操作因为AQS本身就是要实现锁如果用锁去保护“入队”那这个锁又得再实现一次队列无限套娃。CAS自旋几乎是不可避免的选择。第二这个队列是双向链表节点持有prev和next两个指针。为什么要双向因为在等待队列中线程被唤醒后需要往后找后继节点而取消排队时又需要从后往前清理前驱节点。只有单向链表的队列在“删除中间节点”这件事上非常痛苦双向链表可以优雅地处理取消等待的场景。2.3 排进队列之后acquireQueued的自旋与阻塞addWaiter返回的是入队的Node但这仅仅是“排上了”还没完。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; // help GC failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }这个循环体只有两种情况会让线程继续一是前驱节点正好是head说明轮到它了它再次尝试拿锁成功则结束循环二是拿锁失败那就进入shouldParkAfterFailedAcquire parkAndCheckInterrupt把线程真正阻塞起来。值得注意的是“轮到你了”并不是“直接给你锁”而是再给你一次抢锁的机会。因为锁可能在释放的瞬间又有新线程“插队”把它抢走了非公平锁。所以即使你的前驱是headtryAcquire也不一定成功无法成功就得继续park。这个细节点破了很多人的一个误区CLH队列不是“锁的接力棒”它只是“等待者的排队表”真正能不能拿到锁还是看CAS抢不抢得到。parkAndCheckInterrupt是将当前线程挂起的操作底层调用LockSupport.park()。一旦线程park就不会占用CPU。这也回应了“自旋锁”和“阻塞锁”的区别AQS队列里虽然用了自旋来入队但等待时的线程是真正阻塞的不是无限忙等。只有在入队、唤醒、抢锁这些瞬时操作上用了自旋。2.4 释放锁unlock和unparkSuccessor的唤醒链当线程执行完临界区代码调用unlock()public void unlock() { sync.release(1); }release方法会先调用tryRelease(arg)ReentrantLock里对应的是protected final boolean tryRelease(int releases) { int c getState() - releases; if (Thread.currentThread() ! getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free false; if (c 0) { free true; setExclusiveOwnerThread(null); } setState(c); return free; }注意一个容易踩坑的细节如果state减完不是0tryRelease返回falserelease方法根本不会唤醒队列中的任何线程。这是因为ReentrantLock是可重入锁同一个线程可能连续lock了两次state是2。第一次unlock时减到1说明这个线程还没有完全释放锁当然不能唤醒别人。只有state减到0锁才算真正释放才轮得到unparkSuccessor去唤醒后继线程。unparkSuccessor的逻辑看似简单其实有个很讲究的处理它默认找后继节点但后继节点可能已被取消waitStatus为CANCELLED这种情况下会从tail往前扫描找到最靠近head的有效节点来唤醒。为什么是从tail往前扫而不是从head往后扫因为节点入队时先设置prev再CAS尾指针在并发极端情况下next指针可能还没有来得及设置从前往后扫可能漏掉节点而prev指针在节点入队那一刻就已经固定了从后往前扫更可靠。这是AQS源码里一段非常微妙的注释值得反复体会。3. 公平锁与非公平锁两种“排队规则”的本质差异3.1 公平锁的tryAcquire里那一步hasQueuedPredecessors公平锁和非公平锁在acquire排队之后的流程完全一致差异只在tryAcquire这一个方法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; }和2.1里看到的非公平锁入口相比差别就在于c 0分支里多了一条hasQueuedPredecessors()判断。这个方法的实现核心是public final boolean hasQueuedPredecessors() { Node t tail; Node h head; Node s; return h ! t ((s h.next) null || s.thread ! Thread.currentThread()); }它回答的问题是队列里是否有等待线程比当前线程更早注意最后那个条件s.thread ! Thread.currentThread()——如果头节点的后继恰好是当前线程自己说明当前线程已经在队首等待此时不算“有前驱”可以去尝试拿锁。这个细节不能漏否则会出现“重复排队”的边界问题。3.2 两者性能差在哪非公平锁的性能优势来自两点。一是新线程在锁释放瞬间不用被唤醒直接CAS抢锁省掉了线程上下文切换的等待时间二是即使需要唤醒队列里的线程被唤醒的线程和插队线程会同时竞争谁赢都可以整体吞吐量更高。代价是队首线程可能被多次“插队”出现饥饿的极端场景但ReentrantLock的非公平策略并不会完全饿死队列线程因为插队线程如果没抢到还是会乖乖排到队尾。公平锁则严格FIFO谁的等待时间最长谁先获得锁线程几乎不会饿死。但代价是频繁的线程阻塞与唤醒上下文切换开销大吞吐量通常只有非公平锁的几分之一。Netty在绝大多数场景默认用非公平锁ThreadPoolExecutor内部也用非公平锁来调度work thread因为这些场景对“公平性”不敏感但对吞吐量敏感。还有一个实际经验如果你不确定该用哪种锁优先用非公平锁。公平锁只有在业务上对“等待顺序”有严格要求的场景才值得引入比如某些交易撮合队列、任务执行顺序敏感的场景。否则你会为公平性付出一笔不小的性能税。3.3 锁重入state不只是0和1可重入锁的实现本质是把“锁的持有次数”记在state里。每次lock成功state加1每次unlockstate减1。当state减到0锁才真正释放。这种设计最大的好处是锁的占用和释放可以分布在不同代码层级不用像传统自旋锁那样同一线程再次进入时必须先释放外层锁。不过这里有个隐藏的调试陷阱如果你在代码里手动调用了tryLock、unlock或者通过反射获取state值你会看到state可能是2、3这种大于1的数。不要慌那是重入次数。但如果你看到state大于1且长时间不变说明某线程重入了但没有成对释放恭喜你找到bug了。4. 中断响应与条件队列AQS的另一半4.1 lockInterruptibly是怎么实现“中途走人”的普通的lock()对中断是无视的线程被park后就算收到中断信号也会继续留在阻塞队列里等待而lockInterruptibly()在等待途中收到中断会直接抛出InterruptedException并退出队列。关键代码在AQS的doAcquireInterruptibly方法里private void doAcquireInterruptibly(int arg) throws InterruptedException { 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; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }和acquireQueued的唯一区别就是检测到中断时立即抛出InterruptedException。一旦抛出finally块里的cancelAcquire就会执行把当前节点从队列里清理掉同时调整前驱后继的指针。这和我第2.4节提到的“从后往前清理取消节点”的机制正好呼应。4.2 ConditionAQS里的第二个队列ReentrantLock关联的ConditionObject其实是AQS的一个内部类。调用condition.await()时线程会从“锁队列”转移到一个“条件队列”同时释放锁当其他线程调用condition.signal()时条件队列中第一个等待节点会被转移到锁队列的尾部等待重新获取锁。这里的核心是await方法里的两步操作把当前线程封装的节点加入条件队列不是AQS的主队列完整释放锁一直释放到state为0否则阻塞中的线程永远等不到锁释放。两个队列的关系就类似于候诊室和诊室诊室锁队列里的人在排队等着看医生候诊室条件队列里的人是被医生点名“先出去玩会儿”的等医生喊号signal才回到诊室外继续排队。有一个经典问题signal方法执行后被唤醒的线程真的能立刻执行吗答案是否定的。signal只是把节点从条件队列搬到锁队列该线程仍然要等锁、重新排队。这在生产者消费者场景里经常被误解导致很多初学者以为signal 获得锁。5. 常见问题与避坑实录5.1 “队列是FIFO的为什么非公平锁还会插队”这是我在社区里看到最多的疑问。FIFO管的是“等待队列里的人按顺序被唤醒”但非公平锁的入口CAS发生在入队之前。新线程根本不需要先排队再插队它直接在入队前就抢了一次锁。所以等待队列内部确实是公平的只是“还没入队的人”可以先抢。这不算队列不公而是“入口没有排队”而已。5.2 用jstack排查ReentrantLock卡死如果你怀疑项目中某个线程被ReentrantLock卡住了jstack是非常趁手的工具。在jstack输出里等待ReentrantLock的线程会显示为thread-1 #... waiting on java.util.concurrent.locks.ReentrantLock$NonfairSync...你还会看到该线程处于WAITING或TIMED_WAITING状态说明它在park中。这时候重点关注两个信息被等的锁对象是谁哪个线程持有它。如果持有锁的线程堆栈卡在一个无限循环或IO等待里那就基本定位到问题了。可问题是ReentrantLock不像synchronized那样在堆栈上直接打出monitor owner所以最好在代码里额外打印持有锁线程名称或者用jstack配合jcmd Thread.dump_to_file对比定位更准确。5.3 tryLock和lock的区别别用错tryLock()是非阻塞的最多立即尝试一次抢不到就返回false。tryLock(timeout, unit)则是带超时等待的。这是很多人写业务代码时的高频bug来源有些人用了tryLock却忘了处理false导致业务逻辑在并发下静默失败有些人滥用tryLock实现“一次性任务互斥”结果因为不知道tryLock非公平语义在竞争激烈时反复抢不到锁。我个人的经验法则是锁等待时间上限明确的用tryLock带超时必须等到锁的用lock或lockInterruptibly不希望被中断且可以无限等的用lock但一定要防止持锁线程不释放的异常分支。不要一律用tryLock逃课。5.4 源码阅读建议别从头读到尾从场景切入很多朋友读AQS源码读不下去是因为从类的第一个方法开始顺序读读完Node字段就晕了。我建议反过来先写一个ReentrantLock的最小示例加断点从lock()进去一步步看线程在几个if之间跳转再把AQS的四个模板方法单独拿出来看子类实现。这样依赖场景驱动不容易迷路。还有一个技巧在阅读时自己画一下节点的prev、next变化过程。像我第一次读addWaiter时就是因为没有画图对“为什么入队不用锁”始终不得要领。后来把三个节点画在纸上模拟head、tail变化一下就通了。6. 一点个人体会ReentrantLock源码我前前后后读了三四遍每一遍都有新的收获。第一遍关注的是“锁怎么拿怎么放”第二遍关注的是“队列怎么维护取消节点”第三遍开始注意到那些异常处理分支——比如checkInterruptWhileWaiting、cancelAcquire里的复杂指针操作才意识到AQS能在如此复杂的并发环境下保持正确性有多不容易。后来我在看Netty的EventLoop、Dubbo的异步调用、甚至自己写轻量级任务调度器时都会不由自主地回到AQS的设计思路里找灵感。它教我的最重要一件事是并发编程里最难的不是“加锁”而是让所有等待者在公平、效率、可中断之间找到平衡。AQS用一个state加一个双向队列就做到了这个设计放到今天依然不过时。如果你正在准备面试、在阅读Java并发源码、或者只是想搞明白“为什么别人写的并发工具类那么稳”那就从ReentrantLock AQS开始吧。希望你读完这篇文章后也能体会到那个“抢座位”场景背后的优雅。