1. 从synchronized的痛点说起:为什么还需要ReentrantLock
做Java开发的朋友对synchronized肯定不陌生,我们用它处理并发问题已经很多年了。但有一个场景,不知道你们有没有遇到过:一个方法加了synchronized,结果阻塞在锁上的线程完全不受控制,既不能响应中断,也不能设置等待时间。线程一旦等不到锁,就会一直傻等下去,线上排查问题的时候,看线程dump全是BLOCKED状态,却毫无办法。
更头疼的是,synchronized是隐式获取和释放锁,写代码的时候一旦嵌套层级变多,特别容易忘记在正确的位置释放锁,或者因为某个异常导致锁没有释放,直接造成死锁。我早期做支付系统的时候,就因为这个踩过坑——一个嵌套了三层synchronized的方法,中间某段代码抛了运行时异常,结果锁没释放,整张订单表被锁了一个多小时,线上大量超时报警,那次事故让我对synchronized的心理阴影很大。
ReentrantLock就是来解决这些问题的。ReentrantLock从名字拆开看:Reentrant是可重入的意思,Lock是锁。它位于java.util.concurrent.locks包下,从JDK 1.5开始就是Java并发包里的核心成员,和Atomic系列、ConcurrentHashMap这些一样,是Doug Lea那一套并发框架的重要组件。
它相比synchronized的核心优势,一句话总结:可控性更强。你可以控制锁的获取方式,可以选择公平还是非公平,可以指定等待时间,可以响应中断,还可以用Condition精确唤醒某个等待线程。而synchronized在JDK 1.5时期完全做不到这些(虽然JDK 1.6之后synchronized做了大量锁升级优化,性能上差距在缩小,但API的操作灵活度依然差不少)。
我自己的感受是,synchronized适合那种简单、清晰、加锁范围小的场景,而一旦涉及复杂的并发控制逻辑——比如需要多个条件队列、需要超时控制、需要可中断获取锁——ReentrantLock就是更专业的选择。这篇文章我会从头到尾把ReentrantLock的原理、核心方法、实操案例和坑位都过一遍,尽量让没有并发基础的同学也能看懂,同时让已经有经验的兄弟能校准一些细节。
2. 从state字段说起:ReentrantLock的核心机制拆解
2.1 AQS是什么,ReentrantLock是怎么靠它工作的
要说ReentrantLock,绕不开AbstractQueuedSynchronizer,也就是常说的AQS。这是整个Java并发包的地基,ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock这些工具类的底层都是基于AQS实现的。你甚至可以基于AQS自己写一个同步工具,它能处理绝大部分通用逻辑,比如线程的阻塞和唤醒、等待队列的管理。
AQS的核心设计很简单:一个volatile int state字段加上一个FIFO的CLH变体等待队列。state就是锁的状态标志,这个字段是很多并发工具共用的"状态寄存器":
- 在
ReentrantLock里,state表示锁的重入次数,也正是标题热搜里提到的那个关键点:0表示未被任何线程持有,大于0表示当前持有锁的线程重入了多少次。 - 在
Semaphore里,state表示剩余的许可数量。 - 在
CountDownLatch里,state表示还需要countDown多少次。
AQS通过CAS(Compare And Swap)对state进行原子修改,配合LockSupport的park和unpark实现线程的阻塞与唤醒。线程拿不到锁时,会被封装成Node节点挂到CLH队列尾部,然后通过LockSupport.park把自己挂起;持锁线程释放锁后,会唤醒队列头部的等待线程。
这里有个细节值得展开理解:AQS的等待队列并不是普通意义上的容器,它是一个双向链表。每个节点里保存着线程引用、等待状态等字段。当一个线程尝试获取锁失败时,它会被包装成节点排队,入队的过程通过CAS保证并发安全。队列头部的节点是"哨兵节点",本身不持有线程,只是用来标记队列起点,这样简化了入队和出队的处理逻辑。
2.2 可重入到底是怎么实现的
可重入,通俗说就是:同一个线程可以多次获得同一把锁。比如一个递归方法,或者一个方法调用另一个方法,两个方法都加了同一把锁,如果锁不可重入,那第二次进入时自己把自己锁死了,直接死锁。
ReentrantLock实现可重入的逻辑,核心就在NonfairSync.lock和Sync.nonfairTryAcquire这两个方法里。我们看几个关键片段:
protected final boolean tryAcquire(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; }注意这里的逻辑:
- 第一次判断
c == 0,说明当前无线程持有锁,走CAS尝试占有锁,成功后将当前线程记录为独占锁的拥有者。 - 第二次判断
current == getExclusiveOwnerThread(),说明当前线程就是已经持有锁的线程,这时不需要CAS,直接把state加1,然后更新state。因为锁本来就是自己持有的,不存在竞争,所以直接赋值是安全的。
释放锁时对应的逻辑也很有意思,每次unlock会把state减1,而不是直接归零。只有当state减到0时,才真正释放锁,唤醒等待队列中的下一个线程。这就是为什么你写了多少次lock(),就必须写多少次unlock(),少一次锁都放不干净。
这里有一个经常被问到的面试细节:tryAcquire为什么加锁用CAS,而重入直接赋值?因为加锁时可能有多个线程同时竞争,必须用CAS保证原子性;而重入时独占者就是当前线程,没有竞争,直接改state是安全的。
2.3 state字段的语义在多工具间的区别
很多人学ReentrantLock时会混各种并发工具的状态语义,这里专门整理一张对比表帮大家理顺。同一个state字段,在不同工具里含义完全不一样:
| 工具类 | state字段的含义 | 获取/释放方式 |
|---|---|---|
| ReentrantLock | 锁重入次数,0=未锁定 | tryAcquire CAS加1,tryRelease减1 |
| Semaphore | 剩余许可数 | tryAcquireShared CAS减permits,release加permits |
| CountDownLatch | 还需等待的计数 | countDown减1,为0时放行所有等待线程 |
| ReentrantReadWriteLock | 高16位读锁计数,低16位写锁重入次数 | 读写各自按位操作 |
把state理解成一把锁的"计数表",是理解AQS家族的钥匙。刚开始学的时候,总以为这些都是不同的机制,其实底层全是同一套骨架在支撑,只是state的加减规则不一样而已。
3. ReentrantLock的API详解与使用场景分析
3.1 最基础的使用姿势:lock与unlock
先看最标准的用法,这是每个接触ReentrantLock的人都必须刻进DNA的写法:
ReentrantLock lock = new ReentrantLock(); public void doSomething() { lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); } }有几点必须强记:
lock()方法会阻塞当前线程,直到获取到锁。unlock()必须放在finally块里,这是铁律,无论业务代码是否抛异常,锁都要释放。不写在finally里的写法都是给自己埋雷。lock()和unlock()必须配对,重入多少次就释放多少次,不能多放也不能少放。
这个最基础的模式虽然简单,但你去看生产环境的代码,依然能看到有人把unlock()写在try块里面,或者写在外面但没包finally,这些都是极其危险的。我处理线上故障的时候,死在锁没释放上的案例,比死在业务逻辑bug上的还多。
3.2 tryLock:拿不到锁就干别的,别傻等
lock()的问题在于它会一直阻塞。如果持锁线程因为某种原因迟迟不释放锁(比如远程调用超时),其他线程就会被卡死。生产中更推荐用带超时的tryLock:
if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 拿到锁,执行定时任务 System.out.println("任务开始执行..."); } finally { lock.unlock(); } } else { // 3秒都没拿到锁,直接降级处理 System.out.println("获取锁超时,本次任务跳过"); }tryLock()有两个重载:
tryLock():立即尝试获取锁,拿不到就返回false,不等待。tryLock(long timeout, TimeUnit unit):在指定时间内等待获取锁,期间可以响应中断。
这个API非常适合做分布式任务调度、定时任务执行时的防重入控制。比如我们有个定时任务系统,多个实例部署在同一台机器上(同一个JVM内),为了避免任务重复执行,加锁时就用tryLock(0, TimeUnit.SECONDS),拿到锁就执行,拿不到就说明别人在跑,直接跳过本次。
还有一个坑要提醒:tryLock(long timeout, TimeUnit unit)是可中断的。线程在等待获取锁的过程中,如果被interrupt(),会抛出InterruptedException,这时候锁是没拿到的,所以千万不要在finally里调用unlock(),否则会抛IllegalMonitorStateException——因为你根本没持有锁。
3.3 lockInterruptibly:比synchronized多出来的杀手锏
lockInterruptibly()是可中断获取锁的方法。当线程调用了lockInterruptibly()之后,它有两种方式结束等待:
- 获取到锁,正常返回。
- 在等待过程中被其他线程中断,抛出
InterruptedException。此时线程可以捕获异常,做出相应处理。
我们之前遇到的一个典型场景是:有一个批量处理线程,它要去处理一个大集合,如果用户主动取消任务,我们希望这个线程能从阻塞中脱离出来,而不是一直卡在排队等锁上。
public void processTask() { try { lock.lockInterruptibly(); try { // 执行批量任务 doBatchProcess(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println("任务被取消,中断信号已响应"); } }有个细节要注意:lockInterruptibly()中断不只发生在等待队列里,即使当前线程已经获取到锁,也可能在执行业务的过程中收到中断信号。所以处理完业务之后,建议恢复一下中断状态(调用Thread.currentThread().interrupt()),让上层调用方感知到中断发生过。
3.4 三个用于监控锁状态的方法
ReentrantLock还提供了一些监控方法,排查问题时非常有用:
| 方法 | 作用 | 适用场景 |
|---|---|---|
isLocked() | 判断当前锁是否被某个线程持有 | 判断锁状态,注意它无法区分持锁线程是谁 |
isHeldByCurrentThread() | 当前线程是否持有锁 | 排查重入时锁的归属情况,防止重复加锁 |
getQueueLength() | 返回正在等待获取锁的线程估计数 | 监控锁竞争激烈程度 |
hasQueuedThreads() | 是否有线程在等待锁 | 判断队列是否为空 |
isFair() | 判断锁是否为公平锁 | 确认锁的公平性配置 |
这里强调一下isHeldByCurrentThread(),它非常适合Debug场景。你在一个方法里不确定自己是否加了锁,或者不确定锁是否被当前线程持有,加一行判断即可。另外在开发"可重入但不允许跨线程释放"的框架时,这个方法是必备工具。
4. 公平锁与非公平锁:性能与公平如何权衡
4.1 两种模式的行为差异
ReentrantLock的构造器很有意思,它接收一个boolean fair参数:
new ReentrantLock():默认非公平锁。new ReentrantLock(true):公平锁。
它们的核心差异在tryAcquire的实现里:
非公平锁在lock()之前会先做一次CAS抢锁操作,如果碰巧锁刚好被释放,那么这个新来的线程可以直接抢到锁,不用管队列里有没有其他线程在排队。
公平锁则严格遵循FIFO,每次获取锁之前都要检查等待队列中是否有线程在排队。如果队列前面已经有别的线程在等了,当前线程就不允许抢,必须乖乖排到队尾。
我写段伪代码帮大家理解:
// 非公平锁lock() final void lock() { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); } else { acquire(1); } } // 公平锁lock()(简化) final void lock() { acquire(1); } // 公平锁tryAcquire里的关键判断 protected final boolean tryAcquire(int acquires) { // 多了这个判断:队列中有等待者就不抢 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { // 抢锁成功 } }这个!hasQueuedPredecessors()就是公平锁的灵魂。它检查当前线程前面是否还有排队的线程,如果有,则获取失败,进入排队;如果没有,才允许CAS尝试获取锁。
4.2 非公平锁为什么是默认选择
非公平锁有一个特点:后续线程可能插队抢占锁。这个特性导致了两个结果:
- 好处:减少了线程上下文切换的开销。因为刚释放锁的线程很可能很快又要拿锁,新来的线程直接拿到锁,省掉了From阻塞状态恢复的时间。
- 坏处:可能造成"饥饿"问题,某些线程长期等不到锁。
那为什么JDK还是默认用非公平锁?因为实际测试表明,非公平锁的吞吐量通常比公平锁高很多。在竞争激烈的场景下,非公平锁可以避免线程反复被阻塞和唤醒,让CPU利用率更高。我们日常大多数业务场景,其实并不需要严格的公平性,只要任务能执行完毕即可。我做过压测,高并发下非公平锁比公平锁吞吐量能高出5-20倍,这个性能差距在某些场景下是决定性的。
那公平锁呢?它适合对任务执行顺序有严格要求的场景。比如某个信号量系统,要按用户请求到达的先后顺序分配资源,这时必须公平锁,否则先到的请求可能一直被后来的抢跑。
4.3 一个简单的公平性对比实验
为了让大家直观感受两者的差异,我写了个简单的测试方法:
public static void testLock(ReentrantLock lock, String lockName) { long start = System.currentTimeMillis(); for (int i = 0; i < 5; i++) { int taskId = i; new Thread(() -> { lock.lock(); try { System.out.println(lockName + " 任务" + taskId + " 获取到锁"); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } }).start(); } }当锁存在竞争时,用非公平锁运行,你可能会看到任务4抢在任务0前面执行;而公平锁则严格按照任务0、1、2、3、4的顺序执行。这里的核心观察点:公平锁的打印顺序一定是0-1-2-3-4,非公平锁则可能各种乱序,因为线程启动和抢锁的时间有微小错位。
我想强调的是,选择公平锁还是非公平锁,要结合业务场景来判断。如果你没明确理由需要公平性,就选默认的非公平锁,它的性能表现对大多数场景更友好。需要公平时,再显式创建公平锁。
5. 高级用法:基于Condition实现线程间精准协作
5.1 Condition和wait/notify的区别在哪里
Condition是ReentrantLock的"条件队列",对应synchronized里的Object.wait()和Object.notify()。但Condition远比wait/notify灵活:
synchronized的等待唤醒机制是单条件队列,一个锁只有一个等待队列,notify()随机唤醒一个线程,notifyAll()唤醒所有线程。Condition支持多条件队列,也就是一个锁可以创建多个Condition对象,每个对象维护独立的等待队列。这样你可以做到更精细的线程调度,精确唤醒某一类线程。
从代码上理解更直观。一个锁可以创建多个条件:
ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition();假设有一个仓库,生产者往仓库里放货,消费者从仓库里取货。用synchronized的写法,只能wait和notifyAll,每次唤醒所有线程,让它们自己争夺锁去检查条件;用ReentrantLock配合两个条件变量,生产者在仓库满时等待notFull,消费者在仓库空时等待notEmpty,生产者只唤醒消费者,消费者只唤醒生产者,避免了无谓的竞争和唤醒。
5.2 生产消费者完整实例:阻塞队列实战
我写一个基于ReentrantLock和Condition实现的有界阻塞队列,这是经典的生产消费者模型。这个例子在面试中经常被问到,在工作中也很有参考价值:
import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class MyBlockingQueue<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 MyBlockingQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lockInterruptibly(); try { while (queue.size() == capacity) { // 队列满了,生产者等待 notFull.await(); } queue.offer(item); System.out.println(Thread.currentThread().getName() + " 生产: " + item); // 唤醒一个消费者 notEmpty.signal(); } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lockInterruptibly(); try { while (queue.isEmpty()) { // 队列空了,消费者等待 notEmpty.await(); } T item = queue.poll(); System.out.println(Thread.currentThread().getName() + " 消费: " + item); // 唤醒一个生产者 notFull.signal(); return item; } finally { lock.unlock(); } } }几个关键点需要反复咀嚼:
await()必须在持有锁的情况下调用,否则会抛IllegalMonitorStateException。这和wait()必须持有锁才能调用是同一个道理。await()会释放当前线程持有的锁,然后线程进入等待状态。当被signal()唤醒后,它并不是立即继续执行,而是要先重新竞争锁,拿到锁之后才从await()位置继续往下执行。while循环判断条件,不能用if。为什么?因为await()被唤醒后,可能出现"伪唤醒"(spurious wakeup),Java官方文档也提到这种可能性。即便没有伪唤醒,多个消费者被唤醒后,只有一个能抢到锁,等它消费完队列又空了,下一个消费者才拿到锁,此时如果用的是if,它就会直接去queue.poll(),结果拿到null。用while条件会在每次唤醒后重新检查条件,确认真的满足才继续。
5.3 signal和signalAll与await的配合细节
signal()唤醒的是当前Condition等待队列中的一个线程,signalAll()唤醒的是所有等待线程。
什么时候用signal(),什么时候用signalAll()?有一个经验法则:如果唤醒一个线程后,它一定能满足继续执行的条件,就可以用signal();如果不确定,或者可能多个线程需要同时被唤醒,就用signalAll()。
拿上面生产者消费者的例子来说,生产一个商品就应该只唤醒一个消费者,因为一个商品只够一个消费者消费。但如果用的是多个消费者竞争的模型,或者signal()唤醒的那个线程可能还是无法满足条件(比如队列又满了),那时用signalAll()会更安全。
还有一点必须牢记:await()和signal()的顺序不能颠倒。如果先调用signal()再调用await(),那signal()唤醒的队列里没有等待线程,这个信号就"丢"了,之后这个线程去await()时永远没人唤醒它,直接死等。写生产消费者代码时,要把"唤醒操作"放在"放下资源之后、释放锁之前"这个位置,确保等待者能被及时唤醒。
5.4 Condition的awaitNanos和超时控制
Condition除了基本的await(),还有几个带超时的方法:
// 等待指定时间,返回剩余等待时间 long awaitNanos(long nanosTimeout); // 等待到指定的绝对时间(毫秒) boolean awaitUntil(Date deadline); // 等价于awaitNanos(TimeUnit.MILLISECONDS.toNanos(time)) boolean await(long time, TimeUnit unit);我在写一个带超时控制的资源池时用过awaitNanos。思路是:线程从连接池里获取连接时,最多等500毫秒,超时就直接返回失败,不一直阻塞:
public Connection getConnection() { lock.lock(); try { long remaining = TimeUnit.MILLISECONDS.toNanos(500); while (pool.isEmpty()) { try { remaining = hasConnection.awaitNanos(remaining); if (remaining <= 0) { throw new TimeoutException("获取连接超时"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } } return pool.poll(); } finally { lock.unlock(); } }注意这个awaitNanos的返回值是剩余时间,它可能因为"伪唤醒"提前返回,所以要放到while循环里,用返回值更新remaining,直到remaining小于等于0才认为超时。这一点是最容易写错的,很多人都直接写一次awaitNanos,不处理返回值,超时控制就失去了准确性。
6. ReentrantLock与synchronized的正面PK:如何选择
6.1 功能与灵活性全面对比
很多初学者容易纠结该用synchronized还是ReentrantLock,我这里从实际开发的角度做一个直接对比:
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 锁获取方式 | 隐式,进入同步代码块自动获取 | 显式,需要调用lock() |
| 锁释放方式 | 隐式,退出同步代码块自动释放 | 需要手动调用unlock() |
| 是否可中断 | 否,阻塞时无法响应中断 | 是,lockInterruptibly() |
| 是否支持超时 | 否 | 是,tryLock(timeout) |
| 公平性 | 非公平 | 支持公平和非公平 |
| 条件变量 | 单条件,wait/notify | 多条件,Condition |
| 锁是否可重入 | 是 | 是 |
| 性能 | JDK1.6后做的锁升级,竞争不激烈时性能优 | 竞争激烈时吞吐量依然可观 |
从这个表能看出来,ReentrantLock在灵活性上全面碾压synchronized。但synchronized也有它的独特优势:写法极简、不会忘释放锁、JVM层面通过monitor实现、与synchronized语义结合的JIT优化(偏向锁、轻量级锁)让它竞争不激烈时性能极好。
一个很有意思的细节:JDK 1.6之后,synchronized经过锁升级优化(无锁→偏向锁→轻量级锁→重量级锁),在很多低竞争场景下性能并不比ReentrantLock差,甚至更好。所以早期"一定要用ReentrantLock,因为synchronized慢"的说法,在今天已经不成立了。
6.2 什么场景选synchronized,什么场景选ReentrantLock
我的选择标准,分享给大家参考:
用synchronized的场景:
- 方法级或代码块的简单互斥,不需要超时、中断机制。
- 代码追求极简,避免因为忘记
unlock()导致死锁。 - 低竞争场景,利用JVM的偏向锁、轻量级锁优化。
- 只需要一个条件队列,
wait/notify够用。
用ReentrantLock的场景:
- 需要限时等待获取锁,不能让线程无限期阻塞(比如外部接口调用前加锁,规定最多等2秒)。
- 需要响应中断,支持任务取消。
- 需要公平锁保证任务按顺序执行。
- 需要多个
Condition条件队列,精准唤醒某一类线程(生产消费者模型)。 - 需要查询锁状态,比如
isLocked()、getQueueLength()。
这里说个实际案例:我们之前有个订单号生成器,要求同一秒内的订单号递增且不重复。最初用synchronized,一切正常。后来有个下游服务频繁持有锁超过500毫秒,其他线程全部堵死,出现了大量的线程堆积。排查后换成ReentrantLock的tryLock(500, TimeUnit.MILLISECONDS),超时直接走备用生成策略,故障面立即缩小,线程堆积问题也就消失了。
6.3 源码级别:state字段在两个锁中的不同语义
最后从源码角度看一个本质区别。synchronized是基于monitor对象的,也就是每个Object都有一个Monitor关联,加锁就是争用这个Monitor的_owner字段;它并没有一个"重入次数计数器"暴露出来。但JVM在实现时,通过_recursions字段记录重入次数。
ReentrantLock则是完全基于AQS的state字段,这个字段是Java层面的、可见的、可控的。也正因如此,你才能在tryAcquire时看到state + 1、在tryRelease时看到state - 1,一切都有迹可循。
关于state和重入次数,有两点值得记忆:
- 重入次数是有上限的。因为
state是int类型,理论上最多重入Integer.MAX_VALUE次,超过会抛Error("Maximum lock count exceeded")。虽然正常业务到不了这个量级,但这个溢出判断在源码里是有的。 - state不是简单的"是否锁定"标志。它需要区分"哪个线程持有"以及"持有几次"。
getExclusiveOwnerThread()方法就是用来记录当前持有锁的线程的。
7. 实战中遇到的坑以及排查技巧
7.1 高频问题:unlock调用次数不匹配
ReentrantLock最大的坑就是锁释放和获取次数不匹配。常见错误有三种:
- 只调用了一次
lock(),但在finally里无条件调用了两次unlock()。 - 在分支里
lock(),但unlock()没有覆盖所有退出路径。 - 使用
tryLock()时,没有判断返回值就直接unlock()。
这三种都会导致IllegalMonitorStateException,因为当前线程并未持有锁却尝试释放锁。我做代码审查时,一眼就能扫出这种问题,但自己写的时候也容易犯,所以写锁相关代码时,保持"一个lock对应一个unlock"的对称结构很重要。
这里给一个安全的写法模板:
/** * 安全加锁模板 * 只有获取锁成功,才进入try块;不成功时走else分支 */ if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 业务处理 } finally { lock.unlock(); } } else { // 获取锁失败的处理 }7.2 死锁问题的检测与预防
用ReentrantLock时需要自己管理锁的获取和释放,这增加了死锁的风险。AQS的等待队列里,死锁表现为多个线程互相等待对方持有的锁。
预防死锁的几个原则:
- 避免嵌套锁。如果非要嵌套,必须保证所有线程获取锁的顺序一致。
- 使用带超时的获取锁。
tryLock在超时后会返回false,可以让线程放弃当前锁,重试或者释放已持有的锁。 - 锁的粒度尽量小。能锁一个方法就不要锁一个类,能锁一个对象就不要锁所有对象。
另外,线上排查死锁时,用jstack导线程快照是最快的办法。ReentrantLock的等待线程在dump里会显示在park状态,并标注等待的锁对象以及持有者。注意看Found one Java-level deadlock这个关键字,它会直接告诉你循环等待的路径。
7.3 Thread.interrupt的响应范围与处理技巧
lockInterruptibly()和tryLock(timeout)这两个方法在等待过程中可以响应中断,但lock()不行。很多人以为lock()也响应中断,结果写了catch (InterruptedException e)放在lock()外面,却发现根本没走到catch,线程还是被阻塞住了。记住:无参lock()是不响应中断的,它会忽略中断标志继续等待。
响应中断之后还有一件容易被忽略的事:捕获了InterruptedException之后,要重新设置中断标志,比如调用Thread.currentThread().interrupt()。因为InterruptedException被抛出时,中断标志会被清除,如果不恢复,上层代码就感知不到线程曾经被中断过。这是我多年排查问题时学到的经验,虽然是小细节,但对任务取消机制影响很大。
7.4 锁泄漏与线程池结合的注意点
把ReentrantLock用在线程池里要非常小心。如果线程池中的任务使用了锁且没有正确释放,那么下次这个线程再执行其他任务时,可能还持有上一把锁,导致严重的并发问题。确保锁的释放不受任务执行路径影响,再强调一遍,finally块加unlock()是最可靠的。
另外,当线程池的任务被拒绝(RejectedExecutionException)时,如果任务已经加了锁但没释放,也会导致锁泄漏。所以在提交任务前先获取锁的写法,比在任务内部加锁更安全:
// 比较推荐:在提交任务前加锁 if (lock.tryLock()) { try { executor.submit(() -> { doWork(); }); } finally { lock.unlock(); } }当然这个设计取决于你锁的粒度,有时候锁必须包着整个任务的执行过程,这时候就要在任务内部保证锁一定被释放。
8. ReentrantLock的后续扩展:ReadWriteLock和StampedLock
8.1 ReentrantReadWriteLock:读写分离的适用场景
提到ReentrantLock,不得不提它的大哥ReentrantReadWriteLock。如果你的场景是"读多写少",用ReentrantLock把所有读写操作都互斥,性能非常浪费;读锁和读锁之间是兼容的,多个线程可以同时持有读锁,只有写锁和任何锁互斥。
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); rwLock.readLock().lock(); try { // 读操作,可以并发执行 } finally { rwLock.readLock().unlock(); }在缓存场景中,这个锁非常实用。读操作并发执行不需要等待彼此,写操作才需要独占。值得注意的是,ReentrantReadWriteLock也支持公平性设置,它的state字段被分成了高低16位,高16位是读锁的计数,低16位是写锁的重入次数,这个设计相当巧妙,学有余力的同学可以去翻源码看看。
8.2 StampedLock:乐观读的进一步优化
Java 8引入的StampedLock更激进,它提供了一种乐观读机制,读操作全程不需要加锁,只在真正需要判断是否发生写操作时使用validate验证。如果读的过程中没有写操作发生,性能极高。
但要泼盆冷水,StampedLock的API设计比较复杂,而且它不支持可重入,使用门槛比ReentrantLock高很多。我自己只在极个别的性能瓶颈场景用过,普通业务代码里使用ReentrantLock就足够了。对于大多数开发者,先把ReentrantLock吃透,比贪多嚼不烂要务实得多。
9. 我的一点实际经验总结
写了这么多,最后分享一点我个人的使用心得。
ReentrantLock的精髓,并不在于它比synchronized功能多,而在于它把"锁"从一个隐式的语言关键字,变成了一个你可以控制、观察、设计的对象。这个转变带来了巨大的灵活性,但也意味着使用者必须更加自律——你必须自己管理锁的获取和释放,亲自处理中断和超时的逻辑,稍不注意就各种坑。
对于团队而言,我建议定一个简单粗暴的规范:默认使用synchronized,除非你明确需要ReentrantLock的某个特性。不是因为它不好,而是因为synchronized的容错性更好,写起来也不容易出错。ReentrantLock适合那些"必须拿到锁,但又不想无限等下去"的场景,这才是它的主场。
就我个人的实操体验,ReentrantLock在日常开发中最高频的使用方式其实是两个:一是配合tryLock做并发任务防重,二是配合Condition做生产者消费者模型。把这两个场景吃透,配合源码里state字段的理解,面试和实战基本都不会被难住。如果感觉自己对state和AQS的理解还不够深,建议先去看看ReentrantLock的源码,一行一行地跟一遍加锁和释放的全过程,多跟几遍,对这些并发工具的理解会上一个台阶。