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次数 | 老年代使用率 | 备注 |
|---|---|---|---|
| 修复前 0h | 1200 | 62% | 持续增长 |
| 修复前 24h | 2300 | 78% | 告警边缘 |
| 修复前 48h | 3400 | 89% | Full GC频繁 |
| 修复后 0h | 0 | 38% | 重启后基线 |
| 修复后 24h | 2 | 40% | 稳定 |
| 修复后 48h | 3 | 41% | 稳定 |
老年代使用率从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的修复,我踩过不少坑,最大的体会是:面对内存泄漏,先找到根因,再动手改代码;修完之后,一定要拿压测和监控数据说话,不能光靠“感觉没问题了”。而排查过程中最大的敌人,其实是那些“看起来在清理,实际没生效”的静默失败逻辑——它们会让所有上层防御都变成摆设。希望这次的完整复盘,能帮大家在下次遇到类似问题的时候,少走一些弯路。