JVM 反模式与排查宝典

3 阅读10分钟

概述

本系列前九篇从类加载机制、四种经典收集器、G1 的 SATB 与 RSet、ZGC/Shenandoah 的染色指针、GC 日志全字段解读、JIT 分层编译与逃逸分析、内存泄漏 MAT 排查、JVM 参数最佳实践、到 JMH 基准测试,构建了 JVM 调优的完整知识体系。然而知识最终要转化为解决问题的能力——当生产环境出现 OOM、CPU 飙高、频繁 Full GC、死锁、Metaspace 泄漏时,如何快速定位根因并给出修复方案?本文作为系列⑤的收官之作,将前九篇的理论知识串联成一套完整的排查武器库和反模式清单。

“线上 OOM 了,日志显示 'Java heap space',下一步该查什么?CPU 飙到 100%,top -H 看到一个叫 'GC task thread' 的线程占了 60%,到底是代码死循环还是 GC 频繁?jstack 输出里大量 BLOCKED 线程,如何快速找到是哪个锁对象?频繁 Full GC 每次耗时 5 秒,如何区分是内存泄漏还是堆参数不够?”——这些问题是每个 Java 开发者在线上故障面前的真实困境。本文将从 OOM、CPU、死锁、GC、类加载五大类反模式出发,逐一给出“现象→诊断→根因→修复→验证”的完整排查闭环,让读者拿到 jstack/jmap/jstat 的输出不再迷茫。

核心要点:

  • OOM 三种类型:Java heap space / Metaspace / Direct buffer memory 的快速区分
  • CPU 飙高两种根因:死循环(top -H + jstack)vs 频繁 GC(jstat -gcutil + GC 线程名)
  • 死锁诊断:jstack -l 自动检测 + ThreadMXBean 编程监控
  • GC 异常调优:频繁 Young GC(增大年轻代)/ Full GC(排查泄漏)/ 晋升失败(调整 Survivor)
  • 类加载泄漏:-XX:+TraceClassLoading + Arthas classloader + MAT OQL 查询
  • 完整工具链:jstack/jmap/jstat/jcmd/MAT/Arthas/GCViewer/JMH

文章组织架构:

flowchart TD
    A["1. OOM 三种类型排查"] --> B["2. CPU 飙高定位"]
    B --> C["3. 死锁与锁竞争"]
    C --> D["4. GC 异常调优"]
    D --> E["5. 类加载与 Metaspace 泄漏"]
    E --> F["6. JMH 验证优化"]
    F --> G["7. 系列⑤收尾"]
    A -->|"Heap/Metaspace/Direct"| A1["决策树"]
    B -->|"死循环 vs GC"| B1["双路径"]
    C -->|"jstack -l"| C1["死锁解读"]
    D -->|"YGC/FGC/Promotion"| D1["三场景"]
    E -->|"ClassLoader/CGLIB"| E1["泄漏路径"]
    F -->|"Score/Error"| F1["可信度"]
    G -->|"知识体系"| G1["工具链全景"]

流程说明: 模块 1-5 逐一拆解五大类反模式的排查闭环;模块 6 用 JMH 验证优化效果;模块 7 作为系列⑤收尾,串联前九篇的知识链。

关键结论: JVM 调优不是“背参数”,而是“掌握现象→诊断→根因→修复→验证的完整闭环”。OOM 先看异常类型区分是堆/元空间/堆外、CPU 飙高先看线程名区分是死循环还是 GC、死锁用 jstack -l 自动检测、GC 异常用 jstat 和 GC 日志联合分析、类加载泄漏用 TraceClassLoading 和 Arthas 追踪。这五大排查闭环覆盖了线上 JVM 故障的 90% 场景。


1. OOM 三种类型排查:Heap Space / Metaspace / Direct Memory

当 JVM 抛出 OutOfMemoryError,第一条线索就是异常消息。三种常见类型对应完全不同的内存区域和排查方向:

OOM 类型异常信息特征主要泄漏区域关键排查工具
堆内存溢出java.lang.OutOfMemoryError: Java heap space堆(对象实例)MAT Dominator Tree + Path to GC Roots
元空间溢出java.lang.OutOfMemoryError: Metaspace元空间(类元数据)TraceClassLoading + Arthas classloader + MAT OQL
直接内存溢出java.lang.OutOfMemoryError: Direct buffer memory堆外直接内存jcmd VM.native_memory + MAT 分析 DirectByteBuffer

1.1 Java heap space:堆内存不足

典型日志:

java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3236)
    at java.util.ArrayList.grow(ArrayList.java:267)
    ...

常见原因: 内存泄漏(ThreadLocal 值未 remove、静态集合持续增长、长生命周期对象持有短生命周期引用)、-Xmx 设置过小、大对象分配(大数组、大文件一次性读入内存)。

排查闭环:

  1. 开启 HeapDump: 添加 JVM 参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/heapdump.hprof,OOM 时自动生成堆转储。
  2. 用 MAT 打开 hprof 文件,进入 Dominator Tree 视图。 按 Retained Heap 降序排列,占据内存最大的对象通常是泄漏根对象。例如,发现某个 HashMap 的 retained heap 高达 800MB。
  3. 选中该对象,右键 Path to GC Roots → exclude weak/soft references,追踪强引用链。结果可能显示:HashMaptableEntryvalueUser 对象,而这些 User 对象被一个静态集合 CacheManager.allUsers 持有,永不被回收。
  4. 修复: 根据引用链,找到代码中导致对象残留的静态字段或集合,改为弱引用或定期清理。例如限制缓存大小,使用 WeakHashMap 或 Caffeine 的 TTL 策略。

实例:ThreadLocal 泄漏排查

// 有问题的代码
public class RequestContext {
    private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
    public static void set(User u) { currentUser.set(u); }
    // 缺少 remove()
}

在 MAT 中打开 Dominator Tree,搜索 java.lang.ThreadLocal$ThreadLocalMap$Entry,会发现大量 Entry 实例,其 value 引用的 User 对象无法回收。Path to GC Roots 会追溯到线程对象的 threadLocals 字段。修复:在请求结束时调用 currentUser.remove()

1.2 Metaspace:元空间溢出

典型日志:

java.lang.OutOfMemoryError: Metaspace

常见原因: 动态生成类过多(CGLIB、Javassist、JDK Proxy)且未缓存;Groovy 脚本引擎频繁创建 ClassLoader 未关闭;Lambda 代理类大量生成;JSP 编译。类加载器本身被引用导致其加载的所有类无法卸载。

排查闭环:

  • 添加 JVM 参数: -XX:+TraceClassLoading -XX:+TraceClassUnloading -XX:MaxMetaspaceSize=256m 观察类加载/卸载频率。若输出中 Loaded 数量持续增长而 Unloaded 很少,说明类泄漏。
  • Arthas 在线诊断: 执行 classloader 查看 ClassLoader 实例数和加载的类数量。如果发现大量非系统 ClassLoader(如 groovy.lang.GroovyClassLoader)且各自加载了大量类,很可能泄漏。
  • 用 MAT 进一步验证: 使用 OQL 查询 SELECT * FROM java.lang.Class WHERE className LIKE '%.CGLIB%'LIKE '%Proxy%',查看这些动态类的实例数。通常结合 Dominator Tree 找到持有这些 Class 的 ClassLoader 对象。
  • 修复:
    • 缓存 Enhancer(CGLIB):Enhancer 对象应单例,或使用 Enhancer.create() 后保存生成的类,避免重复创建新类。
    • 对于 Groovy,调用 GroovyClassLoader.close()
    • 为 JDK 代理使用缓存:Proxy.getProxyClass 仅调用一次,用 ConcurrentHashMap 存储。
    • 设置 -XX:MaxMetaspaceSize 作为兜底,触发 Full GC 时尝试卸载无用的类。

1.3 Direct buffer memory:堆外直接内存溢出

典型日志:

java.lang.OutOfMemoryError: Direct buffer memory
    at java.nio.Bits.reserveMemory(Bits.java:695)
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)

常见原因: Netty 的 ByteBuf 未调用 release();NIO 的 DirectByteBuffer 依赖 Cleaner 虚引用回收,若 GC 不及时且分配速率高,堆外内存会先于 GC 耗尽;Kafka 的 RecordAccumulator 未关闭。

