ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

避免活性故障:死锁、活锁、饥饿 + 死锁排查【重点精讲 + 总结】

避免活性故障:死锁、活锁、饥饿 + 死锁排查【重点精讲 + 总结】

活性(Liveness):程序最终一定能向前推进、完成任务;与之对应的是安全性(Safety):坏事情永远不会发生。 死锁、活锁、饥饿,全部属于活性故障:线程卡在那里,一直无法正常执行完业务。

一、三个概念对比(核心区分)

1. 死锁(Deadlock)【最常见】

定义:一组线程互相持有对方需要的锁,同时等待对方释放锁,所有人都静止,不再做任何动作,永久卡住。

一句话:大家拿着锁、互相等,全都不动了

死锁 4 个必要条件(四个同时满足才会死锁,破坏任意一个即可避免):

  1. 互斥条件:资源是排他锁,同一时间只能一个线程持有(synchronized、ReentrantLock 排他模式)
  2. 持有并等待:线程已经拿到 A 锁,不释放,再去请求 B 锁
  3. 不可剥夺:锁只能持有者主动释放,别人抢不走、不能强行拿走
  4. 循环等待:线程形成环形等待链:T1 等 T2、T2 等 T3 … Tn 等 T1

✅ 典型场景: T1:锁 A → 请求锁 B T2:锁 B → 请求锁 A

2. 活锁(Livelock)

定义:线程没有阻塞、没有挂起,一直在运行、不断重试,但是永远无法完成目标。线程状态是 RUNNABLE,不是 BLOCKED/WAITING。

一句话:一直在忙活,原地踏步,没有进展

✅ 典型场景: 两个线程同时拿到资源检测冲突,然后主动释放、退让,再同时重试,反复礼让,谁都拿不到资源。 比如:简易自旋锁,冲突就 sleep 一小段,两个线程休眠时间完全一样,每次唤醒又撞车。

区别死锁:死锁线程不动;活锁线程一直在跑。

3. 饥饿(Starvation)

定义:线程一直拿不到它需要的资源,长期得不到执行机会,一直被插队,迟迟无法推进,甚至永远执行不到。

一句话:资源被优先级高 / 抢占能力强的线程持续霸占,弱者一直轮不到

✅ 典型场景:

  1. ReentrantLock 默认非公平锁:高并发下,新来的线程直接抢到锁,队列里等待很久的线程持续拿不到(饥饿)
  2. 线程优先级设置差距过大,低优先级线程几乎不被调度
  3. 一个线程持有锁时间极长,其他线程排队一直等

区分三兄弟速记:

  • 死锁:都不动(阻塞等待)
  • 活锁:一直在动,但白跑(自旋重试)
  • 饥饿:想跑,但是轮不到它(资源被抢占)

二、如何预防这三类活性故障

✅ 死锁预防(破坏 4 条件之一)

  1. 破坏持有并等待:一次性申请所有需要的锁,要么全部拿到,一个都拿不到就不持有任何锁
  2. 破坏不可剥夺:支持锁超时、尝试获取(tryLock(timeout)),拿不到就释放已经持有的锁,回头重试
  3. 破坏循环等待【工程最常用】:全局统一锁的获取顺序

    比如:不管哪个线程,要锁 A、B,永远先拿 A,再拿 B,禁止反过来

  4. 互斥条件很多业务无法破坏(必须排他),一般不在这里下手

编码规范:优先统一加锁顺序 + 使用带超时的 tryLock,不要无脑用 synchronized 嵌套。

✅ 活锁预防

  1. 退让重试时,加入随机退避时间(jitter),不要固定休眠时长
  2. 限制重试次数,超过次数直接失败,不要无限自旋
  3. 排队机制(队列锁,公平锁),避免互相礼让撞车

✅ 饥饿预防

  1. 锁选择:需要避免饥饿就用ReentrantLock (true) 公平锁(FIFO 排队,先来先得)

    代价:公平锁上下文切换多,吞吐更低;非公平性能高,但有饥饿风险

  2. 不要滥用线程优先级
  3. 持有锁的代码尽量短,锁内不做 IO、慢逻辑,尽快释放锁
  4. 限制单个线程占用资源时长

三、重点:死锁排查(Java 线上排查流程,实操)

