ReentrantLock 与死锁:从可重入原理到死锁复现,一篇彻底讲透!
引言
在 Java 并发里,synchronized 和 ReentrantLock 是两大基础同步工具。
-
面试喜欢问:“synchronized 和 ReentrantLock 有什么区别?”
-
实战中,大家又经常被问:“为什么会死锁?怎么避免?”
-
再加上一个高频词:“可重入锁(Reentrant)” —— 更容易搞混。
可重入解决的是“自己锁自己”的问题,死锁解决的是“互相等待”的问题。
这篇文章就围绕一个主线,把这两个概念一次性讲透:
ReentrantLock 与死锁:从可重入原理到死锁复现,再到如何用 ReentrantLock 辅助避免死锁。
一、什么叫“可重入锁”?先把这个概念掰开讲
1.1 可重入的直观理解
可重入(Reentrant)= 同一个线程可以多次获取同一把锁,而不会被自己卡死。
也就是说:
- 第一次
lock()成功 - 在“锁内部”又调用了需要同一把锁的方法,再次
lock() - 不会阻塞自己,而是简单地把“持有计数 +1”
- 只有
unlock()到计数为 0,锁才真正释放
典型场景:方法嵌套调用。
public class ReentrantDemo {
public synchronized void a() {
System.out.println("in a");
b(); // a() 里面再调用 b()
}
public synchronized void b() {
System.out.println("in b");
}
}
两个 synchronized 都是 synchronized(this):
a()拿到this的锁a()内部调用b(),b()再次请求this的锁- 如果锁不可重入,这里会:自己等自己释放锁 → 自我死锁
- 但由于 JVM 的 Monitor 锁是可重入的,同一线程可以重复进入
可重入解决的是:“同一个线程在锁内再次请求同一把锁会不会卡死自己”。
1.2 如果锁不可重入,会怎么死?
假设 synchronized 是不可重入锁(只是假设):
public synchronized void a() {
System.out.println("in a");
b();
}
public synchronized void b() {
System.out.println("in b");
}
时间线:
-
线程 T1 调用
a()-
获取
this的锁,进入a()
-
-
a()中调用b()-
b()是synchronized(this),需要再次获取this的锁
-
-
锁已经被 T1 占用,且“不可重入”
-
T1 被阻塞在
b()的入口
-
-
但 T1 卡在这里,就没法执行完
a()-
自然也释放不了
this的锁
-
-
于是:T1 等 T1,自我死锁
这就是“不可重入锁”最致命的问题:同一个线程可以把自己锁死。
小结
- 可重入锁解决的是“同一个线程重复加锁”的问题
synchronized本身就是可重入锁ReentrantLock在名字里直接把这个特性写出来
下一节,我们正式看下 ReentrantLock 的可重入特性和基本用法。
二、ReentrantLock:名字里就写着“可重入”
ReentrantLock 在 java.util.concurrent.locks 包中,是一个“显式锁类”。
2.1 基础用法模板(必须写对)
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void incr() {
lock.lock(); // 加锁
try {
count++;
} finally {
lock.unlock(); // 解锁必须写在 finally
}
}
public int get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
关键点:
- 一定要记住:
lock()/unlock()要成对出现,unlock()放在finally里。 - 否则,一旦中间抛出异常,没解锁 → 永久死锁。
2.2 ReentrantLock 的可重入示例
public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void a() {
lock.lock();
try {
System.out.println("in a");
b(); // 内部再调用 b()
} finally {
lock.unlock();
}
}
public void b() {
lock.lock();
try {
System.out.println("in b");
} finally {
lock.unlock();
}
}
}
执行过程(同一线程):
a()lock()成功,计数从 0 → 1a()调用b(),再次lock(),计数从 1 → 2b()执行完unlock(),计数从 2 → 1(锁还没真正释放)a()执行完unlock(),计数从 1 → 0(锁真正释放)
一句话:
同一线程可以多次
lock()同一把ReentrantLock,内部维护一个“持有计数”,unlock()到 0 才释放。
小结
synchronized和ReentrantLock都是可重入锁- 可重入 = 避免自己锁死自己
- 这和我们接下来要讲的“死锁”不是一回事,死锁是“互相等待”
下面进入本文重点:死锁到底是怎么发生的?ReentrantLock 又能怎么帮忙?
三、死锁到底是怎么“死”的?
先强调一点:
可重入锁 ≠ 不会死锁。
可重入锁只保证“同一个线程不会被自己锁死”,但多个线程之间还是可能死锁。
真正的死锁,通常是这种形态:
- 至少两个线程
- 至少两把锁
- 每个线程都“手里拿着一把锁,再去等另一把锁”
- 锁的获取顺序“刚好反着来”
- 最后谁也等不到谁 → 永久互相等待 → 死锁
3.1 最经典的 synchronized 死锁例子
public class DeadlockDemo {
private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (LOCK_A) {
System.out.println("T1 拿到了 LOCK_A");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("T1 想要 LOCK_B...");
synchronized (LOCK_B) {
System.out.println("T1 拿到了 LOCK_B");
}
}
}, "T1");
Thread t2 = new Thread(() -> {
synchronized (LOCK_B) {
System.out.println("T2 拿到了 LOCK_B");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("T2 想要 LOCK_A...");
synchronized (LOCK_A) {
System.out.println("T2 拿到了 LOCK_A");
}
}
}, "T2");
t1.start();
t2.start();
}
}
输出大概是:
T1 拿到了 LOCK_A
T2 拿到了 LOCK_B
T1 想要 LOCK_B...
T2 想要 LOCK_A...
(然后程序卡死,不再继续)
3.2 死锁的过程拆开看
-
T1:先拿到 A,然后想拿 B → 等待 B
-
T2:先拿到 B,然后想拿 A → 等待 A
此时状态是:
T1 拿着 A 等 B
T2 拿着 B 等 A
谁也不会先释放自己手中的锁,于是就这样互相等,一直僵持下去。
这就是死锁的本质:循环等待。
小结
-
可重入:解决“同线程重复加锁”的问题;
-
死锁:多个线程拿多把锁,加锁顺序不一致,互相等待导致的“环形依赖”。
可重入 ≠ 防死锁。
这两个概念要分开。
接下来,我们看 ReentrantLock 的高级能力如何在“避免死锁”这件事上帮上忙。
四、ReentrantLock 提供了哪些“防死锁的武器”?
synchronized 的问题是:
一旦进入等待锁的状态,就只能干等,既不能中断、也不能限时、也不能快速失败。
而 ReentrantLock 提供了更多可控的方式:
tryLock():尝试加锁,拿不到直接返回 false,不死等tryLock(timeout, unit):带超时的尝试lockInterruptibly():等待锁时可以被中断- 公平锁:避免某些线程长期得不到锁(虽然和死锁不是一回事,但有助于避免“饥饿”)
4.1 用 tryLock 改造“死锁”示例
我们试着用 ReentrantLock 做同样的事情,但加上 tryLock:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class DeadlockAvoidDemo {
private static final ReentrantLock LOCK_A = new ReentrantLock();
private static final ReentrantLock LOCK_B = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
try {
if (LOCK_A.tryLock(500, TimeUnit.MILLISECONDS)) {
System.out.println("T1 拿到了 LOCK_A");
try {
Thread.sleep(100);
System.out.println("T1 尝试获取 LOCK_B...");
if (LOCK_B.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
System.out.println("T1 拿到了 LOCK_B");
} finally {
LOCK_B.unlock();
}
} else {
System.out.println("T1 获取 LOCK_B 失败,放弃本次操作");
}
} finally {
LOCK_A.unlock();
}
} else {
System.out.println("T1 获取 LOCK_A 失败");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "T1");
Thread t2 = new Thread(() -> {
try {
if (LOCK_B.tryLock(500, TimeUnit.MILLISECONDS)) {
System.out.println("T2 拿到了 LOCK_B");
try {
Thread.sleep(100);
System.out.println("T2 尝试获取 LOCK_A...");
if (LOCK_A.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
System.out.println("T2 拿到了 LOCK_A");
} finally {
LOCK_A.unlock();
}
} else {
System.out.println("T2 获取 LOCK_A 失败,放弃本次操作");
}
} finally {
LOCK_B.unlock();
}
} else {
System.out.println("T2 获取 LOCK_B 失败");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "T2");
t1.start();
t2.start();
}
}
这段代码的意义是:
-
如果在指定时间内拿不到第二把锁,就放弃本次操作,并且释放自己手里的锁
-
不会出现“双方永远等下去”的情况
这是 ReentrantLock 在“辅助避免死锁”上的一个非常重要的能力:
我们可以通过业务逻辑 + tryLock 来打破“循环等待”。
当然,这不是自动防死锁,而是:
你有了工具,可以在设计时主动规避死锁。
小结
synchronized:要么拿到锁、要么一直等ReentrantLock:可以tryLock(),可以设置超时,可以在等待时被中断- 这些能力让你有机会在“快要死锁”的情况下主动退出,避免彻底卡死系统
五、实战中如何减少死锁风险?(和用什么锁同样重要)
无论是 synchronized 还是 ReentrantLock,只要有“多锁 + 多线程”,就存在死锁可能。
要减少死锁风险,更多靠设计习惯。
5.1 统一加锁顺序(最有效策略)
规则:所有线程获取多把锁时,顺序必须一致。
比如约定:所有地方都先锁 A,再锁 B:
// 正确写法示例
synchronized (A) {
synchronized (B) {
// ...
}
}
只要所有线程都一致这么写,就不会出现:
线程 1:A → B
线程 2:B → A
也就不会产生“循环等待”。
这是业界防止死锁的第一原则。
5.2 尽量减少锁的颗粒度 & 数量
锁越多、嵌套越深,死锁风险越高。
典型的工程实践:
- 避免在锁内调用外部不受控的方法(如第三方库回调)
- 避免在锁内做 I/O、网络访问、数据库操作(服务很慢,锁就一直占着)
5.3 用 ReentrantLock 的 tryLock 做兜底
核心思路:
- 如果拿不到第二把锁,就放弃本次操作,释放已有的锁,进行降级处理/重试
- 不要无休止等待
可以用于:
- 高并发下的资源竞争
- 避免因为极端时序导致系统整体挂死
小结
解决死锁,核心是设计,ReentrantLock 只是给你提供了更多“可选的策略接口”。
六、总结:ReentrantLock 与死锁的真正关系
最后用几句话把全文收一下:
-
可重入锁(Reentrant)解决的是:同一个线程在锁内部再次请求同一把锁会不会被自己卡死的问题。
synchronized和ReentrantLock都是可重入锁- 内部用计数方式:每次加锁 +1,解锁 -1,减到 0 才真正释放
-
死锁(Deadlock)解决的是:多个线程之间互相等待对方的锁,造成循环等待的问题。
-
典型模式:
-
- T1 拿锁 A 等锁 B
- T2 拿锁 B 等锁 A
-
这和“可重入”完全是两个维度
-
ReentrantLock 不会自动帮你“防死锁”,但它提供了 tryLock / 可中断 / 超时 等机制,让你有机会在业务层设计“避免死锁”的策略。
-
写并发代码的原则是:
- 简单场景:
synchronized足够 - 高并发 + 高可用需求:用
ReentrantLock+ 合理的锁设计(统一加锁顺序、尽量少锁、加 tryLock 等兜底)
更多推荐
所有评论(0)