当前位置: 代码网 > it编程>编程语言>Java > 生产环境SpringBoot应用内存泄漏排查流程

生产环境SpringBoot应用内存泄漏排查流程

2026年09月10日 Java 我要评论
1. 一次真实的线上故障:你以为加内存就能解决问题吗?生产环境突然报警:服务响应变慢,cpu 飙升,紧接着 oom。你登录服务器,看到 java.lang.outofmemoryerror: java

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)

复现步骤:

  1. 启动应用。
  2. 用压测工具或循环请求 /leak 数十次,注意不要超过线程池线程数。
  3. 观察 gc 日志和内存使用,最终 oom,生成 heap dump 文件。

预期结果:

  • 多次请求后,老年代持续增长,gc 越来越频繁,最后 oom。
  • 自动生成的 dump 文件存于 /tmp/dumps 下。

分析步骤:

  1. 用 jvisualvm 打开 dump,查看“类”直方图。可以看到 byte[] 实例数以十万计,retained size 巨大。
  2. 双击 byte[],选择“引用” → “谁引用了我”。可以看到每个 byte[] 都被某个 threadlocalthreadlocalmapthreadlocalmapthreadlocalmapentry 引用,而 entry 的 key 是 threadlocal 对象本身。
  3. 继续回溯,entry 持有者是一个 thread 对象。而线程名是什么?很可能是 http-nio-8080-exec-* 。
  4. 这就是标准 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。内存持续上升。

排查步骤:

  1. 使用 jvisualvm 查看堆直方图,发现 list 或 arraylist 实例数量异常,retained size 很大。
  2. 点击“查看对象” → “引用”,发现 arraylist 被一个 hashmap$node 引用,而 node 被 hashmap 引用,hashmap 被 mycache 类(静态字段)引用。
  3. 最终 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. 常备排查工具箱:典型命令与使用场景

我们将常使用的命令整理为一张速查表。

工具命令/用法能发现什么
jpsjps -l找到 java 进程 pid
jstatjstat -gcutil 1000查看 gc 利用率、各区容量
jmapjmap -histo打印类实例直方图
jmapjmap -dump:live,format=b,file=heap.bin获取堆转储
jcmdjcmd vm.oom_historyjdk 11+ 显示 oom 历史
jstackjstack查看线程栈,协助分析死锁等
jinfojinfo查看 jvm 参数
jcmdjcmd 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应用内存泄漏的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com