生产环境 Spring Boot 应用内存泄漏排查实战
1. 一次真实的线上故障:你以为加内存就能解决问题吗?
生产环境突然报警:服务响应变慢,CPU 飙升,紧接着 OOM。你登录服务器,看到 java.lang.OutOfMemoryError: Java heap space,于是重启服务,加内存,以为万事大吉。但运行数天后,同样的问题再次发生。你有没有想过,内存并不是被无端占满的,而是像漏水一样,一点点漏掉的?内存泄漏(Memory Leak)指的是那些不再使用的对象却仍然被引用,无法被垃圾回收器(GC)回收,导致可用内存越来越少,最终触发 OOM。
本文的目的,不是教你如何"加内存",而是带你从故障现象出发,掌握一整套排查与定位内存泄漏的方法,并通过多个可复现的示例,让你能够在自己的生产环境中实战运用。
2. 先建立整体思维:内存泄漏的一般模型与排查流程
在进入具体技术细节之前,先记住一句话:内存泄漏的本质是"该回收的对象没有被回收"。对象为什么没有被回收?因为有活着的引用指向它。那么,这些引用从何而来?可能是缓存、ThreadLocal、静态集合、ClassLoader、JNI 引用等。
将整体拆成三部分:
- 内存分代与对象生命周期:堆内存分为新生代和老年代,大部分对象在新生代 "出生即死亡",而泄漏对象会不断晋升到老年代,最终撑爆堆。
- 泄漏来源分类:常见的有线程导致(ThreadLocal、线程池)、缓存未清理(本地缓存、静态集合)、类加载器泄漏(元空间泄漏)、应用容器问题(如 Spring 容器的单例 Bean 中持有临时对象)。
- 排查工具与手段:jmap、jstat、jcmd、MAT、VisualVM、JProfiler 等,以及 GC 日志分析。
一次请求、线程或数据会按什么顺序流转?我们以一次请求为例:
text
请求进入 → DispatcherServlet → Controller → Service → DAO → 数据库
↓
操作本地缓存、ThreadLocal 等
↓
返回响应 → 应释放所有临时引用
如果某个环节错误地持有了本应释放的引用,就可能导致泄漏。通常,泄漏对象会从新生代慢慢进入老年代,等到老年代占满触发 Full GC,但 Full GC 后仍无法释放,则 OOM。
下面我们通过一个总览图来看排查流程:
text
现象:内存持续增长、GC 频繁、OOM
↓
收集 GC 日志(启动时打印)与 Heap Dump
↓
分析 Heap Dump:找出大对象、重复对象、类加载器引用链
↓
定位到嫌疑代码(缓存、ThreadLocal、类加载器)
↓
修复代码,并通过压力测试验证
3. 堆转储:看清内存里到底有什么
3.1 堆转储是什么?为什么需要它?
当内存出现问题时,你无法通过代码审查直接"看出"几百个对象的引用关系。堆转储(Heap Dump)就是内存的"快照",它能告诉你每一个对象的类型、大小、引用关系。它就像事故现场的"行车记录仪",记录了那一刻内存中所有对象的"存在原因"。
3.2 怎么获得堆转储?
-
jmap 手动转储(注意会暂停 JVM):
bashjmap -dump:live,format=b,file=/opt/dumps/heap.bin <pid> -
jcmd(更现代,JDK 8u+):
bashjcmd <pid> GC.heap_dump /opt/dumps/heap.bin -
自动转储:在 JVM 启动参数中设置 OOM 时自动 dump:
bash-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/dumps/
注意:转储会暂停应用(尤其 /dump:live 会触发 Full GC),谨慎线上操作。推荐在部署时提前开启自动转储。
3.3 分析堆转储
堆转储是二进制文件,需要分析工具。
一张对比表:
| 工具 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| MAT(Eclipse Memory Analyzer) | 强大、开源 | 需要安装 Eclipse 插件或独立程序;吃内存 | 深入分析引用链,找出 GC Root |
| VisualVM | 集成多种 JVM 监控与 dump 分析 | 分析大型 dump 时较慢 | 快速查看对象直方图,连接远程 JVM(需要 jstatd) |
| JProfiler | 界面友好、功能全面 | 商业软件 | 实时跟踪分配,适合开发者环境 |
| YourKit | 性能优秀、功能全面 | 商业软件 | 与 JProfiler 类似 |
推荐初学者使用 VisualVM 先看对象直方图(按 Retained Size 降序),关注大对象或数量异常多的对象。然后用 MAT 检查 Dominator Tree 和 "Suspects"。MAT 能自动推测泄漏可疑点。
3.4 完整示例:从 OOM 到定位 ThreadLocal 泄漏
目标:在 Spring Boot 应用中模拟一个典型的 ThreadLocal 泄漏场景,并完整走一遍排查流程。
前置环境:JDK 11、Maven、IDE。
输入:一个 Spring Boot 2.7 项目,提供一个 REST 接口,每次调用该接口时向一个 static ThreadLocal 中塞入 10MB 的字节数组,但不清理。当线程被线程池复用后,ThreadLocal 会被复用,导致持续积累。
完整示例代码(可运行):
java
// DemoApplication.java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@RestController
class LeakController {
private static final ThreadLocal<byte[]> TL = ThreadLocal.withInitial(() -> new byte[0]);
@GetMapping("/leak")
public String leak() {
byte[] data = new byte[10 * 1024 * 1024]; // 10MB
TL.set(data); // 存入 ThreadLocal,但从不 remove
return "ok";
}
}
启动参数(关键):
bash
-Xms256m -Xmx256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dumps -Xlog:gc*:file=/tmp/gc.log
(注意 JDK 11 使用 -Xlog,JDK 8 使用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log)
复现步骤:
- 启动应用。
- 用压测工具或循环请求
/leak数十次,注意不要超过线程池线程数。 - 观察 GC 日志和内存使用,最终 OOM,生成 heap dump 文件。
预期结果:
- 多次请求后,老年代持续增长,GC 越来越频繁,最后 OOM。
- 自动生成的 dump 文件存于 /tmp/dumps 下。
分析步骤:
- 用 jvisualvm 打开 dump,查看"类"直方图。可以看到 byte\[\] 实例数以十万计,Retained Size 巨大。
- 双击 byte\[\],选择"引用" → "谁引用了我"。可以看到每个 byte\[\] 都被某个 ThreadLocalThreadLocalMapThreadLocalMapThreadLocalMapEntry 引用,而 Entry 的 key 是 ThreadLocal 对象本身。
- 继续回溯,Entry 持有者是一个 Thread 对象。而线程名是什么?很可能是 http-nio-8080-exec-* 。
- 这就是标准 ThreadLocal 泄漏链。修复方式就是每次使用完 remove。
这里最容易误解的是:ThreadLocal 的 key 通常设计为弱引用,但 value 是强引用。当线程存活时,即使 key 被回收,value 依然存在,且无法访问,导致泄漏。
这个小例子背后的原理:Tomcat 处理 HTTP 请求的工作线程默认不会销毁(因为线程池),请求结束后线程归还池中,但 ThreadLocalMap 中的 entry 并未清除,导致 data 一直活着。
4. ThreadLocal 泄漏:另一层"线程持有"的陷阱
你可能已经从前面的示例中感受到 ThreadLocal 的危险性。现在,我们深入剖析其设计原理与边界。
可以先把它理解成"线程私有变量",每个线程都有一份独立的副本。其内部通过 Thread 类里的 ThreadLocalMap 实现,每个 Thread 持有一个 ThreadLocalMap,ThreadLocal 作为键,对应 set 的对象作为值。
这里最容易误解的是:很多人以为 ThreadLocal 用完了自动清空,但实际上它需要手动 remove。尤其是使用线程池时,线程会被复用,如果不清理,旧值会带到下一次请求中,造成逻辑错误(串数据)和内存泄漏。
具体场景:假设你在拦截器中设置了一个 ThreadLocal 保存用户 ID,请求结束后忘记 remove。下一次请求由同一个线程处理时,ThreadLocal 中仍是上一个用户的 ID,轻则逻辑错误,重则数据泄露。
什么时候适合用?当你想避免在方法间层层传递参数时,可以使用 ThreadLocal。常见用途包括:分布式 ID、用户上下文、事务传播。但必须遵循"用后即清"原则。
常见错误:
- 用了静态 ThreadLocal 但从不 remove(如示例)
- 使用线程池却不清理
- 没有意识到子线程不会继承父线程的 ThreadLocal(InheritableThreadLocal 才有传递能力,但也需谨慎)
排查建议:如果在堆转储中发现大量被线程持有的对象,且线程是长期存活的,可以重点检查 ThreadLocalMap 的 entry。在 jstack 中也可查看线程栈,但无法直接看到 ThreadLocalMap 内容。
5. 缓存未清理:从本地缓存到静态集合
5.1 常见的缓存泄漏场景
Spring Boot 应用中,缓存是一个非常常见的泄漏点,因为它天生就是保存引用的容器。常见的缓存实现包括:HashMap 作为本地缓存、Guava Cache、Caffeine,以及 Spring Cache 抽象。如果缓存只增不减,且 key 不随时间过期,就会撑爆堆。
举个例子,你写了一个静态 Map 当作缓存:
java
public class MyCache {
private static final Map<String, List<String>> cache = new HashMap<>();
public static void put(String key, List<String> value) {
cache.put(key, value);
}
// 没有 remove 方法
}
业务代码持续调用 put,而没有清理旧数据。几天后内存就满了。
5.2 缓存设计的取舍
选用带过期策略的缓存(如 Caffeine)能显著降低风险。但要注意即使使用 Caffeine,如果 key 是无限增长的(例如用户ID),也需要设置最大大小或过期时间。
一张对比表:
| 缓存类型 | 自动淘汰 | 适合场景 | 注意事项 |
|---|---|---|---|
| HashMap | 无淘汰 | 数据数量固定且较小 | 必须手动 remove |
| LinkedHashMap(LRU) | 可以自定义 LRU | 需要简单的 LRU | 实现麻烦 |
| Guava Cache | 可配 LRU、时间过期 | 需要简化缓存管理 | 依赖 Google |
| Caffeine | 支持 LRU/LFU、过期 | 高性能场景 | 推荐使用 |
| Redis(外部) | 有 TTL、淘汰策略 | 分布式缓存 | 不占用本地堆 |
5.3 排查案例:静态集合中的永久引用
场景:一个 Spring Boot 服务,提供用户订阅接口。代码中有一个静态 Map 记录用户ID到其订单列表。订单不断新增,但用户注销后没有删除 key。内存持续上升。
排查步骤:
- 使用 jvisualvm 查看堆直方图,发现 List 或 ArrayList 实例数量异常,Retained Size 很大。
- 点击"查看对象" → "引用",发现 ArrayList 被一个 HashMap$Node 引用,而 Node 被 HashMap 引用,HashMap 被 MyCache 类(静态字段)引用。
- 最终 GC Root 是系统类。使用 MAT 可以更清晰地看到 Dominator Tree。
修复:
- 为缓存设置最大大小或时间过期,例如使用 Caffeine:
java
Cache<String, List<String>> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
- 或者在用户注销时主动 remove。
原则:所有缓存必须设置淘汰策略,并监控缓存大小。
6. 元空间泄漏:不是堆内存,但一样致命
6.1 元空间是什么?
在 Java 8 之后,方法区被实现为"元空间"(Metaspace),存储类的元数据、方法字节码、常量池等。它并不在堆中,而是直接使用本地内存,默认大小是无限的,但 JVM 会根据需要增长。如果不断有新的类加载器加载类,且这些类加载器无法被回收,那么元空间就会持续增长,最终撑爆本机内存(OutOfMemoryError: Metaspace)。
6.2 类加载器泄漏的典型场景
Spring Boot 应用在频繁部署(devtools 热部署)、使用动态代理、生成字节码框架(如 CGLIB、ASM)、使用 JSP 时,可能会创建大量自定义类加载器。如果这些类加载器被全局静态引用,导致无法回收,则元空间泄漏。
6.3 排查与预防
排查元空间泄漏时,使用以下命令观察:
bash
jstat -gcmetacapacity <pid> 1000
jcmd <pid> VM.metaspace
查看加载类的数量:
bash
jcmd <pid> VM.class_histogram
如果发现某些类被反复加载且数量持续上升,检查是否有类加载器被长期持有。
解决方法:
- 避免使用 Java 动态代理生成大量类,考虑使用 ASM 并复用类生成器。
- 如果你在开发中频繁重启类加载器(如 IDE 的 reload),确保没有第三方库持有 AppClassLoader 引用。
以下是一个元空间泄漏模拟示例(适合演示):
java
// MetaspaceLeakSimulator.java 伪代码,不能直接运行
// 演示用一个静态 List 保存所有 ClassLoader
static final List<ClassLoader> CLASSLOADERS = new ArrayList<>();
while (true) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
// 通过 JavaCompiler 动态生成一个类,并在自定义 ClassLoader 中加载
ClassLoader loader = new URLClassLoader(new URL[]{...}, getClass().getClassLoader());
CLASSLOADERS.add(loader); // 持有了类加载器引用,阻止回收
}
注意:这里仅是示意,动态生成类需要复杂的字节码生成。真正的复现需要大量类加载。
7. GC 日志分析:内存泄漏的第一道防线
7.1 如何开启 GC 日志?
GC 日志记录了每次垃圾回收的耗时、内存变化等。它是检测内存泄漏趋势的利器。开启方式在不同 JDK 版本略有不同。
JDK 8:
bash
-Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=10M
JDK 11+:
bash
-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags
7.2 如何分析 GC 日志?
可以用工具如 GCEasy、GCViewer 或手动分析。关注以下几点:
- GC 频率:是否越来越频繁?
- 内存曲线:每次 GC 后到底内存是否降下来?如果老年代容量每次 GC 后没有减少,且不断增长,可能是泄漏。
- 停顿时间:Full GC 时间是否过长?
以下是一个典型的 GC 日志片段(JDK 11 格式),可以从中观察趋势:
text
[2023-01-01T00:00:00.000+0800][gc,heap] GC(0) Pause Young (Allocation Failure) 100M->50M(256M) 10ms
[2023-01-01T00:00:05.000+0800][gc,heap] GC(1) Pause Young (Allocation Failure) 200M->150M(256M) 20ms
[2023-01-01T00:00:10.000+0800][gc,heap] GC(2) Pause Full (Allocation Failure) 250M->240M(256M) 500ms
[2023-01-01T00:00:15.000+0800][gc,heap] GC(3) Pause Full (Allocation Failure) 240M->235M(256M) 600ms
可以看到每次 Full GC 后,占用只减少了 5-10M,说明大量对象是存活的,极可能是泄漏。
一个对比表格:
| GC 指标 | 健康系统 | 泄漏系统 |
|---|---|---|
| Young GC 后内存 | 降到基线 | 每次都在同一水平 |
| Full GC 频率 | 很少 | 频繁 |
| Full GC 后老年代 | 降至合理水平 | 几乎不降 |
7.3 从 GC 日志到行动
当你从 GC 日志中预测到泄漏时,应立即获取堆转储进一步定位。不要等待 OOM。
8. 常备排查工具箱:典型命令与使用场景
我们将常使用的命令整理为一张速查表。
| 工具 | 命令/用法 | 能发现什么 |
|---|---|---|
| jps | jps -l | 找到 Java 进程 PID |
| jstat | jstat -gcutil 1000 | 查看 GC 利用率、各区容量 |
| jmap | jmap -histo | 打印类实例直方图 |
| jmap | jmap -dump:live,format=b,file=heap.bin | 获取堆转储 |
| jcmd | jcmd VM.oom_history | JDK 11+ 显示 OOM 历史 |
| jstack | jstack | 查看线程栈,协助分析死锁等 |
| jinfo | jinfo | 查看 JVM 参数 |
| jcmd | jcmd GC.heap_info | 查看堆信息 |
注意,这些命令会消耗资源或暂停应用。生产环境使用时需谨慎,一般可以选择在低峰期或开启自动 dump。
9. 常见误区:很多人以为这样就没问题
内存泄漏排查中,有以下常见误区,需要特别指出。
- 误区一:认为内存泄漏只表现 OOM。OOM 是最终结果,之前的持续内存增长和频繁 GC 才是泄漏的信号。
- 误区二:只加内存而忽略代码问题。这只会让进程活得更久,但最终还是会崩溃,而且可能掩盖问题造成更大损失。
- 误区三:ThreadLocal 是自动清理的。看看官方文档,它要求最后的 remove 是 best practice。
- 误区四:堆转储只能在 OOM 时做。应该定期在内存较高时主动获取,也许能避免 OOM。注意 OOM 后 dump 和正常 dump 的环境差异。
10. 生产实践建议:如何预防和快速响应
预防胜于治疗。以下实践建议将降低生产遇到的概率。
- 应用上线前加入监控:使用 Micrometer 暴露内存指标(heap、nonheap、metaspace),并通过 Prometheus + Grafana 可以实时观测曲线。
- 启动参数加入 HeapDumpOnOutOfMemoryError 和 GC 日志。
- 定期压测并观察 GC 日志,尤其是长时间运行后的表现。
- 代码审查阶段就检查缓存、ThreadLocal、静态集合的使用是否符合规范。
11. 排障清单:当报警来了,你可以按这个顺序执行
- 第一步:观察指标。确认是否内存持续增长(如已定义 Prometheus 图表则直接查看,否则使用 jstat)。
- 第二步:获取 GC 日志和线程栈。
- 第三步:若认为堆内存问题,执行 jmap -histo 快速看 top 对象柱状。也可以直接 jmap -dump 获取 dump。注意停顿。
- 第四步:用 MAT 打开 dump,分析 Suspects。
- 第五步:根据嫌疑人对象回溯到代码位置。
- 第六步:修复后重启,并重复压测以验证。
我们完整画一个决策流程图:
text
内存告警
↓
是否频繁 Full GC?
├─ 否 → 观察服务器资源,可能不是内存泄漏,检查其他方面
↓是
获取当前堆前 20 个类实例(jmap -histo)
↓
是否有异常大/异常多的类?
├─ 否 → 继续监控,可能增长曲线呈锯齿形且稳定
↓是
对嫌疑类获取堆转储(保留 dump)
↓
用 MAT 分析 GC 根路径
↓
定位代码:缓存?ThreadLocal?类加载器?
↓
修复并验证
12. 面试/复盘问题:加深你的思路
- 什么情况会收到"OutOfMemoryError: Java heap space"?它和"OutOfMemoryError: Metaspace"有何不同?
- ThreadLocal 为何可能发生内存泄漏?为什么 key 用弱引用仍泄漏?
- 如何区分正常的内存浮动和内存泄漏?
- 你会如何设计一个定时监控,以尽早发现内存泄漏?
- Java 8 与 Java 11 在元空间上的行为有何区别?
13. 总结:把知识收拢成一张决策地图
现在,我们可以把全篇文章浓缩成一张表:
| 问题类型 | 症状 | 常见场景 | 主要工具 | 预防与修复 |
|---|---|---|---|---|
| 堆内存泄漏 | 老年代持续增长,Full GC 后不降低 | 缓存未清理、ThreadLocal 未 remove、静态集合持有大量对象 | jmap、MAT | 使用弱引用、清理线程、缓存淘汰、及时 remove |
| 元空间泄漏 | Metaspace 占用持续增长 | 动态生成类未释放、热部署 | jcmd VM.metaspace、jcmd VM.class_histogram | 控制类加载器数量,去除强引用 |
| 本地内存泄漏 | RSS 增长但堆未涨 | DirectBuffer、JNI 分配未释放 | pmap、Native Memory Tracking | 使用堆外内存时注意释放;开启 NMT 监控 |
当你面对一个具体问题时,可以先判断它属于哪一类,再使用对应的工具和手段。
参考资料
- Oracle. Java SE Documentation: HotSpot Virtual Machine Garbage Collection Tuning Guide(官方)
- 埃克尔. Java 编程思想(第 4 版),机械工业出版社,2007。
- OpenJDK. JDK 工具参考:jhsdb、jcmd、jmap、jstat(官方文档),见 Oracle 官方文档。
最后,希望你能带着框架去实践,把本文中的示例跑一遍,再结合生产环境优化。你会在实战中发现,内存泄漏并非想象中的那么难。