Java 实例 – 死锁及解决方法
在多线程编程的世界里,死锁就像一场“互相等待”的僵局。两个或多个线程因为争夺资源而陷入无限等待,谁也不肯先放手,最终整个程序陷入停滞。这就像两个人同时伸手去拿同一双筷子,又都等着对方先放,结果谁也吃不上饭。
对于 Java 开发者来说,死锁虽然不常见,但一旦发生,排查起来非常头疼。今天我们就通过几个真实可运行的 Java 实例,带你彻底搞懂死锁的成因、识别方法和解决策略。本文适合初学者和中级开发者,代码均经过实际测试,可直接复制运行。
死锁的四大必要条件
要理解死锁,必须先掌握它发生的四个基本条件。这四个条件就像四把锁,必须同时满足,死锁才会出现。
互斥条件
一个资源一次只能被一个线程占用。比如打印机只能被一个任务打印,不能同时被两个线程使用。在 Java 中,synchronized 关键字就实现了这种互斥。
占有并等待
线程在持有资源的同时,还请求其他资源。例如,线程 A 拿到了锁 1,然后去申请锁 2,但此时锁 2 被线程 B 占用。
不可抢占
已经获得的资源不能被强制释放。线程必须主动释放资源,不能被系统强行夺走。这就像你握着手机,别人不能直接抢走。
循环等待
存在一个线程的循环等待链。比如线程 A 等待线程 B 的资源,线程 B 等待线程 C 的资源,线程 C 又等待线程 A 的资源,形成闭环。
⚠️ 只要破坏其中任意一个条件,死锁就不会发生。
一个典型的死锁实例
让我们通过一个具体例子来观察死锁是如何发生的。
public class DeadlockExample {
// 定义两个共享资源对象
private static final Object resource1 = new Object();
private static final Object resource2 = new Object();
// 线程1:先获取 resource1,再获取 resource2
public static class Thread1 implements Runnable {
@Override
public void run() {
synchronized (resource1) {
System.out.println("Thread 1 拿到了 resource1");
try {
Thread.sleep(1000); // 模拟处理时间
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试获取 resource2,但此时被 Thread2 占用
synchronized (resource2) {
System.out.println("Thread 1 拿到了 resource2");
}
}
}
}
// 线程2:先获取 resource2,再获取 resource1
public static class Thread2 implements Runnable {
@Override
public void run() {
synchronized (resource2) {
System.out.println("Thread 2 拿到了 resource2");
try {
Thread.sleep(1000); // 模拟处理时间
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试获取 resource1,但此时被 Thread1 占用
synchronized (resource1) {
System.out.println("Thread 2 拿到了 resource1");
}
}
}
}
public static void main(String[] args) {
// 启动两个线程
new Thread(new Thread1()).start();
new Thread(new Thread2()).start();
}
}
代码解析
resource1和resource2是两个独立的锁对象。Thread1先获取resource1,再尝试获取resource2。Thread2先获取resource2,再尝试获取resource1。- 由于线程执行速度可能不同,
Thread1拿到resource1后,Thread2可能已经拿到了resource2。 - 此时
Thread1等待resource2,Thread2等待resource1,两者互相等待,形成死锁。
运行这段代码后,程序会永久卡住,没有任何输出,CPU 占用率可能飙升,但程序不会结束。
如何检测死锁?
Java 提供了强大的工具来检测死锁。我们可以通过 jstack 命令查看线程堆栈信息。
使用 jstack 检测死锁
-
编译并运行上面的死锁程序:
javac DeadlockExample.java java DeadlockExample -
在另一个终端中查找进程 ID:
jps输出类似:
12345 DeadlockExample -
使用 jstack 查看线程状态:
jstack 12345
在输出中你会看到类似:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x0000000002a0a000 (object 0x000000076b000000, a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x0000000002a0a010 (object 0x000000076b000010, a java.lang.Object),
which is held by "Thread-1"
这说明:线程 1 等待线程 0 持有的锁,而线程 0 又在等待线程 1 持有的锁,正是典型的死锁。
避免死锁的四种策略
策略一:统一加锁顺序
最有效的方法是强制所有线程以相同的顺序获取锁。这样就不会形成循环等待。
public class FixedDeadlock {
private static final Object resource1 = new Object();
private static final Object resource2 = new Object();
public static class Thread1 implements Runnable {
@Override
public void run() {
// 统一按 resource1 -> resource2 的顺序获取锁
synchronized (resource1) {
System.out.println("Thread 1 拿到了 resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resource2) {
System.out.println("Thread 1 拿到了 resource2");
}
}
}
}
public static class Thread2 implements Runnable {
@Override
public void run() {
// 也按相同的顺序获取锁
synchronized (resource1) {
System.out.println("Thread 2 拿到了 resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resource2) {
System.out.println("Thread 2 拿到了 resource2");
}
}
}
}
public static void main(String[] args) {
new Thread(new Thread1()).start();
new Thread(new Thread2()).start();
}
}
✅ 两个线程都先获取
resource1,再获取resource2,不会形成循环等待。
策略二:使用超时机制
Java 提供了 tryLock() 方法(配合 ReentrantLock),可以设置超时时间,避免无限等待。
import java.util.concurrent.locks.ReentrantLock;
public class TimeoutLock {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public static class Worker implements Runnable {
private final String name;
private final ReentrantLock first;
private final ReentrantLock second;
public Worker(String name, ReentrantLock first, ReentrantLock second) {
this.name = name;
this.first = first;
this.second = second;
}
@Override
public void run() {
// 尝试获取第一个锁,最多等待 2 秒
if (first.tryLock(2, java.util.concurrent.TimeUnit.SECONDS)) {
try {
System.out.println(name + " 拿到了第一把锁");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试获取第二把锁
if (second.tryLock(2, java.util.concurrent.TimeUnit.SECONDS)) {
try {
System.out.println(name + " 拿到了第二把锁");
} finally {
second.unlock();
}
} else {
System.out.println(name + " 获取第二把锁超时,放弃");
}
} finally {
first.unlock();
}
} else {
System.out.println(name + " 获取第一把锁超时,放弃");
}
}
}
public static void main(String[] args) {
// 线程1:先拿 lock1,再拿 lock2
new Thread(new Worker("Thread-1", lock1, lock2)).start();
// 线程2:先拿 lock2,再拿 lock1
new Thread(new Worker("Thread-2", lock2, lock1)).start();
}
}
策略三:避免嵌套锁
尽量减少锁的嵌套使用。如果必须使用,确保逻辑清晰,避免在持有锁时执行耗时操作。
策略四:使用工具类检测
在开发阶段,可以引入 ThreadMXBean 监控线程状态。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
public class ThreadMonitor {
public static void checkDeadlock() {
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadBean.findDeadlockedThreads();
if (deadlockedThreads != null && deadlockedThreads.length > 0) {
System.out.println("检测到死锁!共有 " + deadlockedThreads.length + " 个线程陷入死锁");
for (long id : deadlockedThreads) {
System.out.println("死锁线程 ID: " + id);
}
} else {
System.out.println("当前无死锁");
}
}
public static void main(String[] args) {
// 模拟运行一段时间后检测
try {
Thread.sleep(5000);
checkDeadlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
实际开发中的建议
- 不要在 synchronized 块中调用外部方法,尤其是可能再次获取锁的方法。
- 锁的粒度要尽量小,避免长时间持有锁。
- 使用 ReentrantLock 代替 synchronized,可获得更灵活的控制(如超时、公平性等)。
- 在日志中记录锁的获取与释放,便于排查问题。
总结
死锁是多线程编程中的经典难题,但只要掌握其成因和应对策略,就能有效避免。我们通过一个真实案例展示了死锁的形成过程,学习了如何使用 jstack 检测死锁,并提供了四种实用的解决方案:统一加锁顺序、使用超时机制、避免嵌套锁和引入监控工具。
记住:预防胜于治疗。在设计多线程程序时,从一开始就考虑锁的使用顺序和资源管理,远比事后排查要高效得多。
Java 实例 – 死锁及解决方法,不仅是技术问题,更是一种编程思维的训练。当你能优雅地处理并发问题时,你的代码才真正走向成熟。