排查闭环:

  • 查看堆外内存占用: 使用 jcmd <pid> VM.native_memory summary(需开启 -XX:NativeMemoryTracking=summary)。输出中关注 Internal 部分的 reservedcommitted,尤其是 Direct Byte Buffers 项。
    - Internal (reserved=1450MB, committed=1450MB)
          (malloc=50MB #3200)
          (mmap: reserved=1400MB, committed=1400MB)   <-- 直接内存映射
    
  • MAT 分析 DirectByteBuffer 实例: 在堆转储中搜索 java.nio.DirectByteBuffer,查看其实例数量和容量。虽然这些对象的 Java 堆占用很小(仅 header + address),但它们关联的堆外内存可通过 capacity 字段估算。如果存在大量未释放的 DirectByteBuffer,即可定位引用链。
  • 代码审查: 重点检查 Netty 的 PooledByteBufAllocator 使用是否正确,在 finally 块中调用 ReferenceCountUtil.release(byteBuf);Kafka 生产者 close() 是否被调用。

修复:

  • 显式释放:((DirectBuffer) byteBuffer).cleaner().clean();(仅限确认不再使用且无法被 GC 追踪的场景,慎用)。
  • 设置 JVM 参数限制堆外内存:-XX:MaxDirectMemorySize=1g(默认与 -Xmx 相同,可独立设置更小的值以暴露问题)。
  • 使用 Netty 的 ResourceLeakDetector 检测泄漏:设置级别 -Dio.netty.leakDetection.level=paranoid

OOM 三种类型决策树图

flowchart TD
    OOM[OutOfMemoryError 异常] -->|Java heap space| HEAP[堆内存不足]
    OOM -->|Metaspace| META[元空间不足]
    OOM -->|Direct buffer memory| DIRECT[堆外直接内存不足]
    HEAP --> HEAP1[MAT Dominator Tree 找大对象]
    HEAP1 --> HEAP2[Path to GC Roots 追引用链]
    HEAP2 --> HEAP3[静态集合/ThreadLocal泄漏/大缓存]
    HEAP3 --> HEAP4[修复泄漏 或 增大-Xmx]
    META --> META1[TraceClassLoading 看类加载频率]
    META1 --> META2[Arthas classloader 查加载器实例]
    META2 --> META3[MAT OQL 查 CGLIB/Proxy 类]
    META3 --> META4[缓存 Enhancer / 关闭 Groovy / 增大 MaxMetaspaceSize]
    DIRECT --> DIRECT1[jcmd VM.native_memory 看堆外占用]
    DIRECT1 --> DIRECT2[MAT 查 DirectByteBuffer 实例及 capacity]
    DIRECT2 --> DIRECT3[代码审查 ByteBuf.release / Kafka close]
    DIRECT3 --> DIRECT4[修复资源释放 或 增大 MaxDirectMemorySize]

a) 主旨概括: 该决策树从 OOM 的异常信息入手,分支到三种内存区域,给出各自的根因分析和标准修复路径,形成快速判别的思维模型。

b) 逐元素分解: 三个主分支分别对应 Heap、Metaspace、Direct Memory;每个分支内从左到右体现“现象→工具→根因→修复”的递进关系,如 Heap 分支经过 MAT 分析→引用链→泄漏点→修复。

c) 设计原理映射: 决策树映射了 JVM 内存管理模型:堆由 GC 管理,泄漏需分析对象可达性;Metaspace 存储类元数据,依赖类加载器生命周;直接内存由 Bits 类分配,只能通过对象清理虚引用释放,因此排查路径各有侧重。

d) 工程联系与关键结论: 面对 OOM,必须先读异常类型,避免盲目调参。堆内存泄漏占线上 OOM 的 70% 以上,MAT Dominator Tree 是定位根对象的银弹;Metaspace 泄漏常由动态代理引发,务必开启类加载日志;直接内存泄漏多见于 NIO 框架,需配合 NMT 和代码审查。


2. CPU 飙高定位:死循环 vs 频繁 GC 的区分与定位

CPU 100% 是线上高频故障,根源主要有两类:死循环(用户线程空转)和频繁 GC(GC 线程占用 CPU 做无用回收)。快速区分两者,才能对症下药。

2.1 第一步:找到消耗 CPU 的线程

使用 top -H -p <pid> 查看进程内线程的 CPU 占用:

PID   USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND
12345 app       20   0 5030824 1.5g  15572 S  0.0  9.8   0:00.00 java
12346 app       20   0 5030824 1.5g  15572 S 48.7  9.8   2:32.18 java   <-- GC task thread#0 或 http-nio-8080-exec-1
12347 app       20   0 5030824 1.5g  15572 S  0.3  9.8   0:12.34 java

观察 CPU 最高的线程名(通过 COMMAND 粗略,但需要用 jstack 确认)。如果线程名含 GC task threadVM Periodic Task Thread 等,则是 GC 或 JVM 内部线程;若是 http-nio-8080-exec-1pool-1-thread-1 等业务线程,则是应用逻辑导致。

2.2 死循环定位:top -H + jstack

根因一:死循环/死锁/活锁

  • 确认线程名:top -H 中看到 http-nio-8080-exec-1 长期占用 99% CPU。
  • 获取线程 nid: printf '%x\n' <线程 tid> 得到 16 进制 id,例如 printf '%x\n' 12346 输出 303a
  • 查看线程栈: jstack <pid> | grep -A 20 nid=0x303a 将显示该线程的完整调用栈,通常能定位到具体代码行:
    "http-nio-8080-exec-1" #49 daemon prio=5 os_prio=0 tid=0x00007f9c0010b000 nid=0x303a runnable [0x00007f9b8d5e7000]
       java.lang.Thread.State: RUNNABLE
          at com.example.service.Calculator.loopForever(Calculator.java:15)
          at com.example.controller.MainController.calc(MainController.java:22)
          ...
    
    查看 Calculator.java:15,发现 while(true) { // do something } 无 sleep 或条件等待。
  • 修复: 在循环内加入 Thread.sleep(1) 或使用条件变量 LockSupport.parkNanos(),或改为基于阻塞队列的事件驱动模式,避免空转。若有死锁,转模块 3 处理。

2.3 频繁 GC 定位:jstat -gcutil + GC 线程名

根因二:频繁 GC

  • 线程名识别: top -H 中 CPU 最高的是 GC task thread#0G1 Young RemSet Sampling 等,说明 GC 线程在疯狂工作。
  • 实时监控 GC 频率: 使用 jstat -gcutil <pid> 1000,每秒输出一次 GC 统计:
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
      0.00 100.00  78.54  95.12  92.34  89.12  15234   456.123   23   56.789  512.912
      0.00 100.00  82.10  95.08  92.34  89.12  15235   456.234   23   56.789  513.023
    
    可以看到 YGC 每秒递增 1 次,且 YGCT 快速增加,说明年轻代 GC 极其频繁;老年代 O 使用率居高不下,FGC 偶尔发生。这种模式常见于年轻代过小或对象分配速率过高。
  • 进一步分析 GC 日志: 若开启了 -Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps,用 GCViewer 打开,观察 Heap 使用趋势和 GC 停顿分布。可以确认 Young GC 平均停顿、晋升量等。

区分方法总结: 看线程名。业务线程高 CPU → 死循环;GC 线程高 CPU → 内存/参数问题。 前者查代码逻辑,后者查 GC 配置和内存泄漏。

CPU 飙高双路径排查图

flowchart TD
    TOP[top -H -p PID<br>观察高CPU线程] --> NAME{线程名判断}
    NAME -->|业务线程名<br>http-nio-xxx| LOOP[死循环/死锁]
    NAME -->|GC task thread#0| GC[频繁GC]
    LOOP --> LOOP1[printf '%x' tid 转16进制]
    LOOP1 --> LOOP2[jstack grep nid 定位代码行]
    LOOP2 --> LOOP3[修复循环逻辑/增加sleep]
    GC --> GC1[jstat -gcutil pid 1000 实时监控]
    GC1 --> GC2[观察 YGC/FGC 频率和耗时]
    GC2 --> GC3{根因分析}
    GC3 -->|Young GC 每秒多次| YGC[Eden 区太小/对象分配高]
    GC3 -->|Full GC 频繁| FGC[老年代泄漏/堆内存不足]
    YGC --> YGC_FIX[增大-Xmn 或优化临时对象]
    FGC --> FGC_FIX[jmap dump + MAT 分析泄漏/加大堆]

a) 主旨概括: 该流程图展示 CPU 100% 的两种典型根因和对应排查链,强调通过线程名快速分流,避免误判,直达问题核心。

b) 逐元素分解:top -H 出发,线程名分支为业务线程和 GC 线程,分别引出 jstack 代码定位和 jstat 监控两条路径,最终给出不同的修复方向。

c) 设计原理映射: CPU 飙高本质是线程持续处于可运行状态。死循环是用户代码造成的,GC 频繁是内存管理压力造成的,两者在 OS 调度视角均表现为 RUNNABLE,区分必须依赖线程标识。JVM 为 GC 线程统一命名,提供天然判别信号。

d) 工程联系与关键结论: 线上 CPU 飙高不要重启了事,先花 30 秒用 top -H 确认线程名,可节省数小时排查时间。死循环问题频发于无保护的 while 循环,务必加入退避或超时机制;GC 频繁问题暴露的是内存配置或泄漏,需用 jstat 量化再针对优化。


3. 死锁与锁竞争:jstack -l 检测与锁热点分析

