死锁与活锁排查:死锁产生的四个必要条件、死锁检测与恢复、活锁与饥饿现象分析
说到并发编程里的死锁,我脑子里立刻浮现出几年前的一个深夜。
那会儿线上一个支付服务突然卡死,所有线程都挂住了。我登录上去一看,四个线程互相等着对方释放锁,谁也不让谁。说白了,就是典型的死锁。那次事故让我彻底明白了——死锁不是理论问题,是真能让你半夜爬起来修Bug的。
死锁产生的四个必要条件
死锁要发生,必须同时满足四个条件。缺一个都不行。我个人习惯把这四个条件记成「互请不环」,好记又形象。
| 条件 | 说明 | 我的理解 |
|---|---|---|
| 互斥条件 | 资源一次只能被一个线程占用 | 说白了就是锁只能一个人拿 |
| 请求与保持 | 线程拿着一个锁,还去请求另一个锁 | 手里有碗,还想要锅 |
| 不剥夺条件 | 资源不能被强制拿走 | 除非你主动释放,别人抢不走 |
| 循环等待 | 形成一条等待环路 | 你等我,我等他,他等你 |
我在项目中遇到过最典型的场景:线程A持有锁1,请求锁2;线程B持有锁2,请求锁1。嗯,这就是教科书级别的死锁。
核心要点:四个条件必须同时成立才会死锁。只要破坏其中一个,死锁就解了。
死锁检测与恢复
死锁检测这事儿,我建议分两步走:运行时检测和事后分析。
运行时检测
Java里可以用 ThreadMXBean 来检测死锁。我一般这么写:
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = bean.findDeadlockedThreads();
if (deadlockedThreads != null) {
// 有死锁,打印线程信息
ThreadInfo[] infos = bean.getThreadInfo(deadlockedThreads);
for (ThreadInfo info : infos) {
System.out.println("死锁线程: " + info.getThreadName());
}
}
这段代码我放在监控脚本里,每5秒跑一次。一旦检测到死锁,就触发告警。
恢复策略
检测到死锁后怎么恢复?我常用的方法有三种:
- 强制终止线程:杀掉其中一个线程,释放它的锁。代价是可能丢失数据。
- 回滚事务:让线程回滚到安全点,释放所有资源。适合数据库场景。
- 资源抢占:强制剥夺某个线程的资源。嗯,这个实现起来比较麻烦。
注意:我曾经在线上用过强制终止线程的方式,结果导致一笔订单状态不一致。后来我改成了先记录日志,再人工介入处理。自动化恢复要谨慎。
活锁与饥饿现象分析
活锁和饥饿,这两个问题比死锁更隐蔽。你想想看,线程明明在运行,但就是干不了正事。
活锁
活锁是什么?两个线程互相谦让,结果谁也没法干活。
举个例子:线程A和线程B都需要锁1和锁2。A拿到锁1,发现锁2被B占着,就释放锁1说「你先来」。B拿到锁2,发现锁1被A占着,也释放锁2说「你先来」。结果两个线程一直在让来让去,就是干不了活。
我在项目中遇到过类似情况。一个消息队列的消费者,两个线程互相重试,导致消息处理延迟飙升。后来我加了个随机等待时间,打破了这种「礼貌性死循环」。
避坑指南:我曾经在重试逻辑里忘了加随机退避,结果活锁把CPU吃满了。记住,重试一定要加随机延迟,别用固定间隔。
饥饿
饥饿比活锁更惨。一个线程一直拿不到资源,永远等下去。
常见原因有两个:
- 优先级倒置:低优先级线程持有锁,高优先级线程等着。高优先级线程一直抢不到CPU。
- 不公平锁:某些线程总是被跳过,永远拿不到锁。
我建议用公平锁来解决饥饿问题。Java里的 ReentrantLock(true) 就是公平锁,它会按请求顺序分配锁。
// 公平锁示例
ReentrantLock fairLock = new ReentrantLock(true);
public void doWork() {
fairLock.lock();
try {
// 业务逻辑
} finally {
fairLock.unlock();
}
}
不过要注意,公平锁性能比非公平锁差一些。我一般只在确实有饥饿风险时才用。
总结一下:死锁是线程互相等待,活锁是线程互相谦让,饥饿是线程被冷落。三种问题都要重视,但处理方式不一样。
排查并发问题,我个人的习惯是:先看线程堆栈,找出所有等待的线程。然后分析锁的持有关系,判断是死锁、活锁还是饥饿。最后对症下药。
嗯,记住一点:预防永远比恢复更重要。写代码时多想想锁的顺序,多用超时机制,能省去很多半夜排查的麻烦。