JVM 线上故障排查与调优实战:从 OOM 到 GC 优化的完整指南

前言

线上服务出现 OOM(OutOfMemoryError)是 Java 开发者最不愿面对却迟早会遇到的问题。本文通过一个真实的电商订单系统故障案例,完整记录从现象观察 → 日志分析 → 根因定位 → 调优方案 → 效果验证的全过程,帮助大家在遇到类似问题时能够有条不紊地处理。


一、故障现象

某日收到告警:订单服务响应时间从正常的 50ms 飙升至 30s+,大量请求超时,最终触发熔断。

监控表现:

  • CPU 使用率:80% → 99%(持续高位)
  • 内存使用:8GB / 16GB(持续增长,GC 后不回落)
  • Full GC 频率:每 30s 一次(正常应为几小时一次)
  • 线程数:1200+(正常 200 左右)

二、初步诊断:快速定位方向

2.1 确认问题类型

先用 jps 确认进程 PID:

jps -lvm
# 输出: 28472 com.example.order.OrderApplication --server.port=8080

# 查看进程启动参数
jinfo 28472 | grep -i "gc\|heap\|cms\|g1"

2.2 查看 GC 状态(实时)

# 实时查看 GC 情况,每1秒刷新
jstat -gcutil 28472 1000

# 输出示例:
#   S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
#  77.22   0.00  100.00  99.45  88.32  84.67   4125   123.456  234   567.890  691.346

关键发现:Old 区(O)使用率 99.45%,元数据区(M)88.32%,说明大量对象进入了老年代,且 Full GC 几乎无法回收。


三、深入分析:堆内存快照

3.1 导出堆转储文件

# 生成 heap dump(推荐用 jmap,线上慎用 gcore)
jmap -dump:format=b,file=/tmp/heap.hprof 28472

# 如果进程可能卡顿,用 -F 强制
jmap -F -dump:format=b,file=/tmp/heap.hprof 28472

3.2 分析堆内存(MAT 工具)

将 heap.hprof 拉到本地,用 Eclipse MAT 打开,重点关注:

Dominator Tree(支配树):按对象 retained heap 排序,找最大占用者。

本次案例 Dominator Tree 结果:

  • HashMap$Node[] - 45,231 对象, Retained Heap: 892 MB
  • OrderContext - 12,458 对象, Retained Heap: 634 MB
  • String - 890,123 对象, Retained Heap: 142 MB

3.3 Histogram 对比分析

# 导出 Histogram(按类名排序)
jmap -histo 28472 | sort -k 3 -rn | head -30

四、根因定位:从代码到机制

4.1 发现问题代码

通过 MAT 的 "Path to GC Roots" 功能,追踪 OrderContext 的引用链,发现:

问题代码片段 1:静态 Map 缓存无限膨胀

@Service
public class OrderService {

    // 危险:大对象作为 Map 的 Value,且没有清理机制
    private static final Map<String, OrderContext> ORDER_CACHE = new HashMap<>();

    public void createOrder(OrderDTO dto) {
        OrderContext ctx = buildContext(dto);
        // 永远只 put,从不 remove
        ORDER_CACHE.put(dto.getOrderId(), ctx);
        processOrder(ctx);
    }

    // 问题:订单完成后没有清理 cache
    // 结果:ORDER_CACHE 持续膨胀 -> 老年代占满 -> Full GC -> OOM
}

问题代码片段 2:ThreadLocal 未清理

public class OrderContextHolder {

    private static final ThreadLocal<OrderContext> CONTEXT = new ThreadLocal<>();

    public static void set(OrderContext ctx) {
        CONTEXT.set(ctx);
    }

    // 忘记 remove:线程池复用 -> ThreadLocal 内存泄漏
    public static void clear() {
        // 被注释掉的 remove,导致内存泄漏
        // CONTEXT.remove();
    }
}

4.2 机制层面解释

为什么 HashMap.Entry 占用这么大?

OrderContext 里嵌套了大量数据:

public class OrderContext {
    private String orderId;
    private UserDTO user;           // 内含详细用户信息
    private List<OrderItemDTO> items; // 每单平均 5-10 个商品
    private PaymentInfo payment;     // 支付信息
    private LogisticsInfo logistics; // 物流信息
    private Map<String, Object> ext; // 扩展数据
}

一个 OrderContext 约 50KB,45,000 个约 2.25GB!


五、调优方案

5.1 短期修复(止血)

@Service
public class OrderService {

