news 2026/10/8 9:39:20

Java服务频繁OOM?一次内存泄漏排查实战:从GC日志到MAT定位

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java服务频繁OOM?一次内存泄漏排查实战:从GC日志到MAT定位

前段时间线上一个Java服务频繁OOM,每次重启后能撑两三天,然后又挂。看了下监控曲线,内存像台阶一样往上爬,典型的泄漏节奏。原本以为是什么高并发下的复杂bug,结果定位到最后,发现是个非常简单的小坑,但排查过程确实绕了不少路。这篇把整个排查和分析过程完整记录下来,包括用了哪些命令、怎么读GC日志、怎么用MAT看dump文件,以及最后那个“小坑”到底是什么。

如果你是做Java后端或者维护线上服务的,对jstat、jmap、MAT这些工具不陌生但没系统排过内存问题,这篇可以作为一份实战参考。即使是新手,按照里边的思路和命令走一遍,也能建立一套自己的排查框架。

1. 现象描述与初步判断

1.1 服务表现:隔几天就OOM一次

这个服务是一个普通的Spring Boot应用,部署在两台4C8G的云主机上,JVM堆设成4G,用的G1垃圾收集器。平时接口响应都正常,也没有特别大的流量波动,但每到凌晨业务低峰期,偶尔会有一个实例突然“消失”,K8s里显示OOMKilled,容器被重启。

刚开始以为是夜间批处理任务造成的,查了定时任务日志,发现确实有几个凌晨跑的数据汇总Job,但任务执行时间都不长,内存峰值也就几百MB,不至于把4G堆打满。后来又怀疑是连接池泄漏,检查了数据库连接、Redis连接、HTTP客户端连接池,全都没问题。

直到有一次在OOM之前抓到了当时的现场日志,看到这样的内容:

java.lang.OutOfMemoryError: Java heap space Exception in thread "http-nio-8080-exec-233" java.lang.OutOfMemoryError: Java heap space

这一下就确定了是堆内存的问题,而且不是栈溢出,也不是Metaspace或者直接内存溢出。接下来就好办了,直接围绕堆内存排查。

1.2 这里先搞清楚一个基础概念:内存泄漏和内存溢出的区别

很多新手容易把这两个词混在一起。内存泄漏(Memory Leak)是对象已经不再使用,但GC无法回收,导致内存被白占;内存溢出(OutOfMemoryError)是堆内存真的不够用了,连新对象都分配不出来。泄漏是原因之一,溢出是结果。服务反复OOM,十有八九就是有对象一直堆积,把堆熬干了。

用生活里的例子说,漏水的水池,一边进水一边漏水,如果漏水的速度小于进水的速度,水池迟早会满。内存泄漏就是这个“永不关闭的水龙头”,对象只创建不回收,堆空间被一点点蚕食。

判断是不是泄漏,不能光看OOM日志,得看内存趋势。下面这个台阶状的内存增长曲线,就是泄漏最典型的特征:每次GC之后内存能降一点,但降不到初始水位,整体重心不断抬高,直到触发Full GC也收不回来,最终OOM。

2. 排查过程:从GC日志到堆dump

2.1 第一板斧:jstat看GC趋势

登录到出问题的机器上,在服务刚重启后不久,先用jstat观察GC情况:

jstat -gcutil <pid> 1000 30

输出里重点看这几列:

  • YGC/YGCT:年轻代GC次数和耗时
  • FGC/FGCT:Full GC次数和耗时
  • S0/S1/E:幸存区和Eden区占用百分比
  • O:老年代占用百分比
  • M:Metaspace占用百分比

我当时拿到的情况是:Eden区和Survivor区都正常,每次Minor GC之后都能清干净,但是老年代O那一路从20%慢慢爬到50%、70%,Full GC之后也只能回落几个百分点,然后继续涨。这说明老年代里有大量对象没法被回收,而且持续在产生。

