☰
GODService内存泄漏修复全记录:JVM堆分析与GC调优实战
2026/10/6 1:22:43 网站建设 项目流程

GODService的内存泄漏修复,前后花了将近两周时间。作为内部承载几十万长连接的服务,它本来一直很稳定,谁知一次例行升级后,内存告警成了半夜的常客。这篇报告把这次泄漏的完整排查思路、根因、修复方案和验证结果都梳理出来,希望能给同样在跟常驻服务内存问题搏斗的同事一点参考。

1. 半夜的内存告警:GODService堆占用异常攀升

1.1 现象:稳定运行三周后突然出现连续告警

那天凌晨2:14,监控平台连续推送了两条告警:第一条是GODService堆内存使用率突破85%,第二条是Full GC耗时超过2秒。当时我第一反应是流量冲高了,但调出监控曲线之后发现,请求量、在线连接数、CPU使用率都还在正常范围内,唯独内存曲线在持续走高。JVM的GC日志显示,老年代使用率从启动时的1GB,在三周内缓慢爬升到了超过3GB,而JVM设置的最大堆是4GB。这还不算最糟的,最糟的是每次Full GC完之后,老年代使用率并不会明显下降,有时候甚至会不降反升。这种“GC后内存仍然高企”的曲线,几乎可以立刻怀疑对象泄漏,而不是单纯的大对象分配问题。

我第一时间通过jstat -gcutil <pid>观察GC指标,发现FGC(Full GC次数)在告警前已经达到了每小时两百多次,而且FGCT(Full GC累计耗时)增长非常线性。当时立刻先做了一次服务重启,堆内存回落到正常水平。但重启只是拖延,两天后老年代使用率再次爬升到2.5GB以上。这个“重启后好转,持续运行一段时间后恶化”的特征非常典型,基本可以排除流量突增或者上游把大对象打进来的情况——流量再大,GC也应该能把垃圾回收掉。只有一批对象被一直持有着,才会让内存像滚雪球一样越滚越大。

1.2 第一轮排查:JVM参数与GC日志的初步判断

遇到这种问题,我习惯先把GC日志完整开起来。GODService原先只开了-XX:+PrintGCDetails -XX:+PrintGCDateStamps,但日志没有分时间窗口,排查的时候翻起来很难受。我先把它停掉,换成统一格式的新参数:

-Xlog:gc*:file=/data/logs/godservice/gc-%t.log:time,uptime,level,tags:filecount=20,filesize=20m

同时保留-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=/data/logs/godservice/,以防真的OOM时能留下现场。这里有个经验:单纯依赖OOM时的自动dump,有时候会非常被动,因为进程可能已经卡死,dump文件过大导致磁盘满。所以最好在告警触发时主动抓dump。

刷了大概三个小时的GC日志,我看到一个明显规律:Young GC还算正常,但每次Full GC之后,老年代使用率都会保持在一个新的高位上,几乎不往下降。这说明有一批对象从新生代晋升到老年代后,就再也没有被回收过。顺手用jmap -heap <pid>看了一眼存活对象,其中byte[]占掉了将近40%的空间。长连接服务里有大量byte[]很常见,但让我警觉的是:这些byte[]到底是被谁持有的?当时我怀疑和Channel的接收缓冲或者SSL加解密缓冲有关,接下来的堆dump正好证实了这一点。

2. 用heap dump撬开泄漏点的嘴

2.1 jmap抓取堆快照的正确姿势

在内存告警峰值附近,我抓了一次现场的堆快照。命令很简单:

jmap -dump:live,format=b,file=godservice_heap.hprof <pid>

注意这里用了live,意味着在执行dump之前会先触发一次Full GC,然后再把存活对象输出到文件。有些同事会担心Full GC影响线上服务,但在已经决定要分析泄漏的临界场景下,一次Full GC的代价是值得的。而且live模式的好处是,它会滤掉绝大多数“正常短命”的对象,让堆里剩下的几乎都是GC Root可达的对象,这样分析泄漏点时噪音更小。

