读完这篇,你能做什么
能从线程栈画出等待环,并用统一锁顺序消除两个资源之间的死锁。
学习理由、准备要求与知识点
为什么学它
死锁表现为请求一直等待而 CPU 可能很低,需要从线程和锁的关系查找原因。
先读这些内容
知识点清单
- 互斥资源
- 持有并等待
- 循环等待
- 统一锁顺序
ThreadMXBean- 线程转储
tryLock超时- 锁外执行慢操作
运行前先确认JDK17 ThreadMXBean与Lock,ThreadImpl源码固定17.0.12+7。算法对给定等待图找环,不主动制造真实死锁;图是否足以证明死锁取决于资源语义与采样一致性。
01先弄懂它是什么
A 线程拿着账户 1 的锁等账户 2,B 拿着账户 2 的锁等账户 1,双方又都不能先释放,程序就可能一直卡住,这就是死锁。普通锁竞争可能等一会儿就恢复,慢请求也未必涉及锁。因此看到一个线程是 BLOCKED,只能知道它在等监视器,不能直接断言有死锁。
预防这类问题的一种常见办法,是让所有操作按同一顺序拿锁。例如转账时始终先锁编号较小的账户,再锁较大的账户。大家只沿同一顺序向前等待,就不能绕回较小资源形成等待环。这是在破坏经典死锁条件中的循环等待;互斥、持有并等待、不可抢占等条件也参与分析。
并发停滞还包括活锁和饥饿。活锁中线程不断退让或重试却没有完成有效工作;饥饿中某个线程长期得不到资源;线程池任务同步等待同池子任务也可能形成容量依赖停滞。这些问题不一定被监视器死锁检测API报告,排错需要同时观察锁、任务依赖和资源池容量。
文中这几个词,先认一认
- 等待图 Wait-for Graph
- 把线程等参与者画成点,A 指向 B 表示 A 正等 B 持有或负责释放的资源。
- 锁顺序
- 规定所有操作都按同一个次序获取相关资源,例如账户编号从小到大。
- 活锁
- 线程一直在重试或互相退让,看似活动不断,实际却没有完成业务。
- 饥饿
- 某个线程长期抢不到资源或运行机会,即使其他线程仍在前进。
02看看它怎么工作
先采集线程正在执行哪里、正在等哪把锁,以及锁在谁手里,再把“谁等谁”连起来。A 等 B、B 等 C、C 又等 A,就出现一个需要检查的环。如果这些关系在同一时刻成立,而且每把锁只能有一个持有者、不能被别人强行抢走,这个环就能解释死锁。若把不同时间的日志拼在一起,画出的环可能从未真实出现过。
规定锁顺序后,所有相关路径都要遵守。转账代码按账户 ID 拿锁,但它调用的审计代码又先锁日志对象、再回来锁账户,仍可能绕出一个环。因此,持锁期间尽量不要调用行为未知的回调或远程服务,把这些难以控制的工作移到锁外。两个操作指向同一 ID,或不同对象恰好有相同排序值时,也要提前规定怎么处理,不能让各条路径各选一种顺序。
tryLock超时能让线程放弃等待,但必须释放已获取的其他锁并安全退出或重试。两个线程以完全相同节奏反复拿第一把锁、拿不到第二把就重试,可能演变成活锁;需要明确重试预算与适当抖动。数据库锁还应结合事务范围和数据库自己的等待信息分析,JVM锁顺序不能覆盖远端事务的全部依赖。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart LR
A["线程A 持有账户1"] -->|等待账户2| B["线程B 持有账户2"]
B -->|等待账户1| A
R["所有路径统一按账户ID升序"] --> L["先获取较小ID"]
L --> H["再获取较大ID"]
H --> U["完成后逆序释放"]按顺序看,每一步做了什么
- 采集线程栈、等待对象、持有者与时间信息。
- 建立有明确资源语义的等待图,寻找循环。
- 区分锁死锁、线程池容量依赖、慢IO、活锁与饥饿。
- 在代码层统一资源顺序,缩短临界区并控制回调。
- 用可控并发测试和故障演练验证修复,不靠随机sleep。
03对照实现代码
OpenJDK17.0.12+7 · ThreadImpl#findDeadlockedThreads / findMonitorDeadlockedThreads
原创公开管理API诊断代码。ThreadImpl完成支持性和权限等入口检查后委托VM原生检测;这里不伪造Java版虚拟机锁遍历实现。
import java.lang.management.*;
final class DeadlockProbeModel {
static ThreadInfo[] detect() {
ThreadMXBean bean=ManagementFactory.getThreadMXBean();
boolean synchronizers=bean.isSynchronizerUsageSupported(); // 先检查可拥有同步器的支持范围。
long[] ids=synchronizers ? bean.findDeadlockedThreads() : bean.findMonitorDeadlockedThreads();
if(ids==null) return new ThreadInfo[0]; // null表示此次检测未发现对应类型死锁。
return bean.getThreadInfo(ids,bean.isObjectMonitorUsageSupported(),synchronizers);
// 二次查询时线程状态可能变化,调用方仍应处理数组中的null项。
}
}findMonitorDeadlockedThreads只针对对象监视器等待环,findDeadlockedThreads还覆盖受支持的可拥有同步器。固定ThreadImpl检查运行环境支持后进入原生检测接口,说明Java管理API提供的是虚拟机可见锁关系,不能把它理解成对全部业务依赖的通用死锁证明器。
检测结果是线程ID列表,而不是自动修复动作。继续读取ThreadInfo才能关联栈帧、锁名称和持有者;查询之间系统仍可能变化,因此应保留采集时间并处理已经结束线程的空结果。线程ID在生命周期内唯一,结束后可能复用,长期存档还要结合进程与时间识别。
ThreadMXBean 没查到死锁环,不代表系统一定还在推进。等待 Future、连接名额、外部服务或某个业务状态,都可能卡住,却不在这个 API 能识别的锁关系内。应继续查请求依赖和资源占用,特别看是不是所有工作线程都在等尚未得到线程执行的子任务。
诊断与恢复分开进行。重启可能暂时恢复服务,但会丢失有价值的现场;优先收集足够的线程与锁证据,再按运行环境的恢复策略处理。不要使用不安全的强制线程终止来“释放锁”,因为对象可能停在破坏业务不变量的中间状态。
代码先调用谁,再调用谁
ManagementFactory取得ThreadMXBean- 检查同步器与监视器信息支持范围
- 调用对应
findDeadlockedThreads入口 ThreadImpl委托虚拟机原生检测返回线程ID- 读取
ThreadInfo并结合业务依赖解释现场
04动手写一个例子
迭代DFS返回一条等待环
输入n个参与者和有向等待边,返回任意一条闭合环路径,如[0,1,2,0];没有环返回空。支持自环、重复边和不连通图,使用显式栈避免深链导致Java调用栈溢出。
- 输入 1
0→1,1→2,2→0- 输出 1
[0,1,2,0]
- 输入 2
0→1,1→2- 输出 2
[]
三色DFS将节点分成未访问、当前搜索路径中和已经完成。遇到未访问节点就压栈继续,遇到当前路径中的灰色节点说明有一条返回祖先的边,构成环;遇到已完成黑色节点不表示有环,因为它可能只是另一条路径共享的下游依赖。
递归本来会记住“正在处理哪个节点、接下来检查哪条边”。这里把这两项写进 Frame,再放入自己维护的栈,就不用依赖 Java 方法调用一层层递归。一个节点的边全部检查完,才标成黑色并出栈。parent 记下每个节点是从谁走来的;发现 v 指回当前路径上的 ancestor 后,沿 parent 找回 ancestor,反转这段路径,再在末尾补上 ancestor,就得到首尾相接的环。
对每个未访问节点启动搜索可覆盖不连通分量。每个节点只入栈一次,每条边最多检查一次。算法只回答图中是否有环,图的边是否是真实同时成立的等待关系需要额外证据;对多个可替代资源实例,单纯资源分配图存在环也未必足够证明永久死锁。
为什么这样写是对的灰色节点恰好位于当前显式DFS路径中,parent连接发现路径;指向灰色祖先的边与路径共同构成闭环。
邻接表保存全部边,颜色、父节点与显式栈各O(V);每条边只检查一次,返回环长度最多V+1。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
static final class Frame { final int node; int next; Frame(int node){this.node=node;} }
static List<Integer> cycle(int n,int[][] edges) {
if(n<0) throw new IllegalArgumentException();
Objects.requireNonNull(edges);
List<List<Integer>> graph=new ArrayList<>();
for(int i=0;i<n;i++) graph.add(new ArrayList<>());
for(int[] edge:edges) {
if(edge==null || edge.length!=2 || edge[0]<0 || edge[0]>=n || edge[1]<0 || edge[1]>=n)
throw new IllegalArgumentException("edge");
graph.get(edge[0]).add(edge[1]);
}
int[] color=new int[n],parent=new int[n]; Arrays.fill(parent,-1);
Deque<Frame> stack=new ArrayDeque<>();
for(int root=0;root<n;root++) if(color[root]==0) {
color[root]=1; stack.push(new Frame(root));
while(!stack.isEmpty()) {
Frame frame=stack.peek();
if(frame.next==graph.get(frame.node).size()) {
color[frame.node]=2; stack.pop(); continue; // 所有出边处理完才标为完成。
}
int to=graph.get(frame.node).get(frame.next++);
if(color[to]==0) {
parent[to]=frame.node; color[to]=1; stack.push(new Frame(to));
} else if(color[to]==1) { // 回到当前路径中的祖先,找到真正有向环。
List<Integer> path=new ArrayList<>();
int at=frame.node; path.add(at);
while(at!=to) { at=parent[at]; path.add(at); }
Collections.reverse(path); path.add(to); // 补上终点形成闭合证据路径。
return List.copyOf(path);
}
}
}
return List.of();
}
static void check(boolean ok) { if(!ok) throw new AssertionError(); }
public static void main(String[] args) {
check(cycle(3,new int[][]{{0,1},{1,2},{2,0}}).equals(List.of(0,1,2,0)));
check(cycle(3,new int[][]{{0,1},{1,2}}).isEmpty());
check(cycle(1,new int[][]{{0,0}}).equals(List.of(0,0)));
check(cycle(4,new int[][]{{0,1},{2,3},{3,2}}).equals(List.of(2,3,2)));
check(cycle(3,new int[][]{{0,1},{0,1},{0,2},{2,1}}).isEmpty());
check(cycle(0,new int[0][]).isEmpty());
int n=20000; int[][] chain=new int[n-1][2];
for(int i=0;i<n-1;i++) {chain[i][0]=i;chain[i][1]=i+1;}
check(cycle(n,chain).isEmpty());
System.out.println("PASS 7");
}
}这些特殊情况也要试一试
- 自环返回同一节点首尾相接。
- 重复边不应把无环图误判成环。
- 不连通分量中的环也要发现。
- 长链使用显式栈,不消耗递归调用栈深度。
进阶选读 · 强连通分量与跨系统依赖诊断
场景一个系统包含多个相互独立的锁环、连接池等待和异步任务依赖,需要输出受影响参与者集合。
做法使用Tarjan或Kosaraju找强连通分量,结合资源类型筛选可解释的循环;跨进程事件需记录时间与一致性边界。对不满足单持有者语义的资源,使用更完整资源分配模型。
复杂度强连通分量O(V+E),输出全部简单环则可能指数级,通常无需枚举所有环。
好处与代价分量分析覆盖更完整,但图采集和语义验证更困难;先返回一条可解释环往往更适合快速定位直接根因。
05项目里怎么用
跨对象操作要使用所有路径都认可的排序键。相同账户 ID 应识别为同一资源,或按稳定的次级键处理,不能临时拿可能碰撞的 hashCode 充当唯一顺序。拿到锁后用 finally 释放;第二把锁没拿到时,第一把也必须按规则释放。
锁等待指标应与请求超时、线程池队列、连接池占用和CPU趋势关联。大量线程等待同一慢持有者可能只是长临界区,少量线程形成闭环则是另一类问题。重复采样帮助区分短暂竞争与持续停滞,但采样次数本身不替代明确的锁依赖证据。
修复后用可控时序测试覆盖相反方向转账、同账户操作、异常回滚和获取超时。不要靠sleep让线程“更容易撞上”,可用屏障或门闩建立测试起点。测试本身也要有超时与清理,避免为了验证死锁把测试进程永久挂起。
容易踩的坑
- 只看BLOCKED线程数量就断言死锁。
- 只有主流程遵守锁顺序,回调路径反向获取。
tryLock失败后忘记释放已取得的锁。- 统一节奏无限重试形成活锁。
- 管理API无报告就宣称全部业务不存在停滞。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 统一锁顺序 | 能从结构上排除相应循环等待 | 必须覆盖全部路径与资源身份 | 多对象原子操作 |
带期限tryLock | 等待可控,允许放弃和重试 | 需清理、预算与活锁防护 | 可安全失败的业务操作 |
| 缩小共享状态或消息化 | 减少同时持有多资源需求 | 增加异步协议与一致性设计 | 跨模块协作与高竞争流程 |
| 死锁检测与恢复 | 能发现已发生的等待环 | 依赖检测范围,恢复可能有业务成本 | 运行诊断与容错兜底 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么统一严格锁顺序能排除循环?
展开参考答案与推导
等待沿资源顺序递增,有限严格序无法最终回到更小起点,因此不能形成相应循环等待。
02DFS遇到已访问节点就说明有环吗?
展开参考答案与推导
不说明,只有指向当前搜索路径中的灰色祖先才形成回边;黑色节点可能是共享下游。
03死锁、活锁和饥饿怎样区分?
展开参考答案与推导
死锁相互等待无法继续,活锁持续行动却无有效进展,饥饿是个别参与者长期得不到资源。
换个情况,还会处理吗
01CPU很低,全部池线程都在Future.get,检测无死锁,下一步做什么?
展开参考答案与推导
检查同池子任务依赖和执行容量,可能没有监视器环却没有空闲工作者执行被等待任务。
02双向转账偶发卡住,如何修复并验证?
展开参考答案与推导
统一按稳定账户ID获取两把锁,处理同账户与异常释放,用受控并发双向转账测试总额与完成期限。
03tryLock重试后线程一直忙但无请求完成,可能发生什么?
展开参考答案与推导
可能活锁或重试风暴,检查释放策略、统一重试节奏和预算,使用抖动、限次或更稳定的锁顺序。
写代码试一试
01实现返回证据路径的有向环检测。
- 输入 / 条件
0→1、1→2、2→0- 输出 / 预期
[0,1,2,0]
提示 三色DFS加parent。
展开参考答案与推导
按正文显式栈遍历,遇灰色祖先回溯闭环。
02设计按账户ID顺序获取锁的转账。
- 输入 / 条件
账户2向账户1转10- 输出 / 预期
仍先锁账户1再锁账户2
提示 先排序资源,业务方向与锁顺序分离。
展开参考答案与推导
同账户明确空操作或拒绝,验证余额和溢出后原子更新,finally释放。
03扩展输出所有强连通分量。
- 输入 / 条件
0↔1,2→3,3↔4- 输出 / 预期
循环分量{0,1}与{3,4}
提示 Tarjan低链接值。
展开参考答案与推导
每节点一次DFS,维护索引、low与栈状态,单节点只有自环才构成循环分量。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能从线程栈画出等待环,并用统一锁顺序消除两个资源之间的死锁。
本篇重点加练
锁顺序仅覆盖已纳入协议的资源,回调和慢操作可能引入隐藏等待边。
- 统一锁顺序
- 锁外执行慢操作
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
三个线程分别持有打印机、文件锁和缓存锁并等待下一项资源。画出资源分配图,说明哪些条件同时成立时才形成循环等待。
- 提交内容
- 资源图与条件说明
- 怎样算完成
- 区分资源节点和线程节点
- 包含一个无环等待样本
练到的知识点互斥资源 · 持有并等待 · 循环等待
- 作业 02设计评审
系统使用业务 ID 排序获取两把锁,但两个不同资源可能 ID 相同。评审锁顺序是否完整,并声明同资源请求与 ID 冲突的行为。
- 提交内容
- 锁排序契约
- 怎样算完成
- 顺序对所有资源可比较
- 包含相等和未知标识
练到的知识点统一锁顺序
- 作业 03代码推演
多个模块各自对内部锁排序,但跨模块调用把一种资源锁带入另一模块。评审局部顺序能否组合成全局严格顺序。
- 提交内容
- 跨模块锁关系图
- 怎样算完成
- 包含回调返回路径
- 列出未纳入排序的资源
练到的知识点统一锁顺序
综合应用
- 作业 04排错实验
在独立本地子进程构造两个线程互等,用
ThreadMXBean与线程转储各采集一次证据;实验设定自动终止期限。- 提交内容
- 程序、转储与等待图
- 怎样算完成
- 只终止实验进程
- 每条等待边可追溯到证据
练到的知识点ThreadMXBean · 线程转储
- 作业 05代码推演
任务持第一把锁后限时获取第二把锁,同时还要执行慢日志归档。分析超时、归档失败和中断时的资源持有情况。
- 提交内容
- 控制流与资源表
- 怎样算完成
- 说明超时未获取锁的路径
- 标出慢操作的执行范围
练到的知识点tryLock 超时 · 锁外执行慢操作
- 作业 06编程实践
实现本地双资源预订模型,随机与固定顺序并发请求,记录等待时长、成功、取消和失败;加入第三类资源检验顺序规则是否可扩展。
- 提交内容
- 源码、固定种子及记录
- 怎样算完成
- 有总运行时间上限
- 同时检查资源守恒与线程结束
练到的知识点互斥资源 · 持有并等待 · 循环等待 · 统一锁顺序 · ThreadMXBean · tryLock 超时 · 锁外执行慢操作
- 作业 07设计评审
方法持有状态锁后同步调用监听器,监听器可能读取文件或等待其他任务。定义允许在锁内调用的接口契约。
- 提交内容
- 回调行为与资源评审
- 怎样算完成
- 包含用户提供的未知实现
- 区别可重入和不阻塞承诺
练到的知识点锁外执行慢操作
- 作业 08编程实践
编写本地锁依赖采集演示,在有限工作负载中记录锁持有时发起的资源等待,生成候选环;明确日志缺失时的报告方式。
- 提交内容
- 程序和依赖报告
- 怎样算完成
- 检测候选而非宣称覆盖全部执行
- 实验有自动结束期限
练到的知识点统一锁顺序 · 锁外执行慢操作
进阶挑战 · 掌握前置后选做
- 作业 09面试场景
线程都能运行但业务长时间不前进,死锁检测结果为空。设计区分活锁、饥饿和队列依赖等待的取证方案。
- 提交内容
- 诊断决策表
- 怎样算完成
- 每种假设有不同观察指标
- 不将检测不到锁环写成无并发故障
练到的知识点循环等待 · 统一锁顺序 · ThreadMXBean · 线程转储 · tryLock 超时
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 反例构造
构造所有显式互斥锁都遵循顺序,但系统仍因等待 Future 或回调形成无进展的场景。
- 提交内容
- 等待图与证据需求
- 怎样算完成
- 区分监视器锁环和任务依赖环
- 不靠无限循环直接制造卡死
练到的知识点统一锁顺序 · 锁外执行慢操作
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Java 17:ThreadMXBean (新标签页)↗
阅读线程锁信息和死锁检测范围,结合线程栈定位锁循环。
- 实践参考文档英文
Java 17:jcmd (新标签页)↗
阅读 VM、Thread、GC 与 JFR 命令及影响提示,按证据需求选择诊断操作。
- 深入参考文档英文
Java 17:ReentrantLock (新标签页)↗
阅读可重入、
tryLock和可中断获取,在 finally 中落实解锁责任。 - 实践实践指南中文
Arthas 入门:看看正在运行的 Java 程序 (新标签页)↗
先只练 dashboard、thread 与停止工具。小练习:对自己启动的 math-game 记录一次线程状态,找到主线程正在执行的方法,并说明观察证据;复杂表达式和运行时改代码暂后学。 文档和示例公开;连接 Java 进程需要相应权限并满足兼容要求。页面中的历史输出用于说明命令,不代表当前版本要求。
- 深入参考文档英文
ThreadImpl固定源码 (新标签页)↗
识别Java管理入口与虚拟机检测边界。
- 深入参考文档英文
JDK17 Lock (新标签页)↗
核对可中断、带期限获取与finally释放模式。