这是我经历过最难受的一次线上事故排查,从凌晨两点折腾到第二天下午三点,整整 13 个小时。这篇文章完整还原整个过程。

服务背景:Spring Boot 2.7、JDK 11、G1GC、K8s 部署、内存限制 2GB

第一现场:告警来了,先做什么

第一反应:别急着重启

大多数人第一反应是重启 Pod,让服务先恢复。这没错,但有个致命问题——OOM 发生时的现场就没了

正确的处理顺序:

1. 先保留现场,再重启

# 在 Pod 被 K8s 自动 kill 之前,手动 dump 堆内存
kubectl exec -it order-service-7d9f8b-xk2p9 -- \
  jmap -dump:live,format=b,file=/tmp/heap-$(date +%Y%m%d%H%M).hprof 1

# 把 dump 文件拷出来(Pod 重启后文件就没了)
kubectl cp order-service-7d9f8b-xk2p9:/tmp/heap-202405091402.hprof ./heap.hprof

2. 看 K8s 事件日志

kubectl describe pod order-service-7d9f8b-xk2p9

# 关键信息在这里:
# Last State: Terminated
#   Reason:   OOMKilled         ← 确认是内存问题
#   Exit Code: 137              ← 137 = 128 + 9,被 SIGKILL 干掉

3. 看 GC 日志(如果之前开了的话)

# 查看 Pod 最后几分钟的 GC 日志
kubectl logs order-service-7d9f8b-xk2p9 --previous | grep -E "GC|OutOfMemory" | tail -50

我们当时 GC 日志长这样:

[2024-05-09T01:47:33.241+0800] GC(1823) Pause Full (Allocation Failure) 1948M->1951M(2048M) 8.234s
[2024-05-09T01:47:41.488+0800] GC(1824) Pause Full (Allocation Failure) 1951M->1952M(2048M) 9.102s
[2024-05-09T01:47:50.602+0800] GC(1825) Pause Full (Allocation Failure) 1952M->1952M(2048M) 11.847s
[2024-05-09T01:47:50.602+0800] OutOfMemoryError: Java heap space

看到了吗——Full GC 三次,每次 GC 完内存几乎没有减少(1948M→1951M→1952M),说明堆里有大量对象 GC 无法回收,这是内存泄漏的典型特征,不是内存不够的问题。

第二步:理解 JVM 内存结构(排查前必须懂)

很多人分不清 OOM 的根本原因,先把结构搞清楚。

JVM 内存结构Heap(堆)— 受 -Xmx 控制Young Generation(新生代)Eden Space新对象分配在这里Minor GC 清理S0SurvivorS1SurvivorOld Generation(老年代)— OOM 重灾区长期存活对象 / 晋升对象内存泄漏时这里会被慢慢填满Full GC 也回收不掉 → OOM非堆区域Metaspace(元空间)类元数据、常量池Code CacheJIT 编译后的机器码Thread Stack每个线程独立的栈帧Direct MemoryNIO 堆外内存

本次 OOM 发生在 Old Generation → Java heap space

JVM 内存结构。本次 OOM 的报错是 Java heap space,锁定在 Old Generation

OOM 的三种主要类型:

报错信息

发生区域

常见原因

Java heap space

堆(老年代)

内存泄漏、对象过大

GC overhead limit exceeded

GC 时间超过 98%,回收不到 2%

Metaspace

元空间

类加载泄漏、动态代理太多

Direct buffer memory

堆外

NIO/Netty 堆外内存泄漏

unable to create native thread

线程栈

线程数超限

我们的报错是 Java heap space,GC 日志也印证了这点,接着往下查。

第三步:分析 GC 日志,确认是泄漏不是配置问题

在 dump 文件分析之前,先从 GC 日志确定方向,是「内存配置不够」还是「内存泄漏」。

堆内存趋势对比:泄漏 vs 配置不足内存泄漏(我们的情况)时间2G1GFull GC几乎无效OOM!GC 后内存不降 → 内存泄漏配置不足(正常波动)时间2G1GGC 后内存大幅回落 → 加内存即可

内存泄漏时 Full GC 后堆占用几乎不降(左),配置不足时 GC 后会明显回落(右)

我们的 GC 日志是左图特征:

GC(1823) 1948M->1951M  ← GC 前后几乎没变化
GC(1824) 1951M->1952M  ← 还在涨
GC(1825) 1952M->1952M  ← 满了,GC 回收 0MB