前提:现象:服务卡住、接口超时、线程池不干活、CPU 不一定高(死锁线程大多 BLOCKED,CPU 低,这点和活锁区分)

排查步骤(线上标准流程)

Step1:确认进程 PID

jps -l # 或者 ps -ef | grep java

Step2:导出线程栈(核心命令)

两种方式任选,推荐先 jstack:

# 直接打印线程栈到控制台 jstack <pid> # 输出到文件,方便下载分析 jstack <pid> > thread.txt

重点:jstack 会自动检测 JVM 死锁,在文件末尾搜索关键字:Found one Java-level deadlockJVM 可以直接发现synchronized / ReentrantLock 形成的死锁(AQS 的死锁 jstack 能识别)

Step3:看懂死锁日志模板

Found one Java-level deadlock: ============================= "Thread-1": waiting to lock monitor 0x00007f8b94003c68 (object 0x000000076ab34290, a java.lang.Object), which is held by "Thread-0" "Thread-0": waiting to lock monitor 0x00007f8b94003b28 (object 0x000000076ab34280, a java.lang.Object), which is held by "Thread-1"

日志直接告诉你:

  • 哪些线程
  • 各自在等哪个锁
  • 锁被哪个线程持有 往下看栈帧,可以定位到代码行号,直接找到嵌套加锁的地方。

Step4:辅助工具(复杂场景)

  1. JConsole / JVisualVM:图形界面,线程页,检测死锁按钮一键查看
  2. Arthas(阿里,线上神器,生产首选,不用装 JDK 工具包)
# arthas 命令 thread -b # -b 专门检测阻塞死锁线程,直接打印死锁信息,比jstack更友好 thread -n 5 # 查看top5 CPU线程(区分死锁和CPU高的活锁场景)

⚠️ 注意:thread -b只能发现锁等待型死锁;活锁、饥饿,arthas 和 jstack 不会标记为 deadlock,需要看线程状态 RUNNABLE、循环重试代码。

Step5:区分容易混淆现象

  • 死锁:线程状态 BLOCKED / WAITING,jstack 标记 Found deadlock,CPU 低
  • 活锁:线程 RUNNABLE,循环自旋重试,CPU 可能偏高,JVM 不报死锁
  • 饥饿:部分线程一直在队列等待,锁被其他线程持续拿到,没有环形等待链,JVM 不报死锁

Step6:修复方案(拿到代码位置后)

  1. 统一加锁顺序(优先方案)
  2. 改成 tryLock + 超时,超时释放锁,抛出告警,不永久阻塞
  3. 拆锁:把大锁拆细,减少嵌套锁,尽量不要嵌套获取多把锁
  4. 替换锁结构:用无锁 CAS、并发容器,避开锁嵌套

四、坑点提醒

  1. synchronized是不可中断、没有超时的;一旦嵌套顺序错,死锁后只能重启进程
  2. ReentrantLock 支持 tryLock (时间)、lockInterruptibly,可控性远高于 synchronized,适合多锁场景
  3. 数据库死锁 ≠ Java 线程死锁:数据库死锁是 InnoDB 事务锁,要看 mysqlshow engine innodb status,jstack 看不到
  4. 分布式死锁(Redis 分布式锁):跨进程、跨服务,JVM 栈完全看不到,要单独排查锁 key、释放逻辑、锁超时、续约

五、终极浓缩总结(背诵版)

✅ 活性三故障一句话

  1. 死锁:循环等待,互相拿锁,全部阻塞不动;4 条件同时满足;jstack 可直接检出。
  2. 活锁:无阻塞,一直自旋重试、互相避让,永远无法成功;加随机退避、限制重试次数解决。
  3. 饥饿:资源持续被抢占,部分线程长期拿不到资源;公平锁、缩短锁持有时间缓解。

✅ 死锁排查口诀

jps 拿 pid → jstack/arthas thread -b → 搜 Found deadlock → 看线程栈定位代码行 优先统一锁顺序,其次 tryLock 带超时,尽量减少锁嵌套

✅ 避坑总原则

能不加嵌套锁就不加;多锁必须定全局顺序;优先带超时可中断锁;锁内逻辑要短;区分 JVM 线程死锁、DB 死锁、分布式锁死锁。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进