引言
在单体Java应用中,并发编程中的死锁是一个相当棘手的问题。本文将深入探讨死锁问题的本质、常见场景、危害以及有效的规避与解决方案,帮助你构建更健壮的并发程序。
死锁问题的本质
哲学家就餐问题是解释死锁的经典模型。假设有五位哲学家和五根筷子,每位哲学家需要同时获得左右两边的筷子才能进餐。试想一种极端情况:所有哲学家都拿起了左边的筷子,同时等待右边的筷子被释放。由于没有人会放下已持有的筷子,所有人都将无限期等待,这就形成了死锁。
这个例子清晰地揭示了死锁产生的四个必要条件:
- 互斥:一个资源(如锁)每次只能被一个线程持有。
- 请求与保持:线程在持有至少一个资源的同时,还在等待获取其他线程持有的资源。
- 不可剥夺:线程已获得的资源在未使用完之前,不能被强行抢占。
- 循环等待:存在一个线程等待链,其中每个线程都在等待下一个线程所占有的资源。
映射到哲学家就餐问题上:
- 每根筷子(资源)只能被一位哲学家(线程)使用。
- 哲学家持有一根筷子后,在获得另一根筷子前不会放下。
- 其他哲学家不能强行拿走他人手中的筷子。
- 五位哲学家彼此等待,形成了一个循环等待的环路。
与某些数据库系统(具备死锁检测与超时机制)不同,JVM自身并不提供强大的死锁自动恢复能力。一旦线程陷入死锁,就可能永久阻塞,导致:
- 关键业务线程僵死,整个进程的特定业务流程停滞。
- 系统吞吐量下降,严重时可能导致服务部分或全部不可用。
通常,解决死锁的唯一方法是重启应用,并期望同样的问题不再发生。
死锁引发的危害
饥饿问题
死锁线程会长期占用CPU时间片,可能导致其他线程得不到执行机会,引发线程饥饿。例如:
- 因死锁而阻塞的线程恰好具有较高优先级,可能长时间占用CPU,导致低优先级线程无法获得执行时间片。
- 某个持有锁的线程因陷入无限循环或执行耗时极长的任务而无法释放锁,导致所有依赖该锁的线程长时间等待。
在Java中,虽然可以通过Thread.setPriority()设置线程优先级,但这仅是给操作系统的调度建议,实际影响甚微。因此,由优先级不当引发的死锁进而导致饥饿的概率不高。通常建议,除非有明确需求,否则不要轻易调整线程优先级。
响应性下降
对于计算密集型的后台任务,如果使用并发容器频繁写入热点数据,可能会阻塞并发读操作,导致读操作响应时间变长。为了保障前端应用或GUI的流畅响应,可以考虑适当降低后台任务的线程优先级,或采用分段锁等技术来分散并发压力。
活锁问题
活锁是另一种形式的活跃性问题。线程并未被阻塞,却因不断重复相同的失败操作而无法推进工作。例如,一个线程将处理失败的任务重新放回队列头部并立即重试,导致该失败任务持续占据执行权,后面的任务永远得不到执行。
解决活锁的常见方法是引入随机性。例如,在Redis Sentinel的Leader选举机制中,节点在主观下线后会随机等待一段时间再发起投票,以此降低同时竞选的概率,提高一次选举成功的几率。同样,我们可以为失败的任务设计随机退避的重试策略,在随机延迟后再将其重新放入队列。
void sentinelTimer(void) {
// 检查是否进入Tilt模式(如时钟异常)
sentinelCheckTiltCondition();
// 处理所有监听的Redis实例
sentinelHandleDictOfRedisInstances(sentinel.masters);
// ... 其他逻辑
// 引入随机性调整执行频率,避免多个哨兵同时行动
server.hz = REDIS_DEFAULT_HZ + rand() % REDIS_DEFAULT_HZ;
}
详解不同的死锁案例与解决方案
锁顺序不一致导致的死锁
这是最经典的死锁案例,根本原因是两个线程获取锁的顺序相反,各自持有一把锁后,相互等待对方释放另一把锁,形成循环依赖。
假设存在两把锁:leftLock和rightLock。线程A执行leftRight()方法,先获取leftLock再获取rightLock;线程B执行rightLeft()方法,顺序相反。死锁流程如下:
- 线程A获取
leftLock。
- 线程B获取
rightLock。
- 线程A尝试获取
rightLock,发现被B持有,进入等待。
- 线程B尝试获取
leftLock,发现被A持有,进入等待。
- 双方陷入永久等待。
对应代码如下:
private static final Object leftLock = new Object();
private static final Object rightLock = new Object();
// 线程A:先左后右
public static void leftRight() {
synchronized (leftLock) {
synchronized (rightLock) {
System.out.println("线程A上锁成功");
}
}
}
// 线程B:先右后左
public static void rightLeft() {
synchronized (rightLock) {
synchronized (leftLock) {
System.out.println("线程B上锁成功");
}
}
}
测试用例:
Thread t1 = new Thread(() -> leftRight());
Thread t2 = new Thread(() -> rightLeft());
t1.start();
t2.start();
运行后,两个线程将陷入僵持。使用jstack等工具可以定位到死锁的线程和锁信息。
解决方案非常简单:统一所有线程获取锁的顺序。让线程A和线程B都按照相同的顺序(例如,先leftLock后rightLock)来竞争锁,即可打破循环等待条件。
动态顺序死锁
考虑一个转账方法,为了保证原子性,它需要同时锁住转出账户和转入账户:
public static void transfer(Account from, Account to, int amount) {
synchronized (from) {
synchronized (to) {
from.setMoney(from.getMoney() - amount);
to.setMoney(to.getMoney() + amount);
}
}
}
表面看来没有问题,但考虑以下并发场景:
- 线程1:调用
transfer(账户A, 账户B, 100) // A向B转账
- 线程2:调用
transfer(账户B, 账户A, 200) // B向A转账
- 线程1锁住A,尝试锁B。
- 线程2锁住B,尝试锁A。
这又构成了一个循环等待的死锁环路。
解决方案是定义一个固定的锁排序规则。例如,根据对象的hashCode或某个唯一ID来决定获取锁的顺序,确保所有线程都遵循同一顺序。
// 加时锁,用于hashCode相等时的竞争
private static final Object tieLock = new Object();
public static void transfer(Account from, Account to, int amount) {
int fromHash = System.identityHashCode(from);
int toHash = System.identityHashCode(to);
if (fromHash < toHash) {
synchronized (from) {
synchronized (to) {
doTransfer(from, to, amount);
}
}
} else if (fromHash > toHash) {
synchronized (to) {
synchronized (from) {
doTransfer(from, to, amount);
}
}
} else {
// 哈希冲突时,使用额外的锁来保证顺序
synchronized (tieLock) {
synchronized (from) {
synchronized (to) {
doTransfer(from, to, amount);
}
}
}
}
}
private static void doTransfer(Account from, Account to, int amount) {
from.setMoney(from.getMoney() - amount);
to.setMoney(to.getMoney() + amount);
}
协作对象间的嵌套死锁与开放调用
这类死锁更加隐蔽,锁并非在同一个方法内获取,而是在持有当前对象锁的情况下,去调用另一个对象的方法(该方法也会尝试获取其自身锁)。
例如,有两个互相调用的服务类,它们都需要在调用完成后对内部计数器进行原子累加:
class AService {
private int count;
public synchronized void aFunc(BService b) { // 1. 获取AService锁
b.func(this); // 2. 尝试获取BService锁
count++; // 3. 操作共享资源
}
public synchronized void func(BService b) { }
}
class BService {
private int count;
public synchronized void bFunc(AService a) { // 1. 获取BService锁
a.func(this); // 2. 尝试获取AService锁
count++; // 3. 操作共享资源
}
public synchronized void func(AService a) { }
}
并发调用aService.aFunc(bService)和bService.bFunc(aService)时,同样会形成死锁环路。
问题的根源在于“锁的嵌套包含”:一个方法在持有自身锁的范围内,去调用需要获取其他锁的代码,形成了锁A -> 锁B的包含关系。当多个这样的包含关系交叉时,死锁就产生了。
解决方案是采用“开放调用”:缩小同步代码块的范围,仅在直接操作共享资源时才加锁,避免在持有锁的情况下调用外部方法。
class AService {
private int count;
public void aFunc(BService b) { // 不再在方法级别加锁
b.func(this); // 开放调用
synchronized (this) { // 仅对共享资源操作加锁
count++;
}
}
public synchronized void func(BService b) { }
}
class BService {
private int count;
public void bFunc(AService a) {
a.func(this); // 开放调用
synchronized (this) {
count++;
}
}
public synchronized void func(AService a) { }
}
改造后,调用链变为并行获取锁的关系,打破了循环等待。但需要注意,开放调用可能会将原本的原子操作拆分为非原子操作,需要根据业务场景谨慎评估。
死锁的诊断与工具推荐
当怀疑系统发生死锁时,可以遵循以下步骤进行排查:
- 定位阻塞:使用监控工具(如
jstack、JConsole、VisualVM)或运维/DevOps中常用的Arthas等,查看线程堆栈,定位处于BLOCKED状态的线程及它们等待的锁。
- 梳理链路:根据堆栈信息,找出所有竞争同一组锁的线程和对应的代码函数,绘制出可能的调用链与锁依赖关系。
- 推理复现:基于代码逻辑和调用链,推理出导致死锁的具体并发执行时序,并尝试在测试环境复现。
- 修复验证:根据上述案例的解决思路(统一顺序、排序锁、开放调用等)进行代码修复,并通过压力测试验证问题是否解决。
掌握Java并发问题的诊断技巧,是每一位后端开发者必备的能力。熟练使用这些工具能帮助你快速定位并发瓶颈与故障根源。