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 实例 – 死锁及解决方法,不仅是技术问题,更是一种编程思维的训练。当你能优雅地处理并发问题时,你的代码才真正走向成熟。
