Skip to content

JVM 线上诊断 ​

凌晨三点 CPU 100% 告警。top 看 pid → jstack dump 线程栈 → 几百个 RUNNABLE 线程栈顶全是 HashMap.get()——你以为找到了根因。但再看 vmstat,context switch 每秒才 200——不是业务线程在烧 CPU,是所有线程在 while(true) 自旋等锁。排查方向从「慢查询」180 度转向「锁竞争」——只差一个 vmstat。线上排障最怕的不是查不到,是查对了方向但看了错误的数据。

1. JVM 常见故障速查 ​

现象首选诊断方向
CPU 100%top -Hp <pid> → 找最忙的线程 → jstack 看栈
频繁 Full GCGC 日志 → 内存 dump → MAT 分析大对象
OOM-XX:+HeapDumpOnOutOfMemoryError → MAT 分析
StackOverflow检查无限递归 / 过深调用栈
Metaspace OOM检查动态代理/反射/脚本引擎是否生成大量类

2. CPU 100% 诊断流程 ​

bash
# 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>"

输出的栈信息会告诉你这个线程在执行什么代码。常见的根因有四种:

根因一:死循环或正则回溯

txt
"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 线程占用

txt
"GC task thread#0 (ParallelGC)" os_prio=0 cpu=892341.56ms

特征:top -Hp 中 CPU 最高的不是应用线程而是 GC 线程。说明频繁 GC 导致 CPU 被 GC 线程占用。先查 GC 日志,确认是否 FGC 频繁。

根因三:锁竞争(监视器阻塞)

txt
"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 编译

txt
"CompilerThread0" os_prio=0 cpu=345212.78ms

特征:top -Hp 中 CPU 最高的是 CompilerThread,说明 JIT 正在编译大量代码。通常是正常的预热行为,如果持续占用则需检查 CodeCache。

3. Heap Dump 分析 ​

3.1 获取方式 ​

bash
# 方式 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.hprof

3.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 自动分析后报告:

txt
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 排序:

txt
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 分析 ​

bash
# 获取方式
jstack <pid>              # 推荐
kill -3 <pid>             # 输出到 stdout
Arthas: thread            # 交互式分析

4.1 关键线程状态 ​

状态含义关注点
RUNNABLE正在运行或等待 CPUCPU 热点线程
BLOCKED等待获取锁锁竞争问题
WAITING无限期等待wait()、join()、park()
TIMED_WAITING限时等待sleep()、wait(timeout)

4.2 死锁检测 ​

jstack 输出末尾会自动检测死锁:

txt
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 在同一把锁上:

txt
"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 的栈信息:

txt
"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:接口变慢

bash
# 追踪方法耗时,找出哪个调用慢
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:确认线上代码是否最新

bash
# 反编译正在运行的代码
jad com.example.OrderService

# 对比源码,确认是否是最新版本

场景 3:查看方法参数和返回值

bash
# 监控方法调用
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)分析。

启动录制:

bash
# 录制 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.check

JFR 能看到什么:

数据类别内容
CPU 热点哪个方法消耗最多 CPU
内存分配哪个方法分配了最多对象
锁竞争哪把锁等待时间最长
GC 事件每次 GC 的耗时和回收量
I/O文件和网络 I/O 的耗时
线程线程状态变化、死锁检测

JMC 分析:

bash
# 打开 JMC 图形界面
jmc

JMC 提供自动分析功能,能标记出潜在的性能问题(如“方法编译时间过长”、“GC 停顿过长”)。JDK 21 官方配置 default.jfc 推荐用于持续录制,通常开销低于 1%;profile.jfc 会记录更多事件,适用于短时性能分析,不能套用同样的开销保证。

6.1 JFR 分析实战 ​

场景: 用 JFR 录制了 60 秒的生产环境运行数据,用 JMC 打开分析。

第一步:看 JMC 的自动分析结果

(以下为根据录制数据整理的教学示意,并非特定版本 JMC 的原始输出;实际界面名称、规则结果与统计字段以所用版本为准。)

JMC 打开 .jfr 文件后,左侧 "Rule Results" 中自动标记了问题:

txt
⚠️ 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+:

bash
# 以下为 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 异常的情况下逐渐变慢。

bash
# 查看已编译方法数量;该计数不能代替容量监控
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 日志 ​

bash
# 简要编译事件
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=60s

JFR 中可关注 CompilerCompilation、CompilerInlining 和 Deoptimization。使用 JITWatch 时,应选择与日志格式匹配的版本。日志选项和输出字段可能随 JDK 版本变化。

9. 工具选择指南 ​

遇到问题时,选对工具能事半功倍:

问题类型首选工具辅助工具
CPU 高top -Hp + jstackArthas thread -n 3
内存泄漏 / OOM-XX:+HeapDumpOnOutOfMemoryError + MATArthas heapdump + dashboard
GC 问题GC 日志(-Xlog:gc*)+ jstatJFR 的 GC 事件
接口变慢Arthas traceJFR CPU 热点
锁竞争jstack / Arthas thread -bJFR 的 Lock 事件
确认线上代码Arthas jad—
综合性能分析JFR(持续录制)JMC 可视化分析
CodeCache 问题-Xlog:compilation*、JFR 编译事件jstat -compiler(仅看编译计数,不能代替容量观测)

