身边总有人问我,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,绕不开AQS(AbstractQueuedSynchronizer,抽象队列同步器)。这个名字听着唬人,但它的核心思想其实很朴素:用一个状态位加一个线程等待队列,完成对多线程竞争资源的管理。
打个不太严谨但很好懂的比方:把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 什么场景才需要公平锁?
我见过有些团队把所有锁都设成fair=true,理由是“防止线程饥饿”。但实际观察下来,很多场景根本没这个必要。公平锁真正有用的场景大概是这样的:
- 每个线程持有的锁时间差距很大,而且某些关键线程确实需要保证在合理时间范围内获得执行机会。
- 系统对任务执行顺序有强要求,比如按请求到达顺序处理任务,避免后续任务抢跑。
我个人的建议是:默认用非公平锁,除非你明确感知到“饥饿问题”,并且用数据证明了它确实存在,再切换公平锁。不要为了理论上的公平牺牲实际的吞吐量。
5. ReentrantLock与synchronized的对比与选择
5.1 一张表看清核心差异
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 锁获取方式 | 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 Map<Integer, 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 千万别忽略条件变量在真实业务中的价值
在复杂的业务编排里,往往会遇到“某个资源准备好了再继续执行”的需求。比如一个多阶段任务:第一阶段的结果只有一份,第二阶段需要等到第一阶段完成才能开始。用ReentrantLock+Condition可以精确地管理这种依赖关系:
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 公平锁导致性能不达预期,别慌,先量化
有人把公平锁切上线后,发现吞吐量下降了很多。这不是锁坏了,而是公平锁天然要付出上下文切换的代价。排查比较有效的方式是:压测里对比fair=true和fair=false两套参数,同时看吞吐量、平均等待时间、线程活跃数。
如果业务里并没有“严格公平”的诉求,我建议直接用非公平锁。如果确实需要公平,可以考虑在公平锁基础上减少锁持有的时间,比如把耗时的非核心逻辑移出临界区。锁粒度越小,公平锁带来的抖动也越小。
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是一把很好用的锁,但它同样是一把需要谨慎对待的锁。搞清楚它的能力边界、性能代价和坑点,把它放到最合适的位置上去,才是真正的“深入浅出”。希望我这篇实践总结,能让你少踩几个坑,也让你在下次面试或者写代码时,对这把锁多一分底气。