内存泄漏是Java线上服务最隐蔽的问题之一。应用刚启动时一切正常,运行三五天后Full GC越来越频繁,最终抛出OutOfMemoryError直接崩溃。因为进程重启后内存归零,问题被暂时掩盖,等到下次复现又要从头排查。其实只要方法得当,结合GC日志和堆转储,定位内存泄漏并不困难。本文围绕Spring Boot应用,系统地讲一讲排查思路和具体操作。

先搞清楚JVM内存结构,泄漏可能藏在哪
很多人一提到内存泄漏就只盯着堆,实际上JVM的内存区域远不止堆一个。排查之前必须先明确各个区域的职责,否则很容易方向跑偏。JDK 8之后永久代被移除,取而代之的是元空间(Metaspace),它使用本地内存;堆之外还有直接内存(Direct Memory),被Netty、NIO等广泛使用;此外每个线程还会占用栈空间。OutOfMemoryError的提示信息会告诉你具体是哪个区域出了问题,比如Java heap space表示堆内存不足,Metaspace表示元空间溢出,Direct buffer memory则是堆外内存不够。
不同区域的泄漏原因差别很大。堆泄漏通常是不再使用的对象仍被强引用持有,比如静态Map不断累积数据;元空间泄漏常见于动态类生成场景,例如频繁反射生成代理类、Groovy脚本反复编译、CGLIB动态代理滥用;直接内存泄漏则多见于Netty的ByteBuf没有release、NIO的DirectByteBuffer被回收时机不对。Spring Boot应用大量使用CGLIB代理和内嵌Tomcat的NIO,这几个区域都要纳入排查范围。
建议在启动参数中显式设置各区域上限,避免默认值掩盖问题:堆用-Xms和-Xmx设置,元空间用-XX:MaxMetaspaceSize,直接内存用-XX:MaxDirectMemorySize。设置了上限后,一旦某个区域异常增长,会更快暴露出来,而不是无限制膨胀直到拖垮整个物理机。
开启并读懂GC日志
GC日志是排查内存问题最重要的信息来源,开销很低却信息量巨大。JDK 8可以使用如下参数:
java -Xms2g -Xmx2g \ -XX:+PrintGCDetails \ -XX:+PrintGCDateStamps \ -XX:+PrintGCTimeStamps \ -Xloggc:/var/log/app/gc.log \ -XX:+UseGCLogFileRotation \ -XX:NumberOfGCLogFiles=5 \ -XX:GCLogFileSize=20M \ -jar app.jar
JDK 9及以后统一改用了-Xlog参数,写法如下:
java -Xms2g -Xmx2g \ -Xlog:gc*,gc+heap=debug:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \ -jar app.jar
拿到日志后重点观察三个信号。第一是GC后堆的剩余量:每次Full GC或Major GC之后,老年代占用的字节数如果呈现持续上升趋势,比如从500MB慢慢爬到1.5GB,基本可以断定有对象无法回收,这就是内存泄漏最典型的特征。第二是GC频率:Young GC间隔从几秒一次缩短到几百毫秒一次,说明老年代被快速填满,Eden区频繁触发晋升。第三是停顿时间:单次停顿从几十毫秒增长到数秒,服务响应也会跟着抖动。下面是一条典型的Young GC日志,方括号内的数字表示GC前该区域容量、GC后使用量、总容量:
2024-05-20T10:15:32.108+0800: 3521.456: [GC (Allocation Failure) [PSYoungGen: 680M -> 72M(764M)] 680M -> 72M(1984M), 0.0182341 secs] [Times: user=0.05 sys=0.01, real=0.02 secs]
如果想快速生成图表辅助分析,可以把日志文件丢进GCEasy或GCViewer这类工具,它们能自动统计吞吐量、平均停顿、老年代增长曲线,比肉眼逐行看日志直观得多。
命令行工具实时诊断与堆转储分析
确认存在泄漏后,下一步是找出到底是什么对象在膨胀。先在容器或服务器上用jps找到进程号,再用jstat -gcutil pid 1000每秒输出一次各区域使用率。观察一段时间,如果老年代占用率(O列)在每次Full GC后逐次抬高,泄漏实锤;如果元空间的M列持续增长,问题就出在类加载上。还可以用jmap -histo:live pid查看存活对象的直方图,按实例数或字节数排序,注意这个命令会触发一次Full GC,生产环境低峰期执行。
最终定位一般要靠堆转储。通过-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/让OOM时自动留存现场,或者手动执行jmap -dump:live,format=b,file=heap.hprof pid。拿到hprof文件后用Eclipse MAT打开,先看Leak Suspects报告,它会自动给出疑似泄漏点。再看Dominator Tree,按Retained Heap排序,排在前面的往往就是元凶。通过Path to GC Roots可以查清引用链,看是谁一直握着这批对象不放。
举个例子,MAT显示某个ConcurrentHashMap占用了800MB,其中全是Session对象,引用链指向一个静态字段,顺藤摸瓜发现是开发者自己写了一个本地缓存Map,只往里放数据从未做过淘汰。修复方案要么换成Caffeine并设置过期时间和最大容量,要么定时清理,问题即可根治。
Spring Boot应用常见的泄漏场景与代码示例
第一种是静态集合无限增长。下面的写法在业务代码里非常常见,看起来无害,实际上每次请求都会往Map里塞一条记录:
public class MetricsHolder {
// 静态Map随请求无限膨胀,属于典型泄漏
private static final Map<String, Object> CACHE = new ConcurrentHashMap<>();
public static void record(String key, Object value) {
CACHE.put(key, value);
}
}正确做法是引入带淘汰策略的缓存,例如Caffeine配置maximumSize和expireAfterWrite,超限自动驱逐。第二种是ThreadLocal没有清理。Tomcat的工作线程是复用的,线程不销毁,ThreadLocalMap里的Entry就一直在线程上,请求结束后必须调用remove():
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
public void handleRequest(HttpServletRequest request) {
try {
CONTEXT.set(buildUserContext(request));
doBusiness();
} finally {
CONTEXT.remove(); // 关键:线程复用场景必须清理
}
}第三种是资源未关闭,包括数据库连接、文件流、HTTP客户端响应体。Spring Boot中如果在长生命周期Bean里不断new数据库连接或HttpClient而不关闭,连接对象及其内部缓冲区会持续累积。利用try-with-resources语法或者依赖注入连接池是稳妥的做法。此外,自定义类加载器场景下(比如热部署、脚本引擎),旧类无法卸载会造成元空间泄漏;注册事件监听器后不反注册,监听器持有大对象也会拖住整个对象图。
总结一下排查路径:先通过GC日志确认泄漏趋势和发生区域,再用jstat、jmap缩小范围,最后靠堆转储加MAT找到具体对象和引用链,修复时对症下药。日常开发中养成及时清理资源、给缓存设置上限、规范使用ThreadLocal的习惯,大部分内存泄漏问题都可以提前避免。
JVM内存泄漏排查Spring Boot内存优化GC日志分析修改时间:2026-09-13 10:08:35