☰
一次 Java 内存泄漏排查过程:从告警到定位修复
2026/9/26 20:10:37 网站建设 项目流程

摘要:本文以一次线上 Java 服务频繁 Full GC、内存占用持续升高的真实事故为主线,系统讲解 Java 内存泄漏的概念、JVM 内存模型、常见泄漏场景、排查工具链(jstat、jmap、jstack、MAT、Arthas 等)以及完整的分析定位流程,并结合静态集合、ThreadLocal、连接未关闭、监听器未注销等典型案例给出修复方案与工程化预防规范。文章适合 Java 后端开发、SRE 以及需要处理线上 JVM 稳定性问题的同学阅读。

一、事故背景:一个周三凌晨的告警

周三凌晨两点,监控群里弹出一条告警:支付网关服务 memory_used 达到容器内存上限的 85%,且持续上升。紧接着,第二条告警:Full GC 次数在最近 10 分钟内达到 37 次,GC 停顿时间显著拉长。凌晨值班的同学先做了一轮应急处理:临时扩容、重启服务。重启之后指标短暂回落,半小时后内存曲线又像爬山一样重新涨了上去。

这说明问题不是偶发的流量尖峰,而是典型的内存泄漏:随着请求不断处理,本应被垃圾回收器回收的对象一直没有被回收,堆内存被持续占用,最终把系统推向频繁 Full GC 甚至 OutOfMemoryError 的深渊。

本文要还原的,就是这次事故完整的排查思路、工具使用与修复过程。在开始之前,我们需要先花一点篇幅把「内存泄漏」这个概念说清楚。很多人一听到内存泄漏,会理所当然地想到 C 语言里的 malloc 之后忘记 free,但在 Java 这种带垃圾回收的语言里,内存泄漏的表现形式并不一样。

二、Java 内存泄漏到底是什么

2.1 先纠正一个常见的误解

在 C/C++ 中,内存泄漏指分配出去的内存没有释放,进程再也无法使用这块内存。而在 Java 中,JVM 自带垃圾回收器(Garbage Collector,GC),正常情况下不再被引用的对象会被自动回收。因此 Java 的内存泄漏并不是「申请了内存忘了释放」这么直白,而表现为:某些对象在逻辑上已经不再被使用,但由于存在一条或多条仍然有效的引用链,垃圾回收器认为它们仍然存活,因此永远无法回收。

换句话说,Java 内存泄漏的本质是对象的生命周期被意外延长。这些对象就像被遗忘在缓存里的快递,仓库管理员(GC)永远不会去清空它们,仓库(堆内存)只会越来越满。

2.2 内存泄漏与内存溢出的关系

这是两个经常被混淆的概念,简单区分如下:

  • 内存泄漏(Memory Leak):是造成内存浪费的原因之一,表现为无用对象无法被回收,堆占用随时间增长。
  • 内存溢出(OutOfMemoryError,OOM):是最终的表现结果,当堆内存或元空间等区域被用完时,JVM 抛出的错误。

两者关系可以理解为:内存泄漏是「病因」,内存溢出是「症状」。当然,内存溢出也可能由其他原因造成,比如单个对象过大、堆本身配置过小、元空间不足等,但持续上涨后稳定不回落、重启后复现的内存问题,首要怀疑方向就是内存泄漏。

2.3 什么样的曲线像内存泄漏

观察内存曲线是判断泄漏的首要手段。典型的内存泄漏曲线有几个特征:

  • 锯齿状上升:每次 Young GC 之后内存会回落到一个「波谷」,但这个波谷的高度一次比一次高,整体呈阶梯式上涨。
  • 周期性 Full GC 但无法下降:Full GC 的次数越来越多,停顿越来越长,但每次 GC 后释放的内存非常有限。
  • 重启可短期缓解但会复发:服务重启后内存从低位开始,但运行一段时间后又爬上去,周期与业务量的关系并不完全对应。

相反地,如果内存曲线呈现健康的锯齿状,波谷长期稳定在一条水平线上,即使业务高峰期内存升高,低谷也能降回来,那就属于正常的对象创建与回收节奏,不必过度紧张。