抓dump之前我记下了当时的堆使用率,抓完之后又记了一次,目的是确认这次GC对内存的短期影响,也方便后续对比。dump文件生成后有2.8GB,不能直接在笔记本上分析,我把它拷贝到公司的分析机上,用Eclipse MAT加载。如果文件过大,可以先用jhat或者MAT的Keep unreachable objects选项去过滤,但live模式下本身没有太多不可达对象,直接分析很顺畅。还有一个小技巧:dump前最好确认磁盘空间足够,否则抓到一半磁盘满了,不仅白抓,还可能把进程搞挂。GODService的dump目录我单独挂了一块数据盘,就是为了避免这种情况。

2.2 MAT主导分析:从Dominator Tree到保留集

打开MAT之后,我第一步是看Overview,确认最大的一块内存是什么。然后直接进入Dominator Tree,按Retained Heap排序,这样能最快找到“谁真正吃掉了最多的堆”。这里需要解释一下:Shallow Heap是对象本身占用的内存,而Retained Heap是“如果这个对象被回收,连同它引用到的所有对象一起被回收掉的内存”。排查泄漏时,我们关心的是Retained Heap,因为它代表了长期占用内存的容器及其所有累赘。

排在第一位的是一个java.util.concurrent.ConcurrentHashMap实例,Retained Heap占了整个堆的68%左右。展开它,发现里面大约有47万个SessionContext对象,每个SessionContext再往下展开,可以看到它内部持有ChannelWrapper、byte[] sessionKey、加解密上下文、用户信息对象等,累计看下来每个大约有几十KB到几百KB不等。这基本就是泄漏源了。

我右键这个ConcurrentHashMap,选择“Path to GC Roots -> exclude weak references”,看到了完整的引用链:

static ConnectionManager.mConnections -> ConcurrentHashMap -> SessionContext

也就是说,这个map是ConnectionManager类的一个静态字段,而所有SessionContext都被这个静态map强引用着。到这里,问题的方向已经非常明确,接下来就是搞清楚:为什么这些SessionContext明明已经断开了连接,却仍然躺在map里?

3. 根因锁定:一个被静态集合永久持有的不可达对象

3.1 内存泄漏的四种经典形态与GODService的对应关系

在定位这个问题的过程中,我顺手把常见的Java内存泄漏形态和GODService的情况做了个对照,这里也分享给各位:

泄漏形态典型特征GODService是否命中
静态集合类持有临时对象常驻Map/List往里面扔对象,从不清理命中,mConnections是核心泄漏点
缓存过期未清理设置了过期时间但实际没有触发清理命中,定时清理逻辑存在但从未生效
监听器/回调未注销注册时到处引用,注销时只清了一部分部分命中,ChannelListener存在但没有回调到remove
ThreadLocal与线程池复用线程长期存活,ThreadLocal里的对象无法释放附赠发现,异常分支中确实存在

这个表格不是标准答案,但可以帮助我们在排查时快速归类。GODService最典型的就是第一、第二类结合体:表面上是缓存没有过期清理,实际上是因为清理逻辑里的比较方法根本匹配不上,所以缓存里积压的全是“逻辑上已经失效、实际上无法删除”的对象。这种静默失败最坑的地方在于,代码上看起来每一步都在做正确的事,但底层的基础设施出了问题,所有上层补救都会跟着一起失效。

3.2 为什么WeakHashMap没有发挥作用

最开始在讨论修复方案时,有个同事提议说“把ConcurrentHashMap换成WeakHashMap不就行了?”我差点也同意。但仔细看代码之后发现,这条路在这个场景下走不通。

原因是:mConnections的key是ChannelWrapper,而ChannelWrapper虽然名字带Wrapper,内部也只是简单包了一个Channel,并没有重写equals和hashCode。我们每次在连接建立时,会new一个ChannelWrapper作为key存入map;在连接断开时,remove方法传入的却是另一个ChannelWrapper。由于没有重写equals,两个对象哪怕是同一个Channel,比较下来依然不相等,于是remove失败。这个情况下,用WeakHashMap也一样没救——因为key(ChannelWrapper)被Netty的ChannelPipeline、EventLoop等其他强引用路径所持有,弱引用根本不会让Entry被GC回收。所以,解决这个问题的关键不是选择弱引用容器,而是要让remove真正生效,也就是要把“能正确比较对象身份”的步骤补上。