结论:内存泄漏,不是内存配置不够。加内存也没用,必须找泄漏点。

第四步:用 MAT 分析 heap dump

这是整个排查最核心的一步。

配置 JVM 自动 dump(生产必备)

在排查之前,先说一下正确的生产配置。如果提前加了这两个参数,OOM 发生时会自动生成 dump 文件,不需要手动抢救:

# JVM 启动参数里加上这两行
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heap-dump/

# K8s deployment.yaml 里配置
env:
- name: JAVA_OPTS
  value: >-
    -Xms1g -Xmx2g
    -XX:+UseG1GC
    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/data/logs/heap-dump/
    -Xlog:gc*:file=/data/logs/gc.log:time,uptime:filecount=5,filesize=20m

我们当时没配,所以只能事故发生时手动 dump,差点没来得及。

下载并打开 MAT

# MAT 官方下载:https://eclipse.dev/mat/
# 打开大文件需要增加 MAT 自身的内存(编辑 MemoryAnalyzer.ini)
-Xmx6g

# 加载 heap dump(我们的文件 1.8GB)
# File → Open Heap Dump → 选择 .hprof 文件
# 首次加载会建索引,2-3分钟

第一眼:看 Leak Suspects Report

Eclipse Memory Analyzer — heap-20240509.hprofLeak Suspects ReportOne instance of "java.util.HashMap" loaded by...HeapProblem 1: HashMap 占 1.6 GB (81%)Problem 2: Thread locals 占 180 MB (9%)其他对象 198 MB (10%)Problem 1java.util.HashMap @ 0xd1c3a820retained heap: 1,638,940,672 bytes (1.6 GB)→ com.example.order.cache.LocalCacheManager→ orderCacheMap (static field)★ 静态 HashMap,持有 1.6GB 数据!

MAT 的 Leak Suspects Report 一眼就锁定了:一个静态 HashMap 持有 1.6GB 内存

MAT 的 Leak Suspects 直接报出了嫌疑人:

Problem 1:
  One instance of "java.util.HashMap"
  loaded by "jdk.internal.loader.ClassLoaders$AppClassLoader"
  occupies 1,638,940,672 (81.07%) bytes.

The memory is accumulated in one instance of "java.util.HashMap"
loaded by "jdk.internal.loader.ClassLoaders$AppClassLoader @ 0xc00180a0"

用 Dominator Tree 定位具体代码

Leak Suspects 告诉了我们是什么,Dominator Tree 告诉我们为什么。

MAT 操作步骤:
Window → Heap Dump Details → Dominator Tree
按 Retained Heap 降序排列

Dominator Tree 输出(模拟):

Class Name                                    Shallow Heap   Retained Heap
─────────────────────────────────────────────────────────────────────────
java.lang.Thread @ main                            48 B      1,721 MB
  └─ com.example.order.cache.LocalCacheManager     32 B      1,638 MB
       └─ orderCacheMap: java.util.HashMap         64 B      1,638 MB
            ├─ [entry] orderId=10000001             ...        2.1 KB
            ├─ [entry] orderId=10000002             ...        2.1 KB
            └─ ... (共 820,000 个 entry)

找到了!**LocalCacheManager.orderCacheMap 这个静态 HashMap,塞了 82 万个订单对象,占了 1.6GB。**

用 OQL 进一步确认

MAT 支持 SQL 风格的查询语言:

-- 查看这个 HashMap 里有多少条目
SELECT count(*) FROM java.util.HashMap$Entry e
WHERE e.@GCRootInfo != null

-- 查看最大的几个对象
SELECT TOP 10 * FROM java.util.HashMap$Entry e
ORDER BY e.@retainedHeapSize DESC

第五步:找到根因——代码里的定时炸弹

找到了泄漏对象,接着去代码里找原因。

有问题的代码

@Component
publicclass LocalCacheManager {

    // 问题就在这里:静态 HashMap,永远不清理
    privatestaticfinal Map<String, OrderDTO> orderCacheMap = new HashMap<>();

    @Autowired
    private OrderRepository orderRepository;

    public OrderDTO getOrder(String orderId) {
        // 先查缓存
        if (orderCacheMap.containsKey(orderId)) {
            return orderCacheMap.get(orderId);
        }

        // 查数据库,放入缓存
        OrderDTO order = orderRepository.findById(orderId);
        orderCacheMap.put(orderId, order);  // ← 只进不出!
        return order;
    }
}

