qinghuan
← JUC + JVM / 全部笔记

单元 114 / JUC + JVM

P1 · 重点提升L3 · 深入

Java 死锁:找出谁在等谁,再统一拿锁顺序

从线程记录画出谁在等谁,分清普通等待、死锁和任务饥饿,再用统一拿锁顺序消除等待环。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能从线程栈画出等待环,并用统一锁顺序消除两个资源之间的死锁。

学习理由、准备要求与知识点

为什么学它

死锁表现为请求一直等待而 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["完成后逆序释放"]

按顺序看,每一步做了什么

  1. 采集线程栈、等待对象、持有者与时间信息。
  2. 建立有明确资源语义的等待图,寻找循环。
  3. 区分锁死锁、线程池容量依赖、慢IO、活锁与饥饿。
  4. 在代码层统一资源顺序,缩短临界区并控制回调。
  5. 用可控并发测试和故障演练验证修复,不靠随机sleep。

03对照实现代码

ThreadMXBean死锁检测的Java入口与VM边界 (新标签页)↗

OpenJDK17.0.12+7 · ThreadImpl#findDeadlockedThreads / findMonitorDeadlockedThreads

原创公开管理API诊断代码。ThreadImpl完成支持性和权限等入口检查后委托VM原生检测;这里不伪造Java版虚拟机锁遍历实现。

核心机制代码 · 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 能识别的锁关系内。应继续查请求依赖和资源占用,特别看是不是所有工作线程都在等尚未得到线程执行的子任务。

诊断与恢复分开进行。重启可能暂时恢复服务,但会丢失有价值的现场;优先收集足够的线程与锁证据,再按运行环境的恢复策略处理。不要使用不安全的强制线程终止来“释放锁”,因为对象可能停在破坏业务不变量的中间状态。

代码先调用谁,再调用谁

  1. ManagementFactory取得ThreadMXBean
  2. 检查同步器与监视器信息支持范围
  3. 调用对应findDeadlockedThreads入口
  4. ThreadImpl委托虚拟机原生检测返回线程ID
  5. 读取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+E)额外空间 O(V+E)

邻接表保存全部边,颜色、父节点与显式栈各O(V);每条边只检查一次,返回环长度最多V+1。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 51 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能从线程栈画出等待环,并用统一锁顺序消除两个资源之间的死锁。

本篇重点加练

锁顺序仅覆盖已纳入协议的资源,回调和慢操作可能引入隐藏等待边。

  • 统一锁顺序
  • 锁外执行慢操作

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    三个线程分别持有打印机、文件锁和缓存锁并等待下一项资源。画出资源分配图,说明哪些条件同时成立时才形成循环等待。

    提交内容
    资源图与条件说明
    怎样算完成
    • 区分资源节点和线程节点
    • 包含一个无环等待样本

    练到的知识点互斥资源 · 持有并等待 · 循环等待

  2. 作业 02设计评审

    系统使用业务 ID 排序获取两把锁,但两个不同资源可能 ID 相同。评审锁顺序是否完整,并声明同资源请求与 ID 冲突的行为。

    提交内容
    锁排序契约
    怎样算完成
    • 顺序对所有资源可比较
    • 包含相等和未知标识

    练到的知识点统一锁顺序

  3. 作业 03代码推演

    多个模块各自对内部锁排序,但跨模块调用把一种资源锁带入另一模块。评审局部顺序能否组合成全局严格顺序。

    提交内容
    跨模块锁关系图
    怎样算完成
    • 包含回调返回路径
    • 列出未纳入排序的资源

    练到的知识点统一锁顺序

综合应用

  1. 作业 04排错实验

    在独立本地子进程构造两个线程互等,用 ThreadMXBean 与线程转储各采集一次证据;实验设定自动终止期限。

    提交内容
    程序、转储与等待图
    怎样算完成
    • 只终止实验进程
    • 每条等待边可追溯到证据

    练到的知识点ThreadMXBean · 线程转储

  2. 作业 05代码推演

    任务持第一把锁后限时获取第二把锁,同时还要执行慢日志归档。分析超时、归档失败和中断时的资源持有情况。

    提交内容
    控制流与资源表
    怎样算完成
    • 说明超时未获取锁的路径
    • 标出慢操作的执行范围

    练到的知识点tryLock 超时 · 锁外执行慢操作

  3. 作业 06编程实践

    实现本地双资源预订模型,随机与固定顺序并发请求,记录等待时长、成功、取消和失败;加入第三类资源检验顺序规则是否可扩展。

    提交内容
    源码、固定种子及记录
    怎样算完成
    • 有总运行时间上限
    • 同时检查资源守恒与线程结束

    练到的知识点互斥资源 · 持有并等待 · 循环等待 · 统一锁顺序 · ThreadMXBean · tryLock 超时 · 锁外执行慢操作

  4. 作业 07设计评审

    方法持有状态锁后同步调用监听器,监听器可能读取文件或等待其他任务。定义允许在锁内调用的接口契约。

    提交内容
    回调行为与资源评审
    怎样算完成
    • 包含用户提供的未知实现
    • 区别可重入和不阻塞承诺

    练到的知识点锁外执行慢操作

  5. 作业 08编程实践

    编写本地锁依赖采集演示,在有限工作负载中记录锁持有时发起的资源等待,生成候选环;明确日志缺失时的报告方式。

    提交内容
    程序和依赖报告
    怎样算完成
    • 检测候选而非宣称覆盖全部执行
    • 实验有自动结束期限

    练到的知识点统一锁顺序 · 锁外执行慢操作

进阶挑战 · 掌握前置后选做

  1. 作业 09面试场景

    线程都能运行但业务长时间不前进,死锁检测结果为空。设计区分活锁、饥饿和队列依赖等待的取证方案。

    提交内容
    诊断决策表
    怎样算完成
    • 每种假设有不同观察指标
    • 不将检测不到锁环写成无并发故障

    练到的知识点循环等待 · 统一锁顺序 · ThreadMXBean · 线程转储 · tryLock 超时

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    构造所有显式互斥锁都遵循顺序,但系统仍因等待 Future 或回调形成无进展的场景。

    提交内容
    等待图与证据需求
    怎样算完成
    • 区分监视器锁环和任务依赖环
    • 不靠无限循环直接制造卡死

    练到的知识点统一锁顺序 · 锁外执行慢操作

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