
身边总有人问我Java并发编程那么多锁synchronized用了十几年为什么还要搞出一个ReentrantLock重入锁这俩到底啥区别还有更扎心的问题——明明我用了ReentrantLock线上还是出现了偶发的状态错乱这又该怎么排查我不是来给你背面试八股文的。这篇文章更像是我这几年把ReentrantLock从“会用”到“敢在生产环境里折腾”的一份实战总结。我会把重入锁的底层逻辑、核心API、公平性取舍、以及和synchronized的恩怨情仇一次讲透还会附上我真实踩过的坑和排查思路。无论你是准备Java面试还是正在调一个偶发并发问题这篇都值得你花十五分钟静下心看。1. ReentrantLock解决什么问题——先理解synchronized的“边界”1.1 从一次线上超卖事故聊起去年我经手过一个库存扣减接口代码本身不复杂就是经典的“查库存、判断、扣减”。最开始用的是synchronized方法锁压测的时候一切正常。但到了业务高峰期客服那边陆续反馈“明明看到有库存下单却提示无货”一查日志发现同一时刻有多个线程都读到了同一个库存数然后各自扣减overbook了。问题就出在synchronized虽然能保证原子性但它管不住“先查后扣”这种需要跨多个操作的临界区保护。而且synchronized的等待是不可中断的一旦某个线程持有锁时间过长其他线程只能干等着连“我不想等了”的诉求都无法表达。换句话说synchronized是一个“足够简单但不太灵活”的互斥工具。而ReentrantLock是站在它肩膀上设计的“高阶版本”解决了几个synchronized长期以来的痛点支持可中断地获取锁——线程在等待锁时可以被外部中断不再死等。支持超时地获取锁——拿不到锁就放弃避免长时间阻塞。支持公平锁——先来先服务避免线程饥饿。支持多个条件变量——一个锁上可以挂多个等待队列线程间的协作更精细。正是这些差异让ReentrantLock在高并发、高可用场景里有了不可替代的位置。但是要注意它绝不是要完全取代synchronized这一点我后面会专门展开。1.2 ReentrantLock名字里的三个关键信息很多初学者会把ReentrantLock当成一个黑盒API拿来就用。但我建议你先拆一下这个名字它本身就是一份设计说明书Lock说明它是一种显式的锁需要手动获取、手动释放不像synchronized那样由JVM隐式管理。Re前缀强调“再次”的能力。entrant进入。合起来就是“可重入”的意思——同一个线程可以多次获取同一把锁。“可重入”这个特性特别关键。想象一个递归方法它在每次递归调用前都尝试加锁如果是非重入锁那么第二次进入时就会跟自己死锁。而可重入锁允许同一个线程反复持有同一把锁只需要在释放时相应地减去持有次数即可。2. 可重入的底层逻辑——锁状态与AQS的秘密2.1 AQS整个并发包的基石说到ReentrantLock绕不开AQSAbstractQueuedSynchronizer抽象队列同步器。这个名字听着唬人但它的核心思想其实很朴素用一个状态位加一个线程等待队列完成对多线程竞争资源的管理。打个不太严谨但很好懂的比方把AQS想象成一个银行柜台。柜台上有一块显示牌状态位写着当前服务到几号了。如果号码无人办理状态是0如果有人办理业务状态变成1如果同一个人需要连续办三个业务状态就累加到3。后来的人看到“正在忙”就去旁边的休息区座位上排队CLH队列。对应到代码层AQS里有一个关键字段// AQS 内部 private volatile int state;state 0锁未被任何线程持有。state 0锁已被某个线程持有数值表示重入次数。抢锁失败但还想等的线程会被封装成Node节点放进FIFO队列中。为什么用int而不用boolean因为如果是简单的布尔值你只能表达“有锁/无锁”没法记录这个锁被同一个线程拿了几次。而int天然支持累加和递减完美匹配可重入的需求。2.2 重入的计数与释放逻辑ReentrantLock内部维护了一个state计数器但真正记录“当前持有线程”的是AQS的exclusiveOwnerThread字段。加锁与释放的核心逻辑可以精简为加锁非公平模式下的快速路径final boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { // 没有任何线程持有锁尝试抢占 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // 如果当前线程已经是锁的持有者说明是重入直接累加 else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) // overflow throw new Error(Maximum lock count exceeded); setState(nextc); return true; } return false; }释放锁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减到0锁才是真正意义上的释放如果只是从3减到2锁仍然被当前线程持有。这就是“重入多少次就要释放多少次”的底层原因。2.3 中断与等待队列的“人性化”设计synchronized最大的槽点之一就是当线程进入阻塞等待时它不响应Thread.interrupt()——你只能等不能中途退出。而ReentrantLock则提供了lockInterruptibly()方法专门解决这个问题。它的实现思路是线程在入队等待锁的过程中会不断检查中断标志位。一旦检测到被中断会立即抛出InterruptedException并退出等待队列。这在写复杂系统时尤其有用比如某个任务被用户取消了我们可以通过中断来唤醒那些还卡在锁上的线程避免资源白白占用。因此ReentrantLock本质上提供的不仅仅是一把“锁”而是一套可中断、可超时、可公平的并发控制策略。理解了这一点后面看它的API就会豁然开朗。3. 核心使用姿势与典型代码模式3.1 最标准的lock/unlock模板ReentrantLock最基础的使用方式网上有很多写法但我强烈建议你把这个模板背下来因为它能帮你避开90%的“忘了解锁”问题ReentrantLock lock new ReentrantLock(); public void doSomething() { lock.lock(); try { // 临界区业务代码 } finally { lock.unlock(); } }为什么必须在finally里解锁因为你无法保证临界区的代码一定不出异常。一旦中途抛出运行时异常你写的lock.unlock()如果放在后面就会因为异常逃逸而根本执行不到锁就不会释放。最终结果是其他线程永远拿不到锁整个系统直接卡死。这个低级错误我在很多生产事故里都见到过。还有一个隐藏的细节lock()方法放在try之外。这是因为如果lock()本身在获取锁的过程中抛出了异常那么此时锁并没有被成功持有如果紧接着在try块里执行unlock()反而会抛出IllegalMonitorStateException。把lock()放在try之前是最稳妥的。3.2 lockInterruptibly()让锁具备“响应中断”的能力如果一个线程正在等待一把被长时间占用的锁我们用lock()去抢锁它可能陷入无限期阻塞。但如果改用lockInterruptibly()就可以从外部中断它ReentrantLock lock new ReentrantLock(); public void doTask() throws InterruptedException { lock.lockInterruptibly(); try { // 业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }业务场景举例比如一个批量任务处理系统管理者希望能在限定的时间内完成所有任务如果某个任务因为锁竞争迟迟无法获得锁超时后就要取消它。这时候lockInterruptibly()配合Future.cancel(true)就可以优雅地实现——“这个线程你别等了赶紧回来”。要注意调用interrupt()并不会立刻让lockInterruptibly()抛出异常而是在线程进入等待队列、并且检测到中断标志位的时候才会生效。理解这一点就不会出现“我调了interrupt但没反应”的困惑。3.3 tryLock()拿不到锁就干别的不死磕tryLock()是我个人在实际项目里用得最多的方法。它提供了一种非阻塞的获取锁方式拿不到锁就立刻返回false而不是傻乎乎地等在那里。ReentrantLock lock new ReentrantLock(); public void updateData() { if (lock.tryLock()) { try { // 成功拿到锁执行更新 } finally { lock.unlock(); } } else { // 拿不到锁走降级逻辑比如记录日志、异步重试、直接返回 System.out.println(锁被占用稍后重试); } }更常用的是带超时时间的三参数版本if (lock.tryLock(3, TimeUnit.SECONDS)) { // 3秒内拿到锁执行任务 } else { // 超时仍未拿到锁执行降级 }为什么推荐这种方式因为在实际分布式/高并发系统中“无限等待一把锁”本身就是一个风险点。如果持有锁的线程因为网络IO、GC停顿等原因迟迟不释放锁其他线程只能陪着死等。而tryLock(timeout)给了系统一个“自愈”的机会我可以选择放弃或者走降级策略。这种设计思路比把全部线程都堵死在锁上要优雅得多。3.4 多条件变量Condition一个锁上的多条等待队列我们常把ReentrantLock和synchronized对比其实对应到线程协作层面ReentrantLock里的Condition比synchronized的wait/notify要灵活得多。一个经典生产者-消费者场景最能说明问题ReentrantLock lock new ReentrantLock(); Condition notEmpty lock.newCondition(); Condition notFull lock.newCondition(); // 生产者 public void produce(String item) throws InterruptedException { lock.lock(); try { while (queue.size() MAX_SIZE) { notFull.await(); // 队列满了生产者等待 } queue.offer(item); notEmpty.signal(); // 通知消费者可以取了 } finally { lock.unlock(); } } // 消费者 public String consume() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 队列空了消费者等待 } String item queue.poll(); notFull.signal(); // 通知生产者可以继续生产 return item; } finally { lock.unlock(); } }这里最爽的地方在于一个锁上可以创建多个Condition每个Condition维护独立的等待队列。生产者等待notFull消费者等待notEmpty互不干扰。而如果用synchronized你只能有一组wait/notify要区分“队列满”和“队列空”就得靠额外标志位代码很容易写错。4. 公平锁与非公平锁默认值不是拍脑袋定的4.1 公平与非公平的底层差异ReentrantLock构造器里有一个boolean fair参数。传true是公平锁传false默认是非公平锁。它们的区别体现在新来的线程在获取锁时是直接尝试插队还是老实排队。非公平锁新线程到达时先直接试试CAS抢锁抢不到才进入等待队列。公平锁新线程到达时先检查等待队列如果有线程在排队就直接进入队尾只有队列为空时才尝试抢锁。源码级对比更加直白。非公平锁走的是nonfairTryAcquire它上来就是if (c 0) { compareAndSetState(0, acquires) }垄断性地抢一把。公平锁则在加锁前多了一个关键判断// AQS.FairSync 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; } } // ... 重入逻辑同非公平锁 return false; }hasQueuedPredecessors()的逻辑通俗讲就是——“是不是已经没有比我更早来排队的了”如果没有我才能去抢有我就乖乖进队列。4.2 为什么默认是非公平锁非公平锁的性能通常优于公平锁这是业界共识。原因在于非公平锁的“插队”行为减少了线程上下文切换的频率。因为刚被唤醒的线程需要从等待状态切换到运行状态而新来的线程本来就在可运行状态如果允许它直接抢锁它能更快地进入临界区进一步降低锁的释放和竞争之间的空档期。公平锁则每次都要严格执行“排队—唤醒—再抢”的流程唤醒一个线程涉及系统调用和上下文切换在高并发、高竞争的情况下开销非常明显。有个比较形象的说法非公平锁能最大化CPU吞吐量但可能造成“后来者先服务”的不公平现象公平锁则用一部分性能换来了公平性保障。如果你的业务不要求绝对公平默认的非公平锁完全够用。4.3 什么场景才需要公平锁我见过有些团队把所有锁都设成fairtrue理由是“防止线程饥饿”。但实际观察下来很多场景根本没这个必要。公平锁真正有用的场景大概是这样的每个线程持有的锁时间差距很大而且某些关键线程确实需要保证在合理时间范围内获得执行机会。系统对任务执行顺序有强要求比如按请求到达顺序处理任务避免后续任务抢跑。我个人的建议是默认用非公平锁除非你明确感知到“饥饿问题”并且用数据证明了它确实存在再切换公平锁。不要为了理论上的公平牺牲实际的吞吐量。5. ReentrantLock与synchronized的对比与选择5.1 一张表看清核心差异对比维度synchronizedReentrantLock锁获取方式JVM隐式管理进同步块自动获取手动lock()必须主动获取锁释放方式JVM隐式管理出同步块自动释放手动unlock()通常放finally可中断获取不支持支持 lockInterruptibly()超时获取不支持支持 tryLock(timeout)公平策略不公平默认可公平可不公平条件变量仅一个等待集wait/notify一个锁可绑定多个Condition性能Java 8后不断优化低竞争场景足够高竞争场景表现更稳也有额外开销调试友好度通过线程dump即可看到持有锁的栈非阻塞锁可用tryLock避免灾难这个表格不是让你背的而是方便你在实际技术选型时快速定位。核心结论一句话功能需求简单、代码块很小的场景用synchronized需要超时、中断、公平策略、多条件变量等复杂控制的场景用ReentrantLock。5.2 别迷信“ReentrantLock一定比synchronized快”很多Java面试题会问“ReentrantLock和synchronized谁性能好”这类问题在Java 8之后其实越来越没有标准答案。JDK团队对synchronized做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级路径在大多数低竞争场景下synchronized的开销已经非常小。但是在高竞争的场景下ReentrantLock的灵活性带来的优势依然明显。比如你能用tryLock()替代无限阻塞用lockInterruptibly()快速响应取消信号。这些能力不是“性能快”而是“可控性强”。我见过不少团队看到某篇文章说“ReentrantLock性能更好”就把全项目里的synchronized都替换掉结果代码复杂度上去了收益却没体现出来。这里有个非常实用的建议先在压测中把锁竞争度量化出来。如果锁竞争激烈、等待时间长再考虑换ReentrantLock如果压测显示并发量不大保持synchronized反而更清爽。5.3 代码可维护性也是关键指标ReentrantLock虽然功能强大但它把“获取锁”和“释放锁”的责任完全交给了程序员。一个不小心漏掉unlock()线上就是一场灾难。而synchronized由JVM保证即使代码块抛出异常锁也会自动释放心智负担要小很多。所以我在团队里立过一个不成文的规矩能用synchronized解决问题就不要轻易引入ReentrantLock。只有当明确需要它的高级特性时才去用它而且必须写成统一的模板方法/工具类配套单元测试覆盖异常路径。这不是能力问题而是工程稳健性问题。6. 实战案例用ReentrantLock改造一个高并发库存扣减6.1 原始代码的问题很多同学写库存扣减时天然会想到synchronized修饰方法public synchronized boolean deductStock(int productId, int count) { int stock getStock(productId); if (stock count) { return false; } // 模拟耗时的库存更新操作 updateStock(productId, stock - count); return true; }看起来没问题但细想一下synchronized修饰在方法上锁的粒度是整个方法。如果一个下单逻辑里还包了其他耗时操作比如校验、风控、通知那么这把锁会被持有很久其他请求全部得排队。一旦有线程卡在外部调用整个接口的吞吐量就崩了。6.2 用ReentrantLock聚焦“最关键的几步”改造思路是让锁只保护“查库存、扣库存”这段核心逻辑而不是整个方法同时用tryLock控制等待时间避免线程无限期卡住public class StockService { private final ReentrantLock lock new ReentrantLock(); private final MapInteger, Integer stockMap new ConcurrentHashMap(); public boolean deductStock(int productId, int count) { // 使用tryLock最多等待2秒拿不到锁就快速失败 if (!lock.tryLock(2, TimeUnit.SECONDS)) { // 可以记录重试日志也可以直接返回失败 return false; } try { // 只在最关键的几步加锁 Integer stock stockMap.get(productId); if (stock null || stock count) { return false; } stockMap.put(productId, stock - count); return true; } finally { lock.unlock(); } } }这样的好处很明显加锁范围小了锁的竞争程度降低。tryLock(timeout)给了系统超时自愈能力不会让所有请求无限堆积在锁等待上。用finally保证任何情况下锁都会释放。当然这里只是一个单体应用的演示。真正的库存扣减在分布式系统下还得用Redis分布式锁、数据库行锁或者ZooKeeper锁等方式来解决但ReentrantLock的单体优化思路是通用的。6.3 千万别忽略条件变量在真实业务中的价值在复杂的业务编排里往往会遇到“某个资源准备好了再继续执行”的需求。比如一个多阶段任务第一阶段的结果只有一份第二阶段需要等到第一阶段完成才能开始。用ReentrantLockCondition可以精确地管理这种依赖关系ReentrantLock lock new ReentrantLock(); Condition stageOneDone lock.newCondition(); private boolean stageOneFinished false; public void firstStage() { lock.lock(); try { // 执行第一阶段 stageOneFinished true; stageOneDone.signalAll(); // 通知所有等待的线程 } finally { lock.unlock(); } } public void secondStage() throws InterruptedException { lock.lock(); try { while (!stageOneFinished) { stageOneDone.await(); // 等第一阶段完成 } // 执行第二阶段 } finally { lock.unlock(); } }这种“多条件状态标志”的组合在synchronized时代写起来非常别扭但用Condition就很自然。条件变量不仅是一个等待机制更是一种线程间状态同步的抽象。7. 踩坑记录与常见问题排查实录7.1 忘记unlock导致死锁进程直接凉凉我初学ReentrantLock时踩过最痛的坑就是忘记在finally里解锁。当时写了一个定时任务临界区里抛了一个空指针异常unlock()根本没执行到。结果整个JVM里所有需要这把锁的线程全部阻塞应用日志里全是“无响应”最后只能重启服务救场。排查这类问题的第一步是jstack抓线程快照jstack pid thread_dump.txt然后搜索waiting on monitor或者locked关键字看看哪些线程持有锁、哪些线程在等待。如果发现大量线程处于WAITING状态并且都指向同一把锁基本可以断定是锁没有正确释放。这时候要回头检查是不是所有unlock()都在finally里。7.2 lock与unlock不配对造成“幽灵持有”还有一类隐蔽问题是代码里通过一个方法lock()却在另一个方法里unlock()。比如public void run() { lock.lock(); process(); lock.unlock(); }看起来没问题但如果process()内部又调用了lock()同一个锁那么线程就会发生重入。此时一次unlock()并不能真正释放锁因为state还停留在1以上。等到外层继续执行unlock()后锁才真正释放。如果两层之间某个分支提前return了就会造成外层unlock()不执行锁被长期占用。解决方法是保持“加锁和解锁在同一个方法层级”的原则不要跨方法获取和释放锁。实在需要跨方法的要保证所有路径都在finally中正确释放。7.3 把tryLock当成普通锁来用会吃掉并发度有个常见的错误写法lock.tryLock(); try { // 临界区 } finally { lock.unlock(); }tryLock()在抢锁失败时会返回false但代码根本没有检查返回值于是线程没拿到锁就直接进入临界区执行了。两个线程同时执行临界区的后果就是并发安全直接失效数据错乱。正确的写法是必须在拿到true之后才进入临界区if (lock.tryLock()) { try { // 只有拿到锁才到这儿 } finally { lock.unlock(); } } else { // 没拿到锁的逻辑 }别看这是一个细节我review过不少代码这个坑出现的频率高得吓人。本质上是对“非阻塞获取锁”的语义理解不到位。7.4 公平锁导致性能不达预期别慌先量化有人把公平锁切上线后发现吞吐量下降了很多。这不是锁坏了而是公平锁天然要付出上下文切换的代价。排查比较有效的方式是压测里对比fairtrue和fairfalse两套参数同时看吞吐量、平均等待时间、线程活跃数。如果业务里并没有“严格公平”的诉求我建议直接用非公平锁。如果确实需要公平可以考虑在公平锁基础上减少锁持有的时间比如把耗时的非核心逻辑移出临界区。锁粒度越小公平锁带来的抖动也越小。7.5 与synchronized混用时的顺序风险在一个复杂系统里有时候锁会被同时用在synchronized代码块和ReentrantLock临界区里这就涉及“锁的顺序”问题。如果线程A持有ReentrantLock再去争synchronized锁而线程B持有了synchronized锁再去争ReentrantLock双方就会形成循环等待最终死锁。这种跨锁类型的死锁排查起来比单类型锁更隐蔽因为jstack里你能看到两个不同的锁状态。最佳防线是统一锁顺序。所有代码在获取多把锁时都按同一个全局顺序去获取避免交叉。加锁顺序一旦乱掉后面再堆功能迟早出事故。8. 我从实际项目中总结的几条心得写到这里我再分享几个实操层面的体会这些不是从文档里抄来的是真刀真枪跑出来的经验。第一入手ReentrantLock不要急着看源码。先写几个小demo把lock、unlock、tryLock、lockInterruptibly、Condition这些API全跑一遍感受一下它们在多线程下的行为。源码看不懂没关系跑起来的现象你记住了再回头看AQS的代码就通透了。第二压测是检验锁设计是否合理的唯一标准。我见过很多团队把锁代码写得非常优美但一上压测就原形毕露锁竞争严重时吞吐量直接腰斩。建议用JMH或者简单的多线程压测脚本设置不同的线程数比如4、8、16、32去跑你的临界区观察响应时间和吞吐量的变化。只有量化了竞争度你才能决定是该缩小临界区、改用tryLock、还是引入别的并发原语。第三锁是手段不是目的。如果你的业务模型允许我强烈建议你优先考虑无锁方案比如AtomicInteger、ConcurrentHashMap、LongAdder等。它们利用CAS和无同步容器在高并发下往往比锁更轻量。ReentrantLock能解决很多问题但“尽量少用锁”才是并发编程的上策。第四也是最重要的多线程代码上线前一定要review异常路径。单测只测正常流程远远不够你要故意让临界区抛出异常看看锁是否能正常释放你要模拟中断看看lockInterruptibly是否能优雅退出你甚至要模拟死锁看看监控预案能不能捞到线程快照。在这些极端路径上花的时间永远比在故障现场抢救要划算。ReentrantLock是一把很好用的锁但它同样是一把需要谨慎对待的锁。搞清楚它的能力边界、性能代价和坑点把它放到最合适的位置上去才是真正的“深入浅出”。希望我这篇实践总结能让你少踩几个坑也让你在下次面试或者写代码时对这把锁多一分底气。