三、JVM 内存模型与垃圾回收基础

要真正看懂排查过程,必须对 JVM 内存布局和垃圾回收机制有基本认识。这一节不求深入到底层源码,但求建立一张清晰的「内存地图」。

3.1 JVM 运行时数据区

Java 虚拟机规范把运行时内存划分为多个区域,对于排查内存泄漏,我们重点关注三个区域:

  • 堆(Heap):几乎所有对象实例和数组都在这里分配,是内存泄漏的主战场。堆又被分代 GC 划分为新生代(Young Generation)和老年代(Old Generation)。
  • 元空间(Metaspace):JDK 8 之后替代永久代,存放类的元数据,主要受-XX:MaxMetaspaceSize控制。类的元数据如果不合理地在运行时动态加载、卸载不掉的类,也会造成元空间泄漏。
  • 线程栈(Java Virtual Machine Stack / Native Method Stack):每个线程私有,存放栈帧中的局部变量、方法参数等。局部变量对堆中对象持有强引用,方法调用结束后栈帧被销毁,引用随之解除。

其中堆内存是最常见的泄漏区域。需要注意的是,分析堆时会用到可达性分析,其起点被称为GC Roots,包括:栈帧中的局部变量、类的静态字段、常量池引用、JNI 引用、活跃线程等。只要对象从 GC Roots 可达,它就会被视为存活。

3.2 对象从生到死

一个对象的生命周期大致如下:

  1. 使用new在 Eden 区分配内存。
  2. 经历若干次 Minor GC(Young GC)仍然存活的对象,会被晋升到 Survivor 区,再经过一定次数轮换后晋升到老年代。
  3. 老年代空间不足时触发 Major GC / Full GC,若不存活才被回收。

内存泄漏对象由于始终被强引用,无论经过多少轮 GC 都无法被回收。它们可能在年轻代时由于频繁复制而很快进入老年代,随后长期驻留,这也是为什么泄漏对象分析时常常能在老年代 Histogram 里看到它们占据大量空间。

3.3 引用类型与泄漏的关系

Java 提供四种引用,理解它们对解决泄漏很有帮助:

  • 强引用(Strong Reference):最常见的Object obj = new Object()。只要强引用存在,对象就不会被回收。大部分泄漏都是「过长的强引用链条」造成的。
  • 软引用(Soft Reference):内存不足时会被回收,适合做缓存。软引用本身要配合对象生命周期理解,不能无脑缓解泄漏。
  • 弱引用(Weak Reference):只要被 GC 发现,无论内存是否充足都会被回收,适合用于WeakHashMap、监听器等场景。
  • 虚引用(Phantom Reference):用于对象回收前的通知处理,通常配合引用队列做清理工作。

在修复部分我们会看到,把缓存从强引用改为弱引用、软引用是常见手段,但这只是「缓解」,真正要做的是从根源上解除没有必要的引用。

四、Java 常见内存泄漏场景盘点

在动手排查之前,先建立一份「嫌疑名单」能显著提升效率。下面是根据工程经验总结的高频泄漏场景,几乎每一类都能在真实事故中找到对应案例。

4.1 静态集合类的无界增长

这是最经典、最容易被忽视的泄漏来源。比如把数据放入static HashMap、static List或static ConcurrentHashMap,只进不出。只要服务不重启,这些集合就永远可达,收集的对象也随之永驻堆中。

