跳到主要内容
约 8 分钟3 张卡

线程转储

线程转储记录采集时刻的线程栈、状态和部分锁关系,用于判断线程正在执行或等待什么。本文先介绍采集方法和线程状态,再说明怎样区分死锁、锁竞争与线程池饥饿。最后介绍 Java 21 虚拟线程的观测范围。

线程转储是快照,单份栈无法直接说明线程已等待多久、消耗了多少 CPU。等待持续时间要结合 Java Flight Recorder(JFR)等事件记录判断。JFR 是 JVM 提供的运行事件记录工具。

1. 面试核心​

先记住这几条,再读正文
  • 采样时刻。 线程转储记录采集时刻的调用栈与状态,间隔几秒采集多份可以帮助判断等待是否持续。
  • 锁等待环。 锁引起的死锁中的线程互相持锁并等待,按锁编号连接持有者与等待者可以得到循环等待关系。卡 · 采集线程转储
  • 状态含义。 BLOCKED 表示等待进入 synchronized 的 monitor,ReentrantLock 通过 park 等待时可表现为 WAITING。
  • 等待原因。 空闲工作线程等待任务时也会处于 WAITING,而父任务占满线程池后等待同池子任务,会造成线程池饥饿。卡 · 线程状态与栈
  • 虚拟线程覆盖。 Thread.dump_to_file 可包含虚拟线程,但不会自动检测死锁。卡 · 虚拟线程的线程转储

2. 采集线程转储​

排查卡顿时,先保存线程当时在做什么,再与业务指标对齐。平台线程可以用以下命令采集:

jcmd <pid> Thread.print -l > thread-01.txt

首次采集后,间隔几秒再采 2—4 份,同时记录线程池与请求指标:

  • 线程池的线程总数,以及 active,也就是正在执行任务的工作线程数。
  • queue,也就是等待执行的任务数量。
  • 同一时间段的 CPU 使用率和请求延迟。

-l 会增加 ownable synchronizer 信息,即具有独占持有者的同步器信息,例如 ReentrantLock 使用的同步器。输出开销随线程数量变化,因此采集时也要留意对应用的影响。

Java 21 的 Thread.print 主要用于观察平台线程。虚拟线程使用 Thread.dump_to_file 观察。各命令覆盖的线程和锁信息不同,记录结果时要注明所用命令。具体选项可用 jcmd <pid> help Thread.dump_to_file 查看。

3. 线程状态与栈​

线程状态说明 JVM 认为线程处于哪一类活动中,调用栈则帮助定位具体代码。读表时需要把两者放在一起看:

状态含义与例子
RUNNABLE执行或可运行,也可处于部分 native I/O
BLOCKED等待进入或重新进入 synchronized monitor
WAITING无期限 wait、join 或 park
TIMED_WAITING带期限 wait、sleep 或 park
NEW / TERMINATED尚未启动 / 已结束

monitor 是 synchronized 使用的监视器,用于协调线程对临界区的访问。park 则让线程挂起等待许可,是许多并发工具实现等待的基础。

空闲工作线程在任务队列中 park,属于正常等待。如果队列和请求等待持续增长,就需要从栈中确认线程在等锁、连接还是 Future,以及谁负责唤醒它。对于 TIMED_WAITING,还要检查超时时间是否符合任务期限。

ReentrantLock 通过 park 等待时,状态可为 WAITING。所以,排查锁竞争要同时看调用栈和锁对象,不能只统计 BLOCKED 数量。NEW 和 TERMINATED 分别表示尚未开始与已经结束,需要优先分析仍在处理请求的线程。

4. 死锁​

锁引起的死锁发生在多个线程互相等待对方持有的锁时。比如,一个线程按 L1、L2 的顺序进入嵌套 synchronized,另一个按 L2、L1 的顺序进入,就会有这样的等待关系:

线程 A 持有锁 L1,等待锁 L2
线程 B 持有锁 L2,等待锁 L1