顺便说一个容易踩的坑:jstat输出里FGC如果一直增加,不要直接断定就是Full GC太频繁。要看FGC发生的时间间隔和每次回收后的老年代占用变化。有的服务FGC数量不多,但每次回收效果极差,这才更符合泄漏的特征。

2.2 第二板斧:jmap确认对象分布

GC趋势有了初步判断后,先用jmap -heap看堆的整体配置和当前分区使用情况:

jmap -heap <pid>

这一步主要确认堆参数有没有生效,比如G1的MaxHeapSize、InitialHeapSize,还有当前各区域的实际占用。

紧接着用jmap -histo看一眼对象实例数的分布:

jmap -histo:live <pid> | head -50

这能粗略看出哪一种对象特别多。不过这里要提醒一下,-histo:live会先触发一次Full GC,线上环境如果堆很大、GC停顿时间长,要小心一点,最好在低峰期操作。我当时看到的结果是byte[]和char[]数量很大,但这在Java服务里太常见了,没法直接定位到问题,只能说明内存里确实存了大量数据。

2.3 第三板斧:jmap dump堆快照

histo不够用,就得抓堆快照。这个操作也要小心,dump过程中服务会暂停,因为要冻结堆状态才能生成一致性快照。线上大堆环境,先评估一下停顿时间能不能接受,尽量在流量低峰执行。

jmap -dump:live,format=b,file=/data/dump/heap_20240115.hprof <pid>

如果有条件,强烈建议在启动参数里加上自动dump,这样OOM发生时JVM会自动把当时的堆快照落盘,省得事后抓不到现场:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/

这条参数看起来平平无奇,关键时候能救命。没有现场dump,全靠事后复现,排查难度会翻好几倍。

dump文件拿到手之后,我先把文件大小看了一眼,4G的堆配置,dump文件竟然有3.8G,说明堆里边确实塞满了东西。

3. 用MAT分析dump文件:定位可疑对象

3.1 MAT的基本使用思路

dump文件拿到了,接下来用MAT(Memory Analyzer Tool)分析。MAT是Eclipse家的开源工具,专门做堆dump分析,官网直接下载解压就能用,Mac和Windows都有对应版本。文件太大打不开的话,在MAT的MemoryAnalyzer.ini里把-Xmx调大,比如:

-Xmx4g -Xmx6g

我这边3.8G的dump文件,本地16G内存的机器,给MAT分配6G堆就很稳。

打开dump文件之后,MAT会自动计算并生成一个报告,但我们真正要看的不是那个自动报告,而是几个关键视图:

  • Dominator Tree(支配树):看哪些对象占用的堆最大
  • Leak Suspects(泄漏嫌疑):MAT自动分析出的嫌疑对象
  • Histogram(类直方图):按类维度看实例数和占用大小

3.2 支配树上的大鱼:一个3GB的HashMap

当时直接打开Dominator Tree,一眼就看到一个大块头:一个java.util.HashMap实例,Retained Size占了3GB多。这个HashMap挂在某个静态字段下面,包名一看就是业务代码里的一个工具类。

点进这个HashMap的内部结构,看到它里面有大量Entry,每个Entry的key是一个业务ID字符串,value是一个自定义的对象。整个Map里存了上百万个条目,这就是老年代持续增长的元凶。

再看引用链,这个Map是static修饰的,属于类级别的全局容器。只要类加载器不被回收,这个Map和它里面的所有对象就永远不会被GC盯上,即使业务上早就不需要那些数据了,它们依然稳稳地挂在堆里。

3.3 为什么不是普通的“忘记移除”:追溯value来源

找到Map本身还不够,得搞明白这些对象是怎么进去的、为什么没有被移除。顺着代码一追,发现这个Map设计本意是做一个“短暂缓存”,接口里每来一个请求就往里放一条数据,但“短暂”两个字完全没有实现——既没有设置过期时间,也没有在接口结束之后移除,更没有做容量上限控制。

时间一长,每天几十万请求,每个请求都往Map里塞一条,几个月下来堆里就有上百万条垃圾数据。这其实就是最原始形态的内存泄漏:程序逻辑上忘记释放不再使用的引用。

