JVM 线上诊断
凌晨三点 CPU 100% 告警。
top看 pid →jstackdump 线程栈 → 几百个RUNNABLE线程栈顶全是HashMap.get()——你以为找到了根因。但再看vmstat,context switch 每秒才 200——不是业务线程在烧 CPU,是所有线程在while(true)自旋等锁。排查方向从「慢查询」180 度转向「锁竞争」——只差一个vmstat。线上排障最怕的不是查不到,是查对了方向但看了错误的数据。
1. JVM 常见故障速查
| 现象 | 首选诊断方向 |
|---|---|
| CPU 100% | top -Hp <pid> → 找最忙的线程 → jstack 看栈 |
| 频繁 Full GC | GC 日志 → 内存 dump → MAT 分析大对象 |
| OOM | -XX:+HeapDumpOnOutOfMemoryError → MAT 分析 |
| StackOverflow | 检查无限递归 / 过深调用栈 |
| Metaspace OOM | 检查动态代理/反射/脚本引擎是否生成大量类 |
2. CPU 100% 诊断流程
# 1. 找到 Java 进程 PID
jps -l
# 2. 找到 CPU 最高的线程
top -Hp <pid>
# 3. 将线程 ID 转为十六进制
printf "%x\n" <tid>
# 4. 在 thread dump 中按 nid 匹配该线程(nid 为十六进制,不含 0x 前缀;建议 -A 行数取大些以保留完整栈)
jstack <pid> | grep -A 40 "nid=0x<tid in hex>"输出的栈信息会告诉你这个线程在执行什么代码。常见的根因有四种:
根因一:死循环或正则回溯
"Thread-1" #12 prio=5 os_prio=0 cpu=985421.23ms
java.lang.Thread.State: RUNNABLE
at com.example.Processor.process(Processor.java:45) ← 死循环位置
at com.example.Handler.handle(Handler.java:23)特征:线程状态为 RUNNABLE,CPU 时间极高。如果是正则回溯(ReDoS),栈顶通常有 java.util.regex.Pattern 相关方法。
根因二:GC 线程占用
"GC task thread#0 (ParallelGC)" os_prio=0 cpu=892341.56ms特征:top -Hp 中 CPU 最高的不是应用线程而是 GC 线程。说明频繁 GC 导致 CPU 被 GC 线程占用。先查 GC 日志,确认是否 FGC 频繁。
根因三:锁竞争(监视器阻塞)
"Thread-2" #13 prio=5 os_prio=0 cpu=567823.12ms
java.lang.Thread.State: BLOCKED
at com.example.SharedResource.access(SharedResource.java:30)
- waiting to lock <0x00000007aab3a0d0> ← 等待监视器锁特征:多个线程处于 BLOCKED 状态,等待同一个监视器(synchronized 锁)。持锁线程若在执行慢操作(I/O、慢查询),会让其他线程长时间进不去临界区。注意:监视器阻塞本身不消耗 CPU;若线程转储里大量线程是 RUNNABLE 且 CPU 持续偏高,更应怀疑忙循环或 CAS 自旋,而不是锁等待。
根因四:JIT 编译
"CompilerThread0" os_prio=0 cpu=345212.78ms特征:top -Hp 中 CPU 最高的是 CompilerThread,说明 JIT 正在编译大量代码。通常是正常的预热行为,如果持续占用则需检查 CodeCache。
3. Heap Dump 分析
3.1 获取方式
# 方式 1:OOM 时自动生成(推荐开启;容器环境需确认 dump 目录可写且磁盘空间充足)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dumps/
# 方式 2:手动 dump
jmap -dump:format=b,file=heap.hprof <pid>
# 方式 3:Arthas
heapdump /path/to/heap.hprof3.2 MAT(Memory Analyzer Tool)四大核心功能
1. Leak Suspects Report。 自动分析 dump 文件,识别可疑的内存泄漏点。
2. Histogram。 按类统计对象数量和占用空间。找到"数量异常多"或"占用异常大"的类。
3. Dominator Tree。 按支配关系定位保留了大量堆内存的对象:某对象的 Retained Heap 是移除它后可随之释放的内存,真正的占用往往来自它支配的对象图,而非对象自身的浅堆大小。
4. Path to GC Roots。 从某个对象出发,追溯到 GC Root 的引用链。回答"为什么这个对象没有被回收"。
3.3 MAT 实战:分析一次内存泄漏
场景: 应用运行一段时间后 OOM,拿到 dump 文件用 MAT 分析。
第一步:打开 Leak Suspects Report
MAT 自动分析后报告:
Problem Suspect 1:
4,523 instances of "com.example.CacheEntry", loaded by "app classloader"
occupy 1,892,345,678 bytes (45.2% of heap)
These instances are referenced from:
java.util.HashMap @ 0x7f8b2c012340
→ com.example.CacheManager.cache (CacheManager.java:15)MAT 告诉你:CacheEntry 对象占了堆的 45%,被 CacheManager 的 cache 字段(一个 HashMap)持有。
第二步:用 Dominator Tree 确认
打开 Dominator Tree,按 Retained Heap 排序:
com.example.CacheManager @ 0x7f8b2c012300
└─ java.util.HashMap @ 0x7f8b2c012340
└─ java.util.HashMap$Node[16384] @ 0x7f8b2c020000
└─ 4,523 x CacheEntry(每个约 418KB,为含关联引用的估算值,非对象浅堆)CacheManager 的 Retained Heap 约 1.8GB(MAT 计算的保留堆大小,非对象自身大小),说明这 4,523 个 CacheEntry 经它支配、当前仍可达而未被回收。
第三步:定位代码
CacheManager 是单例,cache 字段只写入、不清理。确认条目确实只增不减后,再补 TTL 与容量限制;修复后对比不同时点的堆快照,确认条目数量与 Retained Heap 趋于稳定。
4. Thread Dump 分析
# 获取方式
jstack <pid> # 推荐
kill -3 <pid> # 输出到 stdout
Arthas: thread # 交互式分析4.1 关键线程状态
| 状态 | 含义 | 关注点 |
|---|---|---|
| RUNNABLE | 正在运行或等待 CPU | CPU 热点线程 |
| BLOCKED | 等待获取锁 | 锁竞争问题 |
| WAITING | 无限期等待 | wait()、join()、park() |
| TIMED_WAITING | 限时等待 | sleep()、wait(timeout) |
4.2 死锁检测
jstack 输出末尾会自动检测死锁:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f8b4c003a18 (object 0x00000007aab3a0d0, a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x00007f8b4c006358 (object 0x00000007aab3a0e0, a java.lang.Object),
which is held by "Thread-1"4.3 非死锁场景:多线程 BLOCKED 在同一把锁
比死锁更常见的情况是多个线程 BLOCKED 在同一把锁上:
"http-nio-8080-exec-1" #15 prio=5
java.lang.Thread.State: BLOCKED
at com.example.OrderService.createOrder(OrderService.java:30)
- waiting to lock <0x00000007aab3a0d0> (a java.lang.Object)
which is held by "http-nio-8080-exec-5"
"http-nio-8080-exec-2" #16 prio=5
java.lang.Thread.State: BLOCKED
at com.example.OrderService.createOrder(OrderService.java:30)
- waiting to lock <0x00000007aab3a0d0> (a java.lang.Object)
which is held by "http-nio-8080-exec-5"
"http-nio-8080-exec-3" #17 prio=5
java.lang.Thread.State: BLOCKED
at com.example.OrderService.createOrder(OrderService.java:30)
- waiting to lock <0x00000007aab3a0d0> (a java.lang.Object)
which is held by "http-nio-8080-exec-5"三个线程都在等同一把锁,持锁线程是 exec-5。接下来搜索 exec-5 的栈信息:
"http-nio-8080-exec-5" #20 prio=5
java.lang.Thread.State: RUNNABLE
at java.net.SocketInputStream.socketRead0(Native Method)
at com.example.OrderService.createOrder(OrderService.java:31)
- locked <0x00000007aab3a0d0> (a java.lang.Object)exec-5 持有锁,但在做网络 I/O(socketRead0)——它在等下游响应(例如数据库)。这就是所有其他线程被阻塞的根因:持锁线程在同步 I/O 上阻塞。注意:socketRead0 只说明涉及套接字读取,不能仅凭这一帧断定对端一定是数据库,还需结合连接对象与调用链确认。
修复方向:将同步 I/O 移出 synchronized 块,或改用异步 I/O。
5. Arthas 核心命令
Arthas 是阿里开源的 Java 诊断工具,无需重启即可实时诊断线上问题。
| 命令 | 用途 | 示例 |
|---|---|---|
dashboard | 实时看板(线程、内存、GC) | dashboard |
thread | 线程分析 | thread -n 3(最忙的 3 个线程) |
thread -b | 查找阻塞线程 | thread -b |
trace | 方法调用链路耗时 | trace com.example.UserService getUser |
jad | 反编译线上代码 | jad com.example.UserService |
watch | 方法入参/返回值/异常 | watch com.example.UserService getUser '{params, returnObj}' |
heapdump | 生成 dump 文件 | heapdump /tmp/dump.hprof |
ognl | 执行表达式 | ognl '@com.example.Config@getInstance()' |
5.1 常用诊断场景
说明:
trace跟踪的是匹配方法内部的调用耗时,不是分布式链路追踪,也不保证覆盖完整请求链路;jad只能反映运行时加载类的反编译结果,不能单独证明部署制品版本(应另核对发布记录或制品校验值);watch与ognl在生产环境可能输出凭证、个人信息或大对象,也可能产生副作用,应限定观察字段、确认表达式无副作用,并在排障结束后及时停止。
场景 1:接口变慢
# 追踪方法耗时,找出哪个调用慢
trace com.example.OrderService createOrder
# 输出
+---[3.2ms] com.example.OrderService:createOrder()
+---[1.1ms] com.example.UserDao:findById()
+---[0.8ms] com.example.OrderDao:save()
+---[1.2ms] com.example.NotificationService:send() ← 这里最慢场景 2:确认线上代码是否最新
# 反编译正在运行的代码
jad com.example.OrderService
# 对比源码,确认是否是最新版本场景 3:查看方法参数和返回值
# 监控方法调用
watch com.example.UserService getUser '{params[0], returnObj}' -x 2
# 输出
params[0]: 12345
returnObj: User{id=12345, name='Tom', age=25}6. JFR(Java Flight Recorder)
JFR 是 JDK 内置的低开销性能分析工具,可以在生产环境持续录制,事后用 JDK Mission Control(JMC)分析。
启动录制:
# 录制 60 秒,输出到文件
jcmd <pid> JFR.start duration=60s filename=recording.jfr
# 持续录制(手动停止)
jcmd <pid> JFR.start settings=default filename=continuous.jfr
jcmd <pid> JFR.stop
# 查看正在录制的任务
jcmd <pid> JFR.checkJFR 能看到什么:
| 数据类别 | 内容 |
|---|---|
| CPU 热点 | 哪个方法消耗最多 CPU |
| 内存分配 | 哪个方法分配了最多对象 |
| 锁竞争 | 哪把锁等待时间最长 |
| GC 事件 | 每次 GC 的耗时和回收量 |
| I/O | 文件和网络 I/O 的耗时 |
| 线程 | 线程状态变化、死锁检测 |
JMC 分析:
# 打开 JMC 图形界面
jmcJMC 提供自动分析功能,能标记出潜在的性能问题(如“方法编译时间过长”、“GC 停顿过长”)。JDK 21 官方配置 default.jfc 推荐用于持续录制,通常开销低于 1%;profile.jfc 会记录更多事件,适用于短时性能分析,不能套用同样的开销保证。
6.1 JFR 分析实战
场景: 用 JFR 录制了 60 秒的生产环境运行数据,用 JMC 打开分析。
第一步:看 JMC 的自动分析结果
(以下为根据录制数据整理的教学示意,并非特定版本 JMC 的原始输出;实际界面名称、规则结果与统计字段以所用版本为准。)
JMC 打开 .jfr 文件后,左侧 "Rule Results" 中自动标记了问题:
⚠️ Hot Methods:
com.example.QueryBuilder.buildQuery() — 占用 12.3% CPU
java.util.regex.Pattern.matches() — 占用 8.7% CPU
⚠️ GC Stall:
GC 暂停时间平均 234ms,超过阈值 200ms
⚠️ Lock Instances:
java.util.concurrent.ConcurrentHashMap @ 0x7f8b — 平均等待 45ms第二步:看 CPU 热点
点击 "Hot Methods",看到 QueryBuilder.buildQuery() 消耗了 12.3% CPU。展开调用栈,发现这个方法在循环中反复拼接字符串。
第三步:看内存分配
点击 "Allocation",发现 QueryBuilder 每次调用分配了大量临时 StringBuilder 和 String 对象。这些对象在新生代被快速回收,但频繁分配导致 Young GC 频繁。
第四步:修复
将字符串拼接改为预分配的 StringBuilder,减少临时对象分配。再次录制确认 CPU 和 GC 均有改善。
7. GC 日志与参数
GC 日志是判断回收频率、停顿和空间增长的第一手资料。以下命令适用于 JDK 9+:
# 以下为 JVM 启动参数(需随 java 命令传入,不能单独在 shell 中执行)
# 常规信息
-Xlog:gc*=info:file=gc.log:time,uptime,level,tags
# 需要更多阶段细节时
-Xlog:gc*=debug:file=gc.log:time,uptime,level,tags读取 G1 日志时,先关注回收类型、各代空间变化和耗时:
| 字段 | 含义 |
|---|---|
Pause Young (Normal) | 正常的年轻代回收 |
Pause Mixed | 回收部分老年代 Region 的 Mixed GC |
Pause Full | 整堆或老年代压力下的 Full GC |
Eden: 1024M(1024M)->0B(1024M) | Eden 回收前后的使用量与容量 |
Old: 2048M->2100M | 老年代回收前后的使用量 |
real=0.08 secs | 日志记录的墙上时间;是否等于应用停顿取决于收集器和字段定义 |
字段名称和格式会随 JDK 与收集器变化。常用参数见 JVM 核心参数速查,完整排查案例见 GC、资源与综合诊断和 GC 延迟与分配异常。
8. JIT 编译与 CodeCache 观察
8.1 CodeCache 满
CodeCache 容量不足时,HotSpot 会停止接受新的编译,已有编译代码通常仍可继续执行;服务可能在没有 OOM 或 GC 异常的情况下逐渐变慢。
# 查看已编译方法数量;该计数不能代替容量监控
jstat -compiler <pid>
# 结合 JFR 或编译日志观察容量、停止编译和反优化事件
jcmd <pid> JFR.start settings=profile filename=codecache.jfr duration=60s
java -Xlog:codecache=info -jar app.jar容量上限和缺省值取决于 JDK、CPU 与配置。确认持续接近上限后,再评估 -XX:ReservedCodeCacheSize,并检查 Groovy、动态代理等是否生成了异常大量的代码。
8.2 编译线程 CPU 与去优化抖动
| 症状 | 通常含义 | 检查方向 |
|---|---|---|
| 启动后几分钟编译线程 CPU 偏高 | 正常预热 | 观察是否随热点稳定而下降 |
| 部署或热部署后短暂抖动 | 新类加载导致批量去优化 | 检查 -Xlog:compilation*=info 中的 deoptimization |
| 运行一段时间后突然变慢 | CodeCache 停止编译或动态代码过多 | 对照本节 CodeCache 排查步骤 |
如需压测启动阶段,可以评估 -XX:TieredStopAtLevel=1 等策略,但它会改变编译层级和峰值性能,不能作为长期默认配置直接套用。
8.3 打印与分析 JIT 日志
# 简要编译事件
java -XX:+PrintCompilation -jar app.jar
# 完整编译日志,供 JITWatch 等工具分析
java -XX:+UnlockDiagnosticVMOptions \
-XX:+LogCompilation \
-XX:LogFile=jit.log \
-jar app.jar
# 使用 JFR 记录 JIT 事件
jcmd <pid> JFR.start settings=profile filename=jit.jfr duration=60sJFR 中可关注 CompilerCompilation、CompilerInlining 和 Deoptimization。使用 JITWatch 时,应选择与日志格式匹配的版本。日志选项和输出字段可能随 JDK 版本变化。
9. 工具选择指南
遇到问题时,选对工具能事半功倍:
| 问题类型 | 首选工具 | 辅助工具 |
|---|---|---|
| CPU 高 | top -Hp + jstack | Arthas thread -n 3 |
| 内存泄漏 / OOM | -XX:+HeapDumpOnOutOfMemoryError + MAT | Arthas heapdump + dashboard |
| GC 问题 | GC 日志(-Xlog:gc*)+ jstat | JFR 的 GC 事件 |
| 接口变慢 | Arthas trace | JFR CPU 热点 |
| 锁竞争 | jstack / Arthas thread -b | JFR 的 Lock 事件 |
| 确认线上代码 | Arthas jad | — |
| 综合性能分析 | JFR(持续录制) | JMC 可视化分析 |
| CodeCache 问题 | -Xlog:compilation*、JFR 编译事件 | jstat -compiler(仅看编译计数,不能代替容量观测) |
9.1 端到端排查案例:接口变慢
现象: 某个 API 接口从 200ms 降到 3 秒。
第一步:确认 Full GC 是否与接口延迟峰值重合
# 看 GC 日志
tail -f gc.log
# 发现每 2 分钟一次 Full GC,每次耗时 1.5 秒
# 确认堆状态
jstat -gcutil <pid> 1000
# O 区持续增长到 99% 后触发 FGC排查时对齐接口延迟、请求量与 GC 日志的时间戳:只有延迟峰值与 Full GC 停顿在时间上吻合,才能把接口变慢归因于 GC;否则还需检查 CPU、锁等待与下游耗时。
第二步:找到老年代中的大对象
# 生成包含全部对象的堆快照;不使用 live,避免为筛选存活对象而强制 Full GC
jmap -dump:format=b,file=heap.hprof <pid>
# 只保留存活对象时可加 live,但会先执行 Full GC,线上慎用
# jmap -dump:live,format=b,file=heap-live.hprof <pid>
# 或用 Arthas 导出默认全堆快照
heapdump /tmp/heap.hprof无论使用哪种工具,导出过程都可能触发安全点停顿并产生大量磁盘 I/O。线上应预留足够的磁盘空间,优先在低峰期执行。
第三步:MAT 分析
Leak Suspects Report 与 Retained Heap 分析显示,com.example.ReportGenerator 的引用链保留了约 2GB 的 ArrayList<ReportData> 对象图(示例数据);还需检查该集合的生命周期,确认这些数据是否应继续存活。
第四步:定位代码
# 用 Arthas 反编译线上代码
jad com.example.ReportGenerator发现 ReportGenerator 在每次请求时将数据加入一个静态 ArrayList,但从不清理。
第五步:修复并验证
将 ArrayList 改为请求级局部变量,或补充清理逻辑。修复后在相近负载下重新观察老年代使用趋势、Full GC 频率、接口延迟与对象保留情况,确认内存占用趋于稳定且业务数据未因清理而丢失。
10. JVM 核心参数速查
| 类别 | 参数 | 说明 |
|---|---|---|
| 堆内存 | -Xms4g -Xmx4g | 初始/最大堆;设为相同值是常见部署策略,需结合容器内存限制与运行环境决定 |
| 新生代 | -Xmn2g | 设置新生代大小;具体效果受收集器与 JDK 版本影响 |
| 栈大小 | -Xss256k | 每个线程的栈大小(示例值;过小可能在深调用链触发 StackOverflowError,需结合调用深度与压测确定) |
| GC 算法 | -XX:+UseG1GC | 选择收集器 |
| GC 停顿 | -XX:MaxGCPauseMillis=200 | G1 的停顿目标,不是硬性上限保证;实际停顿取决于堆状态与工作负载 |
| GC 日志 | -Xlog:gc*=info:file=gc.log | JDK 9+ 统一格式 |
| OOM dump | -XX:+HeapDumpOnOutOfMemoryError | OOM 时自动 dump |
| dump 路径 | -XX:HeapDumpPath=/path/ | dump 文件存储位置 |
| Metaspace | -XX:MaxMetaspaceSize=256m | 限制 Metaspace 上限(示例值,需结合类加载规模确定) |
| 压缩对象引用 | -XX:+UseCompressedOops | 64 位 HotSpot 在符合条件时通常默认启用,以目标进程实际配置为准 |
| 压缩类指针 | -XX:+UseCompressedClassPointers | 默认行为受 JVM 配置与运行参数影响,以目标进程实际状态为准 |
11. 实战案例集
以上内容是诊断工具和方法的速查手册。以下案例集均为构造或匿名化的教学案例,每个案例都包含完整的背景、排查链路、根因定位和修复验证:
- 正则灾难性回溯(ReDoS)—— 一行正则烧了银行的支付网关
- 本地缓存无上限 —— 把整个订单表装进内存
- CGLIB 动态代理未复用 —— 爆掉 256MB Metaspace
- 背靠背 Full GC —— 双十一订单服务的蜕变
- 连接池耗尽 —— 200 个线程全卡在 getConnection()
- Arthas + JFR 综合诊断 —— 接口从 50ms 变成 3000ms 的全链路追踪
- 支付回调的 Young GC 风暴 —— 日志拼接每秒造 300MB 垃圾
- 索引热更新的 Survivor 复制风暴 —— 500MB 对象在新生代来回搬家
- SafePoint 同步延迟 —— GC 只花了 0.14 秒,线程却停了 2.26 秒
- Log4j2 + PretenureSizeThreshold 组合技 —— 2MB 的"日志炸弹"直冲老年代
- Tomcat LimitLatch —— 连接数配置导致服务间歇性假死
- Netty 直接内存泄漏 —— 堆正常但容器被 OOMKilled
JVM 诊断专题到此结束。从字节码 → 类加载 → 运行时数据区 → 对象布局 → GC → JIT → 线上排查,本专题把 JVM 运行机制与线上诊断工具连成了一条主线。
与后续专题的连接:
- Java 并发:AQS 依赖对象头和 Monitor,synchronized 依赖 Mark Word 锁升级
- Spring:反射依赖 Class 元数据,CGLIB 依赖字节码操作
- 性能优化:GC 调优依赖分代模型和收集器特性的理解