按锁标识连接等待者与持有者,就能得到循环等待关系。monitor 转储中的 locked 表示已持有的锁,waiting to lock 表示正在等待的锁。对于基于 AQS 的锁,还要结合 park 等待栈和 ownable synchronizer 持有信息。AQS 是 Java 并发工具协调同步状态和等待线程的基础框架。

jstack -l 或 jcmd Thread.print -l 能报告部分 monitor 与同步器死锁。报告中可出现 Found one Java-level deadlock,并列出相关线程。Future 依赖和外部资源等待则需要补充业务关系,再判断是否形成环。

修复要改变造成循环等待的锁获取方式:

  • 统一各条代码路径的获取顺序,让线程按同一顺序拿锁。
  • 减少嵌套持锁,或合并需要一起保护的临界区,简化所有权关系。
  • 使用 tryLock(timeout) 设置失败出口,并配套释放已持有的锁和有限重试。

重启可以暂时恢复服务,造成循环等待的代码仍需修复。

5. 锁竞争​

锁竞争指多个线程争用同一把锁。持有者仍能完成工作,但等待者会排队,因此吞吐下降、请求长尾变长。下面的代码把远程调用放在了锁内:

synchronized void record(Metric metric) {
remoteClient.send(metric);
}

示例持锁执行远程调用。远程响应慢时,其他调用者会等待该 monitor。只要持有者最终退出,队列仍能向前推进。

多份转储若持续显示同一持有者停在远程调用中,并有大量 BLOCKED 等待者,就需要检查这段慢 I/O。JFR 的 jdk.JavaMonitorEnter 记录 monitor 竞争,jdk.ThreadPark 提供 park 等待事件。启用对应事件后,可以进一步观察等待时长与等待对象。async-profiler 的 lock 模式也能提供锁竞争的采样视角。

优化需要围绕共享状态与持锁时间展开:

  • 把 I/O 等慢操作移出锁内,缩短临界区。
  • 将彼此独立的状态拆开保护,减少无关请求互相等待。
  • 用不可变快照、并发集合或消息传递调整共享方式,同时验证数据一致性。
  • 按热点 key 分片时,检查请求是否仍集中在少数分片,避免竞争只是换了位置。

6. 线程池饥饿​

线程池中的任务也会互相等待,即使没有形成 monitor 锁环,任务仍会停止推进。下面的代码如果在池内父任务中执行,就需要检查子任务由谁运行:

Future<Result> child = executor.submit(task);
return child.get();

假设父任务已占满固定大小的线程池。每个父任务都把子任务提交到同一池,再调用 get() 同步等待结果。这时子任务留在队列中,所有工作线程却都被等待中的父任务占用,子任务就没有执行者。

排查时,要把线程总数、active、queue 和任务提交关系放在一起看。active 达到容量、queue 持续积压,且栈都停在子 Future 上,就符合这种依赖关系。

修复可以避免池内同步等待,改为子任务完成后触发后续操作,也就是非阻塞编排。还可以让子任务使用独立执行器,但仍需控制队列大小和下游容量,避免把等待转移到数据库等资源上。

7. 活锁与饥饿​

线程有动作却没有完成任务时,需要区分活锁与饥饿。活锁指线程不断改变状态或重试,却始终无法完成工作。密集重试时,CPU 使用率还会升高。饥饿则指特定任务长期得不到锁、许可或调度机会,而其他任务仍有进展。

单份 RUNNABLE 栈难以区分正常计算与反复重试。需要结合任务完成数、重试数、锁获取延迟和连续采样,判断线程是否持续活动却没有完成任务。JFR 可以补充这一时间段的运行事件。

CPU 排查见 CPU 使用率。

8. 虚拟线程的线程转储​

Java 21 的虚拟线程能让大量阻塞任务共享较少的平台线程。为观察这些任务的业务栈,可以导出 JSON 线程转储:

jcmd <pid> Thread.dump_to_file -format=json /safe/path/threads.json