这里顺便提一句:WeakHashMap和WeakReference虽然都叫“弱”,但应用场景完全不同。WeakHashMap适合key的生命周期完全依赖外部强引用的场景(比如缓存元数据),而一旦key本身被系统的其他地方强持有,它就会彻底失效。GODService这个场景里,Channel是Netty框架管理的,生命周期必然被框架强持有,所以WeakHashMap只是一个听起来合理的错误答案。

3.3 代码层面的问题:事件监听器注册后从未注销

顺着引用链再往代码里看,事情更清楚了。GODService在客户端连接建立时,注册了一个ChannelListener用于监听连接关闭事件,在channelInactive()方法中确实调用了connectionManager.removeContext(channel)。但同时,连接关闭时,监听器本身也应该被移除。问题出在removeContext方法的实现里:

public boolean removeContext(Channel channel) { return mConnections.remove(new ChannelWrapper(channel)) != null; }

由于ChannelWrapper没有重写equals,这个remove永远返回null,日志里也一直没有任何告警——因为null被理解为“没有这个连接”,实际上是“删不掉这个连接”。这是一种典型的静默失败,非常恶心。

另外还有一个定时清理任务,每60秒扫描一次mConnections,想把超过90秒没有心跳的SessionContext清掉。但清理条件里虽然判断了超时时间,最终执行的却是同一个removeContext方法,所以同样静默失败。于是这些“已经死掉”的连接会一直留在内存里,直到堆爆炸。而且因为9762(时间长了)这些SessionContext里引用的ChannelWrapper又连着底层的Socket资源,看似一个个对象在堆里,实际上也拖住了文件描述符、ByteBuf等外部资源,问题就不仅是内存了。

4. 修复:从换掉强引用到重建生命周期管理

4.1 第一版修法:将缓存容器改为WeakReference

这里必须坦白一个我踩的坑。刚开始我没有深究remove失效的真实原因,第一反应是“那我把值改成WeakReference,让SessionContext本身弱引用掉,不就行了吗?”于是改了第一版,把ConcurrentHashMap<ChannelWrapper, WeakReference<SessionContext>>,然后把后续代码适配了一下。

灰度跑了一段时间后,内存曲线确实没有继续涨,因为SessionContext本身变成WeakReference了。但另一方面,我发现整个服务的吞吐量变差了,GC频率反而更高。原因也很简单:SessionContext虽然被弱引用,但只要它被业务线程短暂访问,就会重新被“拉活”,而且WeakReference也会导致对象分配和回收模式变化。这个修法本质上是在掩盖泄漏,并没有解决“remove失效”这个根因。后来我还是回滚了它,老老实实把根因修掉。

这次回滚让我认识到,看到内存泄漏就条件反射地想到“用弱引用”,其实是不动脑子的表现。正确的顺序是:先确认对象为什么没被回收(引用链),再决定修法和容器类型。引用链都是强引用,那你要做的事是斩断那条强引用链,而不是给对象加一个弱引用外壳。

4.2 第二版修法:显式移除与生命周期回调

最终的修复方案有三步。

第一,重写ChannelWrapper的equals和hashCode。比较的依据是ChannelId,因为ChannelId是全局唯一的:

public class ChannelWrapper { private final Channel channel; private final ChannelId channelId; public ChannelWrapper(Channel channel) { this.channel = channel; this.channelId = channel.id(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ChannelWrapper that = (ChannelWrapper) o; return Objects.equals(channelId, that.channelId); } @Override public int hashCode() { return channelId.hashCode(); } public ChannelId getChannelId() { return channelId; } }

第二,把removeContext方法统一成基于ChannelId,这样避免再出现“传入对象类型不一致”的问题。同时,在Channel关闭时,统一通过ChannelId去map里删key,并返回被删除的SessionContext,接着调用sessionContext.release(),把里面持有的byte[]、加解密Buffer等资源立即置空,方便GC尽快回收。release方法实现如下:

public void release() { if (this.sessionKey != null) { Arrays.fill(this.sessionKey, (byte) 0); this.sessionKey = null; } if (this.cipherContext != null) { this.cipherContext.reset(); this.cipherContext = null; } this.userInfo = null; this.lastHeartbeat = -1L; }

第三,定时清理任务不再依赖removeContext,而是先用ChannelId构建一个待删除列表,然后一次性batch remove,并且在清理时同时检查Channel状态,如果Channel已经关闭或者心跳超时,就执行释放。这样就算有漏网的连接,定时任务也能兜底清掉。

public void onChannelClosed(ChannelId channelId) { SessionContext ctx = mConnections.remove(channelId); if (ctx != null) { ctx.release(); } }

这一版改完后,生命周期管理清晰了很多:连接建立时putContext,连接关闭时removeContext,心跳超时时定时清理,三个动作用的都是同一个ChannelId,不会再有静默失败的空间。

4.3 一个容易被忽视的隐患:ThreadLocal与线程池复用

在改完主流程之后,又顺手发现了一个新的问题。GODService的EventLoop线程在处理某些请求时,会把当前SessionContext放进一个ThreadLocal里,正常结束时会remove,但有一个异常处理分支里忘了remove。因为EventLoop线程是固定数量、长期复用的,比如默认8个线程,这就导致即使只有8个对象被ThreadLocal持有,每个对象又可能携带几百KB的缓存,长期驻留在线程上,一样会拖累GC。这个虽然在本次泄漏中占比不大,但属于同类问题,必须一并修掉。

修复代码很简单,把ThreadLocal的赋值包在try-finally里:

private static final ThreadLocal<SessionContext> current = new ThreadLocal<>(); public void handle(ChannelHandlerContext ctx, Object msg) { SessionContext sessionContext = new SessionContext(ctx.channel()); current.set(sessionContext); try { // 业务处理,可能抛出异常 } finally { current.remove(); } }

像这种写法,就算中间业务出现任何异常,ThreadLocal里的对象也会被清掉。线程池复用时,这种问题往往是隐形的,平时跑着没事,时间长、并发高时就会突然出现莫名其妙的CPU和内存告警。所以我在这次修复的系统评审里,特意把这个点也写进了checklist,以后凡是ThreadLocal垫底,就必须看到remove。

5. 修复验证与压测回归:让数据说话

5.1 灰度环境对比测试:GC频率与堆占用

修复完代码后,我先在灰度环境发了一版。灰度环境有和线上一致的连接模型,但流量少一些。跑了48小时,对比之前的指标,效果立竿见影。

修复前48小时内,Full GC次数累计从约1200次涨到3400次,老年代使用率持续爬升超过800MB;修复后48小时内,Full GC次数只增加了3次,老年代使用率曲线基本走平,波动幅度控制在500MB以内。我用jstat -gcutil <pid>取了几个时间点的数据,做成简单的趋势表:

时间点FGC次数老年代使用率备注
修复前 0h120062%持续增长
修复前 24h230078%告警边缘
修复前 48h340089%Full GC频繁
修复后 0h038%重启后基线
修复后 24h240%稳定
修复后 48h341%稳定

老年代使用率从89%回落到41%左右,而且基本走平,说明根因确实堵住了。Full GC次数从“小时级别”降到“两天3次”,这不仅是内存指标的改善,更是GC暂停对RPC超时影响的巨幅下降——之前那种“偶发毛刺”请求,基本就来自Full GC的STW。

5.2 全链路压测与限流回归

接下来是压测。我们模拟了10万长连接客户端,维持每10秒一次心跳,持续压了8小时。观察堆使用曲线,呈典型的“锯齿状”:每次GC后内存明显回落,下一轮业务处理再慢慢涨上去,但整体基线不再上台阶。压测期间我还专门盯着mConnections的size,用JMX定期捞出来看,连接数始终在预期范围内波动,断开连接后size会立刻降下来,不再像之前那样只增不减。

另外也跑了限流回归:把连接数瞬间从10万降到1万,观察内存能否快速回收。修复前,即使断开了9万个连接,堆内存也要等很久才慢慢降下来;修复后,断开连接后一两分钟内,堆内存就能回到断连前的水平,这要归功于SessionContext的显式release。

这里我还额外验证了一个边界情况:如果客户端直接拔网线,没有走正常的TCP挥手,Channel的close事件会不会触发?结果发现Netty的IdleStateHandler会兜底,超过心跳周期后触发close,而我们的onChannelClosed清理逻辑会执行。这很重要,因为真实网络里“非正常断开”的比例其实不低,如果不兜住,泄漏还是会继续累积。

5.3 监控与告警阈值的重新校准

堵住泄漏之后,我把监控体系也强化了一遍。原来GODService只对堆内存使用率设了85%的告警,这个阈值在正常情况下其实很高,等触发时往往已经快到OOM了。我给堆内存加了一条更早的告警:老年代使用率超过70%就开始关注,超过75%触发P2告警,超过80%触发P1告警。另外增加了Full GC耗时P99和每十分钟Full GC次数两个指标,这些指标会直接反映堆是否在异常增长。

我个人的经验是:对于常驻服务,内存告警一定不能只设一个“堆使用率”的粗粒度阈值。最好把“老年代增长斜率”和“Full GC频率”也拉进来,因为泄漏往往是渐进式的,单看绝对数值很容易被日常波动掩盖。我们现在给老年代使用率加了一个30分钟窗口的线性拟合,斜率超过某个阈值就提前告警——这样在下一次内存告警潮来临前,我们有足够时间去处理。

6. 这类泄漏的通用排查思路与防复发建议

6.1 排查工具链的组合使用

这次GODService的排查,严格来说并不复杂,但如果没有一套清晰的工具链,很容易在现象里打转。我把自己的组合分享出来:

  • 先用jstat -gcutil和GC日志确认问题方向,判断是对象分配速率过高还是内存回收异常。分配速率过高会表现为Eden区频繁打满、Young GC次数飙升;回收异常则表现为老年代持续增长、Full GC后不回落。这两类问题的排查路径差别很大。
  • 再用jmap -heap和jmap -dump:live抓快照,在内存峰值时抓,不要等到OOM再抓。OOM时堆往往已经被撑爆,dump过程耗时更长,而且现场可能已经被污染。
  • 接着用MAT分析Dominator Tree和Path to GC Roots,直接找到持有大量对象的那条引用链。注意要区分Shallow Heap和Retained Heap,重点看Retained Heap。
  • 最后回代码里验证,看是容器清理逻辑失效,还是生命周期没有闭环。这一步不能跳过,不然你会纠结在“为什么这些对象还活着”而忘了问“我到底在哪一步忘了remove”。

6.2 代码评审中要对静态集合、缓存、监听器保持敏感

现在我在做代码评审时,有几类代码一定会多看几眼:静态字段上的Map/List、没有设置过期策略的缓存、addListener和removeListener不成对的代码、ThreadLocal有没有remove。很多时候内存泄漏并不是某一段代码写得特别深,而是两个小地方组合在一起埋了雷。GODService这次就是最典型的例子——一个equals没有重写的小类,直接让一个清理逻辑形同虚设。像这种问题,光靠单元测试很难发现,只有压力堆叠到一定程度才会爆炸。

我还总结了一条经验:凡是看到“长时间运行的进程”里使用全局容器,必须要在容器对应的生命周期阶段找到对应的删除动作。如果添加动作和删除动作不在同一个类里,就要格外小心。最好是封装成register/unregister、open/close、put/remove这种成对API,在代码层结构上强制保证一致性。

6.3 高并发服务的泄漏修复节奏控制

最后聊一下发布节奏。这种修复不建议直接全量发到生产,哪怕代码写得再有信心,也先走灰度。灰度环境尽量模拟线上流量,运行至少48小时,观察GC和堆占用曲线再放量。另外,整个修复过程最好由一个熟悉全貌的同事主导,不要多人同时改,避免改完之后不知道是哪个变动让指标变好的,后面出问题也没法定位。

我在这次修复过程中,还总结了一个特别实际的节奏:先通过dump和MAT确认根因,然后写一个最小化修复(只改真正有问题的两行代码),灰度验证,确认有效后再补充防御性代码(比如定时清理、ThreadLocal防护)。不要试图在一次发布里把所有潜在风险都修完,那样如果指标变好,你不知道是哪个修改起作用;如果指标变差,你也不知道是哪个修改引入的回归。分步走,每次修改都能对应到明确的指标变化,后面的排查会轻松很多。

这次GODService的修复,我踩过不少坑,最大的体会是:面对内存泄漏,先找到根因,再动手改代码;修完之后,一定要拿压测和监控数据说话,不能光靠“感觉没问题了”。而排查过程中最大的敌人,其实是那些“看起来在清理,实际没生效”的静默失败逻辑——它们会让所有上层防御都变成摆设。希望这次的完整复盘,能帮大家在下次遇到类似问题的时候,少走一些弯路。

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

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

立即咨询