    // 改造1:使用 LinkedHashMap LRU 或定期清理
    private static final Map<String, OrderContext> ORDER_CACHE =
        Collections.synchronizedMap(new LinkedHashMap<>(1024, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, OrderContext> eldest) {
                return size() > 500; // 最多缓存 500 条,自动淘汰最老的
            }
        });

    // 改造2:增加定时清理任务
    @Scheduled(fixedRate = 300000) // 每5分钟
    public void cleanupCache() {
        int before = ORDER_CACHE.size();
        long expireTime = System.currentTimeMillis() - 30 * 60 * 1000;
        ORDER_CACHE.entrySet().removeIf(e -> e.getValue().getCreateTime() < expireTime);
        log.info("Cache cleanup: {} -> {}", before, ORDER_CACHE.size());
    }
}
// 改造3:ThreadLocal 正确清理
public class OrderContextHolder {

    public static void clear() {
        CONTEXT.remove(); // 必须调用 remove()
    }

    // 建议在 Filter 中统一清理
    @Component
    public class ContextCleanupFilter extends OncePerRequestFilter {
        @Override
        protected void doFilterInternal(HttpServletRequest request,
                                        HttpServletResponse response,
                                        FilterChain chain) throws Exception {
            try {
                chain.doFilter(request, response);
            } finally {
                OrderContextHolder.clear();
            }
        }
    }
}

5.2 JVM 参数调优

java -jar order-service.jar \
  -Xms4g -Xmx4g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=200 \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/oom.hprof \
  -XX:+PrintGCDetails \
  -XX:+PrintGCDateStamps \
  -Xloggc:/var/log/gc.log \
  -XX:MetaspaceSize=256m \
  -XX:MaxMetaspaceSize=512m

G1GC vs CMS 对比(16GB 堆场景):

特性 CMS G1GC
停顿模型 STW 可预期停顿
大内存表现 停顿不可控 MaxGCPauseMillis=200 可控
内存碎片 长期碎片多 相对更优
适用场景 < 4GB 大于等于 4GB

5.3 日志分析验证

调优后 GC 日志对比:

调优前(CMS):

2025-03-01T14:30:25.123: [GC ... CMS: 1536M->1024M(2048M), 5.234s]
2025-03-01T14:30:55.678: [Full GC ... CMS: 2048M->2047M(2048M), 8.901s]

调优后(G1GC):

2025-03-01T15:00:00.000: [GC pause (G1 Evacuation Pause) 12.345: [Eden: 512M->512M(1024M), 0.089s]
2025-03-01T15:30:00.000: [GC pause (G1 Evacuation Pause) 17.890: [Eden: 512M->256M(1024M), 0.034s]

5.4 最终效果

指标 调优前 调优后
平均响应时间 2000ms+ 45ms
Full GC 频率 每30秒 约6小时1次
老年代使用率 99% 稳定 60%
CPU 使用率 99% 35%
内存占用 持续增长 稳定

六、总结:排查思路梳理

线上 OOM 排查流程:

  1. 监控告警 -> CPU/内存/GC 异常
  2. jps + jinfo -> 确认进程 & JVM 参数
  3. jstat -gcutil -> 快速定位哪个区满了
  4. jmap -dump -> 导出堆快照
  5. MAT / jhat 分析 -> 找大对象 & GC Root
  6. 定位代码 -> 修 Bug
  7. JVM 调参 -> 治标
  8. 灰度上线 + 监控验证

核心经验总结:

  1. 永远不要用静态 HashMap 做无限缓存 — 使用 LRU Cache(Guava / Caffeine)或限制大小
  2. ThreadLocal 用完必须 remove — 线程池复用场景下必漏
  3. 大对象是 GC 的天敌 — OrderContext 这种 50KB 的对象,一次性创建 5 万个约 2.5GB
  4. JVM 参数因场景而异 — G1GC 适合大内存(4GB+),CMS 适合小内存
  5. OOM 时自动 dump — -XX:+HeapDumpOnOutOfMemoryError 让你事后有据可查
  6. GC 日志是最好的诊断工具 — 平时就要开启,别等出问题了才想起来

推荐工具:Eclipse MAT(堆分析)、GCeasy(GC 日志分析)、Arthas(在线诊断)、async-profiler(CPU 火焰图)

希望这篇实战记录能帮你在下次遇到 OOM 时,快速定位、精准修复。

posted @ 2026-05-06 09:58  fitch_liu  阅读(35)  评论(0)    收藏  举报