这里有一个MC比较经典的误区值得说一下:有人觉得Java有GC,所以“不用管释放”;也有人觉得缓存不清理没关系,反正有大不了重启。这两种想法在生产环境都要付出代价。GC只处理不可达对象,static容器里的对象永远“可达”,GC拿它们一点办法都没有。

4. 根因定位与代码层面的“小坑”

4.1 问题代码还原

绕了一大圈,最后定位到的代码逻辑大概是这样一个路子(已脱敏改写过):

public class BizDataCache { private static final Map<String, BizData> CACHE = new HashMap<>(); public BizData getOrCreate(String bizId) { BizData data = CACHE.get(bizId); if (data == null) { data = buildFromRemote(bizId); CACHE.put(bizId, data); } return data; } }

乍一眼看过去,有缓存、有判断、有复用,还挺合理。但致命的问题就在那个static的HashMap上:

  • key的集合理论上是有限的业务ID,但实际线上数据里携带着随机追踪参数,每个请求都是新值
  • Map只增不减,没有任何淘汰策略
  • buildFromRemote返回的value对象内部还挂着一个大列表,进一步放大内存开销

这种问题为什么叫“小坑”呢,因为代码逻辑一眼看过去“没毛病”,不涉及高深的并发问题,也不涉及框架使用错误,就是一个数据结构的选择和生命周期管理问题。

4.2 更隐蔽的同类坑:ThreadLocal和线程池的组合

排查过程中还有一个意外收获,虽然这次不是主因,但值得提醒一句:另一个服务里出现过类似症状,最后定位到是ThreadLocal配合线程池使用,导致线程复用后ThreadLocalMap里的对象一直不释放。典型的错误写法是:

private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT = new ThreadLocal<>(); // 在业务方法里设置值,但是 finally 中忘了 remove()

线程池里的线程是长期存活的,ThreadLocalMap是线程的一个属性,如果不主动remove,里边的对象会跟随线程活到天荒地老。这个问题在netty的异步线程、定时任务线程池里特别常见。

排查方向正确的情况下,MAT里的支配树一眼就能看到ThreadLocalMap占据大量内存,顺着引用链往上追,很快就能找到遗忘了remove()的那行代码。

5. 修复方案与预防措施

5.1 修复:引入带过期机制的缓存组件

定位到根因后,修复反而很简单。不再手写静态HashMap,换成支持过期和容量限制的本地缓存组件。我们用了Caffeine,完全可以替代手写一段“简陋缓存”的写法:

public class BizDataCache { private static final Cache<String, BizData> CACHE = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); public BizData getOrCreate(String bizId) { return CACHE.get(bizId, id -> buildFromRemote(id)); } }

maximumSize限制总量,expireAfterWrite保证数据只会存活30分钟,两把锁把之前Map无限增长的漏洞完全堵死。就算业务上出现了极端情况,缓存最坏也只是被淘汰数据,不会拖垮堆内存。

这里补充一下,选择Caffeine而不是Guava Cache,是因为Caffeine的淘汰策略和并发性能在多数场景下更优。如果项目里不想引入新依赖,用ConcurrentHashMap加定时清理也可以,但要注意“清理”必须真的实现,不能只写注释“定期清理”而不落代码。

5.2 JVM参数层面的预防

修复线上代码之后,还要把预防措施补上。这次踩坑有几个参数特别值得拆开讲。

首先是启动参数里一定要有自动dump和OOM发生时执行额外动作的配置:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/dump/ -XX:OnOutOfMemoryError='kill -9 %p'

HeapDumpOnOutOfMemoryError这一条前面说过了,OnOutOfMemoryError的作用是OOM发生后立刻杀掉进程,避免一个半死不活的服务继续接收流量,方便K8s快速拉起新实例。

然后是G1的参数调优,我这里踩过一个小坑,默认的G1在堆快满的时候才做Full GC,如果线上服务存在慢速泄漏,表现出来就是GC停顿越来越长,但内存已经逼近极限。可以在启动参数里限制一下G1的停顿时间目标:

-XX:MaxGCPauseMillis=200

这个参数告诉G1尽量把GC停顿控制在200ms内,它会动态调整年轻代大小和回收策略。虽然不能解决泄漏本身,但能把GC表现调得更可控,给排查争取时间窗口。

5.3 监控告警不能只盯CPU

这次还有一个体会,监控这块只盯CPU是不够的。很多团队对CPU告警特别敏感,CPU一高马上响应,但内存缓慢增长这种“温水煮青蛙”型的问题,不盯JVM内存曲线的话根本看不出来。

建议至少监控三样东西:

  • 老年代内存占用率:持续上升不回落,就是泄漏信号
  • Full GC次数和耗时:频率增加或者单次耗时明显变大,要警惕
  • 堆内存整体使用率:配合业务的流量曲线做对比,确认是否异常

把这些指标接到Prometheus+Grafana里,配置阈值告警,慢速泄漏最长也不会拖到OOM才发现。

6. 常见问题与排查技巧实录

排查内存问题这种事,多做几次就有手感了。把这次过程中遇到的典型问题和一些实用经验整理一下,直接当速查表用。

6.1 现象与怀疑方向速查

现象可能原因优先排查方向
内存台阶式增长,GC后不回落对象泄漏,堆里有容器只增不减jmap -histo、MAT支配树
刚启动正常,运行一周后慢慢变卡静态Map、缓存失效策略缺失业务代码中static字段、缓存组件配置
Metaspace持续增长动态生成类过多、类加载器泄漏jstat -gcutil的M列、导出Metaspace统计
内存正常但频繁Full GC堆太小或者大对象过多-Xmx配置、G1 region设置、对象大小分布
OOM日志是Direct buffer memory堆外内存泄漏NIO、Netty的ByteBuf使用情况
偶发OOM,dump却很小栈溢出或者创建线程过多线程数、-Xss参数、系统文件描述符

6.2 排查过程中的几个经验和坑

第一,抓dump千万别在高峰期操作。jmap -dump在4G堆上往往会有几秒到十几秒的停顿,线上用户能明显感知到接口超时。我习惯的做法是:早高峰之前或者问题实例已从负载均衡摘除之后再抓,抓完第一时间看Dominator Tree。

第二,histo和dump要用live参数时要先想清楚。jmap -histo:live和jmap -dump:live都会触发Full GC,目的是只保留存活对象。对于排查泄漏来说,用live参数反而更方便,因为死对象本来就不需要关心。但如果Full GC之后对象都被清掉了,嫌疑对象也一起没了,反而不好定位。所以我的建议是:先抓一次不带live的dump,再看情况决定要不要带live抓第二次。

第三,MAT的Leak Suspects报告别全信。它给出的结论是基于启发式规则的,经常会把一些正常的大对象池误判成嫌疑。真正可靠的是Dominator Tree和引用链分析,自己顺着路径追到业务代码,才敢确认根因。

第四,OOM日志一定要保留。容器被K8s重启之后,原来的stdout日志可能就丢了,如果log文件没有持久化,现场彻底没了。建议把JVM的日志输出到文件里,单独保留,别混在业务日志里滚动清理。

-Xlog:gc:/data/logs/gc.log:time,level,tags:filecount=10,filesize=50m

这条命令把GC日志写到固定文件里,保留10个滚动文件,每个最大50MB。排查的时候能精确看到每次GC前后内存的变化细节,比监控曲线更精准。

6.3 修复后的验证方法

改完代码上了线,怎么确认问题真的解决了?不能光靠“观察几天没OOM”来验证。

我在这次排查里的做法是:修复上线后,把旧的dump文件和新的dump文件各抓一次,用MAT对比两个文件里嫌疑对象的总量和引用链,确认大量对象已经不在堆里。同时盯紧GC日志里的老年代占用曲线,修复前后对比,看到“台阶”消失,曲线平稳,才算真正闭环。

