案例:静态字段缓存无淘汰策略导致内存泄漏,最终 OOM

Phase 0 可观测性实战产出,STAR 结构,可直接用于面试的"你排查过什么线上问题"类问题。

背景(Situation)

在本地 Docker Compose 搭建的 Nginx + app-a/app-b(Spring Boot)+ Prometheus/Grafana + SkyWalking + ELK 全套可观测性环境里,给 demo-app 加了一个 /api/report 接口,语义上是"生成报表并缓存结果,避免重复计算":

private static final List<byte[]> RECENT_REPORTS = new CopyOnWriteArrayList<>();

@GetMapping("/api/report")
public Map<String, Object> report(@RequestParam(name = "size", defaultValue = "1") int size) {
    byte[] payload = new byte[size * 1024];
    RECENT_REPORTS.add(payload);
    ...
}

这是一段代码 review 时很容易被放过的真实缺陷:看起来是个合理的性能优化(缓存复用),但 RECENT_REPORTS 没有任何容量上限或过期淘汰逻辑。

任务(Task)

验证这个接口在持续调用下是否存在内存泄漏,并定位到具体是哪一行代码、哪个对象在泄漏。

排查过程(Action)

1. 通过 Metrics 支柱发现趋势异常

用 PowerShell 循环持续调用 /api/report?size=100(每次分配 100KB),在 Grafana 里查 PromQL:

jvm_memory_used_bytes{area="heap"}

观察到关键区别:

  • G1 Eden Space:规律的锯齿状,涨到峰值后瞬间回落——这是正常的 Young GC 行为,不代表问题
  • G1 Old Gen:持续爬升,没有下跌趋势

判断依据:Young GC 只要能把年轻代对象清理掉,说明这些对象确实"死了";但如果 Old Gen 在多次 GC 后依然只涨不跌,说明有对象被"提升"到老年代后,GC 认为它还被强引用着、不敢回收。这是内存泄漏和正常内存波动最核心的区分标准。

2. 用 SkyWalking 交叉验证

在 SkyWalking 的 Service Instance → JVM 面板里看到同样的现象:多次 Young GC 发生、NewGen 每次都应声下降,但 Old Gen 曲线岿然不动——两个独立数据源(Prometheus 和 SkyWalking)指向同一个结论,排除了单一工具误报的可能。

3. 尝试手动抓堆转储,发现运行时镜像缺失诊断工具

第一反应是 docker exec 进容器跑 jmap -dump,但容器基础镜像是 eclipse-temurin:17-jre——只有 JRE,没有 JDKjmap/jcmd/jstack 这些诊断工具根本不存在。这是一个真实的生产坑:很多团队为了减小镜像体积只装 JRE,出问题后发现没法临时抓现场。

改用另一种方式:在 JAVA_OPTS 里加上 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/heapdump.hprof,让 JVM 在真的 OOM 那一刻自动把堆转储写到(已经 bind-mount 到宿主机的)日志目录,不需要手动触发也不需要 docker cp

4. 继续加压,出现"容器状态一直是 Up,但请求卡住"的疑惑

重新构建镜像后继续压测,出现了一个违反直觉的现象:docker ps 里容器状态一直是 Up(不是 Exited),但压测循环卡住不动了。

docker stats 确认:其中一个实例 CPU 飙到 286%(多核打满),另一个实例接近 0%——因为压测是单线程顺序发请求,nginx 轮询恰好把当前请求分给了正陷入 GC 风暴的实例,另一个实例自然空闲。

关键结论:OutOfMemoryError 默认不会让 JVM 进程退出,只会让触发这次分配失败的那个线程失败——容器因此不会变成 Exited。JVM 在堆耗尽后会疯狂尝试 Full GC 试图腾出空间,但 Old Gen 里的对象全被静态字段强引用着回收不掉,导致大量 CPU 耗在无效 GC 上(GC Overhead),业务线程抢不到 CPU,表现为请求挂起。

docker logs 里确认了完整证据链:

Exception in thread "Catalina-utility-1" java.lang.OutOfMemoryError: Java heap space
Dumping heap to /var/log/app/heapdump.hprof ...
... Servlet.service() ... threw exception [Handler dispatch failed: java.lang.OutOfMemoryError: Java heap space]

连 SkyWalking agent 自己的后台线程都被 OOM 掉了,Tomcat 的请求处理线程反复抛 OutOfMemoryError 导致对应请求返回 500,但进程本身存活。

顺带确认了另一个知识点:docker statsMEM USAGE 显示接近 600MB,远超 -Xmx256m 的设置——因为 -Xmx 只限制 Java 堆,不包含 Metaspace、线程栈、JIT 编译缓存、GC 内部结构、SkyWalking agent 自身开销这些堆外内存。