public class CacheHolder { private static final Map<String, Object> CACHE = new HashMap<>(); public static void put(String key, Object value) { CACHE.put(key, value); // 只有 put,没有 remove,也没有容量上限 } }

代码看起来无害,但如果 key 是用户 id、订单号或请求流水号,随着业务运转,Map 会无限膨胀。就算每个对象很小,日积月累也非常可怕。静态集合不是不能有,但必须有容量上限、定期清理或过期策略。

4.2 ThreadLocal 使用后未清理

ThreadLocal在线程池场景下是一枚「定时炸弹」。ThreadLocal 的 value 是存放在线程自身的ThreadLocalMap里的,key 是对 ThreadLocal 实例的弱引用,value 是强引用。一旦 ThreadLocal 实例本身被回收、key 变成 null,value 却依然被线程的 Map 强引用,除非显式remove(),否则在复用线程的池中永远无法回收。

private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); public String format(Date date) { return DATE_FORMAT.get().format(date); // 用完没有 remove(),线程归还线程池后 value 仍然存活 }

如果线程池是固定大小且会被多个任务反复使用,这些残留的 value 对象并非无限增长,泄漏规模受线程数限制;但如果 value 内部又指向其他大对象,例如数据库连接、用户会话、大缓存,实际占用的内存可远超预期。

4.3 各种连接、流、文件未关闭

数据库连接、Socket 连接、文件输入输出流、Zookeeper、Redis 客户端连接等资源如果只打开不关闭,会造成两类问题:一是系统资源泄漏(文件描述符、端口、连接池打满),二是这些连接对象本身及内部缓冲区长期驻留堆内存。

public void query() throws SQLException { Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("select * from orders"); // 忘记 close,长期运行连接池被耗尽,conn/stmt/rs 对象再也无法回收 }

应当使用try-with-resources语法保证资源可靠关闭。连接池本身通常会有超时回收机制,但如果业务代码不归还,回收也只是兜底,且归还期间连接对象依然占用内存。

4.4 内部类隐式持有外部类引用

非静态内部类会隐式持有外部类实例的引用。这种引用关系容易被忽视,一旦内部类实例的生命周期被拉长,它所引用的外部类(往往包含大字段、上下文对象、数据库连接等)也无法回收。

public class OrderService { private byte[] bigBuffer = new byte[10 * 1024 * 1024]; public Runnable createTask() { return new Runnable() { // 匿名内部类持有 OrderService.this @Override public void run() { System.out.println(bigBuffer.length); } }; } }

如果这个Runnable被放入一个生命周期很长的线程池队列或调度器中,而对应的OrderService本来早该销毁,那么bigBuffer这种大对象就会被一直拽住。当内部类只是「借用」外部方法参数、不需要访问外部实例时,可以声明为静态内部类或独立顶层类来切断隐式引用。

4.5 监听器、回调注册后未注销

事件驱动框架中,注册监听器、观察者、回调后忘记移除,会让发布者长期持有订阅者引用。比如 GUI 组件、MQ 消费者、配置中心监听、业务自定义事件总线都会出现这类问题。注册方生命周期短、被注册方生命周期长时,注册方会一直存活,这就是典型的「长生命周期持有短生命周期」反向引用。

eventBus.register(orderCreatedListener); // 之后没有 unregister // eventBus 是单例长生命周期对象,orderCreatedListener 却持有某个请求上下文或状态

解决方案是提供对称的注销机制,或者在监听器内部只依赖弱引用,又或者在注册方生命周期结束的钩子里自动反注册。

4.6 缓存过期策略缺失

自行实现缓存时,如果采用「纯内存、无淘汰、无过期」的策略,本质上就是 4.1 静态集合泄漏的变种。即使使用Guava Cache、Caffeine这类成熟缓存,也需要配置合理的maximumSize、expireAfterWrite、expireAfterAccess和弱引用策略。

Cache<String, User> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(30, TimeUnit.MINUTES) .build(); // 没有容量上限和过期时间的内存缓存就是泄漏温床

缓存是一把双刃剑,提高性能的同时也承担了「对象滞留」的风险,必须从生命周期上做闭环设计。

4.7 单例模式持有过多状态

单例对象本身是长生命周期对象,如果单例内部维护了和业务请求相关的状态(比如某个订单上下文、某个用户临时数据),就会导致每个请求的状态都被单例积累下来。单例应尽量只存放「无状态的服务逻辑」和「真正需要全局共享的配置」,绝不应该存放与单次业务处理相关的可变数据。

public enum ReportCenter { INSTANCE; private final List<Report> pendingReports = new ArrayList<>(); public void add(Report report) { pendingReports.add(report); // 单例集合只增不减,典型的泄漏写法 } }

4.8 自定义类加载器与动态类生成的泄漏

在使用热部署、动态代理、字节码增强、脚本引擎等技术的场景下,如果反复创建自定义ClassLoader来加载新的类,而这些 ClassLoader 因为类的静态字段或被缓存的对象引用而无法被卸载,元空间会持续增长,最终造成OutOfMemoryError: Metaspace。排查此类问题需要关注动态类的加载数量和 ClassLoader 的卸载情况。

4.9 字符串常量的无节制使用

JDK 7 之后字符串常量池移入堆中,String.intern()如果被滥用,可能让大量唯一字符串进入常量池。常量池中的字符串在类卸载前通常长期存活,当被 intern 的字符串数量极大且内容极其多样(比如拼接了用户 id、随机值的字符串)时,也会造成事实上的内存占用失控。应避免对动态生成的、生命周期本就短暂的高基数字符串调用intern()。

五、排查工具链全景

工欲善其事,必先利其器。Java 生态的内存排查工具非常丰富,按「生产环境轻量级 → 离线深度分析」的分类如下。

5.1 JDK 自带命令行工具

这些工具随 JDK 一同安装,生产环境一般都能直接使用:

  • jps:列出本地所有 Java 进程及其进程号(类似 Linux 的 ps,但只针对 JVM)。
  • jstat:实时查看 JVM 的类加载、编译、GC、堆分区容量和使用情况,是最常用的在线观测工具。
  • jmap:生成堆转储(heap dump)文件、查看堆的概要、直方图等。生产环境要慎用jmap:live,可能触发较长停顿。
  • jstack:打印线程快照,用于排查死锁、线程阻塞、长耗时线程,常与内存问题结合使用。
  • jcmd:功能更全面的综合命令,可以代替 jmap、jstack 完成大部分操作,jcmd GC.heap_dump是推荐的堆转储方式。
  • jinfo:查看和调整 JVM 运行参数。

常用命令示例:

jps -l jstat -gcutil 28645 1000 20 jstat -gccapacity 28645 jmap -histo:live 28645 | head -50 jcmd 28645 GC.heap_dump /data/dump/payment-gateway.hprof jstack 28645 > /data/dump/thread-dump.txt

5.2 Arthas:生产环境在线诊断利器

JDK 自带工具已经能解决大部分问题,但在生产环境直接jmap有停顿风险,而且不少容器出于安全考虑没有完整 JDK 工具或不允许远程登录。这里推荐Arthas,它是阿里巴巴开源的 Java 诊断工具,支持 attach 到运行中的 JVM 进程,实时查看类加载、方法调用、对象占用等情况,非常适合「边排查边观察」。

  • dashboard:整体概览线程、内存、GC 信息,相当于一个轻量版监控面板。
  • thread:查看线程状态,找出 CPU 占用高、阻塞或长期存活的线程。
  • heapdump:在线生成堆转储,效果等价于jcmd GC.heap_dump。
  • sc:搜索类加载信息,排查动态类过多导致的元空间泄漏。
  • tt:记录方法调用的参数与返回值,定位业务逻辑中持有大对象的方法。

Arthas 的优势是命令丰富、无需重启服务,缺点是会带来少量性能开销,排查完毕应及时执行stop,不要长期挂载在生产进程上。

5.3 MAT:堆转储离线分析

拿到.hprof文件后,通常使用 Eclipse Memory Analyzer(MAT)进行离线深度分析。以下四个功能几乎覆盖了 90% 的排查场景:

  • Leak Suspects Report:自动给出内存泄漏嫌疑报告,适合快速锁定方向,尤其是第一次面对陌生堆文件时。
  • Histogram:按类聚合对象数量以及 Shallow Heap、Retained Heap,快速找出占用最高的类。
  • Dominator Tree:以支配关系展示对象引用层级,能直观看到某个根对象实际「拽住」了多少内存。
  • Path to GC Roots:从具体对象出发,反向寻找它到 GC Roots 的引用链,是定位泄漏根因的核心一步。

分析大堆时,要提前调整MemoryAnalyzer.ini中的-Xmx,避免 MAT 自身内存不够。对于服务器上不便导出大文件的场景,可以先用jmap -histo缩小范围,再决定是否生成完整堆转储。

5.4 可视化工具与其他辅助工具

除命令行与 MAT 外,还有VisualVM、JProfiler、Async Profiler、YourKit等工具。JProfiler 在复杂引用分析和实时堆遍历方面体验良好;Async Profiler 适合采样 CPU 热点,辅助分析「哪些方法在疯狂创建对象」。实际工作中建议采用「在线工具定位 + 离线堆分析确认」的组合方式。

六、事故排查完整过程:从现象到根因

下面回到文章开头那场支付网关事故,按照真实排查时序逐步还原。整个过程可以拆成五个阶段,每一步都有明确的产出,力图做到「从大范围监控,逐步聚焦到具体代码」。

6.1 第一阶段:应急止血与现场保留

告警出现后,值班同学优先做两件事:一是扩容降级,把流量切到备用节点,先保住服务可用性;二是保存监控截图、GC 日志和关键配置快照。这里有一个重要原则:在根因不清的情况下,不要急着改代码或乱调 JVM 参数,因为频繁的干预会破坏现场,反而让后续排查更难。

随后观察监控大盘:内存曲线呈典型阶梯式上升,每次 Young GC 后波谷一次比一次高;Full GC 频率由平时的数小时一次变为每分钟多次,单次停顿从几十毫秒拉长到数秒。这些现象已经强烈指向内存泄漏,而不是单纯的业务流量高峰。

6.2 第二阶段:用 jstat 观察 GC 趋势

进入容器后先用jps -l找到网关进程号,再执行jstat -gcutil 28645 1000 20持续观测。重点关注三个信号:

  • OU(Old Used):每次 Full GC 后几乎不下降,反而随时间缓慢上升,说明老年代里存在长期滞留对象。
  • FGC 与 FGCT:Full GC 次数快速累积,单次停顿持续拉长,说明系统已经陷入「频繁回收却回收不掉」的状态。
  • S0/S1 新生代区域:回收表现正常,可以排除「新生代对象分配速度过快」这类非泄漏因素。

这一步的意义在于把问题从笼统的「内存高」缩小为「老年代对象无法回收」,进一步指向泄漏。

6.3 第三阶段:jmap 直方图锁定嫌疑对象

确认泄漏方向后,执行jmap -histo:live 28645 | head -80查看存活对象直方图。排除正常业务对象后,三个异常点浮出水面:

  • byte[]数量极大,总占用明显异常。
  • 自定义类com.pay.context.OrderContext的对象数量与已经结束的请求量级严重不符,请求结束了,对象仍然存活。
  • 网关固定线程池的每个工作线程中,ThreadLocalMap都残留了大量OrderContext。

直方图只是快照,能回答「谁多、谁大」,却回答不了「为什么不回收」。因此必须进一步生成堆转储,做引用链分析。

6.4 第四阶段:堆转储与 MAT 支配树分析

避开业务高峰后用jcmd 28645 GC.heap_dump /data/dump/payment-gateway.hprof生成堆转储,下载到本地后用 MAT 打开。先运行 Leak Suspects Report,报告直接提示com.pay.context.OrderContext占据约三分之二的堆空间,与直方图结论一致。

随后打开 Dominator Tree 展开该类,看到的引用结构大致如下:

  • 根节点是java.lang.Thread的某个工作线程。
  • 经由ThreadLocalMap的 Entry 关联到一个 value,即com.pay.context.OrderContext。
  • OrderContext内部又持有订单明细、报文缓存、加签数据等大对象。

支配树显示这些对象被同一批固定线程「钉死」,集中分布在网关线程池的工作线程上。

6.5 第五阶段:Path to GC Roots 定位根因

在 MAT 中选中一个OrderContext实例,执行 Path to GC Roots,得到如下引用链线索:

java.lang.Thread - threadLocals : ThreadLocal.ThreadLocalMap - table : ThreadLocal.ThreadLocalMap.Entry[] - value : com.pay.context.OrderContext

继续结合线程名和业务代码排查,最终确认根因:网关在渠道报文解析时,把OrderContext放进一个ThreadLocal,链路处理完成后只执行了set,没有执行remove。由于使用的是固定大小复用线程池,下一笔请求虽然会覆盖该线程的 value,但低峰期大量线程长时间空闲,上一笔交易的完整上下文便一直残留;高峰期不同渠道还可能产生多个上下文,进一步放大内存占用。

七、根因确认与修复方案

这起事故的根因可以总结为一句话:ThreadLocal 在请求结束后未清理,配合线程池复用,导致 OrderContext 及其关联大对象的生命周期被意外延长为一个线程池线程的运行周期。单条引用链看起来非常直观,但在高并发高峰期被成倍放大,最终表现为老年代持续上涨、Full GC 频繁。

7.1 立即可上线的修复

最直接的修复方式是在网关统一过滤器或拦截器的finally块中显式调用remove(),确保所有退出路径都能清理:

public class OrderContextHolder { private static final ThreadLocal<OrderContext> CONTEXT = new ThreadLocal<>(); public static void set(OrderContext ctx) { CONTEXT.set(ctx); } public static OrderContext get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); // 必须在请求结束的 finally 中调用 } }

在网关责任链末尾增加统一清理,覆盖正常返回、异常抛出以及可能切换线程的路径:

try { OrderContextHolder.set(context); chain.doFilter(request, response); } finally { OrderContextHolder.clear(); }

修复上线后,监控显示老年代内存在每次 Full GC 后明显回落,内存曲线恢复为健康的锯齿状,Full GC 频率回到正常水平。

7.2 修复后的验证

  • 曲线对比:对比修复前后同一时间段的 OU 曲线,波谷回到低位并保持稳定。
  • 灰度观察:先在 10% 流量灰度集群观察 24 小时,确认无异常后再全量发布。
  • 压力测试:使用接近生产峰值的 QPS 压测 2 小时,观察 Full GC 次数与停顿时间是否可控。

八、工程化预防规范

一次线上事故的教训,不应只停留在修复这几行代码上,更重要的是沉淀为团队规范,避免同类问题反复出现。

8.1 代码层面:让资源生命周期可见

  • ThreadLocal 必须配套 remove:建议封装withContext工具方法,并在 Code Review 检查清单中强制要求。
  • 集合与缓存必须有上限:静态集合、自定义缓存要明确容量上限、过期时间和清理策略。
  • 资源统一 try-with-resources:连接、流、文件等实现AutoCloseable的对象都走该语法。
  • 监听器必须有反注册:注册与注销成对出现,或在生命周期钩子中自动清理。

8.2 监控层面:把内存泄漏消灭在早期

  • JVM 指标接入统一监控:至少采集堆内存、非堆内存、GC 次数、GC 耗时、线程数,并设置分级告警。
  • 关注内存波谷趋势:相比瞬时峰值,波谷是否持续走高是更可靠的泄漏预警信号。
  • 保留定时 Heap Dump:核心服务可按天或按周留存堆转储样本,便于对比对象增长趋势。

8.3 架构与配置层面:预留排查条件

  • 容器保留 JDK 诊断工具:至少保留 jps、jstat、jcmd,必要时开放 Arthas 的临时使用通道。
  • 合理设置堆参数:避免堆配置过小导致频繁 Full GC,也要避免堆过大导致单次 GC 停顿过长。
  • 核心链路采用无状态设计:复用对象集中管理,请求级状态明确收尾,减少隐式引用。

九、总结

这次 Java 内存泄漏的排查过程可以浓缩成一条主线:监控曲线发现异常,jstat 确认是泄漏,jmap 锁定嫌疑类,堆转储与 MAT 定位引用链,最终回到代码里的 ThreadLocal 清理缺陷并完成修复。整个流程没有特别玄乎的环节,关键在于按步骤收敛证据、保留现场、从大范围逐步聚焦到根因。

对 Java 开发者来说,内存泄漏并不神秘。只要理解 GC Roots、强引用和对象生命周期,再配合一套熟练的排查流程,绝大多数线上内存问题都能在可控时间内定位并解决。更重要的是在日常编码中养成资源清理、容量控制以及注册注销成对管理的习惯,从源头上减少泄漏的发生。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询