死锁和严重锁竞争会导致线程堆积,服务吞吐量骤降,甚至 CPU 异常(线程自旋或上下文切换风暴)。JVM 内置了死锁检测机制,jstack -l 可一键诊断。

3.1 jstack -l 自动死锁检测

当发生死锁时,jstack -l <pid> 会在输出末尾明确打印:

Found one Java-level deadlock:
=============================
"Thread-A":
  waiting to lock monitor 0x00007f3a1c005e58 (object 0x000000076b000f50, a java.lang.Object),
  which is held by "Thread-B"
"Thread-B":
  waiting to lock monitor 0x00007f3a1c0072a8 (object 0x000000076b000f60, a java.lang.Object),
  which is held by "Thread-A"

Java stack information for the threads listed above:
===================================================
"Thread-A":
    at com.example.DeadLock$1.run(DeadLock.java:25)
    - waiting to lock <0x000000076b000f50> (a java.lang.Object)
    - locked <0x000000076b000f60> (a java.lang.Object)
    ...
"Thread-B":
    at com.example.DeadLock$2.run(DeadLock.java:37)
    - waiting to lock <0x000000076b000f60> (a java.lang.Object)
    - locked <0x000000076b000f50> (a java.lang.Object)

解读关键: waiting to lock <address>locked <address> 的对应关系形成闭环。Thread-A 持有锁 0x...f60,等待锁 0x...f50;Thread-B 持有锁 0x...f50,等待 0x...f60,互为死锁。

代码层面修复: 确保多线程获取锁的顺序一致。例如统一按资源 ID 排序后再加锁,或者使用 ReentrantLock.tryLock(timeout) 并在失败时释放已持有锁,避免死锁。

编程监控: 使用 ThreadMXBean.findDeadlockedThreads() 定时检测,对接告警系统:

ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = mbean.findDeadlockedThreads();
if (deadlockedThreads != null) {
    // 发送告警,打印线程信息
}

3.2 锁竞争热点识别

即使没有死锁,严重的锁竞争也会拖垮性能。jstack 输出中若大量线程处于 BLOCKED 状态,并且等待的锁对象地址相同,说明存在热点锁。

示例输出片段:

"pool-1-thread-3" #23 prio=5 os_prio=0 tid=... waiting for monitor entry
   java.lang.Thread.State: BLOCKED (on object monitor)
    at com.example.HotService.syncMethod(HotService.java:18)
    - waiting to lock <0x000000076b002000> (a com.example.HotService)
    ...
"pool-1-thread-4" #24 prio=5 os_prio=0 tid=... waiting for monitor entry
   java.lang.Thread.State: BLOCKED (on object monitor)
    at com.example.HotService.syncMethod(HotService.java:18)
    - waiting to lock <0x000000076b002000> (a com.example.HotService)
    ...

多个线程都在等待同一个锁对象 0x000000076b002000,说明 syncMethod 是同步瓶颈。

Arthas 监测: 使用 monitor -c 5 命令监控某个类的锁竞争情况,输出每秒的锁争用次数、等待队列长度等,直观看到热点方法。

$ monitor -c 5 com.example.HotService syncMethod
Press Q or Ctrl+C to abort.
Affect(class count: 1 , method count: 1) cost in 30 ms, timestamp: 2026-05-22 11:00:00
  timestamp           class            method       total  success  fail  avg-rt(ms)  fail-rate
------------------------------------------------------------------------------------------------
  2026-05-22 11:00:00 com.example.HotService syncMethod   500      300   200        45        40%
...

优化策略: 缩小同步范围(仅锁必要的代码块)、改用 ConcurrentHashMap 等并发容器、使用 ReentrantReadWriteLock 提高读并发、或无锁方案(AtomicLongLongAdder)。

死锁 jstack 输出解读图

flowchart TD
    JSTACK["jstack -l pid"] --> CHECK{"输出是否含<br>Found one Java-level deadlock?"}
    CHECK -->|"是"| DEAD["解析死锁报告"]
    CHECK -->|"否,大量BLOCKED"| HOT["锁竞争分析"]
    DEAD --> DEAD1["Thread-A: waiting to lock X, locked Y"]
    DEAD1 --> DEAD2["Thread-B: waiting to lock Y, locked X"]
    DEAD2 --> DEAD3["形成锁依赖环 -> 修复锁顺序"]
    HOT --> HOT1["查找同一锁地址的等待线程"]
    HOT1 --> HOT2["Arthas monitor 观察热点"]
    HOT2 --> HOT3["缩小锁范围/换并发容器/无锁化"]

a) 主旨概括: 该图展示通过 jstack 输出判断是死锁还是锁竞争,并分别进入自动解读和热点分析流程,指导优化方向。

b) 逐元素分解: jstack 输出的 “Found one deadlock” 是死锁铁证,直接显示锁持有与等待对应关系;无死锁但线程状态为 BLOCKED 则聚焦锁对象地址与 monitor 命令,确定热点。

c) 设计原理映射: JVM 在线程栈中记录了持有的监视器(locked)和等待的监视器(waiting to lock),死锁检测算法本质是构建锁等待图并寻找环;锁竞争则是同一监视器上的队列长度,体现并发瓶颈。

d) 工程联系与关键结论: jstack -l 是死锁排查的第一武器,输出已自动完成循环检测。对于锁竞争,不要仅凭感觉优化,必须通过 jstack 的锁地址和 Arthas monitor 量化竞争程度,避免过度设计。


4. GC 异常调优:Young GC 频繁 / Full GC 频繁 / 晋升失败

GC 异常直接体现为停顿时间过长或频率过高,影响服务延迟和吞吐量。排查时需要区分具体症状:Young GC 频繁、Full GC 频繁、晋升失败,三者根因和优化方向差异显著。

4.1 频繁 Young GC

现象: jstat -gcutil 每秒递增,YGC 数字飙升,但每次 YGCT 极短(几毫秒);Eden 区使用率频繁在 90%~100% 间震荡。

原因: 年轻代太小(-Xmn-XX:NewSize 设置过小),或者代码在短时间内产生大量临时对象(如频繁字符串拼接、装箱)。

排查:

  • jstat -gcutil pid 1000 观察 YGCYGCT 频率,若 YGC 每秒 > 2 次,说明 Eden 不堪重负。
  • 结合 GC 日志查看 Eden 大小的变化和每次晋升量。
  • 使用 jmap -histo pid | head -20 查看哪些类实例数量最多,定位高频分配点。

修复:

  • 增大年轻代:-Xmn1g 或调整 -XX:NewRatio=2(老年代:年轻代=2:1)。
  • 代码优化:使用 StringBuilder 代替字符串 +、避免自动装箱、使用对象池复用。
  • 若为大型对象分配,可直接分配到老年代(设置 -XX:PretenureSizeThreshold)。

4.2 频繁 Full GC

现象: jstat -gcutil 显示 FGC 不断增加,FGCT 长时间占用;老年代使用率 O 居高不下,Full GC 后仅降一小部分,又迅速上升。

常见原因及判断方法:

  • 内存泄漏: 老年代对象持续积累无法回收。用 jmap -dump:format=b,file=heap.hprof pid 导出堆,MAT 分析 Dominator Tree,若某个业务类 Retained Heap 极大,且 Path to GC Roots 指向静态集合或长生命周期线程,则是泄漏。
  • 堆参数不足: 泄漏排除后,若老年代使用率在 Full GC 后大幅下降(如从 98% 降到 25%),但很快又满,说明业务正常所需内存超过当前分配。需要增大 -Xmx 或降低并发收集器的触发阈值(如 CMS 的 -XX:CMSInitiatingOccupancyFraction=70)。

排查实例: jmap -histo 快速预览占用内存最多的类:

 num     #instances         #bytes  class name