5. 用 Eclipse MAT 分析堆转储,定位到具体代码行

原计划用 IntelliJ IDEA 直接打开 .hprof,但发现内置堆转储分析器(对象直方图、Dominator Tree、GC Roots 引用链)是 Ultimate 版独有功能,Community 版完全没有,只能把二进制文件当文本打开。

换用 Eclipse Memory Analyzer(MAT),过程中又踩了一个坑:MAT 1.17 本身运行需要 JDK 21+,而本机默认是 JDK17——这跟被分析的应用用什么 JDK 打包完全是两回事(分析工具本身的运行时 vs 被分析对象的运行时)。装好 JDK21 后,在 MemoryAnalyzer.ini 里加一行 -vm 指向 JDK21 的 javaw.exe,让 MAT 单独用它启动,不改动系统默认 JDK17。

MAT 的 Leak Suspects Report 给出的结论非常精确:

The class com.example.demo.HelloController, ... occupies 238,536,536 (93.18%) bytes.
The top consumers ... byte[] (2,332 instances totaling 238,527,000) ...

Significant stack frames and local variables
com.example.demo.HelloController.report(I)Ljava/util/Map; (HelloController.java:52)

93.18% 的堆内存都挂在 HelloController 类下面,具体是 2332 个 byte[] 对象(2332 × 100KB ≈ 233MB,跟压测参数完全对得上),并且直接给出了代码行号 HelloController.java:52——正是 RECENT_REPORTS.add(payload) 那一行。

根因(Root Cause)

RECENT_REPORTS 是一个 static 字段,静态字段的生命周期绑定在"类"本身:类只要被类加载器(LaunchedClassLoader)加载着就不会卸载,类加载器又跟着 JVM 活整个生命周期。这意味着任何被静态集合持有的对象,天生就挂在一条永远不会断开的 GC Root 上,垃圾回收器永远不敢回收——这是"用 static 集合做缓存、忘记加容量上限/过期策略"必然导致内存泄漏的根本原因。

解决方案 / 结论(Result)

  • 真正的修复方式:不能用无界的 static 集合做缓存。应该换成有界缓存(比如 Caffeine/Guava Cache,显式设置 maximumSize + expireAfterWrite),或者干脆重新评估是否真的需要缓存原始响应体这种大对象。
  • 排查方法论上最有价值的几个结论:
    1. 区分 GC 噪音和真泄漏:看 Young GC 后 Eden 能不能正常回落(正常),还是 Old Gen 持续只涨不跌(真泄漏)。
    2. OutOfMemoryError 不等于进程崩溃:默认只杀死触发它的那个线程,容器/进程可能一直"存活"但持续对外返回 500——不能只用容器存活状态做健康判断,得看错误率和 GC 指标。
    3. 诊断工具要提前准备:生产镜像如果是 JRE-only(没有 jmap/jcmd),出问题后没法临时抓现场,得提前用 -XX:+HeapDumpOnOutOfMemoryError 兜底,或者干脆保留一个带 JDK 诊断工具的 sidecar/临时排查镜像。
    4. -Xmx 只管堆,不管容器总内存:设容器内存 limit 时要预留堆外开销(Metaspace、线程栈、JIT 缓存、agent 自身开销),否则可能被 cgroup OOMKilled 而不是 JVM 自己的 OOM。
    5. 分析工具本身的运行时和被分析对象的运行时是两回事:MAT/IDE 跑在什么 JDK 上、和 dump 文件是哪个 JDK 生成的,没有必然关系。

面试延伸问题(自己需要能答上来)

  • 如果生产环境不方便重启服务加参数、事先也没配 HeapDumpOnOutOfMemoryError,还有什么办法定位内存问题?(开启 JMX 让 jconsole/VisualVM 远程连接;用 Arthas 这类无侵入诊断工具在线抓 dump/查看对象;容器里如果有 jcmd 也可以直接 jcmd <pid> GC.heap_dump
  • 为什么这里用 CopyOnWriteArrayList 做缓存本身也是个反模式?(CopyOnWriteArrayList 每次写操作都要整体拷贝底层数组,只适合"读多写极少"的场景;当成普通缓存持续写入,本身就有额外的 CPU/内存开销,写操作越频繁、数组越大,代价越高)
  • 怎么从架构层面预防这类问题?(代码 review 时对所有 static 集合类字段重点关注是否有容量上限;引入 Caffeine 这类有界缓存作为团队标准做法,而不是手写 List/Map 当缓存)