内存泄漏在Java世界里从来不是一道显性的报错,而是一场缓慢的失血。当GC日志显示堆占用曲线像台阶一样只升不降,当应用在运行数天后从秒级响应退化到持续Full GC,你才会意识到,那些看不见的对象引用已经像野草一样缠住了新生代和老年代。真正的排查不是背诵八股文,而是沿着引用链,一寸一寸地挖出那个本该被回收却始终存活的对象。
看似“被释放”的内存,其实还攥在手里
很多开发者以为,只要把对象置为null,或者让局部变量走出作用域,内存就会安全归还。这种直觉在绝大多数情况下成立,但偏偏在集合、缓存、线程池、类加载器这些“容器型”结构中失效。一个最经典的场景是静态集合持有短生命周期对象:有人为了方便,把用户Session塞进一个static HashMap,却忘了在会话销毁时remove。这个Map自己永远不会被回收,于是它里面的所有键值对都成了老年代的钉子户。你去看堆dump,能发现成千上万个看似无关的对象被同一个Map的Entry引用着,根节点是一条静态字段。
还有一种隐蔽的形态出现在ThreadLocal的使用中。ThreadLocalMap的生命周期依附于Thread,而线程池里的线程是复用的,不会退出。如果你在请求处理中往ThreadLocal里写入了数据,又没有调用remove,那么下一次请求复用这个线程时,上一次的数据仍然挂在线程上。更严重的是,ThreadLocalMap的Entry是弱引用key、强引用value,一旦value指向了一个重量级对象(比如数据库连接、业务上下文),即使key已经被GC回收,value依然被强引用链钉死。线程池不死,ThreadLocal里的残留对象就永不超生。
如果抱着“内存泄漏就是对象太多”的想法去排查,你可能会试图调大堆内存,但那只会把失血变成一次更漫长的崩溃。真正有效的做法是:先承认泄漏点一定藏在一个不经意的“强引用”里,然后借助工具,把每一个存活对象的GC根路径画出来。
从GC日志里嗅出异常的信号
排查的第一步从来不是马上抓dump,而是先观察现象。打开GC日志,如果发现老年代占用在每次Full GC后并没有回落到接近基线水位,而是呈锯齿状缓慢爬升,同时Full GC的频率越来越高,那么基本可以判定存在持续的对象堆积。另一个信号是堆内存使用率即使在高并发低谷期也降不下来——正常情况下,Minor GC过后新生代应该有明显腾空,而老年代应该保持稳定。如果老年代在低峰期的占用依然高于启动初期的两倍以上,先别调堆,去找谁在持有它们。
接下来需要确认是“真正泄漏”还是“内存增长”。两者在GC日志上的区别是:内存增长往往随着流量波动而起伏,高峰期上去,低谷期能恢复;真正的泄漏则是“只升不降”的单调趋势。为了让特征更明显,你可以故意让系统在低负载下运行一段时间,连续做几次Full GC,然后比较堆占用。如果Full GC之后老年代占用依然纹丝不动地维持在高位,那泄漏就实锤了。
拿到这个判断后,不要急着上去抓dump,因为大数据dump在复杂生产环境中往往超过几个GB,分析起来极其痛苦。更优雅的姿势是先利用jstat实时观测各区间的变化,确认哪个区域在持续膨胀。比如jstat -gcutil <pid> 1000,每一秒打印一次Eden、Survivor、Old区的使用比例。如果你看到Old区不断上升,而Eden区上下波动正常,说明大量长期存活对象正从新生代晋升到老年代,但这些对象本不该长寿。结合GC日志中的晋升耗时和暂停时间,能进一步锁定“晋升潮”发生的时段,再回去追溯那个时段内到底处理了什么类型的业务请求。
抓dump要讲究时机和姿势
抓heap dump有几种方式,各自适用不同场景。如果进程还活着,并且JVM还没完全卡死,建议用jmap -dump:live,format=b,file=heap.hprof <pid>。其中的-live参数会先触发一次Full GC,再dump存活对象——这样一来,临时垃圾都被过滤掉,剩下的都是“真正存活”的对象,分析时噪声更小。但要记住,使用live模式会触发一次完整的STW暂停,在高峰期操作极有可能引起线上告警,所以最好安排在流量低峰或者维护窗口。
另一种思路是用jcmd GC.heap_dump代替jmap,因为jcmd不经过JDI接口,在某些高版本JVM上更稳定。如果你怀疑泄漏与堆外内存或DirectBuffer有关,还得额外抓NMT(Native Memory Tracking)数据:先启动时加-XX:NativeMemoryTracking=summary,然后通过jcmd <pid> VM.native_memory对比运行前后的差值。
拿到dump后,分析工具首选Eclipse MAT。打开一个几个GB的hprof文件时,你第一眼看到的是Histogram,但别急着点类名排列。最关键的视图是“Histogram -> List objects -> with outgoing references”,它能展示某个类的所有实例以及它们各自引用了什么。比如你发现一个DTO对象有100万个实例,每个实例都指向一个业务Service,这明显不正常——因为Service应该是单例,不该被DTO持有。顺着outgoing reference往下钻,你会看到一条引用链:GC根 -> 线程对象 -> ThreadLocalMap -> Entry -> DTO。至此,元凶浮出水面。
一个真实的内存泄漏排查记录
以下案例来自一个保险平台的核保服务,现象是每运行约36小时,接口平均响应延迟从30ms恶化到3秒,Full GC从一天两次暴增到每小时15次。首先用jstat观察,发现老年代使用率从启动时的800MB持续攀升,即使深夜无人使用也保持每小时200MB的速度增加。值班团队一度认为是规则引擎加载的费率表太多,但关闭了一部分规则后,攀升趋势并未改变。
通过MAT分析live dump,发现一个名为UnderwritingContext的类占据了堆内存的42%,实例数量高达19万。它内部有一个Map<String,Object> attributes,这个Map的键是保单号,值是庞大的核保因子对象。正常情况下,一个保单处理完,这个Context就应该被丢弃。为什么会积攒这么多?
查看该实例的GC根引用,MAT显示每个UnderwritingContext都被一个java.lang.ThreadLocal所引用,而Thread的引用链指向了一个名为threadPool-rule-engine-3的线程。可以断定,代码在规则引擎的执行线程里用ThreadLocal缓存了Context来避免重复传参,但在任务结束时的finally块中只清空了业务主流程里显式持有的一个引用,却忘了执行ThreadLocal对象的remove方法。由于线程池复用了核心线程,每个线程的ThreadLocalMap中始终残留上一次任务塞进去的Context,而ThreadLocalMap的value持有强引用,于是每处理一单,内存里就多一粒永存种子。
修复方式很简单:在finally块里调用contextThreadLocal.remove(),而不是仅仅设置为null。注意,先remove再置null才是正确姿势,因为remove会同时清理ThreadLocalMap的Entry,而置null只是断开了你手上的引用,线程内部还持有它。上线后观察48小时,老年代曲线变得平滑,Full GC回归正常。
那类你永远猜不到的泄漏:类加载器与字符串驻留
不是所有内存泄漏都那么“符合直觉”。有一种高难度的泄漏发生在应用服务器或热部署场景下:每一次重新部署,JVM都会创建一个新的类加载器,而如果你不慎让旧加载器加载的对象被全局静态变量引用,那整个类加载器连同它加载的所有类就永远无法被卸载。用MAT排查这类问题时,类名会成为最显眼的线索——你会看到同一份业务类的全限定名出现了成百上千个不同版本,每个版本后面带着不同的ClassLoader地址。如果你还同时使用了反射/字节码增强(CGLib或ASM动态生成的类),这些东西又反过来引用了触发定义它的那个类加载器,泄漏就会滚雪球一样越滚越大。
另一个隐蔽场景是String.intern()的滥用。JDK 8及以后,字符串常量池保存在堆内。如果业务代码对无穷多的UUID、订单号或长文本执行了intern(),这些字符串就进入常量池并永远不会被GC回收。一次查询误用了intern,就可能让堆在一天内多出数GB无法回收的数据。排查这种问题时,MAT的Histogram里String数量异常庞大,且每个String的逻辑值五花八门,但没有任何非String对象引用它们——因为它们被常量池这个内置数据结构所持有。
针对类加载器泄漏,建议直接用jmap -clstats <pid>打印出所有类加载器的存活数量、加载类数量,如果发现某个第三方插件的加载器活跃对象持续增长而业务不增长,就重点看这个加载器。针对intern泄漏,没有捷径,只能通过Code Cache的异常膨胀反推,或者通过jcmd <pid> VM.stringtable查看字符串表的统计,观察数量是否单调递增。
如何把排查变成一种条件反射
如果你问十年经验的JVM调优专家,内存泄漏排查有没有固定的方法论,他多半会告诉你,真正重要的是形成一套“怀疑顺序”。拿到一个可疑的堆形态,先问三个问题:有没有大量生命周期应该很短的对象晋升到了老年代?谁在引用它们?引用者的期望生命周期是多长?第一个问题让GC日志和jstat回答,第二个问题让MAT回答,第三个问题则需要你自己梳理业务代码逻辑。
排查时,千万不要被业务复杂性牵着走。看到一个对象实例数很多,就以为是业务量本身过大——那只是表象。内存泄漏与高并发本质区别在于:高并发下对象虽多,但年轻代可以回收,老年代曲线保持水平;泄漏时老年代上升曲线跟业务流量无关。你可以做一个简单压力实验,用固定QPS打半小时,半小时后停止流量,等三次Full GC后看老年代是否回落到实验前水平。回落了,就不是泄漏;不回落,再看dump。
如果你使用的框架是Spring Boot,还要额外检查CGLIB生成的代理类数量,以及@Async线程池中的提交队列是否无界。一个无界的线程池队列本身就是一种结构性内存泄漏,因为所有排队中的Callable和Runnable都持有完整业务对象图。这种情况下,dump不会给你任何指向明确的GC根,你看到的是大量Task对象排成一列。修复方式不是换GC,而是改造成有界队列并设置合理的拒绝策略。
最后,别忘了为你的诊断动作留痕。在排除问题后,建议用Arthas或Byteman在线上临时对某个类的getter方法做一次耗时采样,通过观察调用链上对象创建频次来验证修复效果。真正的内存泄漏排查不是一次冒险,而是一场系统性考证,你必须交替使用工具、日志和代码推理,直到引用链上的每一个环都有了确切的解释。每当拿到一个看起来不可能的存活对象,不要急着怀疑JVM的GC实现,先问自己:它到底被谁攥着?而答案往往就藏在你某个习以为常的静态字段或线程局部变量中。