问题很清楚:

  1. orderCacheMap 是 static final,生命周期和 JVM 一样长

  2. 每次查询一个新订单都会放进去

  3. 从来没有清理逻辑

  4. 线上订单 ID 数量是无限增长的

这个类是一个新同事写的「本地缓存优化」,上线三周后终于把内存撑爆了。

为什么三周后才爆?

orderCacheMap 内存增长趋势(三周)5/15/35/55/75/85/90500M1G1.6G劳动节流量高峰新订单激增OOM!凌晨 2:02上线4/18

三周内缓存缓慢膨胀,劳动节流量高峰使新订单激增,最终在凌晨触发 OOM

内存增长节奏:

  • 前两周:平均每天新增约 5 万个唯一订单,缓慢膨胀

  • 第三周劳动节:流量高峰,单日新增订单 20 万+

  • 5月9日凌晨:累计 82 万个 entry,1.6GB,堆撑不住了

第六步:排查过程中的辅助工具

只有 MAT 是不够的,还需要这些工具配合。

Arthas:线上实时诊断(不用重启服务)

# 下载 Arthas
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

# 1. 查看内存占用
memory

# 2. 查看最耗内存的类(等价于 jmap -histo)
heapdump --live /tmp/arthas-heap.hprof

# 3. 直接查看静态字段的值(不需要 dump)
ognl '@com.example.order.cache.LocalCacheManager@orderCacheMap.size()'
# 输出:820143   ← 直接看到了 map 里有 82 万条

# 4. 监控某个方法的调用(找到谁在往 map 里塞数据)
watch com.example.order.cache.LocalCacheManager getOrder "{params,returnObj}" -x 2

这一步是关键突破点。用 Arthas 的 ognl 命令直接读到了 orderCacheMap.size() = 820143,1 秒钟确认了问题所在,不需要等 MAT 分析。

jstat:实时监控 GC 状态

# 每 1 秒打印一次 GC 统计(pid 是 Java 进程号)
jstat -gcutil $(pgrep -f order-service) 1000

# 输出示例:
#   S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
#   0.00   0.00  23.45  98.71  94.23  91.45    842    8.234   127  1842.341 1850.575

# 关注这几列:
# O (Old Gen) = 98.71% → 老年代快满了
# FGC = 127 → Full GC 发生了 127 次
# FGCT = 1842 秒 → Full GC 总耗时 30 分钟!!

VisualVM / JConsole:图形化监控

# 开启 JMX 远程监控(在 K8s 环境需要 port-forward)
kubectl port-forward pod/order-service-xxx 9090:9090

# JVM 启动参数加上
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9090
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false

第七步:修复方案

方案一(临时):直接清掉 map(Arthas 热修复)

# 用 Arthas 直接清理内存,不重启服务
ognl '@com.example.order.cache.LocalCacheManager@orderCacheMap.clear()'

# 验证
ognl '@com.example.order.cache.LocalCacheManager@orderCacheMap.size()'
# 输出:0

# 观察内存是否回落
watch -n 2 'jmap -heap $(pgrep -f order-service) 2>/dev/null | grep used'

这步让服务先恢复正常,争取时间做代码修复。

方案二(正确修复):换成有过期机制的缓存

错误写法(原始代码):

// 错误:无界 HashMap,永不过期
private static final Map<String, OrderDTO> orderCacheMap = new HashMap<>();

正确写法一:Caffeine 本地缓存(推荐)

@Component
publicclass LocalCacheManager {

    // Caffeine:带过期时间 + 最大容量限制
    privatefinal Cache<String, OrderDTO> orderCache = Caffeine.newBuilder()
        .maximumSize(10_000)          // 最多缓存 1 万个订单
        .expireAfterWrite(5, TimeUnit.MINUTES)  // 5 分钟后过期
        .recordStats()                // 开启命中率统计
        .build();

    public OrderDTO getOrder(String orderId) {
        return orderCache.get(orderId, id -> orderRepository.findById(id));
    }

    // 暴露缓存统计(方便监控)
    public CacheStats stats() {
        return orderCache.stats();
    }
}
<!-- pom.xml 加依赖 -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

正确写法二:Spring Cache + Caffeine(更优雅)

// 配置
@Configuration
@EnableCaching
publicclass CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager("orders");
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .recordStats());
        return manager;
    }
}