----------------------------------------------
   1:       1250000      100000000  com.example.Order
   2:        500000       40000000  [C

如果 Order 实例数量异常,再用 MAT 分析其引用链,最终定位到某个缓存 Map 未清理旧订单。

修复: 内存泄漏 → 修复引用;堆不足 → 增加 -Xmx4g;避免 CMS 并发失败(Concurrent Mode Failure)可适当降低触发阈值或改用 G1。

4.3 晋升失败(Promotion Failed)

现象: GC 日志中出现 [ParNew (promotion failed) ...] 或 CMS 的 [concurrent mode failure],随后触发一次 Serial Old Full GC,停顿时间数秒。

根因: Minor GC 时,Survivor 区无法容纳存活对象,或老年代碎片化严重,无法找到连续空间容纳晋升对象。

排查与修复:

  • jstat -gcutil 观察 S0/S1 利用率,如果总是在 100% 左右,说明 Survivor 太小。
  • 调整参数:增大 -XX:SurvivorRatio=6(Eden:S0=6:1,默认 8:1,减小分母增大 Survivor),或提高 -XX:MaxTenuringThreshold=15 让对象多在 Survivor 停留,减少过早晋升。
  • 如果频繁晋升失败,可考虑使用 G1 收集器,G1 的 Region 化内存管理和混合 GC 可有效避免老年代碎片化问题。
  • CMS 场景下,可适当降低 -XX:CMSInitiatingOccupancyFraction,让并发收集更早启动,留足缓冲。

GCViewer 辅助分析:gc.log 导入 GCViewer,可直观看到 GC 停顿时间分布、Heap 使用量曲线、晋升量趋势。如果曲线呈现锯齿状但整体上扬,说明存在内存泄漏;若曲线稳定但 Full GC 频繁,则是堆容量不足。

GC 异常三种场景调优图

flowchart LR
    GC[GC 异常] --> YOUNG[频繁 Young GC<br>YGC每秒多次]
    GC --> FULL[频繁 Full GC<br>FGC快速递增]
    GC --> PROMO[晋升失败<br>Promotion Failed]
    YOUNG --> Y1[增大年轻代 -Xmn]
    YOUNG --> Y2[减少临时对象分配]
    YOUNG --> Y3[大对象直接进入老年代]
    FULL --> F1[jmap dump + MAT 查泄漏]
    FULL --> F2[无泄漏则增大 -Xmx]
    FULL --> F3[调低 CMS 触发阈值<br>或改用 G1]
    PROMO --> P1[增大 Survivor<br>调整 SurvivorRatio]
    PROMO --> P2[增加 MaxTenuringThreshold]
    PROMO --> P3[改用 G1 避免碎片]

a) 主旨概括: 该图将 GC 异常分为三类并给出针对性的参数与代码优化策略,形成从症状到方案的快速映射。

b) 逐元素分解: 三个分支对应三种主要 GC 异常模式,每个模式下罗列了典型原因和修复选项,如 Young GC 频繁 → 增大年轻代;Full GC 频繁 → 先判泄漏再调参数;晋升失败 → 调 Survivor 或换收集器。

c) 设计原理映射: 频繁 Young GC 是分配压力与 Eden 容量失衡;频繁 Full GC 是老年代回收效率与空间容量问题;晋升失败则是跨代引用的内存连续性和 Survivor 容纳能力不足,设计上映射到分代收集的核心瓶颈。

d) 工程联系与关键结论: GC 调优切忌盲目加机器或调大堆。先区分异常类型,用 jstat 量化频率,用 MAT 排除泄漏,再依据症状对症下参。晋升失败在生产中常被忽视,却是 CMS 时代停顿的最大元凶,提前规划 G1 迁移是趋势。


5. 类加载与 Metaspace 泄漏:动态代理泄漏与类加载器泄漏

Metaspace OOM 本质是类元数据空间耗尽,通常伴随类加载器泄漏或动态生成类失控。排查需要追踪类加载行为。

5.1 典型泄漏场景

  • Groovy 脚本引擎: 每次执行脚本都创建新的 GroovyClassLoader,但未调用 close(),导致加载的类及加载器本身无法回收。
  • CGLIB 动态代理未缓存: 每次创建代理对象时调用 Enhancer.create() 生成新类,随着调用次数增多,类数量线性增长。
  • Lambda 表达式: 在大量不同位置使用 Lambda,JVM 会为每个表达式生成匿名类,若持续创建新 Lambda 可能引起类数量膨胀(JDK 8 早期常见,后续版本有优化,但仍需注意)。
  • JSP 编译: 老系统如果 JSP 文件被频繁修改并重新编译,类加载器可能泄漏。

5.2 排查路径

步骤一: 添加 JVM 参数 -XX:+TraceClassLoading -XX:+TraceClassUnloading,观察类加载和卸载日志。如果日志显示 [Loaded ... from ...] 持续大量输出而 [Unloaded ...] 极少,则存在泄漏。

步骤二: 使用 jstat -gcutil <pid> 关注 MU(Metaspace utilization)列,如果持续增长且 Full GC 后不下降,说明有大量类无法卸载。

步骤三: Arthas 在线诊断:classloader 命令列出所有类加载器实例及其加载的类数量。找到加载了成千上万个类但自身不是系统关键加载器的对象,通常就是泄漏点(如 groovy.lang.GroovyClassLoader 实例过多)。

步骤四: 用 MAT 打开堆转储,OQL 查询动态类:SELECT * FROM INSTANCEOF java.lang.Class WHERE className LIKE '%.CGLIB%',统计数量。再查询这些类的 ClassLoader,通过 Dominator Tree 查看哪个对象持有该 ClassLoader,最终定位到未释放的代码。

5.3 修复与预防

  • CGLIB: 单例 Enhancer 并设置 setUseCache(true),或使用 Enhancer.create() 返回的工厂缓存。也可限制动态类生成的总数。
  • Groovy: 务必在 finally 块中调用 GroovyClassLoader.close(),或复用单个 ClassLoader。
  • Lambda: 尽量减少在热路径创建新的 Lambda 表达式实例,可抽成静态方法引用。
  • 设置上限: 生产环境必须设置 -XX:MaxMetaspaceSize=256m(或根据应用调整),让 Metaspace 溢出尽早触发 Full GC 和 OOM,便于暴露问题,避免无限增长占用 OS 内存。

类加载泄漏排查路径图

flowchart TD
    START["MU持续增长/Metaspace OOM"] --> A["开启 TraceClassLoading/Unloading"]
    A --> B{"Unloaded 远小于 Loaded?"}
    B -->|"是"| C["Arthas classloader 查加载器"]
    C --> D["发现大量非系统 ClassLoader"]
    D --> E["MAT OQL 查动态类:<br>SELECT * FROM Class WHERE name like '%CGLIB%'"]
    E --> F["统计类数量,追 ClassLoader GC Root"]
    F --> G{"根因"}
    G -->|"CGLIB 未缓存"| H["单例 Enhancer + useCache"]
    G -->|"Groovy 未关闭"| I["finally close ClassLoader"]
    G -->|"Lambda 膨胀"| J["抽静态方法引用"]
    H & I & J --> K["验证: MU 稳定/类卸载增加"]

a) 主旨概括: 该图展示了类加载泄漏的完整排查步骤,从监控指标到工具链联动,最终定位到代码级别的修复。

b) 逐元素分解: 由 MU 增长触发,通过 TraceClassLoading 验证泄漏,Arthas 定位有问题的 ClassLoader,MAT 进一步确认动态类数量,最后匹配到具体的代码场景和修复方案。

c) 设计原理映射: 类加载器泄漏是 Java 双亲委派模型下,一个 ClassLoader 若可达则其加载的所有 Class 均不可回收,因此排查重点在于找到未被回收的 ClassLoader 引用链;CGLIB 动态生成类也是将新类注入到特定的 ClassLoader,所以可归于同类问题。

d) 工程联系与关键结论: Metaspace 问题 99% 是类加载器未关闭或动态代理未缓存。线上务必设置 MaxMetaspaceSize 作为底线,同时开启类加载日志和周期 Arthas 巡检,将泄漏消灭在萌芽。


6. JMH 验证优化:优化前后对比与结果可信度判断

任何 JVM 优化(参数调整、代码重构)最终都需要量化的性能验证,JMH 是 Java 微基准测试的黄金标准。在实施优化后,必须用 JMH 提供统计学上可信的结果对比,避免被 JIT 优化、GC 干扰等所迷惑。

6.1 优化前后基准测试设计

为验证优化效果,编写基准测试类,包含优化前后的方法版本,设置合理的 JMH 参数:

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.OPS)
@State(Scope.Thread)
@Fork(3)                 // 3 个独立 JVM 进程,确保可重复性
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)  // 充分预热
@Measurement(iterations = 10, time = 2, timeUnit = TimeUnit.SECONDS)
public class OptimizationBenchmark {
    
    private Calculator optimized;
    private Calculator baseline;
    
    @Setup
    public void setup() {
        optimized = new OptimizedCalculator();
        baseline = new BaselineCalculator();
    }
    
    @Benchmark
    public void baselineTest(Blackhole bh) {
        bh.consume(baseline.compute());
    }
    
    @Benchmark
    public void optimizedTest(Blackhole bh) {
        bh.consume(optimized.compute());
    }
}

关键点: @Fork(3) 保证 JVM 独立,@Warmup 足够让 JIT 编译生效,Blackhole 防止死代码消除。

6.2 结果对比与可信度判断

运行 JMH 后,得到类似输出:

Benchmark                       Mode  Cnt      Score      Error  Units
OptimizationBenchmark.baseline   thrpt   30  12000.123 ±  800.456  ops/s
OptimizationBenchmark.optimized  thrpt   30  32000.789 ±  500.234  ops/s

可信度判断标准:

  • 每个测试的 Error 值(通常为 99.9% 置信区间)应小于 Score 的 20%。若 Error 占比过高,说明测试波动大,结果不可信,需检查测试环境稳定性或增加 Fork 次数。
  • 优化后的 Score 应显著超过 baseline,且 Error 区间不重叠或仅有微小重叠。如上例 optimized Score 32000,Error 500,baseline 12000 ± 800,两者差距远超误差范围,结论可信。
  • 观察 P99、P99.9 等百分位延迟(若测试模式为延迟),确保极端停顿未恶化。

