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可以通过newCondition()创建任意多个Condition实例。
每个Condition都拥有自己独立的等待队列。线程可以调用condition.await()进入等待,也可以由其他线程通过condition.signal()或condition.signalAll()唤醒。这种方式比wait()/notify()更精准,不会出现唤醒一个不相关的线程的情况。
举个例子,一个数据同步队列里,有生产线程往队列里放数据,有消费线程从队列里取数据。队列满的时候,生产线程应该等“队列有空位”这个条件;队列空的时候,消费线程应该等“队列有数据”这个条件。如果用synchronized,你只能有一个等待队列,很容易出现“通知了但对方不在等待这个条件”的情况;用ReentrantLock,就可以创建两个Condition,一个表示“非满”,一个表示“非空”,唤醒语义非常明确。
3.4 锁超时与中断响应的设计逻辑
tryLock(long timeout, TimeUnit unit)和lockInterruptibly()是ReentrantLock区别于synchronized的关键能力。
tryLock带超时:在指定时间内尝试获取锁,获取不到就返回false,线程可以去做其他事情,而不是无限阻塞。底层是LockSupport.parkNanos(),在等待超时后自动醒来。lockInterruptibly:在等待锁的过程中,线程可以被Thread.interrupt()打断。被打断时,它会抛出InterruptedException,从而退出阻塞状态。这在某些需要优雅停机的场景下很有用。
这两个能力本质上都是让线程从“无限等待”变成“有限等待”,在高并发环境下是保护系统资源不被耗尽的重要手段。
4. 源码级拆解:ReentrantLock加锁解锁的完整链路
4.1 加锁的入口:lock()方法做了什么
我们先看非公平锁的lock()流程。当调用lock()时,实际上会走这样一个链路:
java复制public void lock() {
sync.lock();
}
sync是ReentrantLock内部的一个Sync对象,它有两个子类:NonfairSync(非公平锁)和FairSync(公平锁)。NonfairSync.lock()的实现非常经典:
java复制final void lock() {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
} else {
acquire(1);
}
}
这里有两步操作:
- CAS直接尝试抢锁:
compareAndSetState(0, 1)的意思是,如果当前state值为0,就把它改为1,表示“锁被占用了”。这个CAS操作是原子性的,如果成功,直接把当前线程设为锁的持有者。这就是非公平锁“插队”的入口。 - 抢锁失败就进入AQS队列排队:如果CAS失败,说明锁已经被别人持有,这时候调用
acquire(1)进入AQS的等待队列。
acquire(1)是AQS的核心方法,它的逻辑可以概括为三步:
java复制public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
selfInterrupt();
}
}
也就是说,先再尝试一次获取锁(tryAcquire),如果还是失败,就把当前线程包装成一个Node节点,加入到同步队列尾部(addWaiter),然后在队列里自旋或挂起(acquireQueued),直到被唤醒并获得锁。
4.2 tryAcquire:非公平锁如何判断锁能不能拿到
NonfairSync中的tryAcquire方法实际调用的是Sync.nonfairTryAcquire(int acquires),核心代码如下:
java复制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) {
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
return false;
}
这段代码其实就是我之前说的“可重入”的实现关键:
- 如果
state == 0,说明锁没被占用,再次用CAS尝试竞争。 - 如果
state != 0,则检查当前线程是否就是持有锁的线程。如果是,state += acquires,也就是重入计数加一。 - 如果当前线程不是持有者,直接返回
false,走排队逻辑。
这里有一个小细节:每次重入都会判断nextc < 0,也就是防止重入次数溢出。虽然正常项目里几乎不可能发生,但源码的严谨性值得学习。
4.3 公平锁和STF队列的差异
公平锁的tryAcquire实现和非公平锁的核心差别只有一行,就是在state == 0时,会先检查队列中是否有前驱节点:
java复制if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
hasQueuedPredecessors()的实现也比较直观,它会判断当前线程是不是同步队列中的第一个等待线程。如果是,返回false,允许参与抢锁;如果不是,返回true,意味着“你前面还有人,别插队”。
java复制public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
这个检查保证了公平锁严格按照先来后到的顺序获取锁。但同样因为每一次加锁都要做这个检查,所以性能上会比非公平锁慢一些。
4.4 AQS同步队列:线程排队等待的核心结构
很多刚接触AQS的人会觉得acquireQueued很神秘,其实它的本质就是一个“排队自旋”的过程。AQS内部维护了一个双向链表队列,每个等待线程被封装成一个Node节点,节点有prev、next指针,还有线程引用和等待状态。
addWaiter的工作就是把当前线程对应的节点加入到队列尾部。这里用了CAS来保证多线程同时入队时不会产生数据竞争。入队之后,acquireQueued会在一个for循环里不断检查:
- 如果当前节点是头节点的下一个节点,就再尝试获取一次锁(
tryAcquire)。为什么只检查头节点的下一个节点?因为AQS里只有头节点后面的节点才有资格抢锁,其他节点只能等待。 - 如果当前节点的前驱状态是
SIGNAL,就把自己挂起,通过LockSupport.park()停止线程调度,进入等待唤醒状态。 - 如果前驱节点被取消(
CANCELLED状态),就跳过它,调整链表结构。
这个过程看起来复杂,但实际上就是“排队 + 叫号”的模型。每个线程在队列里等着,轮到自己的时候被唤醒去尝试获取锁,获取成功就出队,获取失败就继续等。
4.5 释放锁:unlock()和state递减
释放锁的核心逻辑在Sync.tryRelease(int releases)里,相比加锁来说比较简单:
java复制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;
}
这里有两个关键点:
- 首先会校验当前线程是不是锁的持有者,如果不是,直接抛异常。这就是为什么
unlock()必须在持有锁的线程中调用,否则会报IllegalMonitorStateException。 state每次减1,只有减到0时锁才算真正释放,同时把持有线程清空。如果只加了一次锁,unlock()一次就够了;如果重入了多次,就必须unlock()多次,直到state归零。
释放锁之后,AQS会唤醒同步队列中第一个等待的线程(通常叫“后继节点”),通过LockSupport.unpark()让那个线程从park()中恢复,继续尝试获取锁。释放锁的传播过程保证了阻塞中的线程能及时被唤醒,而不是一直沉睡。
4.6 公平锁与非公平锁的加锁流程对比
| 对比维度 | 非公平锁 | 公平锁 |
|---|---|---|
| 获取锁策略 | 新线程可以CAS插队 | 严格按队列顺序获取 |
| 性能 | 相对高,减少线程切换 | 相对低,存在排队检查开销 |
| 饥饿风险 | 存在,极端情况下可能 | 不存在 |
| 适用场景 | 大多数业务场景 | 需要严格公平控制的场景 |
5. 实战拆解:如何用ReentrantLock实现线程安全
5.1 案例一:模拟售票窗口,同一份票池并发扣减
我先写一个最经典的线程安全案例:模拟多个售票窗口同时卖同一批车票。票是一个共享资源,多个线程并发扣减,不用锁就一定会出问题。
java复制import java.util.concurrent.locks.ReentrantLock;
public class TicketPool {
private int tickets;
private final ReentrantLock lock = new ReentrantLock();
public TicketPool(int tickets) {
this.tickets = tickets;
}
public void sell() {
lock.lock();
try {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName() + " 卖出一张票,余票: " + (--tickets));
} else {
System.out.println(Thread.currentThread().getName() + " 票已售罄");
}
} finally {
lock.unlock();
}
}
}
这里有几个要点需要特别说明:
lock()和unlock()要放在try/finally里,而且lock()必须放在try外面的第一行。这样可以避免这样一种极端情况:万一lock()本身抛异常(虽然正常情况不会),锁根本就没拿到,然后finally又会执行unlock(),导致IllegalMonitorStateException。- 临界区的内容应该尽量精简,只放真正需要保护的代码。不要把一个耗时的I/O操作也放进锁里,否则锁的持有时间过长,其他线程会大量阻塞。
如果同样的逻辑用synchronized写,写法上会简单一点,不需要手动释放锁,但表达不了“锁超时”这种更精细的控制。TicketPool这个例子虽然简单,但它是所有并发控制的基础模型。
5.2 案例二:Condition实现生产者消费者模型
生产者消费者是最能体现Condition价值的场景之一。我们用ReentrantLock配合两个条件变量来实现一个容量有限的阻塞队列。
java复制import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class BlockingQueueWithCondition<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public BlockingQueueWithCondition(int capacity) {
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.offer(item);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
T item = queue.poll();
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
这个代码有几个值得深究的细节:
- 为什么用
while而不是if来判断条件?因为Condition.await()可以被“虚假唤醒”,也就是线程明明没有收到signal(),却自己醒了过来。如果用if,醒过来后不会重新检查条件,就可能出现队列已满还在往里放数据的问题。用while循环可以保证线程每次被唤醒后都重新检查条件是否满足,这是并发编程里一条很重要的经验。 - 为什么
await()必须在持有锁的情况下调用?因为await()内部会释放当前持有的锁,然后进入等待队列。如果在没有锁的情况下调用,会抛出IllegalMonitorStateException。这跟synchronized里调用wait()的前提条件是一致的。 signal()和signalAll()的选择:这里我们唤醒的是“对方”,比如生产者在放入数据后,唤醒正在等待notEmpty的消费者。因为同一时间只需要唤醒一个线程去消费,所以用signal()就够了。
5.3 案例三:tryLock超时实现“拿不到锁就放弃”
在业务开发里,经常碰到一个任务在执行的时候,不希望无限期等待锁。举个例子:一个定时任务要更新某个缓存,但如果上一次更新还在进行中(锁被持有),这次更新直接跳过就好,没有必要阻塞等待。这种场景用tryLock非常方便。
java复制import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class CacheUpdater {
private final ReentrantLock lock = new ReentrantLock();
public void updateCache() {
boolean acquired = false;
try {
acquired = lock.tryLock(100, TimeUnit.MILLISECONDS);
if (!acquired) {
System.out.println("获取锁超时,跳过本次更新");
return;
}
System.out.println("开始更新缓存...");
Thread.sleep(200);
System.out.println("缓存更新完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (acquired) {
lock.unlock();
}
}
}
}
这里的重点在于:tryLock是有返回值的,拿到锁返回true,没拿到返回false。所以unlock()不能无条件执行,必须判断是否真的获取到了锁。另外,tryLock抛出的InterruptedException需要正确处理,一般建议在捕获后重新设置中断标志位:Thread.currentThread().interrupt(),这样可以让上层调用者感知到中断状态。
我还见过一种错误的写法,在tryLock()获取失败后直接unlock(),导致抛出IllegalMonitorStateException。这种错误在并发环境下还不太容易复现,一旦出现就很让人头疼。
5.4 案例四:lockInterruptibly实现可中断等待
假设有一个系统需要优雅停机,正在等待锁的线程在停机命令发出后应该被中断并退出,而不是继续阻塞。lockInterruptibly()就是为这种场景准备的。
java复制import java.util.concurrent.locks.ReentrantLock;
public class Service {
private final ReentrantLock lock = new ReentrantLock();
public void execute() throws InterruptedException {
lock.lockInterruptibly();
try {
System.out.println("执行任务");
} finally {
lock.unlock();
}
}
}
当另一个线程调用Thread.interrupt()中断正在lockInterruptibly()等待的线程时,该线程会从阻塞状态唤醒,并抛出InterruptedException。对比一下,如果用普通lock(),中断信号是无法把线程从锁等待中唤醒的。这一点在实际运维中非常关键,尤其是在做线程池优雅下线的时候,如果不让线程可以被中断,停机操作往往会卡很久。
6. 常见问题与排查技巧实录
6.1 死锁:如何快速定位
ReentrantLock解决了很多synchronized的问题,但它并没有消灭死锁。只要存在多个锁交叉获取,就依然可能死锁。最常见的情况是:线程A持有锁1,等待锁2;线程B持有锁2,等待锁1,两个线程互相“僵住”。
定位死锁最快的方式是用jstack命令。在Linux环境里先通过jps找到Java进程的PID,然后执行:
bash复制jstack <PID>
在输出的线程栈信息中,如果发现类似这样的内容:
code复制Found one Java-level deadlock:
"Thread-1":
waiting to lock monitor 0x...
"Thread-0" holds ...
那就说明死锁已经被JVM检测到了。对于ReentrantLock,jstack会显示类似parking to wait for <0x...>的信息,同时AQS的队列状态也会被打印出来。根据线程栈里的代码调用位置,就能快速定位到是哪两把锁交叉了。
我的经验是,死锁排查最关键的不是会用什么命令,而是在写代码的时候就尽量减少嵌套锁。如果确实需要多个锁,尽量保证所有线程获取锁的顺序一致,可以通过tryLock加超时来打破循环等待。
6.2 锁泄漏:unlock没有执行的严重后果
ReentrantLock不像synchronized那样由JVM自动释放锁,如果代码里写了lock()却忘了在finally里unlock(),或者unlock()被提前返回导致没执行,这把锁就永远释放不了了,所有等待该锁的线程都会永久阻塞。
这是一个非常隐蔽的问题,因为它在代码审查阶段很容易被忽略,只有运行到特定异常路径才会暴露。我自己就踩过一次:一个消费消息的线程在临界区里执行数据库操作,结果抛了异常,但异常没有走finally,导致锁没释放。整个消息消费线程池全部卡死,应用表现为“看起来还活着,但完全不处理新消息”。
排查这种问题,可以先用jstack看看哪些线程处于WAITING (parking)状态,再检查谁占用了锁、占用锁的线程是不是卡在某个异常路径上。但从根本上,还是要在代码层面养成习惯:使用ReentrantLock时,lock()之后第一个动作就是写try/finally,把unlock()放在finally里。
6.3 Condition误用的两个坑
Condition虽然好用,但误用的代价很高。我整理了两个最常见的坑。
第一个坑是在持有锁之前调用await()或signal()。Condition的设计要求调用这些方法前必须先持有对应的ReentrantLock,否则会抛出IllegalMonitorStateException。这和synchronized里的wait()/notify()规则完全一致。很多初学者会忽略这一点。
第二个坑是使用signal()而不使用signalAll()导致线程永远不被唤醒。当多个线程因为不同原因在同一个Condition上等待时,如果只signal()一个线程,有可能唤醒了错误的对象,导致“被通知的人不在等这个条件,在等条件的人永远等不到”。在消费者生产者模型里,一般建议条件不明确时就使用signalAll(),明确知道只需要唤醒某个特定线程时才用signal()。
6.4 ReentrantLock和synchronized的性能对比误区
很多面试题或文章喜欢说“ReentrantLock性能比synchronized好”,这个说法在JDK 1.6之前是成立的,但在现代JDK上已经不准确了。synchronized经过偏向锁、轻量级锁、锁膨胀等优化之后,在低竞争场景下性能甚至优于ReentrantLock。
我自己做一个简单压测时发现,在单线程或低并发场景下,synchronized有偏向锁加持,开销非常低;而在高竞争场景下,ReentrantLock的优势也主要不是性能,而是它提供了超时、中断、多条件队列这些能力。所以选型时不要单纯看性能,要看功能需求。
7. 我在实际项目中使用ReentrantLock的一点心得体会
做了这么多年Java并发开发,我的体会是:synchronized和ReentrantLock不是对手,而是互补的工具。默认情况下用synchronized准没错,它简单、可靠、不容易写出花式bug;但当你遇到那些“等不了这么久”“想中途取消”“需要更细粒度的唤醒”这类场景时,ReentrantLock就是那个能帮你从泥潭里爬出来的工具。
最后分享一个小技巧:如果你用ReentrantLock做重入计数,建议在日志里打印getHoldCount()方法的值,它可以告诉你当前线程重入了多少次锁。这在排查死锁和锁泄漏的时候非常有用,尤其当代码里嵌套调用比较复杂时,单看“有没有unlock”还不够,还要确认“每个lock是否都有对应的unlock”,getHoldCount()直接能把这个数字暴露出来,定位效率会高很多。
