1. 为什么我们需要单独聊聊 ReentrantLock
做Java并发开发的同学,基本都绕不开锁这个话题。我最早接触线程安全的时候,第一反应就是synchronized,毕竟它简单、顺手,方法上一加就完事。但后来在项目里遇到一个支付对账的场景——一个账户被多个线程同时扣款,synchronized保护下的临界区确实不会出现数据错乱,可一旦某个线程在临界区里卡住了(比如等待外部接口响应),其他线程就全部干等在那里,没有任何退出的余地。这个时候我就意识到,单靠synchronized是不够的。
ReentrantLock就是Java并发包里用来弥补 synchronized不足的重型锁实现。它从JDK 1.5开始提供,底层基于AbstractQueuedSynchronizer(AQS)构建,支持可重入、可中断、可超时、公平/非公平策略选择,还支持多个条件变量(Condition)。简单说,synchronized能做到的它都能做到,synchronized做不到的它也能做到。
这篇文章我不想只停留在用法层面,我会把ReentrantLock的底层设计思路、加锁解锁的完整流程、公平锁和非公平锁的实现差异、条件变量的工作原理都拆开讲一遍,再配上可以直接复制的实战案例和我在项目里踩过的坑。适合正在准备Java面试的人,也适合写业务代码时想深入理解并发机制的开发者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先从synchronized的痛点说起
2.1 synchronized到底有什么局限
先说一句公道话,synchronized在JDK 1.6之后引入了偏向锁、轻量级锁、重量级锁的升级机制,性能上已经不是早年那种“笨重”的锁了。在大多数场景下,用它完全没问题。但它的局限性也很明显,主要集中在以下几点:
- 不可响应中断:线程拿到锁之后,如果临界区里出了问题,其他线程只能一直阻塞等待,没有办法把已经阻塞的线程打断。
- 无法设置超时时间:拿不到锁就永远拿不到,没有“等一会儿就算了”这种选项,这在某些对延迟有要求的场景下很致命。
- 只有一个条件队列:
synchronized配合wait()/notify()用,本质上只有一个等待队列,多个消费者/生产者场景下,线程间协作的粒度很粗,容易产生“假唤醒”或通知丢失的问题。 - 非公平且不可改变:
synchronized是不公平锁,而且你没法让它变成公平的。
这些局限性在复杂的并发场景里会被无限放大。比如一个线程持有一把锁,它的临界区里又在等待另一个资源的释放,这时候别的线程想拿锁就只能干等,如果等待的线程越来越多,系统整体吞吐量就会直线下降。
2.2 ReentrantLock带来的核心能力
ReentrantLock从设计上就针对这些痛点做了加强:
- 可重入:同一个线程可以多次获取同一把锁,而不会自己把自己锁死,内部通过计数器记录重入次数。
- 可中断获取锁:通过
lockInterruptibly()方法,等待锁的线程可以被其他线程打断。 - 支持超时获取锁:通过
tryLock(long timeout, TimeUnit unit),在指定时间内拿不到锁就放弃。 - 公平锁/非公平锁可选:构造方法里传入
boolean fair参数,就能指定锁的公平策略。 - 支持多个条件变量:一个锁可以创建多个
Condition对象,让线程可以按不同的条件等待和唤醒,这一点在做生产者消费者模型时特别有用。
我当时把支付对账场景改成ReentrantLock之后,给获取锁加了一个超时时间,某个线程异常卡住的情况被控制在了几百毫秒内,后续线程不会无限期堆积,系统整体稳定性提升了一个档次。
2.3 选型建议:到底该用哪个
很多人喜欢问“到底用synchronized还是ReentrantLock”。我的建议是:如果你的需求只是一个简单的互斥,用synchronized就够了,它写法简单、不容易出错,而且JVM层面做了大量优化。但如果你的场景涉及到超时控制、中断响应、多个条件队列或者公平性要求,那ReentrantLock就是更合适的选择。不是ReentrantLock一定比 synchronized强,而是它提供了更多精细控制的能力,能不能用好,取决于你对它内部机制的理解程度。
3. 核心设计思路拆解:ReentrantLock是如何工作的
3.1 可重入是怎么实现的
可重入这个名字,字面意思就是“可以重复进入”。ReentrantLock内部通过一个state字段来记录锁的状态和重入次数。当线程第一次获取锁时,state从0变为1;如果同一个线程再次获取该锁,state继续加1;释放锁时state减1,直到state变成0,锁才真正被释放。
这里最核心的一点是:锁要记住当前持有它的线程是谁。ReentrantLock内部用exclusiveOwnerThread字段保存当前持有锁的线程,每次加锁时都会检查当前线程是不是持有线程,如果是,直接让state加1,这就是重入的底层逻辑。
打个比方来说,这就像是你进自己的家门,你有一把钥匙(第一次加锁),进了门之后你又进卧室,不会因为在客厅锁了门就把自己关在门外。state就是你手里钥匙的数量,每进一个房间就多一把,出房间就少一把,全部还完了门才真正锁上。
与之形成对比的是synchronized,JVM也会记录持有锁的线程和锁重入计数,这一点两者是类似的,只不过synchronized的计数不直接暴露给开发者。
3.2 公平锁和非公平锁的取舍
ReentrantLock的构造方法支持传入公平性参数。非公平锁是默认策略,它允许线程“插队”,即新来的线程在锁被释放的瞬间可以直接CAS抢锁,而不需要去排队。这样做的优点是减少了线程切换的开销,因为刚释放锁的线程可能还在CPU上运行,让它可以继续持有锁,整体吞吐量更高。缺点是极端情况下可能出现“饥饿”,某个线程长时间抢不到锁。
公平锁则严格遵循FIFO原则,每个线程都按照到达顺序去排队获取锁。这样可以避免饥饿,但代价是性能下降,因为线程切换更频繁,而且“排队”本身有额外的检查开销。
我在生产环境里实际测试过一个任务分发系统,默认非公平锁每秒能处理的请求数明显高于公平锁,大约高出20%到30%左右。所以如果没有特殊的公平性要求,默认用非公平锁就可以了,公平锁只在那些对任务执行顺序有严格要求的场景下才需要。
3.3 条件变量Condition是干什么的
如果说锁是线程的“交通管制”,那么Condition就是专门用于线程间协作的“信号灯”。synchronized配合wait()/notify()只能在一个隐式的条件队列上工作,而ReentrantLock可以通过`newCondit
