06.7 - 大厂实战案例库
定位: 理论照进现实——5个真实生产事故的根因分析与解决方案 面试高频度: ⭐⭐⭐⭐⭐("你遇到过什么JVM问题"是高级工程师必问题)
前言
"你遇到过什么JVM问题?怎么解决的?" ——这可能是JVM面试中最高频的开放题。
面试官不是要听你背概念,而是想验证:
- 你真的在生产环境排查过问题(不是只看过博客)
- 你有系统性的排查思路(不是碰运气)
- 你理解了根因(不只是重启解决了)
下面5个案例覆盖了JVM排查中最常见的场景。建议面试前至少熟练讲出2-3个。
案例一:Groovy脚本引发的Metaspace OOM
现象
| 维度 | 数据 |
|---|---|
| 环境 | 在线规则引擎服务,8C16G |
| 症状 | 上线新功能2天后,告警Metaspace OOM。进程反复重启 |
| 影响 | 每次OOM导致约15秒服务不可用,上游重试加剧了压力 |
排查过程
# 第一次OOM重启后,立即检查Metaspace
$ jstat -gc <pid> 1000 3
MC MU CCSC CCSU YGC YGCT FGC FGCT
51200.0 50000.0 6144.0 5800.0 120 0.800 15 12.000MC=51MB,MU=50MB——Metaspace几乎占满,确认是Metaspace OOM。
# 加载的类数量
$ jstat -class <pid>
Loaded Bytes Unloaded Bytes Time
45678 120MB 0 0 12.0s加载了45678个类!正常服务应该只有几千个类。
# 开启类加载追踪(在启动参数加 -XX:+TraceClassLoading)
# 发现大量 groovy.runtime.metaclass.* 类持续增加
[Loaded groovy.runtime.metaclass.MyRule_12345 from __JVM_defineClass__]
[Loaded groovy.runtime.metaclass.MyRule_12346 from __JVM_defineClass__]
[Loaded groovy.runtime.metaclass.MyRule_12347 from __JVM_defineClass__]
...每次规则执行都创建了一个新的Groovy类!不是缓存了GroovyScript。
根因
// ❌ 错误写法: 每次执行都编译
public class RuleEngine {
public Object execute(String script, Map<String, Object> context) {
GroovyShell shell = new GroovyShell();
Script compiled = shell.parse(script); // 每次parse生成一个新Class
return compiled.run();
}
}GroovyShell.parse()每次调用都会创建一个新的Groovy类(class name递增),这些类被加载到Metaspace后永远不会被卸载。规则每天执行上千次,类数量无限增长。
解决方案
// ✅ 正确写法: 缓存编译后的脚本
public class RuleEngine {
private final Map<String, Script> scriptCache = new ConcurrentHashMap<>();
public Object execute(String script, Map<String, Object> context) {
Script compiled = scriptCache.computeIfAbsent(
DigestUtils.md5Hex(script),
key -> new GroovyShell().parse(script) // 只在第一次编译
);
return compiled.run();
}
}# 同时增加保护
-XX:MaxMetaspaceSize=256m
-XX:+HeapDumpOnOutOfMemoryError经验教训
- 动态脚本引擎(Groovy/GroovyShell/JRuby)极易导致Metaspace OOM——永远是缓存编译结果,不是每次新建Class
-XX:MaxMetaspaceSize是保命参数——没有这个限制,Metaspace泄漏会一直吃掉所有内存- 线上开启
-XX:+TraceClassLoading(配合开关使用,排查完关闭)
案例二:CMS Concurrent Mode Failure → 服务雪崩
现象
| 维度 | 数据 |
|---|---|
| 环境 | 交易核心服务,CMS收集器,4C8G |
| 症状 | 每天晚高峰(19:00-22:00)接口延迟从50ms飙到3s,持续约30分钟 |
| 影响 | 影响下游所有服务,B端用户大面积报错 |
排查过程
# 查看GC情况
$ jstat -gcutil <pid> 1000
S0 S1 E O M YGC YGCT FGC FGCT GCT
0.00 0.00 30.0 92.0 85.0 2000 12.0 10 25.0 37.0Old=92%,FGC=10次。说明Full GC频繁。
查看GC日志(关键发现):
[GC (Allocation Failure) 2019-08-15T19:30:00.123+0800: 120.000:
[ParNew: 2048M->256M(2304M), 0.150 secs]
[CMS: 5120M->4800M(5632M), 1.200 secs] ← CMS耗时1.2秒!
[Metaspace: 200M->200M(256M)], 1.400 secs]
[Times: user=2.50 sys=0.20, real=1.40 secs]发现每次CMS并发标记阶段耗时很长,而且回收量很小(5120M→4800M,只回收了320M)。
关键的日志链条:
2019-08-15T19:28:00.000+0800: 118.000: [GC (CMS Initial Mark) [1 CMS-initial-mark: 5000M(5632M)]]
2019-08-15T19:28:00.050+0800: 118.050: [CMS-concurrent-mark-start]
...
2019-08-15T19:29:00.000+0800: 119.000: [Full GC (Allocation Failure)
[CMS: 5200M->4800M(5630M), 1.5 secs] ← Concurrent Mode Failure!
[ParNew: 2048M->256M(2304M)]]日志显示:CMS并发标记还没完成,新生代ParNew GC后对象要晋升,但老年代空间不够,触发Concurrent Mode Failure,CMS退化为Serial Old Full GC。
根因
业务层面: 晚高峰交易量翻倍,对象创建速率飙升,Old的填充速度超过了CMS并发回收的速度。
JVM层面:
-Xmx太小(8G物理内存只给了5G堆)- CMS的
-XX:CMSInitiatingOccupancyFraction=68触发过早(但业务实际需要收集更早启动) - Old碎片化严重,虽然总空间还有剩余,但连续空间不足以容纳晋升对象
解决方案
# 短期(当天修复)
# 升级到G1GC,用G1的停顿预测模型代替CMS的固定阈值
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=4m
-XX:InitiatingHeapOccupancyPercent=45
# 长期
# 1. 增大堆到6G
# 2. 重构交易流程,减少对象创建修复效果: 更换G1后Concurrent Mode Failure消失,晚高峰接口延迟稳定在80ms以内。
经验教训
- CMS已经JDK 9+废弃也不是没有原因的——Concurrent Mode Failure是老问题的典型
- 选择GC的关键因素:评估对象分配速率(allocation rate),CMS适合低分配速率场景,高分配速率场景应直接上G1或ZGC
- Concurrent Mode Failure发生在并发收集速度 < 对象分配速度时——堆越大越不容易触发
案例三:Netty Direct Memory OOM
现象
| 维度 | 数据 |
|---|---|
| 环境 | Netty 4.x 网关服务,4C8G |
| 症状 | 运行3天后进程OOM,但堆内存HEAP DUMP只有2G(远小于Xmx=4G) |
| 影响 | 网关重启,连接断开 |
排查过程
# OOM错误信息
java.lang.OutOfMemoryError: Direct buffer memory
# 检查堆——发现堆使用正常
$ jstat -gcutil <pid> 1000
S0 S1 E O M YGC YGCT FGC FGCT GCT
0.00 0.00 30.0 40.0 80.0 200 1.20 0 0.0 1.20O=40%,FGC=0——堆完全正常!这不是堆OOM。
# 检查DirectBuffer
$ jmap -histo <pid> | grep -E "Direct|ByteBuffer|Cleaner"
num #instances #bytes class name
45: 15000 1200000 java.nio.DirectByteBuffer ← 对象数异常
78: 15000 900000 sun.misc.CleanerDirectByteBuffer有15000个实例!每个对象本身很小(80字节),但每个指向的堆外内存可能很大。
# 查看进程内存(Linux)
pmap <pid> | grep -E "anon" | awk '{sum+=$2} END {print sum}'
# 输出: 4.2G ← 远超Xmx=4G,确认是堆外内存泄漏根因
// ❌ 错误写法: ByteBuf没有release
public class GatewayHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
// ❌ 没有调用 buf.release()!
// ByteBuf的引用计数不归0 → DirectByteBuffer不被释放
}
}Netty使用引用计数(Reference Counted)管理ByteBuf。每个ByteBuf初始refCnt=1,调用一次release()后refCnt减为0才会释放底层DirectByteBuffer。没有调用release()相当于堆外内存泄漏。
解决方案
// ✅ 正确写法: 确保release
public class GatewayHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
try {
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
// 业务逻辑...
} finally {
buf.release(); // 关键!确保释放引用
}
}
}同时启用Netty的引用计数泄漏检测(用于排查):
-Dio.netty.leakDetectionLevel=paranoid # 严格模式,检测泄漏的ByteBuf经验教训
- Direct Memory OOM的核心陷阱: 堆看起来是正常的,Heap Dump查不出问题
- 排查思路是: OOM路径 ≠ Heap Dump——看错误信息确定OOM类型,而不是一上来就dump
- Netty应用中,遵循"谁分配谁释放"原则,确保每个ByteBuf在finally block中release
pmap+jmap -histo | grep DirectByteBuffer是排查堆外内存泄漏的标准组合拳
案例四:ThreadLocal内存泄漏 → 线程池OOM
现象
| 维度 | 数据 |
|---|---|
| 环境 | 订单处理服务,固定线程池(200线程),8C16G |
| 症状 | 运行2天后Java heap OOM,重启后正常,2天后又OOM |
| 影响 | 每2天重启一次 |
排查过程
OOM日志:Java heap space,使用 -XX:+HeapDumpOnOutOfMemoryError 拿到HEAP DUMP。
MAT分析:
Leak Suspect Report:
- Problem Suspect 1: 一个 java.util.HashMap 实例占用了堆的60%
被 com.example.service.RequestContext 中的 ThreadLocal 持有
→ GC Root: Thread thread-id=85查看ThreadLocal持有链:
Thread[ID=85]
└── ThreadLocalMap
└── Entry(key=RequestContext.currentUser, value=UserInfo[..., role="ADMIN"])
└── value为HashMap实例,包含大量数据再看代码:
public class RequestContext {
private static ThreadLocal<Map<String, Object>> context = new ThreadLocal<>();
public static void setAttribute(String key, Object value) {
if (context.get() == null) {
context.set(new HashMap<>());
}
context.get().put(key, value);
}
}
// 在业务代码中使用
@Async("orderThreadPool")
public void processOrder(Order order) {
RequestContext.setAttribute("orderId", order.getId());
RequestContext.setAttribute("user", order.getUser()); // ← 大量对象加入
// 执行业务逻辑...
// ❌ 没有在finally中调用 RequestContext.clear()
}线程池中200个线程,每个线程执行完任务后没有清理ThreadLocal。线程被复用,但ThreadLocalMap中上次请求的数据还在。随着时间推移,每个线程的ThreadLocalMap都累积了大量对象。
根因
线程池中ThreadLocal未清理。线程池的核心线程永不销毁,ThreadLocalMap中的value永远不会被GC回收。每个线程的Map累积数据,最终撑爆堆。
解决方案
// ✅ 正确写法: try-finally 清理
@Async("orderThreadPool")
public void processOrder(Order order) {
try {
RequestContext.setAttribute("orderId", order.getId());
RequestContext.setAttribute("user", order.getUser());
// 执行业务逻辑...
} finally {
RequestContext.clear(); // 关键!
}
}
public class RequestContext {
private static ThreadLocal<Map<String, Object>> context = new ThreadLocal<>();
public static void clear() {
Map<String, Object> map = context.get();
if (map != null) {
map.clear(); // 清空数据
}
context.remove(); // 删除ThreadLocalMap条目
}
}经验教训
- ThreadLocal + 线程池 = 内存泄漏孵化器。线程池的线程是复用的,而ThreadLocal的数据生命周期与线程绑定——线程不销毁,数据不释放
- 使用ThreadLocal的黄金法则: 谁set,谁remove。在finally中调用
remove() - 使用
RequestContext/ThreadLocal工具类时,提供clear()方法并强制业务代码调用
案例五:Survivor过小导致的过早晋升
现象
| 维度 | 数据 |
|---|---|
| 环境 | 风控规则服务,Parallel GC,4C8G |
| 症状 | 平均响应时间5ms,但每隔几秒会突跳到200ms |
| 影响 | P99延迟不达标 |
排查过程
$ jstat -gcutil <pid> 1000 10
S0 S1 E O M YGC YGCT FGC FGCT GCT
0.00 0.00 70.0 80.0 85.0 5000 30.0 3 2.0 32.0
0.00 0.00 85.0 82.0 85.0 5001 30.1 3 2.0 32.1
0.00 0.00 95.0 85.0 85.0 5002 30.2 4 2.7 32.9 ← Full GC一次
0.00 0.00 45.0 60.0 85.0 5003 30.2 4 2.7 32.9 ← Full GC后Old降到60%Full GC每几秒一次,每次约700ms——这就是那个200ms突跳的来源。
查看GC日志启用 -XX:+PrintTenuringDistribution:
Desired survivor size 1048576 bytes, new threshold 1 (max 15)
- age 1: 2048576 bytes, 2048576 total
- age 2: 0 bytes, 0 total
: 2048576 bytes, 2048576 total关键发现:Survivor期望大小=1MB,但age 1的对象就占了2MB!Survivor放不下age 1的对象 → 所有age 1的对象直接晋升到Old。
# 堆配置检查
$ jmap -heap <pid>
MaxHeapSize = 4G
NewSize = 1G
SurvivorRatio = 8SurvivorRatio=8 → Eden:Survivor=8:1:1 → Survivor大小=1G/10=100MB。
等等,100MB的Survivor为什么只有1MB的期望大小?
问题出在-XX:+UseAdaptiveSizePolicy(Parallel GC默认开启)。它会动态调整Survivor大小,在运行时把Survivor缩减到"足够放下存活对象"的最小值。但这里它估算错了——因为对象年龄分布变化大,Survivor被缩小到只有1MB。
根因
Parallel GC的UseAdaptiveSizePolicy动态调整Survivor到极小值 → Survivor的期望容量(desired survivor size)太小 → age 1的对象直接晋升到Old → Old迅速填满 → Full GC频繁。
解决方案
# 关闭自适应策略,固定Survivor大小
-XX:-UseAdaptiveSizePolicy
-XX:SurvivorRatio=6 # 增大Survivor比例
-XX:MaxTenuringThreshold=15 # 增加晋升年龄(让对象在Survivor多停留几次)
-XX:TargetSurvivorRatio=90 # 允许Survivor使用到90%(默认50%)修复效果: Full GC频率从每5秒1次降到每小时0-1次,P99延迟降到30ms以内。
经验教训
- 默认参数≠最佳参数——Parallel GC的
UseAdaptiveSizePolicy是针对吞吐量的优化,但它可能为了最小化暂停时间而过度缩小Survivor -XX:+PrintTenuringDistribution是调优Survivor最有力的工具——它让你直接看到对象年龄分布- "Full GC频繁"不一定是内存泄漏,很多时候是对象过早晋升导致的
- 使用固定参数的G1(不依赖自适应策略)可以避免这类问题
面试视角
"你遇到过什么JVM问题?"回答框架
面试官问这个问题时,用STAR法则组织回答:
| 要素 | 内容 | 时长 |
|---|---|---|
| Situation | 什么业务、什么环境、什么现象 | 15秒 |
| Task | 问题的表象是什么、为什么严重 | 15秒 |
| Action | 你怎么排查的、用了什么工具、每一步发现了什么 | 45秒 |
| Result | 根因是什么、怎么修的、修完后指标变化 | 15秒 |
准备建议: 从上面5个案例中选2-3个你能自然说出来的,自己练习用STAR框架讲述。
案例选择策略
| 面试级别 | 推荐案例 | 原因 |
|---|---|---|
| 1-3年经验 | 案例五(过早晋升)或案例一(Metaspace) | 考察基础参数理解 |
| 3-5年经验 | 案例三(DirectMemory)或案例四(ThreadLocal) | 考察对JVM/NIO/并发的理解深度 |
| 5年+经验 | 案例二(Concurrent Mode Failure) | 考察GC选型和调优经验 |
| 选一个复杂的 | 案例一(Metaspace + Groovy) | 讲得好能展示系统性排查能力 |
面试回答模板
Q: 你遇到过什么JVM问题?A: (STAR框架) S — 我在上一家公司维护一个规则引擎服务,8C16G,某次上线新功能后频繁OOM重启。 T — 每次OOM都在运行2天左右发生,影响严重。 A — 我先看错误日志确认是Metaspace OOM。用
jstat -class看到加载了4万多类——正常只有几千。用-XX:+TraceClassLoading看到大量Groovy类持续增加。再看代码,发现业务方用了GroovyShell.parse()每次执行规则都编译一个新类。 R — 改了代码,用ConcurrentHashMap缓存编译结果,规则脚本只编译一次。同时加了-XX:MaxMetaspaceSize=256m保底。修复后服务稳定运行再没出现过Metaspace OOM。
📚 相关链接
- ← 返回 **调优索引**
- **OOM排查方法论** - OOM类型与通用排查方法
- **CPU飙升排查方法论** - CPU排查方法
- **FullGC频繁排查方法论** - Full GC排查方法
- **常用工具(jps与jstat与jmap与jstack与jinfo)** - 工具基础
- **JVM参数分类速查** - 参数速查
- **直接内存** - 直接内存原理
- **方法区与元空间** - 元空间原理
- **垃圾收集器详解(Serial到ZGC)** - GC收集器选型