// 使用
@Service
publicclass OrderService {

    @Cacheable(value = "orders", key = "#orderId")
    public OrderDTO getOrder(String orderId) {
        return orderRepository.findById(orderId);  // 缓存透明化
    }

    @CacheEvict(value = "orders", key = "#orderId")
    public void updateOrder(String orderId, OrderDTO dto) {
        orderRepository.save(dto);  // 更新时自动失效缓存
    }
}

如果需要用 Redis(数据量大、多实例共享):

@Cacheable(value = "orders", key = "#orderId",
           cacheManager = "redisCacheManager")
public OrderDTO getOrder(String orderId) {
    return orderRepository.findById(orderId);
}

第八步:事后加固,防止下次再出问题

修完代码只是第一步,更重要的是让下次 OOM 来临前就能发现。

OOM 防护体系JVM 参数加固-XX:+HeapDumpOnOOM-XX:HeapDumpPath=...-Xlog:gc*:file=gc.log-XX:+UseG1GC-XX:MaxGCPauseMillis=200→ OOM 自动保留现场→ GC 日志可回溯监控告警Old Gen 使用率 > 80%→ 告警(预警)Full GC 频率 > 1次/分钟→ 告警(危险)GC 耗时占比 > 10%→ 告警(危险)→ 给 20 分钟→ 手动 dump 再重启代码规范禁止裸用 HashMap 做缓存→ 必须用 Caffeine/Redis缓存必须设 maxSize缓存必须设 TTL静态集合字段→ Code Review 必查→ 防患于未然→ 不依赖监控兜底

三层防护体系——JVM 参数保留现场、监控告警提前预警、代码规范防患未然

必须加的监控指标

# Prometheus + Grafana 告警规则
groups:
-name:jvm-memory
rules:
# Old Gen 使用率超 80% 告警
-alert:JvmOldGenHigh
    expr:|
      jvm_memory_used_bytes{area="heap",id="G1 Old Gen"}
      / jvm_memory_max_bytes{area="heap",id="G1 Old Gen"} > 0.8
    for:5m
    annotations:
      summary:"老年代使用率超过 80%,可能存在内存泄漏"

# Full GC 频率告警
-alert:JvmFullGCFrequent
    expr:rate(jvm_gc_pause_seconds_count{action="endofmajorGC"}[5m])>0.5
    for:3m
    annotations:
      summary:"Full GC 频率过高(>30次/小时),需要立即排查"

# GC 时间占比告警
-alert:JvmGCTimeHigh
    expr:rate(jvm_gc_pause_seconds_sum[5m])/60>0.1
    for:5m
    annotations:
      summary:"GC 耗时占比超过 10%,服务响应受影响"

完整排查流程回顾

OOM 排查完整流程告警触发 / OOM 发现jmap dump + kubectl cp 保留现场分析 GC 日志:泄漏 or 配置不足?GC 后内存回落GC 后内存不降加内存 / 调 GC 参数内存泄漏,继续排查MAT 分析 heap dumpArthas ognl 确认 + 定位代码代码修复 + 上线

完整排查路径——先保现场,再判断类型,最后定位根因

总结:这次 OOM 教会我的几件事

技术层面:

  • 生产环境 JVM 必须加-XX:+HeapDumpOnOutOfMemoryError,OOM 现场转瞬即逝

  • GC 日志 必须开启持久化,没有日志的排查是盲人摸象

  • 本地缓存 永远不能用裸 HashMap,必须有容量上限和过期机制

  • Arthas 的 ognl 命令可以在不重启的情况下直接读取运行时状态,救命工具

排查思路:

  • OOM 分两种:内存泄漏(GC 后不降)和内存不足(GC 后回落),两者解法完全不同,先判断再动手

  • MAT 的 Leak Suspects Report 是第一入口,Dominator Tree 是第二入口,OQL 是精确查询工具

  • 内存泄漏 90% 都是:静态集合 + 只进不出 + 无界增长,找到这三个特征就找到了根因

流程层面:

  • 告警触发后第一件事是保留现场,不是重启

  • 线上用 Arthas 热修,赢得时间;代码层面彻底修,解决根本

这次事故之后,我们在 Code Review Checklist 里加了一条:所有缓存必须使用 Caffeine 或 Redis,禁止裸用 HashMap/ConcurrentHashMap 做本地缓存。

半年过去了,没有再 OOM 过。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