内存与GC问题排查
开篇:凌晨的 OOM 告警是怎么排查的
内存问题是 Java 线上排查中最让人头疼的一类——它不像 CPU 飙高那样能快速用 top + jstack 定位,也不像接口超时那样能通过链路追踪找到瓶颈。内存问题往往是缓慢积累、突然爆发,等你收到告警的时候,进程可能已经在 OOM 的边缘疯狂 FullGC 了。
这篇文章记录了几个真实的内存问题排查案例,从发现到定位到解决,完整还原排查过程。每个案例的排查思路都是可以复用的。
一、OOM 排查实战:LinkedList 内存泄漏
故事背景
某次双十一之前,线上突然出现大量告警。调用方反馈请求超时严重。

同时监控显示 GC 耗时特别长、GC 频率也异常:

排查过程
第一时间拉了线上的堆 Dump。用分析工具做内存泄漏分析——如果没有内部工具,可以用 jmap 导出 + MAT 分析。注意要在 GC 前拉 dump,GC 后的 dump 可能看不到问题对象。

一个 LinkedList 占用了 73% 的内存,内存泄漏特征非常明显。
这段代码是几天前上线的,目的是实现批量支付透支多笔算法。通过崩溃线程的堆栈可以看到程序在不断递归:

顺藤摸瓜找到了具体的 case:算法输入一组订单和最高可用额度,返回最接近额度上限的一组订单。本质是一个"最接近子序列和"问题——LeetCode 上的原题。
开发同学用了分治 + 回溯的方案,预计空间复杂度是 O(n * 2^(n/2))。但和 LeetCode 需求不同的是,业务需要记录"最接近目标值的那组订单",而不仅仅是一个数字。为了记录订单序列,每次递归都在 LinkedList 中保存中间结果,导致空间复杂度爆炸。
解决方案
第一时间通过配置中心将算法切回单笔模式止血。
之后优化方案很简单——把"算法"干掉了。实际业务不需要找最优组合,用简单的贪心策略就能满足需求。
教训:不要炫技。 开发者只考虑了时间复杂度,忽略了空间复杂度。在线上系统中,内存是比 CPU 更稀缺的资源。
二、OOM 排查实战:POI 大文件导出
故事背景
内部平台提供了一个导出报表为 Excel 的功能。随着数据量增长,管理员执行导出操作时系统频繁崩溃。监控显示导出期间内存急剧上升,最终抛出 OutOfMemoryError。
排查过程
配置 JVM 在 OOM 时自动生成堆 Dump:
-XX:+HeapDumpOnOutOfMemoryError或者手动获取:
jmap -dump:live,format=b,file=heapdump.hprof <pid>建议在 GC 前和 GC 后分别获取 Dump。如果只获取一次,一定要知道是 GC 前还是 GC 后的,否则很容易分析错误。
用 MAT 加载 heapdump.hprof 文件分析,发现大量的 XSSFWorkbook 实例占用了大部分堆内存:

定位到代码,发现系统使用 Apache POI 的 XSSFWorkbook 一次性将所有数据加载到内存:
public ByteArrayOutputStream exportData(List<Data> dataList) {
XSSFWorkbook workbook = new XSSFWorkbook();
XSSFSheet sheet = workbook.createSheet("Data");
int rownum = 0;
for (Data data : dataList) {
Row row = sheet.createRow(rownum++);
// 填充数据行
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
workbook.write(out);
return out;
}解决方案
使用 SXSSFWorkbook 替代 XSSFWorkbook。SXSSF 是 POI 提供的流式写入方式,只在内存中保留指定行数的数据,其余写入临时文件:
public ByteArrayOutputStream exportData(List<Data> dataList) {
SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 内存中只保留100行
Sheet sheet = workbook.createSheet("Data");
int rownum = 0;
for (Data data : dataList) {
Row row = sheet.createRow(rownum++);
// 填充数据行
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
workbook.write(out);
out.close();
workbook.dispose(); // 清除临时文件
return out;
}后续改进:随着文件越来越大,导出耗时也变长。考虑用异步方案——后台生成报表,完成后通知用户下载。
三、FullGC 排查实战:数据倾斜引发的内存问题
故事背景
线上系统告警,提示频繁 FullGC 且 GC 耗时严重。

排查过程
登录监控系统查看集群 GC 情况:

从大约 9:03 开始,老年代内存持续上涨,9:13 发生了多次 FullGC。
第一时间做了两次堆 Dump(GC 前一次,GC 后一次)。GC 前大量 String 占用内存,GC 后就被回收了——说明不是内存泄漏,这些对象是可以被回收的垃圾对象。
那问题就变成了:为什么会有这么多对象涌入老年代?
看了 Survivor 区的情况,增长非常快:

推测是这段时间请求量太大,年轻代放不下了,对象直接晋升到老年代。开始查系统监控找原因:
- 翻 RPC 接口调用量——正常
- 翻 MQ 消费量——正常
- 查外部接口调用量——发现一个接口 QPS 飙高
- 查数据库中间件 QPS——飙高明显


结合接口调用量和数据库操作量飙高的时间点,追踪调用链,发现是一个每小时执行的分布式定时任务——扫表处理数据。
奇怪的是只有一台机器出问题。逐台检查过去 12 小时的堆内存数据后发现规律:不同时间点,都会有某一台机器内存飙高,但不固定是哪台。
总结现象:
- 每个整点开始执行分布式扫表任务
- 执行后某台机器老年代被打满,频繁 FullGC
- GC 有效(对象可回收),说明是年轻代放不下导致的,不是泄漏
对比有 FullGC 和没有 FullGC 的机器的日志后发现:以 "22" 开头的用户 ID 数据量特别大(数据严重倾斜)。

因为分布式任务按用户 ID 前两位分段,随机分给不同机器处理。分到 "22" 段的机器就要处理大量数据,产生大量对象,内存直接被打满。由于是随机分配,不同时段轮到不同机器,所以 FullGC 的机器也不固定。
解决方案
让倾斜的数据均匀分布,两个方向:
- 分更细:从前 2 位改为前 3 位分段
- 换分法:按主键 ID 分段而非按用户 ID(但需要解决同一用户数据聚合的问题)
四、FullGC 排查实战:缺少参数校验导致全表查询
故事背景
线上系统告警,频繁 FullGC。

排查过程
查看集群 GC 情况,发现 3 小时内有十几次 FullGC,但不是所有机器都有问题。

分别对有 FullGC 的机器、堆内存占用高的机器、以及正常机器做了 Dump 进行对比——多次 dump 是为了做对比分析。
问题机器的 Dump 中发现一个 ArrayList 存放了 60 多万个业务对象,占了 2 个多 G 内存:


排查代码,排除了 bean 中成员变量累加的可能(全局搜索未发现这种用法),确定是某个查询没有做条件过滤,一次性把全表数据查了出来。
结合堆内存增长的时间点去查日志:

正常查询应该带一个 caseId 参数,但问题请求没有带。看代码发现——同事没有对 caseId 做非空校验,未传 caseId 时走了 queryList,直接查了全表放到 List 里。

前端也有 bug,某些场景下没传 caseId。但归根结底是后端没做好参数校验。
解决方案
对 caseId 做非空校验,未传直接报错返回。同时前端修复传参问题。
教训:参数校验是后端的基本功。即使前端传了,后端也必须校验——永远不要信任调用方。
五、FullGC 排查实战:MetaSpace 被表达式引擎打满
故事背景
定价集群监控提示有少量 FullGC——对于任何生产系统,FullGC 都不能容忍。

监控提示是 MetaSpace 发生 FullGC。MetaSpace 中的类要同时满足三个条件才能被卸载回收:该类所有实例已回收、加载该类的 ClassLoader 已回收、该类对应的 Class 对象无引用。条件非常严苛。
排查过程
初步推测有不断动态创建类的行为。系统中使用了轻量表达式引擎 AviatorEvaluator,正好有这个特征。
Dump 出 MetaSpace 和 Heap:

发现存在大量 Script_${timestamp}_${idx} 类型的类,全部指向 com.googlecode.aviator 包。
找到业务代码:
public class ExpressionUtil {
public static AviatorEvaluatorInstance aviatorEvaluator = AviatorEvaluator.getInstance();
public static BigDecimal calculate(String expression, Map<String, Object> params) {
BigDecimal result = (BigDecimal) aviatorEvaluator.compile(expression).execute(params);
return result.setScale(6, RoundingMode.HALF_UP);
}
}问题在 compile(expression) 这一行——默认是无缓存模式。每次编译都会:
- 创建一个新的
AviatorClassLoader - 生成一个匿名的
Script_xxx类 - 这个类和 ClassLoader 都无法被回收(因为还在被引用)
MetaSpace 就这样被逐步填满了。
解决方案
使用 Aviator 的缓存编译模式:
aviatorEvaluator.compile(cacheKey, expression, true); // cached=true缓存 key 按 ${product}_${code}_${className}_${method} 组装,相同表达式只编译一次,后续直接复用编译结果。
六、排查思路总结
通过以上几个案例,可以总结出一套通用的内存问题排查流程:
关键要点:
- Dump 要在 GC 前做,GC 后问题对象可能已经被回收了
- 多次 Dump 做对比——问题机器 vs 正常机器、不同时间点的同一台机器
- 区分内存泄漏和内存溢出:泄漏是对象应该被回收但没有(引用未断开);溢出是对象本身就太多了(比如数据倾斜产生大量对象、全表查询放到 List 里)
- GC 日志是最重要的线索之一:看老年代/年轻代的增长趋势、GC 前后的空间变化
- 结合业务监控一起看:QPS、调用链、定时任务执行时间点,往往能帮你快速缩小范围
七、常见场景面试题精选
怎么排查线上 OOM?
排查路径:
- 先止血:通过配置中心/开关降级出问题的功能
- 拿 Dump:
jmap -dump:live,format=b,file=dump.hprof <pid>,或提前配置-XX:+HeapDumpOnOutOfMemoryError - 用 MAT/VisualVM 分析:找到占用内存最大的对象,看它的引用链
- 定位代码:从大对象的类型和引用链反推是哪段代码产生的
- 修复验证:修改代码后观察内存走势是否正常
内存泄漏和内存溢出的区别?
内存泄漏(Memory Leak):对象已经不需要了,但因为仍被引用而无法被 GC 回收。比如静态集合中不断添加对象但从不移除、数据库连接未关闭等。泄漏积累到一定程度就会导致内存溢出。
内存溢出(OutOfMemoryError):JVM 的可用内存真的不够了。原因可能是泄漏的积累,也可能是一次性加载了太多数据(全表查询、大文件加载)。
FullGC 频繁但每次都能回收内存,是什么情况?
说明不是内存泄漏(泄漏的对象回收不了)。通常是短时间内产生了大量对象,年轻代放不下,直接晋升到老年代,触发 FullGC。
排查方向:
- 查看这段时间是否有请求量或数据量的突增
- 是否有定时任务批量处理大量数据
- 是否存在数据倾斜导致某台机器处理量远大于其他机器
- 是否有代码在循环中创建大量临时对象
MetaSpace OOM 怎么排查?
MetaSpace 存的是类的元数据。MetaSpace OOM 通常意味着有代码在不断动态生成新的类。
常见原因:
- 表达式引擎(Aviator/OGNL/Groovy)每次编译都生成新类
- 动态代理频繁创建
- 反射操作中的
MethodAccessor膨胀
排查方法:dump MetaSpace,看哪些类在不断增长。通常类名中会带有时间戳或序号,很容易识别。
八、补充案例:常见内存问题模式
除了前面详细展开的案例,以下几种模式也在生产中频繁出现,简要记录排查要点。
模式一:数据库连接池泄漏
现象:应用运行一段时间后响应变慢,最终报"Cannot get a connection, pool error Timeout waiting for idle object"。
原因:代码中获取数据库连接后在异常分支没有正确关闭。try-catch 中只在正常路径 close 了连接,异常路径直接 throw 了。
排查方法:监控连接池的 active 连接数。如果持续增长不回落,就是泄漏。结合代码 review 查找所有获取连接的代码路径,确认每个路径(包括异常路径)都有 close 操作。
解法:使用 try-with-resources 或 finally 块确保连接一定被关闭。
try (Connection conn = dataSource.getConnection()) {
// 使用连接
} // 自动关闭模式二:ThreadLocal 导致内存泄漏
现象:使用线程池的场景中,内存缓慢增长,FullGC 无法完全回收。
原因:ThreadLocal 的值在线程池环境中不会自动清理。线程池中的线程是复用的,ThreadLocal 中的对象会一直随线程存在。如果每个请求都往 ThreadLocal 里 set 一个新对象但不 remove,就会越积越多。
排查方法:Dump 中发现大量对象的 GC Root 是 Thread → ThreadLocalMap → Entry → value。
解法:在请求处理完成后(finally 块中)调用 ThreadLocal.remove()。
模式三:大对象直接进入老年代
现象:偶发性 FullGC,但年轻代 GC(Young GC)频率正常。
原因:JVM 有一个参数 -XX:PretenureSizeThreshold(默认 0,即不限制),如果对象大小超过这个阈值就直接分配到老年代。即使不设这个参数,当 Survivor 区放不下时,大对象也会直接晋升老年代。
排查方法:查看 GC 日志中老年代的增长模式。如果是阶梯式增长(每次增长一大块),说明有大对象直接进入。
解法:
- 检查是否有代码一次性创建了很大的数组或集合
- 适当调大年轻代大小(
-Xmn) - 检查 Survivor 区比例(
-XX:SurvivorRatio)是否合理
模式四:String.intern() 滥用
现象:MetaSpace 或 String Table 持续增长。
原因:大量不同的字符串调用 intern() 方法,被放入字符串常量池中永远不会被回收(JDK 7+ 字符串常量池在堆中,可以 GC,但如果引用一直在就回收不了)。
排查方法:用 -XX:+PrintStringTableStatistics 查看 String Table 的统计信息。
解法:避免对动态生成的字符串调用 intern()。如果确实需要去重,用 ConcurrentHashMap 或 Guava 的 Interner 代替。
模式五:未关闭的 IO 资源
现象:运行一段时间后报"Too many open files"或内存缓慢增长。
原因:打开的 InputStream、OutputStream、FileChannel 等没有关闭,底层的 native 内存和文件句柄不释放。
排查方法:
lsof -p <pid> | wc -l查看进程打开的文件数- 如果文件数持续增长,就是 IO 资源泄漏
解法:所有 IO 资源都用 try-with-resources 管理。
九、JVM 内存排查常用命令速查
日常排查中最常用的几个命令,备忘用:
# 查看 JVM 进程
jps -l
# 查看堆内存使用概况
jmap -heap <pid>
# 导出堆 Dump(GC 前)
jmap -dump:format=b,file=dump.hprof <pid>
# 导出堆 Dump(先做一次 GC 再导出)
jmap -dump:live,format=b,file=dump_after_gc.hprof <pid>
# 配置 OOM 时自动导出 Dump
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/
# 查看 GC 统计信息(每 5 秒刷新一次)
jstat -gcutil <pid> 5000
# 查看 GC 日志(JDK 8)
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/tmp/gc.log
# 查看 GC 日志(JDK 11+)
-Xlog:gc*:file=/tmp/gc.log:time,uptime,level,tags
# 使用 Arthas 查看内存
dashboard # 整体概览
memory # 内存区域详情
heapdump /tmp/dump.hprof # 导出堆 DumpMAT(Memory Analyzer Tool)分析 Dump 时重点关注:
- Leak Suspects Report:自动分析可能的泄漏点
- Dominator Tree:按对象大小排序,找到占内存最多的对象
- Histogram:按类统计实例数和内存占用
- Thread Overview:查看各线程持有的对象
以上命令和工具覆盖了 90% 以上的内存问题排查场景。熟练掌握它们,再配合对业务代码的理解,大部分内存问题都能在一小时内定位到根因。
小结
内存问题的排查看似复杂,但核心方法论很简单:拿 Dump → 找大对象 → 追引用链 → 定位代码。难的不是技术手段,而是在生产环境中保持冷静、系统性地缩小范围。
几个经验教训反复出现在这些案例中:
- 不要炫技——简单方案往往更可靠
- 参数校验永远不能省——一个空参数就能把系统打垮
- 缓存编译结果——任何
compile()操作都该考虑是否缓存 - 数据均匀分布——分布式任务中数据倾斜是隐形杀手
- 流式处理大文件——不要试图把整个文件加载到内存
下次凌晨收到 OOM 告警的时候,希望这些案例能帮你少走弯路。