Java 实例 – 死锁及解决方法(建议收藏)
💡一则或许对你有用的小广告
欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...
,点击查看项目介绍 ;- 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;
截止目前, 星球 内专栏累计输出 82w+ 字,讲解图 3441+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 2900+ 小伙伴加入学习 ,欢迎点击围观
在多线程编程中,死锁是一个令人头疼的问题,它可能导致程序无响应、资源浪费甚至崩溃。对于 Java 开发者而言,理解死锁的成因、识别死锁的场景,并掌握有效的解决方法,是提升代码健壮性和系统性能的关键技能。本文将通过实例解析死锁的核心概念,结合代码示例和解决策略,帮助读者深入理解这一复杂问题,并在实际开发中规避潜在风险。
死锁的定义与核心问题
什么是死锁?
死锁(Deadlock)是指两个或多个线程因互相等待对方释放资源而陷入无限期阻塞的状态。想象两个人同时进入一条狭窄的走廊,彼此都想让对方先通过,结果双方都站在原地等待,无法继续前进——这就是死锁的直观比喻。
在 Java 中,死锁通常发生在多个线程尝试以不同顺序获取共享资源(如对象锁、文件锁等)时。例如,线程 A 已持有资源 X 并等待资源 Y,而线程 B 持有资源 Y 并等待资源 X,此时双方将永远无法继续执行。
死锁的四大必要条件
死锁的发生需要同时满足以下四个条件,理解这些条件有助于识别和预防死锁:
必要条件 | 描述 |
---|---|
互斥 | 资源不能共享,只能由一个线程独占使用。 |
持有并等待 | 线程在等待新资源时,不释放已持有的资源。 |
不可抢占 | 资源必须由持有线程主动释放,其他线程不能强制夺取。 |
循环等待 | 存在线程链,每个线程等待下一个线程持有的资源,最终形成环路。 |
形象比喻:这四个条件如同四根支柱,共同支撑起死锁的“房屋”。只要破坏其中一根支柱,房屋就会倒塌,死锁得以避免。
死锁的典型场景与代码实例
场景一:两个线程互相等待对方的锁
示例代码
public class DeadlockExample {
private static final Object resourceA = new Object();
private static final Object resourceB = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (resourceA) {
System.out.println("Thread 1 holds Resource A");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resourceB) {
System.out.println("Thread 1 holds both A and B");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (resourceB) {
System.out.println("Thread 2 holds Resource B");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resourceA) {
System.out.println("Thread 2 holds both B and A");
}
}
});
thread1.start();
thread2.start();
}
}
分析与结果
- 线程 1 先获取
resourceA
,随后尝试获取resourceB
。 - 线程 2 先获取
resourceB
,随后尝试获取resourceA
。 - 若两个线程同时运行,双方将因互相等待而陷入死锁,程序停止响应。
场景二:资源竞争与嵌套锁
示例代码
class Account {
private double balance;
public synchronized void transfer(Account target, double amount) {
while (balance < amount) {
try {
wait(); // 等待余额充足
} catch (InterruptedException e) {
e.printStackTrace();
}
}
balance -= amount;
target.deposit(amount);
notifyAll(); // 通知其他线程
}
private synchronized void deposit(double amount) {
balance += amount;
}
}
// 使用时可能出现的死锁:
Account a = new Account();
Account b = new Account();
new Thread(() -> a.transfer(b, 100)).start();
new Thread(() -> b.transfer(a, 200)).start();
分析与结果
- 若两个账户同时向对方转账,且转账顺序不同,可能导致双方持有自己的锁并等待对方的锁,从而形成死锁。
解决死锁的四大策略
策略一:避免死锁的条件
具体方法
- 破坏“互斥”条件:对于可共享的资源,采用无锁或非独占式访问(如原子操作)。
- 破坏“持有并等待”条件:要求线程在启动时一次性申请所有所需资源,若无法满足则放弃。
- 破坏“不可抢占”条件:允许强制释放资源(如操作系统通过信号量机制)。
- 破坏“循环等待”条件:为资源分配全局顺序,要求线程按固定顺序获取资源(如先获取 A 再获取 B)。
示例代码(按固定顺序获取锁)
// 修改后的 DeadlockExample,按 A→B 的顺序获取锁
synchronized (resourceA) {
synchronized (resourceB) {
// 执行操作
}
}
// 所有线程统一遵循此顺序,可避免循环等待
策略二:检测与恢复
实现思路
- 检测死锁:通过监控线程的锁状态,判断是否存在循环等待。
- 恢复资源:强制中断线程或回滚资源分配。
Java 标准库未提供直接的死锁检测 API,但可通过以下方式辅助排查:
jstack
工具:生成线程堆栈信息,分析阻塞链。- 代码审查:检查资源获取顺序和锁的嵌套逻辑。
策略三:超时机制与非阻塞锁
示例代码(使用 tryLock
的超时策略)
import java.util.concurrent.locks.ReentrantLock;
ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();
// 尝试获取锁,最多等待 1 秒
if (lockA.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lockB.tryLock(1, TimeUnit.SECONDS)) {
try {
// 执行操作
} finally {
lockB.unlock();
}
}
} finally {
lockA.unlock();
}
}
优势
- 超时控制:避免无限期等待,线程可在超时后释放资源并重试。
- 非阻塞尝试:通过
tryLock()
避免线程因等待而阻塞。
策略四:减少锁的粒度与嵌套
具体实践
- 缩小锁范围:将锁的粒度限制在最小必要范围内,减少资源竞争。
- 避免嵌套锁:若必须嵌套,确保顺序一致或使用更高层的锁管理(如
Lock
接口)。
示例代码(减少锁嵌套)
// 原始代码(存在嵌套锁)
synchronized (resourceA) {
synchronized (resourceB) {
// 业务逻辑
}
}
// 优化后:使用单个复合锁
Object compositeLock = new Object();
synchronized (compositeLock) {
// 同时访问 A 和 B 的操作
}
实战案例:银行转账系统的死锁修复
问题背景
某银行系统的转账功能频繁出现死锁,导致用户无法完成交易。经排查,代码逻辑如下:
class BankAccount {
private double balance;
private final Object lock = new Object();
public void transfer(BankAccount target, double amount) {
synchronized (this.lock) {
if (balance < amount) {
System.out.println("余额不足,等待...");
try {
this.lock.wait(); // 等待余额补充
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
balance -= amount;
synchronized (target.lock) {
target.balance += amount;
target.lock.notifyAll(); // 通知目标账户
}
this.lock.notifyAll(); // 通知其他等待线程
}
}
}
死锁原因分析
- 循环等待:当两个账户 A 和 B 相互转账时,A 持有自身的锁并等待 B 的锁,而 B 同样持有自身的锁并等待 A 的锁。
- 嵌套锁顺序不一致:不同线程获取锁的顺序可能不同,导致环路形成。
解决方案
- 引入全局锁顺序:要求所有转账操作按账户 ID 的升序获取锁。
- 重构代码逻辑:
class BankAccount {
private double balance;
private final long accountId; // 唯一标识
public void transfer(BankAccount target, double amount) {
BankAccount first = this.accountId < target.accountId ? this : target;
BankAccount second = this == first ? target : this;
synchronized (first) {
synchronized (second) {
if (balance < amount) {
try {
first.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
balance -= amount;
second.balance += amount;
first.notifyAll();
second.notifyAll();
}
}
}
}
修复效果
通过强制所有线程按账户 ID 的顺序获取锁,破坏了“循环等待”条件,死锁问题得以解决。
结论
死锁是多线程编程中一个复杂但可控的问题。通过理解其成因、掌握检测方法和预防策略,开发者可以显著降低程序的潜在风险。本文通过实例演示了死锁的发生场景,并提供了具体的解决方法,包括调整资源获取顺序、使用超时锁、减少锁嵌套等。
在实际开发中,建议遵循以下原则:
- 代码审查:定期检查多线程代码的锁逻辑,确保顺序一致。
- 工具辅助:利用
jstack
或性能分析工具监控线程状态。 - 设计优先:在架构阶段就考虑资源竞争问题,避免复杂锁结构。
通过理论与实践的结合,开发者可以将死锁从“程序杀手”转化为提升代码健壮性的契机。