该命令包含平台线程和虚拟线程。它逐线程采集,并非同一时刻的一致快照。命令也不执行自动死锁检测。

ThreadMXBean.findDeadlockedThreads() 检测平台线程的锁环。其结果不覆盖涉及虚拟线程的等待环。

承载虚拟线程的平台线程称为 carrier。Java 21 的虚拟线程在 synchronized 内阻塞时,会被固定在 carrier 上,这称为 pinning。执行 native 方法期间也会固定 carrier。长时间固定会减少可运行其他虚拟线程的 carrier,进而影响吞吐。

虚拟线程仍会争用数据库连接和共享锁。诊断时需要分别观察以下资源瓶颈:

  • 从业务栈确认虚拟线程在等待哪把锁或哪个资源池。
  • 查看 carrier 的 CPU 使用率,判断承载线程是否饱和。
  • 检查 pinning 的持续时间,以及它是否实际影响任务吞吐。
  • 对照数据库连接池与下游服务的容量,确认请求是否在外部资源处积压。

9. 常见问题​

读等待栈时,还需要明确等待方法是否释放锁,以及线程池是否仍有任务完成。

9.1 sleep 与 wait 的锁行为​

线程调用 sleep 时会保留当前持有的 monitor,所以在 synchronized 内休眠也会挡住其他线程。Object.wait 则释放调用对象的 monitor,并在返回前重新获取它。线程持有的其他锁仍需单独管理。

9.2 ReentrantLock 的死锁条件​

9.3 线程池空闲与积压​

10 · 面试题卡片​

10.1 怎样从线程转储定位 Java 死锁​

出现公司:阿里巴巴、字节跳动

考察重点:根据锁标识连接持有者与等待者,区分 monitor 和同步器信息,并理解自动死锁检测的范围。

相关内容:采集线程转储、死锁。

参考回答

定位锁引起的死锁,需要把转储中的锁持有者和等待者连接起来,确认是否构成循环等待。

先用 jcmd <pid> Thread.print -l 采集线程与锁信息,查看 JVM 的自动报告。再根据 locked、waiting to lock 和同步器持有信息核对关系。比如 A 持有 L1、等待 L2,B 持有 L2、等待 L1,就形成环。

保存多份转储确认等待持续,再映射到代码中的锁顺序。修复可以统一获取顺序、减少嵌套,或设计超时后释放锁的协议。修复后重新验证,自动检测范围之外的 Future 等依赖还需结合业务任务关系分析。

10.2 大量线程处于 WAITING,为什么系统仍然可能故障​

出现公司:阿里巴巴

考察重点:结合等待对象、active 和任务队列区分空闲与积压,解释同池父子任务为什么会互相等待。

相关内容:线程状态与栈、线程池饥饿。

参考回答

因为 WAITING 既能表示空闲线程在等任务,也能表示业务线程长期等不到锁、连接或子任务结果。

需要从栈中确认等待目标,再结合 active、queue 和请求延迟判断是否积压。比如,父任务占满固定大小线程池后,把子任务提交到同一池,再同步等待子 Future。所有工作线程都被父任务占用,排队的子任务就没有执行者,形成线程池饥饿。

这种依赖会让业务停止推进,但未必形成 JVM 能自动报告的 monitor 或同步器死锁。

10.3 Thread.print 没有死锁报告,能排除虚拟线程死锁吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明各线程转储命令覆盖哪些线程,以及自动死锁检测为什么会遗漏虚拟线程的等待环。

相关内容:虚拟线程的线程转储。

参考回答

不能排除,因为 Java 21 的 Thread.print 无法完整覆盖虚拟线程。

Thread.dump_to_file 能补充虚拟线程栈,但采用逐线程采集,也不自动检测死锁。ThreadMXBean.findDeadlockedThreads() 的检测范围同样限于平台线程。因此,需要结合锁持有者、任务依赖和多次采集结果,判断虚拟线程是否互相等待。

11. 参考资料​