另外,压测的时候可以故意造一些极端数据,比如把缓存key的随机性加大、增加并发量,让缓存命中率显著下降,观察内存曲线是否依然平稳。只有在这种“恶意输入”下不出问题,才能确认修复是扎实的。

7. 这次排查给我留下的几个小教训

整个过程走下来,最大的体会是:线上内存问题大多数时候都不是什么高深的东西,反而是最朴素的“代码写完了忘了清理”的问题。排查工具再强大,也得一步步从现象推结论,从GC日志到堆快照,再到业务代码,急不得。

还有一点,静态字段和全局容器是最容易藏内存问题的两个位置。写代码的时候,凡是涉及到static、Spring单例Bean里的成员变量、缓存组件,都要多问一句:这个东西的生命周期是谁在管?有没有容量上限?要不要过期?每个问题都问一遍,能挡下一大半的泄漏隐患。

最后分享一个个人习惯:新服务上线第一周,我会每天固定看一眼老年代占用曲线和Full GC频率,连续看一周。大部分慢速泄漏,一周左右就会露出马脚。真等到OOM告警才去排查,虽然也能定位,但那种“半夜被叫起来处理线上事故”的滋味,体会过一次就够了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 9:38:42

陀螺定向短节:复杂煤层瓦斯抽采钻孔轨迹实时控制的破局技术

在井下巷道里做瓦斯抽采钻孔&#xff0c;最怕的不是钻机出故障&#xff0c;而是钻杆在煤壁里“走歪了”自己也看不见。这种事我见得多了——设计穿煤层80米的孔&#xff0c;打出来实际只有40米留在煤层里&#xff1b;顺层孔设计要沿着煤层层理走&#xff0c;结果半路插进夹矸&a…

作者头像 李华
网站建设 2026/10/8 9:37:49

5MW永磁直驱风电并网Simulink仿真:从参数设计到控制实现全拆解

做风电并网仿真的人多少都有这样的体验&#xff1a;查文献时&#xff0c;别人5MW直驱机组跑得行云流水&#xff0c;自己搭模型时却连“永磁同步发电机该用哪个模块”都要犹豫半天。我这套模型从最初一个粗糙的demo&#xff0c;到现在能完整复现5MW永磁直驱海上机组从风速输入到…

作者头像 李华
网站建设 2026/10/8 9:37:23

AutoGPT可运行源码拆包:从环境搭建到工具注册的完整实践

简介&#xff1a;这份资源是面向希望上手 AutoGPT 的开发者与 AI 爱好者的保姆级教程配套源码包&#xff0c;聚焦于解决从环境准备到实际运行的全流程问题。AutoGPT 基于 ChatGPT&#xff0c;可自动完成写代码、写报告、做调研等任务&#xff0c;使用前需安装 Python 并下载项目…

作者头像 李华
网站建设 2026/10/8 9:36:37

C++装饰器模式实战:告别继承爆炸,用层层包装优雅扩展功能

先说个真实场景。我前几年接手过一个日志组件&#xff0c;需求一开始就两个&#xff1a;往文件里写、往控制台里写。后来产品经理加功能&#xff0c;先是加缓存&#xff0c;然后要加密&#xff0c;再然后要校验和&#xff0c;最后还要压缩。最离谱的是&#xff0c;这些功能开关…

作者头像 李华
网站建设 2026/10/8 9:36:06

Win11 WSL2安装Ubuntu全攻略:迁移D盘、CUDA、Docker与避坑指南

简介&#xff1a;这份资源面向希望在 Windows 11 上搭建 Linux 开发环境的开发者&#xff0c;尤其是需要源码管理、代码编译与软件包管理的软件工程人员。内容围绕 WSL2 与 Ubuntu 20.04 的安装配置展开&#xff0c;重点覆盖非系统盘安装方案&#xff0c;帮助硬盘空间紧张或希望…

作者头像 李华
网站建设 2026/10/8 9:34:36

SpringBlade微服务开发平台笔记一:把鉴权配置改到TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华