Skip to content

06.7 - 大厂实战案例库

定位: 理论照进现实——5个真实生产事故的根因分析与解决方案 面试高频度: ⭐⭐⭐⭐⭐("你遇到过什么JVM问题"是高级工程师必问题)

前言

"你遇到过什么JVM问题?怎么解决的?" ——这可能是JVM面试中最高频的开放题。

面试官不是要听你背概念,而是想验证:

  1. 你真的在生产环境排查过问题(不是只看过博客)
  2. 你有系统性的排查思路(不是碰运气)
  3. 你理解了根因(不只是重启解决了)

下面5个案例覆盖了JVM排查中最常见的场景。建议面试前至少熟练讲出2-3个。


案例一:Groovy脚本引发的Metaspace OOM

现象

维度数据
环境在线规则引擎服务,8C16G
症状上线新功能2天后,告警Metaspace OOM。进程反复重启
影响每次OOM导致约15秒服务不可用,上游重试加剧了压力

排查过程

bash
# 第一次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.000

MC=51MB,MU=50MB——Metaspace几乎占满,确认是Metaspace OOM。

bash
# 加载的类数量
$ jstat -class <pid>
Loaded  Bytes    Unloaded  Bytes   Time
 45678  120MB    0         0       12.0s

加载了45678个类!正常服务应该只有几千个类。

bash
# 开启类加载追踪(在启动参数加 -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。

根因

java
// ❌ 错误写法: 每次执行都编译
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后永远不会被卸载。规则每天执行上千次,类数量无限增长。

解决方案

java
// ✅ 正确写法: 缓存编译后的脚本
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();
    }
}
bash
# 同时增加保护
-XX:MaxMetaspaceSize=256m
-XX:+HeapDumpOnOutOfMemoryError

经验教训

  1. 动态脚本引擎(Groovy/GroovyShell/JRuby)极易导致Metaspace OOM——永远是缓存编译结果,不是每次新建Class
  2. -XX:MaxMetaspaceSize是保命参数——没有这个限制,Metaspace泄漏会一直吃掉所有内存
  3. 线上开启 -XX:+TraceClassLoading(配合开关使用,排查完关闭)

案例二:CMS Concurrent Mode Failure → 服务雪崩

现象

维度数据
环境交易核心服务,CMS收集器,4C8G
症状每天晚高峰(19:00-22:00)接口延迟从50ms飙到3s,持续约30分钟
影响影响下游所有服务,B端用户大面积报错

排查过程

bash
# 查看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.0

Old=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层面:

  1. -Xmx太小(8G物理内存只给了5G堆)
  2. CMS的-XX:CMSInitiatingOccupancyFraction=68触发过早(但业务实际需要收集更早启动)
  3. Old碎片化严重,虽然总空间还有剩余,但连续空间不足以容纳晋升对象

解决方案

bash
# 短期(当天修复)
# 升级到G1GC,用G1的停顿预测模型代替CMS的固定阈值
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=4m
-XX:InitiatingHeapOccupancyPercent=45

# 长期
# 1. 增大堆到6G
# 2. 重构交易流程,减少对象创建

修复效果: 更换G1后Concurrent Mode Failure消失,晚高峰接口延迟稳定在80ms以内。

经验教训

  1. CMS已经JDK 9+废弃也不是没有原因的——Concurrent Mode Failure是老问题的典型
  2. 选择GC的关键因素:评估对象分配速率(allocation rate),CMS适合低分配速率场景,高分配速率场景应直接上G1或ZGC
  3. Concurrent Mode Failure发生在并发收集速度 < 对象分配速度时——堆越大越不容易触发

案例三:Netty Direct Memory OOM

现象

维度数据
环境Netty 4.x 网关服务,4C8G
症状运行3天后进程OOM,但堆内存HEAP DUMP只有2G(远小于Xmx=4G)
影响网关重启,连接断开

排查过程

bash
# 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.20

O=40%,FGC=0——堆完全正常!这不是堆OOM。

bash
# 检查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.Cleaner

DirectByteBuffer有15000个实例!每个对象本身很小(80字节),但每个指向的堆外内存可能很大。

bash
# 查看进程内存(Linux)
pmap <pid> | grep -E "anon" | awk '{sum+=$2} END {print sum}'
# 输出: 4.2G   ← 远超Xmx=4G,确认是堆外内存泄漏

根因

java
// ❌ 错误写法: 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()相当于堆外内存泄漏

解决方案

java
// ✅ 正确写法: 确保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的引用计数泄漏检测(用于排查):

bash
-Dio.netty.leakDetectionLevel=paranoid   # 严格模式,检测泄漏的ByteBuf

经验教训

  1. Direct Memory OOM的核心陷阱: 堆看起来是正常的,Heap Dump查不出问题
  2. 排查思路是: OOM路径 ≠ Heap Dump——看错误信息确定OOM类型,而不是一上来就dump
  3. Netty应用中,遵循"谁分配谁释放"原则,确保每个ByteBuf在finally block中release
  4. 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实例,包含大量数据

再看代码:

java
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累积数据,最终撑爆堆。

解决方案

java
// ✅ 正确写法: 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条目
    }
}

经验教训

  1. ThreadLocal + 线程池 = 内存泄漏孵化器。线程池的线程是复用的,而ThreadLocal的数据生命周期与线程绑定——线程不销毁,数据不释放
  2. 使用ThreadLocal的黄金法则: 谁set,谁remove。在finally中调用remove()
  3. 使用RequestContext/ThreadLocal工具类时,提供clear()方法并强制业务代码调用

案例五:Survivor过小导致的过早晋升

现象

维度数据
环境风控规则服务,Parallel GC,4C8G
症状平均响应时间5ms,但每隔几秒会突跳到200ms
影响P99延迟不达标

排查过程

bash
$ 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。

bash
# 堆配置检查
$ jmap -heap <pid>
  MaxHeapSize = 4G
  NewSize = 1G
  SurvivorRatio = 8

SurvivorRatio=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频繁。

解决方案

bash
# 关闭自适应策略,固定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以内。

经验教训

  1. 默认参数≠最佳参数——Parallel GC的UseAdaptiveSizePolicy是针对吞吐量的优化,但它可能为了最小化暂停时间而过度缩小Survivor
  2. -XX:+PrintTenuringDistribution是调优Survivor最有力的工具——它让你直接看到对象年龄分布
  3. "Full GC频繁"不一定是内存泄漏,很多时候是对象过早晋升导致的
  4. 使用固定参数的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收集器选型

Knowledge4J — Java 知识库