注意: JMH 测试环境与生产环境存在差异(GC 收集器、硬件资源、并发度等),JMH 结果只能证明微基准上的优化有效,最终必须在灰度生产环境观察一段时间,确认 GC 频率、CPU 使用率、响应时间等指标真正改善。

JMH 优化验证流程图

flowchart TD
    A[识别优化点] --> B[编写 JMH 基准测试]
    B --> C[运行优化前基准]
    C --> D[应用优化措施]
    D --> E[运行优化后基准]
    E --> F{Score 对比}
    F -->|Error <20% 且差异显著| G[结果可信]
    F -->|Error 过高或差异不显著| H[调整测试/优化方案]
    G --> I[灰度发布监控]
    I --> J[全量推广]

a) 主旨概括: 该流程图建立优化验证的标准化步骤,强调 JMH 基准对比和可信度判断是决定优化是否有效的关键关卡。

b) 逐元素分解: 从优化点识别到灰度发布,核心环节是前后基准测试的统计比较,并引入 Error 比例作为可信度门禁,未通过则返回调整。

c) 设计原理映射: JMH 的设计原理在于隔离 JIT、GC、OS 调度等不确定性,通过 Fork 和预热让对比建立在公平的 JIT 编译态上,因此其统计指标可用于严谨的工程决策。

d) 工程联系与关键结论: 没有 JMH 验证的优化如同盲人摸象。线上看到 GC 优化效果后,必须将变更固化为微基准,形成持续保障,防止后续代码变更回退。Error 占比 <20% 是行业经验阈值,超过则必须增加样本或检查环境。


7. 系列⑤收尾:JVM 深度调优知识体系总结

系列⑤「JVM 深度调优」历时 10 篇文章,从理论基础到实战排查,构成闭环。回顾整个知识链:

  1. 类加载机制:双亲委派、自定义 ClassLoader 实战。
  2. 四种经典 GC 收集器:Serial、Parallel、CMS、G1 的原理与选型。
  3. G1 深度解析:SATB、RSet、Mixed GC。
  4. ZGC/Shenandoah:低延迟染色指针并发收集。
  5. GC 日志全字段解读:每种收集器的日志格式及诊断信号。
  6. JIT 编译深度:分层编译、内联、逃逸分析。
  7. 内存泄漏 MAT 排查:Dominator Tree、Path to GC Roots。
  8. JVM 参数最佳实践:生产就绪参数集。
  9. JMH 基准测试:正确的微基准构建方法论。
  10. 本文:JVM 反模式与排查宝典:将理论转化为线上排障的武器。

串联起来就是:理解类元数据如何管理 → 对象如何分配与回收(收集器) → GC 日志如何解读 → JIT 如何优化 → 如何分析内存泄漏 → 如何调整参数 → 如何测量性能 → 最后如何应对线上故障

本系列与后续系列⑥「Java 关键机制与优化」衔接——I/O 模型、NIO 多路复用、SPI 机制等,深入 Java 平台核心骨架。

JVM 排查工具链全景图

flowchart LR
    JSTACK[jstack<br>线程/死锁] --> THREAD[线程状态/死锁检测]
    JMAP[jmap<br>对象直方图/dump] --> HISTO[快速预览实例数]
    JSTAT[jstat -gcutil<br>GC实时监控] --> GC_DATA[YGC/FGC/MU动态]
    JCMD[jcmd<br>VM.native_memory] --> NMT[堆外内存使用]
    MAT[MAT<br>堆分析] --> DOM[支配树/泄漏根因]
    ARTHAS[Arthas<br>在线诊断] --> LIVE[动态监控/热修复]
    GCVIEWER[GCViewer<br>离线日志] --> TREND[GC频率/停顿趋势]
    JMH[JMH<br>基准测试] --> PERF[优化验证]

a) 主旨概括: 工具链全景图将八种核心工具按功能分为线程、对象、GC、堆外、堆分析、在线诊断、日志分析、性能基准,覆盖 JVM 故障排查全生命周期。

b) 逐元素分解: 每个工具与其关键输出相连,如 jstack → 线程状态/死锁,MAT → 支配树,JMH → 优化验证,形成互补。

c) 设计原理映射: 工具设计对应 JVM 暴露的接口:jstack 打印线程栈和监视器信息,jmap 基于堆遍历,jstat 读取 PerfData 计数器,Arthas 利用 Instrumentation 和 JVMTI,JMH 则模拟生产负载。

d) 工程联系与关键结论: 生产故障黄金 5 分钟:先用 jps 找 PID → top -H 看 CPU → jstat 看 GC → jstack 查线程 → Arthas 深入诊断。这一套组合拳是每个 Java 工程师的肌肉记忆。

系列⑤知识体系总结图

flowchart TD
    A[类加载机制] --> B[GC 收集器全景]
    B --> C[G1 深度: SATB/RSet]
    B --> D[ZGC/Shenandoah: 染色指针]
    B --> E[GC 日志全字段解读]
    A --> F[JIT 分层编译与逃逸分析]
    B --> G[内存泄漏 MAT 排查]
    G --> H[JVM 参数最佳实践]
    H --> I[JMH 基准测试]
    I --> J[本文: 反模式排查]
    J --> K[OOM/CPU/死锁/GC/类加载]
    K --> L[面试高频与实践闭环]

a) 主旨概括: 这张总结图展示了系列⑤ 10 篇文章的依赖与递进关系,从基础原理到工具实战,最终汇聚到本文的线上排查综合能力。

b) 逐元素分解: 以类加载和 GC 基础为根,延伸出不同收集器详细剖析、日志解读和 JIT 优化;内存泄漏工具 MAT 承接参数实践,再通过 JMH 到达反模式排查,形成“原理→分析→测量→实战”的层次。

c) 设计原理映射: 知识体系的演进路线符合实际调优需求:理解内存管理→掌握收集器行为→解读运行时数据→定位问题→验证效果,环环相扣。

d) 工程联系与关键结论: JVM 调优能力的终极体现不是在测试环境跑通参数,而是线上故障时能快速绘制问题地图并精准手术。本文提供的排查闭环将系列所学串联成武器库,足以应对 90% 的 JVM 生产事故。


面试高频专题

