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 日志分析。
一次请求、线程或数据会按什么顺序流转?我们以一次请求为例:
请求进入 → dispatcherservlet → controller → service → dao → 数据库
↓
操作本地缓存、threadlocal 等
↓
返回响应 → 应释放所有临时引用
如果某个环节错误地持有了本应释放的引用,就可能导致泄漏。通常,泄漏对象会从新生代慢慢进入老年代,等到老年代占满触发 full gc,但 full gc 后仍无法释放,则 oom。
下面我们通过一个总览图来看排查流程:
现象:内存持续增长、gc 频繁、oom ↓ 收集 gc 日志(启动时打印)与 heap dump ↓ 分析 heap dump:找出大对象、重复对象、类加载器引用链 ↓ 定位到嫌疑代码(缓存、threadlocal、类加载器) ↓ 修复代码,并通过压力测试验证
3. 堆转储:看清内存里到底有什么
3.1 堆转储是什么?为什么需要它?
当内存出现问题时,你无法通过代码审查直接“看出”几百个对象的引用关系。堆转储(heap dump)就是内存的“快照”,它能告诉你每一个对象的类型、大小、引用关系。它就像事故现场的“行车记录仪”,记录了那一刻内存中所有对象的“存在原因”。
3.2 怎么获得堆转储?
jmap 手动转储(注意会暂停 jvm):
jmap -dump:live,format=b,file=/opt/dumps/heap.bin <pid>
jcmd(更现代,jdk 8u+):
jcmd <pid> gc.heap_dump /opt/dumps/heap.bin
自动转储:在 jvm 启动参数中设置 oom 时自动 dump:
-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 会被复用,导致持续积累。
完整示例代码(可运行):
// 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";
}
}启动参数(关键):
-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 当作缓存:
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:
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 排查与预防
排查元空间泄漏时,使用以下命令观察:
jstat -gcmetacapacity <pid> 1000 jcmd <pid> vm.metaspace
查看加载类的数量:
jcmd <pid> vm.class_histogram
如果发现某些类被反复加载且数量持续上升,检查是否有类加载器被长期持有。
解决方法:
- 避免使用 java 动态代理生成大量类,考虑使用 asm 并复用类生成器。
- 如果你在开发中频繁重启类加载器(如 ide 的 reload),确保没有第三方库持有 appclassloader 引用。
以下是一个元空间泄漏模拟示例(适合演示):
// 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:
-xloggc:/path/to/gc.log -xx:+printgcdetails -xx:+printgcdatestamps -xx:+usegclogfilerotation -xx:numberofgclogfiles=10 -xx:gclogfilesize=10m
jdk 11+:
-xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags
7.2 如何分析 gc 日志?
可以用工具如 gceasy、gcviewer 或手动分析。关注以下几点:
- gc 频率:是否越来越频繁?
- 内存曲线:每次 gc 后到底内存是否降下来?如果老年代容量每次 gc 后没有减少,且不断增长,可能是泄漏。
- 停顿时间:full gc 时间是否过长?
以下是一个典型的 gc 日志片段(jdk 11 格式),可以从中观察趋势:
[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。
- 第五步:根据嫌疑人对象回溯到代码位置。
- 第六步:修复后重启,并重复压测以验证。
我们完整画一个决策流程图:
内存告警 ↓ 是否频繁 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 监控 |
当你面对一个具体问题时,可以先判断它属于哪一类,再使用对应的工具和手段。
最后,希望你能带着框架去实践,把本文中的示例跑一遍,再结合生产环境优化。你会在实战中发现,内存泄漏并非想象中的那么难。
以上就是生产环境springboot应用内存泄漏排查流程的详细内容,更多关于springboot应用内存泄漏的资料请关注代码网其它相关文章!
发表评论