
先从我踩过的一个坑说起。当时我负责的订单服务里有个接口每天要处理几十万次调用某天监控突然报警P99延迟从80ms飙到2.5秒。抓了几次线程栈发现大量业务线程阻塞在同一把synchronized锁上——那个锁保护的是一段本地缓存的更新逻辑理论上应该几微秒就释放结果因为一次下游依赖的联动调用拖慢了持锁时间所有抢锁线程被堵得死死的。那次事故之后我认真从头到尾读了一遍ReentrantLock源码也彻底搞明白了它和synchronized的差异到底在哪里。如果你也想弄懂Java并发包里的各种锁ReentrantLock源码解析是绕不开的第一步——它背后的AQSAbstractQueuedSynchronizer几乎撑起了JUC半壁江山。这篇文章我不绕弯子直接拆源码讲从lock()怎么加锁、unlock()怎么释放、公平锁和非公平锁的排队差异到Condition条件队列的实现思路每一段都配合执行路径和关键代码梳理。最后再把我在实际项目里选型、踩坑的经验总结出来。适合两类人看一类是想把ReentrantLock用明白的Java开发另一类是准备啃AQS源码、需要一条清晰主线的进阶者。读完之后你至少能回答三个问题非公平锁到底哪里不公平可重入是怎么实现的唤醒下一个线程时为什么非要倒着找1. 事故之后我问自己的第一个问题synchronized到底缺什么回到那场线上事故当时最让我难受的不是锁本身慢而是用synchronized几乎没有任何应急出口。所有线程就那么干等着你连让其中一个线程放弃等待的操作都做不了。1.1 三个synchronized给不了的应急出口Java内置锁synchronized用起来确实简单JVM帮忙做锁的升级和释放不用担心忘记解锁。但那次事故暴露出了它三个实际短板。第一不可中断。一个线程如果阻塞在synchronized的 monitor 获取上其他线程调用它的interrupt()方法是无效的它只能一直等。这意味着你没法用任何超时补偿机制去打断一个卡死的等待线程。第二不可超时。synchronized没有tryLock(timeout)这种等不到就算了的语义。如果持锁线程真的出了故障所有等待线程只能无限期等下去而线上最怕的就是这种不确定性。第三单条件队列。synchronized的wait/notify只有一个等待集合虽然notifyAll能唤醒全部但没法做到只唤醒正在等某个资源的那一个线程这种精细化调度。打个比方synchronized就像去政务大厅办事来了就排队轮到你之前你什么都干不了不能换窗口也不能中途走人。而ReentrantLock给你提供了插队抢号非公平、取号排队公平、等不耐烦走人tryLock超时、中途接电话响应中断这些选择。这正好对应了它设计的出发点在synchronized的互斥语义之上补齐一套更加可控的锁机制。1.2 ReentrantLock的能力清单我习惯用一张表来对比ReentrantLock和synchronized这比口头上说谁更好直观得多能力synchronizedReentrantLock可重入支持支持中断响应不支持支持lockInterruptibly超时获取不支持支持tryLock(timeout)非阻塞抢锁不支持支持tryLock()公平排队基本非公平构造参数可指定条件队列数量1个可创建多个Condition释放方式自动释放必须手动unlock底层实现偏向锁/轻量级锁/重量级锁AQS LockSupport注意这张表不是想说明ReentrantLock就一定更高级而是适用场景不同。短小临界区、不需要中断和超时的场景synchronized依然是很好的选择但如果你需要更精细的控制权比如在锁等待中加入超时降级、或者用多个条件队列做精细化唤醒那ReentrantLock就是更合适的工具。2. AQS的秘密一个int变量加一条双向链表如何撑起整把锁开始看ReentrantLock源码之前必须先理解它依赖的地基AbstractQueuedSynchronizer。JUC里一大半工具——CountDownLatch、Semaphore、ReentrantReadWriteLock、ThreadPoolExecutor的Worker——都构建在AQS之上。理解了AQS这些工具全都一通百通。2.1 state字段锁的状态就藏在这一个int里AQS核心是一个volatile int state。ReentrantLock用这个state来表示锁的持有状态0表示当前没有线程持有锁1表示某个线程持有了锁如果同一个线程重入state会继续往上加。也就是说state就是锁被重入了几次的计数。为什么用一个int而不是一个布尔值因为可重入需要计数。每次lock()成功时state加1每次unlock()时state减1只有减到0才表示锁真正被释放。这里有个容易忽略的细节setState()方法做的不是CAS而是普通赋值。原因很简单——能够修改state的线程只有锁的持有者单线程修改一个volatile变量不需要CAS也能保证可见性。CAS是给多个线程同时抢一把锁用的而持有者内部改计数不存在竞争。2.2 队列数据结构CLH变体的双向链表如果线程抢锁失败它会被包装成一个Node节点放进AQS内部的等待队列。这个队列是CLH锁的变体使用双向链表实现。每个Node有几个关键字段prev、next、thread、waitStatus、nextWaiter。waitStatus的取值不多但每个都值得记住waitStatus值含义1CANCELLED线程已取消等待节点将被清理-1SIGNAL当前节点释放锁时有义务唤醒后继节点-2CONDITION节点在条件队列中等待-3PROPAGATE共享模式下需要向后传播唤醒0初始状态为什么需要双向链表而不是单向因为在排队过程中线程可能因为中断或超时被取消。取消一个节点时需要把它的前驱节点指针和后继节点指针正确连接唤醒后继时unparkSuccessor还需要从tail往前遍历查找有效节点。这些操作单向链表做起来很麻烦双向结构才够灵活。2.3 公平与非公平的根源hasQueuedPredecessors()公平锁和非公平锁的tryAcquire逻辑几乎一样唯一的差别在于锁空闲的时候公平锁要多检查一次hasQueuedPredecessors()。这个方法的名字非常直白当前是否有比我排得更靠前的线程在等待。如果有我就不能抢只能继续排队。相当于公平锁是严格遵守先来后到的银行柜台取号机制号码靠前的先办业务。非公平锁则完全不管队列里有没有人等先CAS抢一把抢到了就插队成功。这也解释了为什么非公平锁通常性能更高在锁释放的瞬间恰好有个新线程在做CAS操作它不需要经历入队、挂起、唤醒的完整路径直接拿到锁省掉了一次上下文切换和线程调度。3. lock()加锁源码拆解非公平抢锁与公平排队的逻辑分流ReentrantLock内部有两个Sync子类NonfairSync和FairSync。默认构造函数new ReentrantLock()用的是非公平锁。接下来我们从非公平锁入手逐行看加锁路径。3.1 NonfairSync.lock()CAS抢锁 acquire兜底看NonfairSync.lock()的源码final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }这段代码非常短但包含了两层设计。第一层是快速路径把state从0通过CAS改成1成功就代表当前线程拿到了锁然后通过setExclusiveOwnerThread记录锁持有者整个加锁过程结束没有入队、没有park性能极好。第二层是兜底路径如果CAS失败说明锁已经被别人持有或者锁虽然空闲但抢锁瞬间存在竞争于是进入acquire(1)走完整流程。值得注意的一个细节非公平锁在lock()里无论队列中是否有线程在等待都会先CAS抢一次。就算队列里已经排了三个线程新来的线程也有机会截胡。这个行为就是非公平三个字最直接的体现也是后续所有性能分析的基础。3.2 acquire()的四步标准流程acquire是AQS的方法public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这句话是理解ReentrantLock加锁的钥匙。它做了四件事先调用tryAcquire尝试获取锁失败后通过addWaiter把当前线程封装成Node放到队尾然后进入acquireQueued自旋等待如果等待期间线程被中断过返回后通过selfInterrupt()补上中断标记。这里有个很多初学者容易困惑的点既然已经tryAcquire失败了为什么addWaiter之前不直接入队其实tryAcquire内部本身就包含了一次CAS抢锁也就是说lock()第一次CAS抢锁、acquire里tryAcquire第二次抢锁两次都失败才老实入队。这个双路径抢锁的设计是为了在锁刚好释放的瞬间减少一次不必要的入队和挂起开销——毕竟入队之后再被唤醒成本远比多试一次CAS高得多。3.3 FairSync.tryAcquire()唯一多出的那一行看公平锁的tryAcquireprotected 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; }和非公平锁的nonfairTryAcquire对比核心差别就一行!hasQueuedPredecessors()。只有队列为空或者队首等待者就是当前线程说明是重入才能去CAS抢锁。否则老老实实返回false走排队流程。注意可重入的分支当state 0且当前线程就是owner时直接setState(nextc)不需要CAS。因为此时锁已经被当前线程持有没有其他线程能够竞争修改state。代码里还判断了nextc 0的情况这说明重入次数如果溢出会直接抛Error而不是异常——因为在JVM的内存模型下这种溢出意味着代码逻辑已经严重失控。3.4 acquireQueued()里的自旋与park为什么阻塞前要转一圈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); } }每次循环先检查当前节点的前驱是不是head。为什么只看前驱而不是直接检查锁状态因为AQS队列中head节点代表当前正在持有锁的线程。如果前驱是head意味着轮到自己了可以再尝试一次tryAcquire。这是一种优化式自旋——在真正park挂起之前尽量抓住机会抢到锁避免进入线程的阻塞和唤醒流程。如果tryAcquire还是失败就调用shouldParkAfterFailedAcquire检查前驱节点的waitStatus。逻辑分三种如果前驱是SIGNAL说明前驱已经承诺释放锁时唤醒自己当前线程可以安心 park如果前驱是CANCELLED就跳过前面所有已取消的节点如果前驱是0或其他状态就CAS把前驱改成SIGNAL相当于提前签约一个唤醒义务。这里的一个微妙点在于第一次进入shouldParkAfterFailedAcquire时会把前驱改成SIGNAL但返回false循环会再转一圈只有第二次进入且前驱已经是SIGNAL才真正返回true执行park。这是AQS里一个容易看漏的细节。4. unlock()释放锁源码拆解从state减一到底层线程唤醒加锁讲完解锁同样重要。有一次我在公司内部分享时问大家unlock到底做了什么很多人只知道sync.release(1)但release后面的逻辑一问三不知。这里拆开看。4.1 tryRelease()可重入计数如何安全递减unlock的入口很短public void unlock() { sync.release(1); }release是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; }tryRelease在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; }两个关键点。第一c getState() - releases。因为state是重入计数所以每次unlock只减1。只有减到0才把owner线程置空锁才真正释放。这意味着锁的持有者在重入了N次之后必须unlockN次才能真正释放。少一次unlock轻则锁无法释放重则整条线程和所有排队线程一起卡死。第二线程身份校验。不是当前持有者线程的调用不能释放锁否则直接抛IllegalMonitorStateException。这个约束避免了线程A抢到的锁被线程B误释放的严重问题。4.2 unparkSuccessor()为什么从tail往前找后继release里读headhead的waitStatus不等于0时才调用unparkSuccessor。为什么如果head的waitStatus是0说明它后面没有需要唤醒的线程或者唤醒义务还没建立不需要做唤醒操作。这是AQS减少无用系统调用的一种方式。再看unparkSuccessor的实现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); }一开始优先取node.next因为正常情况下后继节点就是下一个要被唤醒的线程。但如果后继节点的waitStatus是CANCELLED等待过程中取消了或者后继还是null节点还没初始化完成就不能直接使用它。这时候要从tail往前遍历找到离node最近的一个waitStatus 0的节点唤醒它的线程。为什么必须从tail往前而不是从node.next往后扫这里涉及AQS的并发安全设计。在addWaiter中node.prev的赋值先于pred.next的赋值。也就是说节点入队时先通过CAS把节点接到tail上而前一个节点的next指针此时可能还是null。如果从前往后遍历可能漏掉一个看起来断链但实际上已经入队的节点。而从tail往前通过prev指针一定能追溯到所有有效节点。这是CLH变体队列为了并发安全做出的一个精巧设计面试也常问记清楚prev一定可见next不一定这个点。4.3 解锁的完整时间线把整个释放过程串起来看unlock()进入release(1)tryRelease()把state减到0并清空owner然后读取head发现head的waitStatus是-1调用unparkSuccessor()unparkSuccessor从头节点的后继节点中找到第一个有效节点调用LockSupport.unpark()唤醒该线程。被唤醒的线程从parkAndCheckInterrupt()返回回到acquireQueued的自旋循环中。此时它的前驱已经变成了head因为旧head即将出队它再次执行tryAcquire成功获取锁。随后setHead()把自己设为新的head旧head的next置null辅助GC。整个锁交接的流程就完成了。5. 中断、超时与Condition三个synchronized做不到的调用路径ReentrantLock相对synchronized最大的价值就体现在这三个能力上中断响应、超时获取、多条件队列。这一节逐个看实现路径。5.1 lockInterruptibly()阻塞等待也能被中断打断ReentrantLock的lock()在阻塞等待时其实也可以响应中断但它不会立即抛异常而是等成功获取锁后补一次中断标记就是acquire里的selfInterrupt。如果你想在等待锁的过程中被中断就立刻退出应该用lockInterruptibly()public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); }acquireInterruptibly的逻辑是先检查线程中断标志已中断直接抛InterruptedException然后tryAcquire尝试获取锁失败就进入doAcquireInterruptibly。doAcquireInterruptibly的循环结构和acquireQueued几乎一样唯一区别是如果在parkAndCheckInterrupt中发现线程被中断立即throw new InterruptedException()而不是设置一个标志位等拿到锁后再处理。这个差异在业务上很实用。比如一个任务在等待获取锁时外部发起了取消命令想要中断线程lockInterruptibly能立刻结束等待把异常抛给上层做清理和降级而lock()要等到真正拿到锁才处理中断等待时间可能会很长。5.2 tryLock(timeout)等太久我就放弃tryLock()是真正的非阻塞尝试底层就是nonfairTryAcquire(1)抢到返回true抢不到立即返回false不排队不等待。适合那种能拿到锁就处理拿不到就跳过的场景。tryLock(long timeout, TimeUnit unit)带了超时语义底层走doAcquireNanos。这个方法的循环结构大体类似acquireQueued但在几个地方做了调整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); } }两个细节值得注意。第一deadline基于System.nanoTime()计算而不是在循环里每次重新计算now timeout。因为nanoTime在一个线程内是单调递增的用deadline做绝对时间点判断不需要考虑每次循环耗时。第二当剩余超时时间小于spinForTimeoutThreshold约1000纳秒时不再执行parkNanos而是直接自旋等待。因为纳秒级别的park和唤醒本身就是一次系统调用开销可能比自旋还大不如在用户态多转几圈。工程上的教训是tryLock的超时时间不要设成0否则和普通tryLock没区别也不建议设一个非常大的值比如10秒然后指望它兜底因为等待期间如果线程被interruptdoAcquireNanos会抛InterruptedException调用方必须要有处理这个异常的预案。5.3 Condition从wait/notify到精细化生产者消费者模型Condition是ReentrantLock的另一个重头戏。synchronized的wait和notifyAll只能在一个隐式条件队列上操作而ReentrantLock通过newCondition()可以创建多个Condition对象每个Condition都有自己的等待队列实现了按需唤醒。看await()的核心流程简化版public final void await() throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); Node node addConditionWaiter(); int savedState fullyRelease(node); while (!isOnSyncQueue(node)) { LockSupport.park(this); } acquireQueued(node, savedState); }拆开看四步先检查中断然后把当前线程封装成Node放进Condition的等待队列接着fullyRelease把当前线程持有的锁全部释放——注意这里是全部释放savedState保存了重入次数因为一个线程在await时不能带着锁睡觉否则别人永远进不来最后如果节点不在同步队列中就park挂起直到被signal唤醒。sinal()的流程则相对简单private void doSignal(Node first) { do { if ((firstWaiter first.nextWaiter) null) lastWaiter null; first.nextWaiter null; } while (!transferForSignal(first) (first firstWaiter) ! null); }从条件队列的firstWaiter开始逐个尝试把节点通过transferForSignal从条件队列搬家到AQS的同步队列。注意signal本身并不唤醒线程它只是把节点从条件队列移到同步队列。真正的唤醒动作是在持有者释放锁时由unparkSuccessor完成的。5.4 多条件队列的价值一个真实的连接池场景我在实际项目里用过多条件队列解决连接池性能问题。如果用synchronized的wait/notifyAll每当有一个连接被归还notifyAll会把所有等待连接的线程全部唤醒但它们醒来后会发现连接仍然不够大部分又会重新wait造成无意义的CPU空转和上下文切换。改用ReentrantLock之后我创建了两个ConditionnotEmpty和notFull。生产者往池里放入连接后signal(notEmpty)只会唤醒一个正在等待获取连接的消费者消费者归还连接后signal(notFull)只会唤醒一个正在等待归还槽位的生产者。精细化唤醒的效果立竿见影线程空转率大幅下降。这套机制是在AQS同步队列之外再维护了多个独立的条件等待队列是synchronized的wait/notify模型完全做不到的。6. 从源码回到工程我的选型经验和踩坑总结源码读完了最终还是要回到工程决策。这一节我不讲大道理全部是自己在代码评审和线上排查中积累的真实经验。6.1 什么场景我依然会选择synchronized看完前面的对比千万不要得出ReentrantLock一定比synchronized好的结论。我的习惯是如果只是简单的对象锁、代码路径短、不需要中断/超时/公平性要求优先用synchronized。理由有三条。第一synchronized支持锁的自动释放方法抛异常也不会死锁代码更不容易出错。第二Java 6之后synchronized经过偏向锁、轻量级锁等优化无竞争场景下性能并不比ReentrantLock差甚至因为省掉了手动加减锁的开销而更简洁。第三synchronized用在类中的某个方法上时可读性非常好团队协作时不需要额外的约定。JDK自己的并发容器也印证了这一点Vector、Hashtable等老容器内部用的是synchronized而LinkedBlockingQueue内部则用ReentrantLocktakeLock和putLock分离。选型的关键始终是场景而不是哪个更先进。6.2 忘了unlock不是粗心是设计问题有一次代码评审看到同事写了这么一段逻辑加锁后调一个远程接口远程接口在超时前抛了RuntimeException而方法没有finallyunlock()永远不会执行。这就是典型的觉得不会出异常导致的死锁隐患而且这种隐患往往要在灰度发布后才爆发。后来我们定了两条规则。第一加锁后的业务代码只要超过三行就必须用try/finally包住unlock第二IDE的代码模板里把lock()和try-finally一起生成。另外还有一个习惯凡是可以接受抢不到就放弃的业务优先用tryLock代替lock因为lock()在等待锁期间被中断时要等真正拿到锁才处理中断这个行为在部分场景下会让人感觉卡住了而tryLock超时返回false之后至少可以走降级逻辑而不是无限等待。6.3 锁粒度先问持锁时间再做技术选型读源码最大的收获是彻底想明白了锁竞争的本质一个线程持锁期间所有其他线程只能排队或自旋等待时间长短完全由持锁时间决定。所以遇到锁相关性能问题我的第一反应不是换公平锁还是非公平锁而是先看持锁时间能不能压下去。把远程调用、数据库访问、大循环这些慢操作移到锁外比换任何锁实现都更有效。曾经有个性能优化单我通过把锁内的远程调用拆到锁外P99延迟直接降了70%锁本身甚至不用换。这才是真正的性价比之选。6.4 非公平锁的饥饿担忧网上关于ReentrantLock非公平锁最常见的担忧是极端情况下线程会饿死。这个说法理论上没错但在实际业务中很少真正发生。原因在于ReentrantLock的非公平并不是完全无视队列。当一个线程已经进入acquireQueued自旋后它每次被唤醒时都会尝试抢锁并不是永远排在最后。长期饥饿需要一种极端场景——每个锁释放的瞬间都恰好有一个新线程出现并抢到锁且这种状态持续不断。在真实业务里锁竞争通常是有波峰波谷的新线程也不可能永远卡在那个时间点。所以默认构造器选非公平锁更多是基于吞吐量的考虑而不是让某个线程饿着。如果你的场景确实需要保证某些线程的优先级比如定时任务线程必须优先拿到锁那老老实实用公平锁代价是多一些线程切换但换来的是确定性。还有一种折中方案——用tryLock(timeout)加上合理的等待时间既避免了饥饿也保留了非公平锁的吞吐优势。回到文章开头那次线上事故我最终的解决方案其实很简单把持锁期间的下游联动调用移到锁外同时用ReentrantLock的tryLock(200, TimeUnit.MILLISECONDS)做超时降级抢不到锁就返回一个降级结果而不是让线程无限等待。上线后接口P99从2.5秒回到了90ms左右。那次之后我也养成了一个排查习惯遇到锁相关性能问题先用Arthas抓线程栈看waiting状态集中在哪个方法再用jstack连续抓几次对比线程是否长时间停留在同一个锁对象上最后根据代码决定是换成ReentrantLock加超时还是优化锁粒度还是直接改用并发容器。技术选型这件事没有银弹但读懂了源码至少每一步你都知道自己在做什么也知道下一步该往哪走。