9.1 端到端排查案例:接口变慢 ​

现象: 某个 API 接口从 200ms 降到 3 秒。

第一步:确认 Full GC 是否与接口延迟峰值重合

bash
# 看 GC 日志
tail -f gc.log
# 发现每 2 分钟一次 Full GC,每次耗时 1.5 秒

# 确认堆状态
jstat -gcutil <pid> 1000
# O 区持续增长到 99% 后触发 FGC

排查时对齐接口延迟、请求量与 GC 日志的时间戳:只有延迟峰值与 Full GC 停顿在时间上吻合,才能把接口变慢归因于 GC;否则还需检查 CPU、锁等待与下游耗时。

第二步:找到老年代中的大对象

bash
# 生成包含全部对象的堆快照;不使用 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> 对象图(示例数据);还需检查该集合的生命周期,确认这些数据是否应继续存活。

第四步:定位代码

bash
# 用 Arthas 反编译线上代码
jad com.example.ReportGenerator

发现 ReportGenerator 在每次请求时将数据加入一个静态 ArrayList,但从不清理。

第五步:修复并验证

将 ArrayList 改为请求级局部变量,或补充清理逻辑。修复后在相近负载下重新观察老年代使用趋势、Full GC 频率、接口延迟与对象保留情况,确认内存占用趋于稳定且业务数据未因清理而丢失。

10. JVM 核心参数速查 ​

类别参数说明
堆内存-Xms4g -Xmx4g初始/最大堆;设为相同值是常见部署策略,需结合容器内存限制与运行环境决定
新生代-Xmn2g设置新生代大小;具体效果受收集器与 JDK 版本影响
栈大小-Xss256k每个线程的栈大小(示例值;过小可能在深调用链触发 StackOverflowError,需结合调用深度与压测确定)
GC 算法-XX:+UseG1GC选择收集器
GC 停顿-XX:MaxGCPauseMillis=200G1 的停顿目标,不是硬性上限保证;实际停顿取决于堆状态与工作负载
GC 日志-Xlog:gc*=info:file=gc.logJDK 9+ 统一格式
OOM dump-XX:+HeapDumpOnOutOfMemoryErrorOOM 时自动 dump
dump 路径-XX:HeapDumpPath=/path/dump 文件存储位置
Metaspace-XX:MaxMetaspaceSize=256m限制 Metaspace 上限(示例值,需结合类加载规模确定)
压缩对象引用-XX:+UseCompressedOops64 位 HotSpot 在符合条件时通常默认启用,以目标进程实际配置为准
压缩类指针-XX:+UseCompressedClassPointers默认行为受 JVM 配置与运行参数影响,以目标进程实际状态为准

11. 实战案例集 ​

以上内容是诊断工具和方法的速查手册。以下案例集均为构造或匿名化的教学案例,每个案例都包含完整的背景、排查链路、根因定位和修复验证:

  • CPU 与内存泄漏案例

    • 正则灾难性回溯(ReDoS)—— 一行正则烧了银行的支付网关
    • 本地缓存无上限 —— 把整个订单表装进内存
    • CGLIB 动态代理未复用 —— 爆掉 256MB Metaspace
  • GC、资源与综合诊断案例

    • 背靠背 Full GC —— 双十一订单服务的蜕变
    • 连接池耗尽 —— 200 个线程全卡在 getConnection()
    • Arthas + JFR 综合诊断 —— 接口从 50ms 变成 3000ms 的全链路追踪
  • GC 延迟与分配异常案例

    • 支付回调的 Young GC 风暴 —— 日志拼接每秒造 300MB 垃圾
    • 索引热更新的 Survivor 复制风暴 —— 500MB 对象在新生代来回搬家
    • SafePoint 同步延迟 —— GC 只花了 0.14 秒,线程却停了 2.26 秒
    • Log4j2 + PretenureSizeThreshold 组合技 —— 2MB 的"日志炸弹"直冲老年代
  • TCP 层与堆外内存案例

    • Tomcat LimitLatch —— 连接数配置导致服务间歇性假死
    • Netty 直接内存泄漏 —— 堆正常但容器被 OOMKilled

JVM 诊断专题到此结束。从字节码 → 类加载 → 运行时数据区 → 对象布局 → GC → JIT → 线上排查,本专题把 JVM 运行机制与线上诊断工具连成了一条主线。

与后续专题的连接:

  • Java 并发:AQS 依赖对象头和 Monitor,synchronized 依赖 Mark Word 锁升级
  • Spring:反射依赖 Class 元数据,CGLIB 依赖字节码操作
  • 性能优化:GC 调优依赖分代模型和收集器特性的理解

Released under the MIT License.