1. OOM 有哪三种常见类型?如何通过异常信息快速区分并给出排查方向?
一句话回答: 常见 OOM 为 Java heap space(堆溢出)、Metaspace(元空间溢出)和 Direct buffer memory(直接内存溢出),通过异常消息的首个单词即可区分。
详细解释: Java heap space 表示堆内存不足,需用 MAT 分析堆转储,找到 Retained Heap 最大的对象并追 GC Root;Metaspace 表示类元数据区满,应开启 TraceClassLoading 观察类加载频率,结合 Arthas classloader 查泄漏加载器,并用 MAT OQL 查 CGLIB 动态类;Direct buffer memory 是堆外直接内存耗尽,需用 jcmd VM.native_memory 确认,并通过 MAT 查 DirectByteBuffer 实例数与 capacity,审查 NIO 框架的资源释放。
多角度追问:

  • 堆 OOM 时如何在不重启的前提下获取堆转储?(jmap -dump:live 会触发 Full GC,可能影响服务,可提前开启 HeapDumpOnOutOfMemoryError
  • Metaspace 泄漏为何会触发 Full GC?(因为 Full GC 会尝试卸载类,若 Metaspace 已满且无法释放则 OOM)
  • 直接内存默认上限是多少?(JDK 8 默认等于 -Xmx,可通过 -XX:MaxDirectMemorySize 单独限制)
    加分回答: 学术上,直接内存的回收依赖于 java.nio.DirectByteBufferCleaner 虚引用,由于虚引用需在 GC 后由 ReferenceHandler 线程处理,当分配速度超过 GC 和清理速度时就会 OOM,这体现了 Java 堆外内存管理的设计权衡。

2. 线上 CPU 飙升至 100%,如何通过 top -H 和 jstack 区分是死循环还是频繁 GC?
一句话回答: 使用 top -H -p pid 查看 CPU 最高的线程,若线程名为 “GC task thread” 等则是 GC 频繁,若为业务线程名则是死循环。
详细解释: top -H 显示每个线程的 CPU 使用率,观察最高占用线程的 COMMAND 或线程名。GC 线程的命名包含 “GC task thread” 或 “G1 … Sampler” 等;业务线程通常为 “http-nio-xxx-exec-*” 或自定义线程池名。判断为死循环后,用 printf '%x\n' tid 转十六进制 nid,jstack pid | grep -A 20 nid 定位到具体死循环代码;若为 GC 线程,则用 jstat -gcutil pid 1000 观察 YGC/FGC 频率,判断是年轻代过小还是内存泄漏导致老年代频繁 GC。
多角度追问:

  • 如果线程名为 “C2 CompilerThread” 占用 CPU 高呢?(说明 JIT 编译负载高,可能代码热点过多或 JIT 阈值设置激进,需检查代码复杂性或调整编译阈值)
  • 死循环是否一定导致 CPU 100%?(不一定,若循环内有 sleep 或阻塞 I/O,CPU 不会高;需要结合线程状态,RUNNABLE 且无阻塞的循环才会占满 CPU)
  • 频繁 GC 的 CPU 高与用户线程饥饿有何关联?(GC 线程竞争 CPU 导致用户线程暂停或执行缓慢,吞吐量下降)
    加分回答: Linux 的 pidstat -u -t -p pid 1 可同时监控线程级 CPU 使用率,配合 jstack 快照生成火焰图,直观定位热点。

3. 死循环和频繁 GC 分别应该怎么修复?各自的排查路径是什么?
一句话回答: 死循环修复是在循环体内增加退避或改为阻塞等待;频繁 GC 修复需根据 GC 类型调整年轻代/堆大小、修复内存泄漏或更换收集器。
详细解释: 死循环排查路径:top -H → 确认业务线程 → printf 转 nid → jstack 定位循环代码行 → 加入 Thread.sleepLockSupport.park 或条件变量,避免空转。频繁 GC 排查路径:jstat -gcutil 区分 Young GC 或 Full GC 频繁。Young GC 频繁 → Eden 太小或分配速率高,增大 -Xmn 或优化临时对象;Full GC 频繁 → 先用 jmap -dump + MAT 排查老年代泄漏,无泄漏则增大 -Xmx 或降低收集器触发阈值。晋升失败则调整 Survivor 或改用 G1。
多角度追问:

  • 如何快速减少临时对象分配?(使用对象池、StringBuilder 代替 +、避免自动装箱、循环内避免创建新集合)
  • CMS 频繁 Full GC 如何调优?(增大 -XX:CMSInitiatingOccupancyFraction?实际上是降低该值尽早开始并发收集,防止 Concurrent Mode Failure)
  • 什么情况下应该从 Parallel GC 迁移到 G1?(堆 > 4GB 且对停顿时间有严格要求,或 CMS 碎片化严重导致晋升失败频繁)
    加分回答: 在修复循环空转时,可结合 Guava 的 RateLimiterScheduledExecutorService 平滑控制执行频率,避免 CPU 毛刺。

4. jstack -l 检测死锁的原理是什么?输出中的 locked 和 waiting to lock 如何对应?
一句话回答: jstack -l 通过搜索线程栈中的监视器锁信息,构建锁等待图并检测环来发现死锁,locked 和 waiting to lock 的锁对象地址会形成闭环。
详细解释: jstack 在生成线程转储时,记录每个线程持有的 locked <address> 和等待的 waiting to lock <address>。JVM 死锁检测算法会遍历所有线程,构建有向图(线程→等待锁→持有锁线程),若存在环即为死锁。输出中会列出两个或多个线程互相等待对方持有的锁。例如 Thread-A 持有 Lock-X 等待 Lock-Y,Thread-B 持有 Lock-Y 等待 Lock-X,两个地址成对出现,即可确定死锁。
多角度追问:

  • ThreadMXBean.findDeadlockedThreads()jstack 实现有何不同?(ThreadMXBean 直接查询 JVM 内部死锁检测结果,是编程式监控;jstack 是命令行工具,但底层原理相同)
  • 为什么 synchronized 死锁会被检测到,而 ReentrantLock 死锁不行?(ReentrantLock 使用的是 AQS,不是 JVM 内建监视器锁,jstack 的监视器死锁检测无法覆盖,但可通过 ThreadMXBean 的 findDeadlockedThreads 检测 OwnableSynchronizer 死锁,从 JDK 6 开始支持)
  • 如何避免死锁?(统一加锁顺序、使用 tryLock 超时、采用无锁数据结构)
    加分回答: jstack -l 的死锁检测基于 C++ 层的 DeadlockCycleChecker,它会检查监视器锁和 Lock 接口的实现,后者通过 OwnableSynchronizer 聚合,使得检测更全面,但需要 JDK 6+ 并正确实现锁接口。

5. 如何使用 jstat -gcutil 实时监控 GC 频率?Young GC 每秒多次可能是什么原因?如何优化?
一句话回答: 使用 jstat -gcutil <pid> 1000 每秒输出一次各区域使用率和 GC 次数;Young GC 每秒多次意味着 Eden 区过小或对象分配速率过高。
详细解释: jstat -gcutil 输出列 S0/S1/E/O/M 等使用率,YGC/YGCT/FGC/FGCT 等累计次数和时间。连续采样观察 YGC 增量即可算出频率。Young GC 频繁通常是因为:① Eden 容量不足,如 -Xmn 设置 256m 但业务每秒产生 200MB 临时数据;② 代码中大量使用短生命周期对象(如日志拼接、装箱等)。优化方向:增大年轻代(如 -Xmn2g),同时优化代码减少对象创建;若大对象频繁,可设置 -XX:PretenureSizeThreshold 直接进老年代,避免反复拷贝。
多角度追问:

  • 增大年轻代会不会导致单次 Young GC 停顿变长?(会,因为复制对象增多,需要权衡频率与停顿)
  • 如何确定对象分配热点?(使用 JFR 或 Allocation Profiler,如 AsyncGetCallTrace + 火焰图)
  • jstat 输出的 S0/S1 如果长期 100% 正常吗?(不正常,说明 Survivor 太小或晋升阈值过低,需调整 -XX:SurvivorRatioMaxTenuringThreshold
    加分回答: 在容器环境,jstat 可通过 jstat -gcutil <pid> 1s(JDK 9+ 支持时间单位)更方便;若使用 JDK 11+,jcmd GC.heap_info 可查看更详细的 GC 配置,适合脚本化监控。

6. 频繁 Full GC 的常见原因有哪些?如何判断是内存泄漏还是堆参数设置不足?
一句话回答: 常见原因包括老年代内存泄漏、堆内存总量配置过小、元空间不足触发 Full GC、以及收集器并发失败;通过 MAT 分析 Full GC 后堆中对象数量和 Retained Heap 可判断是否泄漏。
详细解释: 判断方法:导出现场堆转储(最好在两次 Full GC 之间),MAT 打开后查看 Dominator Tree。如果老年代中某个业务类的 Retained Heap 占据 50% 以上,且其 Path to GC Roots 显示被静态集合或线程持有,无法通过正常业务回收,则是泄漏。反之,如果对象大多是应回收的、Full GC 后老年代使用率能显著下降(如从 98% → 20%),但很快又满,说明内存确实紧张,业务所需内存超过了当前分配,需增大 -Xmx 或扩容。元空间不足也会导致 Full GC,可观察 MU 列。
多角度追问:

  • 为什么 Full GC 后老年代使用率下降很少(如从 98% → 90%)?(说明大量对象是强可达的,即内存泄漏)
  • 如何在线查看对象年龄分布?(jmap -histo:live 会触发 Full GC,谨慎使用;或使用 Arthas vmtool 动态查看某些类的实例数)
  • CMS 的 “Concurrent Mode Failure” 与 Full GC 有何关系?(并发回收时老年代被填满,导致退化为 Serial Old Full GC,表明碎片化或触发过晚)
    加分回答: 业界常用“3 倍原则”:如果老年代在 Full GC 后很快在 3 次 Young GC 内又满,基本可判定为泄漏或严重容量不足。结合 MAT 的 Leak Suspects Report 可快速辅助定位。

7. 晋升失败(Promotion Failed)是什么?如何通过调整 SurvivorRatio 和 MaxTenuringThreshold 来缓解?
一句话回答: 晋升失败是 Minor GC 时 Survivor 区无法容纳存活对象,老年代又无连续空间导致对象无法晋升,从而触发长时间 Full GC。
详细解释: 调整 -XX:SurvivorRatio=6(默认 8),使 Survivor 空间增大(Eden 占比减小),可容纳更多暂存对象,避免过早晋升。增大 -XX:MaxTenuringThreshold=15 让对象在 Survivor 区多停留几轮,减少对老年代的冲击。但最根本的是分析晋升对象的年龄分布:使用 -XX:+PrintTenuringDistribution 打印 GC 日志中 Desired survivor sizeage 数据,观察实际需要的阈值。若晋升对象多是本应短命的,说明代码在 Survivor 中分配了过大对象或泄漏了短生命周期对象引用,需优化代码。
多角度追问:

  • 什么情况下调整 SurvivorRatio 无效?(Eden 对象存活率过高,如缓存预热期,此时 Survivor 再大也装不下,需提前增大老年代或换 G1)
  • G1 是否还存在晋升失败?(G1 的 Mixed GC 中可能出现 “to-space exhausted”,本质类似,但通过动态调节和 Region 转移控制,频率远低于 CMS)
  • 如何观察晋升失败频率?(GC 日志中搜索 promotion failed,或使用 jstat -gccauseGCC 原因列)
    加分回答: 晋升失败的根本原因在于分代假设被打破:大量对象在 Young GC 后仍然存活。动态调整分代大小的 JVM 参数 -XX:+UseAdaptiveSizePolicy 可在一定程度上自动调节,但在生产定制化强时建议手动指定 Survivor 大小,并压测验证。

8. Metaspace OOM 的常见原因是什么?如何通过 TraceClassLoading 和 Arthas classloader 排查类加载泄漏?
一句话回答: 常见原因是动态代理类(CGLIB、Javassist)未缓存、Groovy 脚本引擎 ClassLoader 未关闭、Lambda 大量生成等,导致类加载器泄漏和类数量爆炸。
详细解释: 开启 -XX:+TraceClassLoading -XX:+TraceClassUnloading 后观察日志,若 [Loaded ...] 数量远大于 [Unloaded ...],则存在泄漏。jstat -gcutilMU 持续增长且 Full GC 后不下降。使用 Arthas 的 classloader 命令列出所有 ClassLoader 实例和加载的类数量,找到加载类数量异常多的自定义加载器。进一步用 MAT 的 OQL SELECT * FROM java.lang.Class WHERE className LIKE '%CGLIB%' 统计动态类数量,通过 Dominator Tree 查看这些类的 ClassLoader 的 GC Root,定位到未释放的脚本引擎或 Enhancer 对象。
多角度追问:

  • 如何快速重启恢复 Metaspace?(无需重启 JVM,可设置 -XX:MaxMetaspaceSize 触发 Full GC 尝试卸载类;若无法卸载,说明泄漏严重,只能重启)
  • Lambda 一定会生成匿名类吗?(在 JDK 8 早期会生成 $$Lambda$ 类,之后通过 invokedynamic 优化,但仍会在首次调用时生成类,大量不同 Lambda 点仍可能导致类膨胀)
  • 什么是类加载器泄漏的“魔鬼三角”?(ClassLoader → 加载的类 → 类的静态字段引用外部对象,外部对象又引用该 ClassLoader,形成循环依赖导致无法 GC)
    加分回答: Metaspace 从 JDK 8 开始替代永久代,理论上只要 ClassLoader 可达,其类就不会被回收。现代框架如 Spring 使用 CGLIB 时内部已缓存 Enhancer,但仍需警惕热部署场景。可使用 -XX:+CMSClassUnloadingEnabled(CMS)或 G1 的 -XX:+ClassUnloadingWithConcurrentMark 允许并发标记时卸载类,配合 -XX:+UnlockDiagnosticVMOptions -XX:+UnsyncloadClass 加速卸载。

9. CGLIB 动态代理为什么容易导致 Metaspace 泄漏?如何修复和预防?
一句话回答: CGLIB 每次创建代理时通过 Enhancer.create() 生成新的类,若不缓存生成的类,会持续增加 Metaspace 占用,最终 OOM。
详细解释: Enhancer 对象可以重用,但每次调用 create() 都会生成一个唯一的代理类名(如 com.example.MyService$$EnhancerByCGLIB$$xxxx),这些类被加载到特定的 ClassLoader 并存储在 Metaspace。如果业务代码每次请求都 new Enhancer 并 create,类数量将线性增长。修复:将 Enhancer 设置为单例,并启用缓存 setUseCache(true);或者使用 Spring 管理的单例代理。预防措施:设置 -XX:MaxMetaspaceSize 限制上限,配合 -XX:+TraceClassLoading 巡检,发现 EnhancerByCGLIB 类激增时快速回滚。
多角度追问:

  • JDK 动态代理会引发同样问题吗?(JDK Proxy 同样会生成代理类,但通常由 Proxy.newProxyInstance 缓存,重复调用会复用已生成类;但若用不同 ClassLoader 或接口组合,仍可能生成新类)
  • 如何在生产环境实时查看 CGLIB 类数量?(Arthas ognl '@java.lang.management.ManagementFactory@getClassLoadingMXBean()' 查看加载类总数;或 classloader -t 查看加载器加载的类列表)
  • 若必须使用多 Enforcer 怎么办?(可限定最大生成数量,或缓存到 WeakHashMap,确保不再使用的代理类被回收)
    加分回答: CGLIB 底层使用 ASM 直接生成字节码,然后将字节码通过 defineClass 注入 JVM,这个过程完全绕过了传统类文件缓存,因此必须自行管理类生命周。在微服务场景,每个接口都可能被代理,建议统一用 Spring AOP 管理,其内部已经做了类缓存。

10. Direct buffer memory OOM 是什么?如何通过 jcmd VM.native_memory 和 MAT 排查堆外内存泄漏?
一句话回答: 直接内存 OOM 指 java.nio.DirectByteBuffer 分配的堆外内存超过 -XX:MaxDirectMemorySize 限制或系统可用内存,通常因 Netty 等未释放 ByteBuf 或 NIO 未及时清理 DirectByteBuffer 所致。
详细解释: 首先,运行 jcmd <pid> VM.native_memory summary(需启用 NMT),查看 Internal 部分的 Direct Byte Buffers 项,确认直接内存提交量是否异常。然后导出堆转储,用 MAT 打开,搜索 java.nio.DirectByteBuffer 实例,查看其 capacity 字段计算总堆外内存。同时找出这些对象的 GC Root 链,看是否被业务线程或连接对象持有而未释放。在 Netty 中,通常要检查 PooledByteBufAllocator 的泄漏检测设置,开启 ResourceLeakDetector 的 PARANOID 级别追踪。修复即确保在 finally 中 ReferenceCountUtil.release(byteBuf)
多角度追问:

  • 能否限制直接内存使用上限?(-XX:MaxDirectMemorySize=512m,默认与 -Xmx 相等,建议根据实际情况单独设置)
  • NMT 开启对性能有影响吗?(summary 级别影响较小,生产可持续开启;detail 级别影响较大,适合临时排查)
  • 为什么 jmap -heap 看不到直接内存?(因为直接内存不在 Java 堆中,jmap -heap 只显示堆配置,必须用 NMT 或 MAT 间接估算)
    加分回答: JDK 11 中引入了 ByteBuffer.allocateDirect 的新实现,通过 Unsafe 分配后立即注册 Cleaner,并在 Bits.reserveMemory 中进行全局计数。如果发现计数持续增长,可以在应用中定时打印 BufferPoolMXBean 信息监控直接内存使用趋势,结合 Prometheus 等构建告警。

11. Arthas 的 thread -b、monitor、classloader、vmtool 命令分别用于排查什么问题?各举一个使用场景。
一句话回答: thread -b 一键检测死锁;monitor 监控方法级锁竞争;classloader 查看类加载器及加载类数量;vmtool 动态查看堆对象(如 ThreadLocal 值、静态字段),实现内存实时探测。
详细解释:

  • thread -b:等效于 jstack -l 的死锁检测,但输出更简洁,可在不退出交互的情况下持续监测。场景:线上偶尔超时,怀疑死锁,运行 thread -b 立即看到 “BlockingLock” 信息。
  • monitor -c 5:监控指定类方法的锁竞争次数、等待队列长度,每 5 秒输出。场景:发现某个服务接口吞吐下降,通过 monitor 发现 OrderService.placeOrder 锁竞争激烈,决定缩小同步块。
  • classloader:列出所有 ClassLoader 实例、哈希值、加载的类数量。场景:Metaspace 增长,使用 classloader 发现大量 GroovyClassLoader 实例,定位脚本泄漏。
  • vmtool:利用 JVMTI 和 Instrumentation 动态获取对象信息,如 vmtool --action getInstances --className java.lang.ThreadLocal --limit 10 查看 ThreadLocal 实例。场景:排查 ThreadLocal 内存泄漏,直接查看哪些线程持有特定的 ThreadLocal 值。
    多角度追问:
  • thread -n 3 如何帮助排查 CPU 高的问题?(列出 CPU 占用 top3 线程的栈,快速定位热点代码)
  • monitor 命令的输出中 “fail-rate” 代表什么?(等待锁超时或获取失败的比例,可反映锁竞争严重程度)
  • 使用 vmtool 有什么风险?(会强制 GC?不太会,但大量获取实例可能影响性能,应限制数量)
    加分回答: Arthas 的底层核心是 InstrumentationJVMTISA 技术,vmtool 结合 ognl 表达式可以动态执行代码,实现了“手术刀式”的在线诊断,不过生产环境使用需谨慎评估安全,建议配合审计。

12. jmap -histo 和 MAT 的 Histogram 有什么区别?前者适合快速预览什么信息?
一句话回答: jmap -histo 是 JVM 内建的对象直方图工具,按类统计实例数和总字节数,方便快速预览内存占用 Top N;而 MAT 的 Histogram 提供交互式排序、筛选、对比、以及深入引用分析的能力。
详细解释: jmap -histo <pid>(或 jmap -histo:live 触发 Full GC 后统计)输出纯文本,适合在命令行下快速定位哪些类的实例数激增或占用内存最多,例如发现有 200 万个 HashMap$Node,怀疑缓存问题。MAT 的 Histogram 视图在此基础上可以按包名过滤、计算 retained size、直接跳转到 Dominator Tree 或 List objects 查看具体对象。生产应急时,先用 jmap -histo | head -20 获取 30 秒内的概览,确认大致泄漏点,再决定是否导出完整堆 dump 做 MAT 深度分析。
多角度追问:

  • jmap -histo:livejmap -histo 有何区别?(:live 会触发 Full GC,仅统计存活对象,更准确但影响性能;无 :live 统计所有对象包括可回收的,可能虚高)
  • 为什么 jmap -histo 输出的 [C 占内存高?([C 是 char 数组,通常由 String 内部持有,说明有大量字符串对象,可检查日志拼接、XML/JSON 解析缓存)
  • MAT 中 Retained Heap 与 jmap -histo 的字节数有何不同?(MAT 计算保留堆大小,即该对象被回收可释放的总内存,更能反映泄漏重要性)
    加分回答: 在容器环境中,jmap 可能因 ptrace 权限受限,此时可用 jcmd <pid> GC.class_histogram 达到类似 jmap -histo 的效果,且无需特殊权限,是更安全的替代方案。

13. 如何用 JMH 验证 JVM 优化效果?优化前后结果对比时,Error 占比超过多少说明结果不可信?
一句话回答: 通过编写包含优化前后的 @Benchmark 测试,设置 @Fork(3) 等 JMH 标准参数,比较 Score 和 Error;业界经验 Error/Score > 20% 则结果不可信。
详细解释: 设计对称的基准测试,保证 @Warmup 充分、使用 Blackhole 防死代码消除。运行后得到带 ± Error 的 Score,如果 Error 占 Score 比例过高(>20%),说明测量波动大,需检查环境负载、JVM 参数(是否固定堆和 GC),或增加 Fork 和迭代次数。可信的优化结果应满足:优化前后 Score 差异超过 2 倍 Error 区间,且百分位数(如 P99.9)也有改善。最后在灰度环境验证实际吞吐和延迟。
多角度追问:

  • 为什么 JMH 强调 Fork 多个 JVM?(避免不同测试因 JIT 编译产物共享、GC 状态等相互影响,保证统计独立性)
  • 如果优化后 Score 提升但 Error 也增大如何理解?(可能优化引入了更高方差,如使用缓存导致偶尔的缓存未命中,需进一步分析原因,不能盲目接受)
  • JMH 能测 Full GC 停顿吗?(JMH 主要测吞吐和平均延迟,不适合测量 STW 时间,需结合 GC 日志或 jdk.GC JFR 事件)
    加分回答: JMH 提供 Profiler 扩展,可以堆栈采样、GC 探测器等,能在基准测试过程中同时收集 GC 事件和 CPU 火焰图,帮助分析优化后的内存分配压力变化。对于 JVM 参数优化,建议用 -jvmArgs 分别指定不同 GC 参数批量运行,结合 Result 的统计方法进行显著性检验(T-Test),这是严谨的工程化方法。

14. (故障排查题)线上服务频繁出现 5-8 秒的 Full GC,导致接口超时。GC 日志显示 [Full GC (Ergonomics) ... ],jstat -gcutil 显示老年代使用率在 Full GC 前达到 98%,Full GC 后降为 25%,但 10 分钟内又回到 90% 以上。jmap -histo 显示大量业务对象实例。请分析:(a) 这是内存泄漏还是堆参数不足?如何通过 MAT Dominator Tree 和 Path to GC Roots 最终确认?(b) 画出从 Full GC 到老年代再次满的时序图;(c) 如何通过 MAT 的 Retained Heap 排序定位到持有大量业务对象的根对象?(d) 如果确认是内存泄漏,给出修复方案和预防措施;如果是堆参数不足,给出 JVM 参数调整建议并用 JMH 验证优化效果。

① 一句话回答: 从 Full GC 后老年代使用率大幅下降(98%→25%)且短时间又满来看,大概率是堆参数不足,而非内存泄漏,但仍需 MAT 确认无静态集合长期持有对象。

② 详细解释:
(a) 判断逻辑: 内存泄漏的典型特征是 Full GC 后老年代无法大幅回收,因为泄漏对象是强可达的;而此处 Full GC 后从 98% 降到 25%,说明大部分对象是可回收的,即业务在短时间内产生了远大于老年代容量的活动数据。但为保险,应用 MAT 确认:

  • 打开堆转储,进入 Dominator Tree,按 Retained Heap 降序。
  • 观察排名最前的对象:若是一个巨大的 HashMapArrayList 且其 Retained Heap 接近老年代泄漏,则可能是泄漏。若最大只是某个短暂的任务集合,且 Path to GC Roots 指向线程栈或可回收的局部变量,说明是正常大对象。
  • 对于可疑的大对象,右键 Path to GC Roots → exclude weak/soft references,若链经过静态字段或线程 ThreadLocal,则是泄漏;若最终到某个 Runnable 或 Controller 方法内,则是正常请求对象。本案例 Full GC 后能降 25%,强烈指示正常业务压力,应加大堆。

(b) 时序图:

sequenceDiagram
    participant App as 应用程序
    participant JVM as JVM堆内存
    participant GC as GC线程
    Note over JVM: 老年代使用率 90%
    App->>JVM: 持续分配对象(业务流量)
    JVM->>GC: 老年代达 98%,触发 Full GC (Ergonomics)
    GC->>GC: 执行 Full GC,耗时 5-8s
    GC->>JVM: 回收大部分对象,老年代降至 25%
    App->>JVM: 恢复正常请求处理,对象继续分配
    loop 10 分钟
        App->>JVM: 高频分配/晋升
    end
    JVM->>GC: 老年代再次达 98%,再次 Full GC
    Note over App,GC: 循环导致频繁长停顿

流程说明:老年代被填满触发 Ergonomic Full GC,回收后应用程序继续产生大量对象,且晋升速率高,10 分钟内再次填满,表现为锯齿状 Full GC 模式。

(c) MAT 定位根对象: 在 Dominator Tree 中按 Retained Heap 从大到小排序,找到占用最大的几个对象,逐个分析其类名。如果全是业务实体类(如 Order),则看它们的直接持有者。选中某个大对象,点击 “List objects → with outgoing references” 查看其内部持有的子对象;再通过 “Path to GC Roots” 确认根引用。如果根是 java.util.concurrent.ThreadPoolExecutor 的 Worker 线程中的局部变量,说明是正在处理的任务,并非泄漏。可以结合 OQL SELECT * FROM com.example.Order 查看总数与时间戳,如果都是最近几秒创建,说明是流量压力而非泄漏。

(d) 修复方案:

  • 若内存泄漏: 修复泄漏代码(如清理缓存、ThreadLocal remove)。
  • 若堆参数不足(更可能): 增加 -Xmx(如从 2g 到 4g),同时增大 -Xmn 适当增大年轻代,减少晋升速率。若使用 CMS,可适当降低 -XX:CMSInitiatingOccupancyFraction=70 提前并发回收,避免等到 98% 才 Ergonomics Full GC。如果硬件允许,切换 G1 且设置预期停顿时间 -XX:MaxGCPauseMillis=200,G1 能通过 Mixed GC 在年轻代和老年代间动态平衡,避免单次长时间 Full GC。
    JMH 验证优化效果: 编写基准测试模拟业务对象分配和一定量的老年代晋升压力,对比调整前后 Full GC 次数和平均停顿时间(虽 JMH 不擅长测 STW,但可结合 GC 日志采集器分析,或使用 -prof gc 统计 GC 事件)。在灰度发布后,监控 jstat -gcutil 的老年代使用率稳定在 80% 以下且无频繁 Full GC 即为成功。

③ 多角度追问:

  • 如果 Full GC 后老年代降不到 25% 而是只降到 85% 呢?(极可能内存泄漏,因为大部分对象无法回收)
  • Ergonomics Full GC 与 System.gc() 触发的 Full GC 有何区别?(Ergonomics 是 JVM 自适应策略决定的,System.gc() 是显式调用,可通过 -XX:+DisableExplicitGC 禁止)
  • 如何避免 Full GC 导致的接口超时?(除优化堆参数外,可启用 GC 前告警或使用 -XX:+ScavengeBeforeFullGC 控制,但更推荐从根源减少 Full GC)

④ 加分回答: 在容器化部署中,堆大小调整需要考虑 Pod 内存限制,建议使用 -XX:MaxRAMPercentage=75.0 而非固定 -Xmx,以动态适应容器规格。同时结合 JFR 录制老年代填充原因,分析对象晋升路径,可以更科学地决定扩大堆还是优化代码。