☰
从堆外内存泄漏到ZGC:订单网关JVM调优实战
2026/10/15 6:29:30 网站建设 项目流程

先交代一下背景。上个月我们公司有个订单网关服务经常在晚高峰报警,主要症状非常典型:容器内存肉眼可见地往上涨,重启能缓解三天,之后又开始爬坡;CPU偶尔飙到90%以上,伴随几次长达好几秒的接口超时。这服务平时看起来一点不像"该爆"的配置——堆内存只给了4G,JVM用的是G1,峰值QPS大约5000,核心链路对P99延迟要求是50毫秒以内。一开始大家怀疑是业务代码有内存泄漏,但排查了好几轮Heap Dump,堆内对象数量完全正常,GC日志也看不出对象异常累积。

后面我接了这个问题的排查,从JMM的内存布局梳理开始,一路摸到堆外内存,最后甚至把垃圾回收器从G1换成了ZGC,过程曲折但收获很大。这篇文章就是这次调优的完整复盘:堆外内存到底藏在哪、怎么定位、GC选型该怎么思考、ZGC落地有哪些配置和代价,以及我们踩过的几个文档里根本不会写的坑。适合所有被线上内存问题折磨过的Java开发者,也适合那些正考虑引入ZGC但还在犹豫的团队。

1. 项目背景与调优思路拆解

1.1 问题的表象与初步判断

先把这个服务的硬配置列清楚:物理机是8核16G内存,JVM堆内存设置-Xmx4g,垃圾收集器用的是G1,接口是订单查询和状态回写,依赖一个自研RPC框架和一个消息队列,线上部署在容器里,内存上限是12G。报警的规律很有意思:每天下午3点前一切正常,之后就逐步走高,晚上8点到10点达到峰值,如果期间没有触发重启,容器内存能涨到11.5G左右,然后被监控系统判定为"内存异常"。

第一反应肯定是看堆内对象。我们连续几天做了jmap dump,用MAT分析之后,结论是堆内对象里根本没有能解释这个涨幅的大头——4G堆最多用到65%,Full GC一天只有几次,每次停顿200毫秒左右。也就是说,Java堆本身是非常健康的。

这就把问题指向了堆外。Java进程的内存占用,绝不仅仅是堆那些事。当时我把进程运行时的RSS(常驻内存)和堆内存做了个对比:堆内4G,Metaspace大约180M,线程栈如果按150个线程算大约150M,剩下还有好几个G去哪了?直觉告诉我,这大概率是堆外内存生长失控。

1.2 服务特征与调优目标

在正式排查之前,我们先和业务方对齐了这个服务的特征,因为内存和GC方案的选择必须匹配业务负载类型。

这个订单网关有很明显的流量峰谷差异,白天平峰大概2000 QPS,晚高峰能冲到5000,遇到活动还会更高。业务方明确说延迟比吞吐更重要,因为网关下游连接的是核心交易链路,一个请求如果因为GC停顿多等几百毫秒,上游可能已经超时重试了。这个特征直接决定了GC选型不能只看吞吐,而要看停顿时间。

调优目标定得很具体:容器RSS稳定在物理内存规划范围内;Full GC次数降到接近零;P99延迟从当前平均40毫秒左右压到20毫秒以内;整个过程必须在真实流量下验证,不能用简单的压测报告代替。目标清楚之后,再去看现状和差距。

1.3 整体思路:先定位,再选型

这里要先说明一个原则:GC调优最忌讳一上来就换垃圾回收器。我当时给团队定的调优路线分三步走。

第一步是定位内存去向,搞清楚RSS增长的来源,到底是堆、堆外Direct Buffer、JNI原生内存、Metaspace还是线程栈;第二步是分析当前G1在这个负载下的GC行为,确认G1是否存在明显短板,比如停顿控制不住、Mixed GC频繁;第三步才是选型评估,如果确认需要换收集器,再比较ZGC和G1的差异,针对真实负载做压测验证。

这套流程的执行难度在于,每一步都需要工具和数据支撑,不能凭感觉。比如我们后来发现堆外内存在某个环节持续增长,如果不先定位到这个点,直接换ZGC可能也能缓解一部分问题,但根本原因是代码层面的缓冲管理,换了收集器之后堆外还是继续泄漏,问题根本不会根治。这也是我想强调的第一条经验:先找到病根,再决定用什么药。

2. 从JMM到内存全景:堆外内存藏在哪里

2.1 JMM与本次调优的对应关系

说到Java内存,面试总爱问JMM,也就是Java内存模型,它描述的是主内存和线程工作内存之间的一致性规则,控制的是volatile、synchronized这些关键字在并发下的可见性语义。但走到GC调优和堆外内存排查这一步,单纯背JMM的概念是不够的,你还需要一张"JVM运行时内存地图"。

可以把JMM的规范理解成"契约",而JVM运行时内存布局是"实现"。JMM告诉你在什么情况下能读到什么值,但真正决定一个对象存到哪里、由谁回收、什么时候回收的,是JVM的内存管理子系统。所以我在排查这个问题时,脑子里始终带着一份完整的清单:Java堆、Metaspace、线程栈、DirectBuffer、JNI引用、本地内存分配器(ptmalloc / glibc malloc)、JIT编译产物(CodeCache)等等,每一个都是独立的内存区域,管理规则完全不同。

这里面重点要强调一个差异:堆内存有GC精确管理,对象不可达就会被回收;堆外内存则完全不同——有些有JVM层面的回收机制但依赖GC触发,有些根本没有自动回收,只能靠业务代码显式释放。很多人以为所有Java内存都归GC管,这是个误区。

2.2 堆外内存的四大来源

经验上,堆外内存的异常增长集中在四个区域,排查时可以从这四个方向入手。

第一是DirectByteBuffer,也就是直接内存。NIO里的ByteBuffer.allocateDirect会分配堆外内存,它有一个对应的Cleaner对象,在GC发生时可以触发底层内存释放,但触发时机依赖GC,而且不是所有GC行为都会处理它。第二个来源是JNI和Native方法,这类内存完全由本机代码管理,Java层一般碰不到,一旦出现JNI全局引用泄漏,表现为内存缓慢上涨且极难定位,只能通过开启NMT(Native Memory Tracking)看分类数据。第三是Metaspace,JDK8之后类元数据放到堆外,如果动态生成大量类,这个区域会持续膨胀。第四是线程栈,-Xss设置多大,每个线程固定占多少,线程池扩容失控时内存也会随之上升。

我在排查时建议团队用一张表来组织问题分类,这样思路不会乱。

内存区域分配入口管理机制常见异常
Java堆new对象GC自动管理对象泄漏、大对象
DirectBufferByteBuffer.allocateDirect / MappedByteBufferCleaner依赖GC触发释放滞后、频繁分配
JNI/NativeJNI调用、native库内部malloc完全由native代码管理全局引用泄漏、无释放路径
Metaspace类加载、动态代理生成类JVM管理,默认无上限类加载器泄漏、Class数量暴涨
线程栈线程创建线程退出后回收线程池膨胀、栈内存占用过高

2.3 为什么堆外内存如此难排查

这问题真的很折磨人。堆内内存可以用jmap dump、MAT分析,对象关系一目了然;但堆外内存没有统一的"Heap Dump"文件,你只能通过工具去观测各类内存占用。更麻烦的是,堆外内存的分配和释放往往不直接在Java层体现,一个DirectByteBuffer对象本身很小,但它背后关联的native内存可能是几十兆,对象被GC回收了,也不代表那几十兆立刻还给操作系统。

另外还有一个经典认知陷阱:很多人以为只要堆内内存正常,进程RSS高一点无所谓的,反正操作系统支持内存页换出。但在容器环境里,内存上限是硬约束,RSS一旦逼近容器limit,直接触发OOM。这导致很多团队直到容器被杀死才发现问题,而到那时候,现场已经很难复现了。

我自己的体会是,排查堆外内存要有三层耐心:第一层,用进程级工具把RSS构成拆出来;第二层,用JVM工具把堆内、堆外、Metaspace、线程栈逐项量化;第三层,最后还要回到代码层面去确认那些"没被回收"的分配到底有没有释放路径。三层缺一不可。

3. 堆外内存定位实战:从命令到代码走查

3.1 第一步:用jstat确认堆内状态

先给排查建一个可靠基线。我们登录到出问题的机器,第一时间跑jstat,观察GC内存实时状态:

jstat -gcutil 27107 1000 10

输出大概长得像这样:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.78 56.23 62.11 89.35 82.12 12846 152.342 217 78.211 230.553

O区(老年代)62%,YGC频率也不算高,Full GC总共217次,每次平均360毫秒左右,这个数据放在一个8核4G堆的服务上,算不得健康,但也谈不上危险。这种情况下最合理的判断是:堆内GC本身不是RSS异常上涨的决定性因素。

这里要注意一个细节,jstat看到的Full GC次数和每条Full GC的停顿时间很重要,但不能只看次数。有些场景下Full GC虽然少,但停顿时间极长,对业务的影响更明显;另一些场景恰恰相反,Full GC频率高但每次很短,说明老年代里有大量可回收对象。这两种情况对应的解决方案完全不同。

3.2 第二步:用pmap和NMT锁定内存增长区域

堆内暂时排除嫌疑后,下一步就是把进程的RSS构成拆开查看。我们先用pmap看进程的内存映射:

pmap -x 27107

重点观察那些标识为anon的内存段,它们不关联任何文件,往往是堆、堆外或者native malloc分配出来的。我对比了几个时间点的pmap采样,发现anon内存段从6.2G一路涨到9.8G,而其中属于Java堆的部分一直是4G没有变化,这就说明多出来的内存来自堆外的malloc分配。

为了进一步确认来源,我给JVM加上了NMT参数,并且重启了一次服务:

-XX:NativeMemoryTracking=summary

等服务运行一两天后,用下面命令导出NMT报告:

jcmd 27107 VM.native_memory summary

NMT报告里会把内存按类型分开,Java Heap、Class、Thread、Code、GC、Compiler、Internal、Other等大类一目了然。我们看到的增长主要落在Internal和Other里,进一步用detail模式:

jcmd 27107 VM.native_memory detail

可以按调用栈看到具体的native分配位置。NMT对线上性能有一定影响,生产环境建议开summary级别,压测时再开detail。

3.3 第三步:代码走查,锁定DirectByteBuffer泄漏

工具把问题定位到了Internal区域后,剩下的就是代码走查。我们过了一遍订单网关的IO模块,发现一个高频路径:网关从下游RPC读取响应的Bytes,做一次字段抽取后,需要转换成另一个框架的Buffer类型,这里用到了ByteBuffer.allocateDirect。这个转换操作每天要执行几百万次,每次分配的大小从几KB到上百KB不等,而且这些Buffer被封装进了一个自定义对象里,没有走Netty的PooledByteBufAllocator,也没有显式回收。

简单说,这就是一个典型的堆外内存分配风暴加释放滞后问题。DirectByteBuffer对象本身在堆内很小,GC很快就能识别它是否可达,但它对应的native内存是否真正释放,其实取决于Cleaner有没有被处理。在G1下,如果这些引用的对象被晋升到老年代,而老年代没有触发GC,那堆外内存就一直在那堆着,表现就是RSS持续上涨。

代码层面,我当时建议的快速止血方案是:改用池化Buffer,至少在新的请求处理路径里复用ByteBuffer;如果没有池化条件,就避免在高频路径里频繁allocateDirect。下面是示意代码,展示一下问题写法:

// 问题写法:高频分配DirectByteBuffer,无池化,无显式释放 public byte[] process(byte[] input) { ByteBuffer buffer = ByteBuffer.allocateDirect(input.length * 2); buffer.put(input); buffer.flip(); // 这里没有释放buffer,也没有归还到池里 byte[] result = new byte[buffer.remaining()]; buffer.get(result); return result; }

3.4 验证实验:触发回收的临界条件

代码走查发现嫌疑之后,我们还做了一个小实验,验证了DirectByteBuffer回收的实际触发条件。实验分三组:第一组分配1000个64KB的DirectBuffer后持有引用,观察堆外内存;第二组分配后主动置null,但不触发GC;第三组分配后置null,然后手动触发System.gc()。

结果符合预期:第二组在无GC情况下,堆外内存完全不降;第三组触发Full GC后,堆外内存立刻下降。这个实验得出的结论很重要:

直接内存的释放依赖GC处理Cleaner,而很多团队为了减少Full GC停顿,会加上-XX:+DisableExplicitGC,这会导致System.gc()失效,堆外内存的释放进一步滞后。

当时我们的服务确实开了DisableExplicitGC,这也是问题被放大的一个关键因素。后面在ZGC落地时会再次提到这个坑。这一步基本确认了:内存问题的根本原因是高频直接内存分配叠加了释放路径不畅。

4. GC选型对比:不是越新越好

4.1 G1时代的问题与妥协

堆外内存找到方向之后,其实还有一个问题摆在那里:即使我们改掉代码里的分配方式,当前G1的表现依然不太让人满意。看GC日志,G1的中断目标设的是200毫秒,但实际情况是,晚高峰流量一起来,Mixed GC的频率就明显增加,停顿时间在150到250毫秒之间波动,偶尔还会冒出一个接近800毫秒的大停顿。

深入日志之后,发现我们服务的对象分配速率太高,G1的Region被快速填满,而RSet的并发维护又消耗了不少CPU。另外还有个细节:订单查询链路会创建一些比较大的对象,超过Region大小一半之后,G1会把这些对象作为Humongous Allocations处理,直接分配进老年代,这类分配是G1停顿的重要来源之一。

G1的定位是"在可控停顿时间的条件下兼顾吞吐",但它的停顿时间其实是目标值,不是硬保证。当堆变大、分配速率变高时,为了完成并发标记和Mixed GC,停顿时间很容易超出预期。对延迟敏感的服务来说,这种不确定性在线上是不可接受的。

4.2 CMS、G1、ZGC关键差异对比

这里把主流垃圾回收器放在一起对比,方便理解选型差异。

收集器并发标记并发清理停顿特征适用堆大小现状
CMS是否,需STW清理停顿碎片化,内存碎片问题严重中小堆JDK14移除
G1是是,Mixed GC阶段并发回收停顿随堆增长略有升高中大型堆默认收集器,广泛使用
ZGC是是,并发转移与重映射停顿几乎不受堆大小影响,通常低于1ms超大堆、大堆延迟敏感JDK15转正,持续演进

对比下来,ZGC最大的优势不是"快",而是"可预期"。它通过染色指针和读屏障,把标记、转移、重映射全链路做成并发,业务线程几乎不会被长时间暂停。这正是订单网关这种对延迟有硬指标的场景所需要的。

4.3 ZGC的核心机制与适用边界

ZGC的底层原理值得多说一句,因为它决定了ZGC的参数调试思路。染色指针是ZGC的基石,GC将对象的存活状态和转移信息直接编码在64位指针的冗余位上,业务线程在访问对象时,通过读屏障拦截指针,判断对象是否被转移过,如果被转移就修正指针并读取新位置。这套设计的直接结果是:GC转移对象时不需要全局暂停所有线程,业务线程是"随取随修正"的。

读屏障虽然带来了一些额外指令开销,但这些开销分散在每次引用访问里,对整体延迟的影响远小于一次长STW。ZGC因此可以实现任意堆大小下,停顿时间基本稳定在个位数毫秒甚至更低。这也是为什么很多大内存服务在从G1切到ZGC后,P99延迟有明显改善。

但ZGC也有适用边界。首先它不适合小堆低延迟之外的场景吗?并不是,真正的问题在于CPU占用。ZGC并发阶段的线程需要持续运转,如果机器核数太少,GC线程会挤占业务线程的CPU。其次ZGC对内存占用并不吝啬,Region机制和并发转移会预留一部分空间,吞吐优先但内存紧张的场景要慎重。最后ZGC在JDK15才转正,如果你还在JDK8上没有升级计划,强行用新收集器反而是一种技术债。

5. ZGC落地实施:参数配置与压测验证

5.1 前置检查:JDK版本与运行环境

在把ZGC作为候选方案之后,我们并没有立刻改配置,而是先做了一轮前置检查。这个环节很重要,很多团队跳过它,直接在JDK8上装一个ZGC,结果编译都过不了。

前置检查主要看四件事。第一,JDK版本,ZGC在JDK11里还是实验特性,到JDK15才转正,JDK17和JDK21的稳定性更好,我们最终选定了JDK17。第二,操作系统,ZGC支持Linux x64和Arm64,其他平台的兼容性并不好,生产环境只考虑Linux。第三,堆外直连内存和文件映射的使用情况,确认没有依赖System.gc()做兜底的代码路径。第四,核数和内存,ZGC并发GC线程会动态调整,如果机器只有4核,需要仔细评估CPU余量。

另外还有一个容易忽略的前置项:检查透明大页(THP)。ZGC的染色指针依赖64位地址的冗余位做状态标记,THP如果开启,可能影响指针状态在物理页上的表现,社区里不少反馈是性能抖动甚至JVM Crash。稳妥做法是关闭THP:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

5.2 关键参数配置清单与逐项说明

我们最终落地的JVM参数如下,每一项都经过压测验证。这里给出一个可直接参考的模板:

-Xmx8g -Xms8g -XX:+UseZGC -XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=2 -XX:ConcGCThreads=2 -XX:ParallelGCThreads=8 -XX:+AlwaysPreTouch -Xlog:gc*:file=/export/log/gc-zgc.log:time,uptime,level,tags:filecount=5,filesize=100m

逐个解释关键参数。Xmx8g和Xms8g是为了让堆一开始就固定下来,这比默认的堆动态伸缩更容易预测RSS。ZCollectionInterval=120表示每120秒主动执行一次GC周期,防止堆长期没有GC触发导致内存压力堆积,注意这不是常规必须项,主要适用于流量波动大的服务。ZAllocationSpikeTolerance=2表示允许分配速率产生2倍左右的突发,数值越大GC越激进,系统选择的余地也越大。ConcGCThreads=2手动限制并发GC线程数,避免在8核机器上ZGC自动启的GC线程过多,具体数值要按机器核数和业务CPU使用率来定。AlwaysPreTouch表示JVM启动时把堆内存全部先触碰一遍,让物理内存提前分配到位,可以减少运行期分配内存页的抖动,代价是启动时间变长,适合在线业务。

还有一点,ZGC日志的采集方式和我们之前用G1时的-XX:+PrintGCDetails已经完全不一样,JDK11之后推荐用统一日志体系-Xlog,正式参数里的gc*表示所有GC相关日志。

5.3 压测方案与前后数据对比

参数定下来之后,我们在公司内部的压测环境做了三轮验证。压测方式是模拟真实流量:单机4000 QPS持续30分钟,中间穿插3次200%流量的尖峰,每次都跑20分钟。压测期间监控P99延迟、GC停顿、RSS、CPU使用率。结果非常直观。

指标G1(调优前)ZGC(调优后)
P99延迟35-45ms9-14ms
Full GC次数30分钟约6次0次
最大GC停顿812ms4.2ms
平均GC停顿180ms1.3ms
容器RSS峰值11.6G8.9G
CPU平均占用48%61%

ZGC的延迟优势非常明显,P99从40毫秒左右直接降到了10毫秒附近,这还没有算上G1在有Humongous分配时的长停顿。但也能看到ZGC的代价,CPU平均占用比G1高了不少,这是因为读屏障和并发GC线程在持续占用CPU。

这里要强调一个数据解读的技巧:不要只看P99或平均延迟,还要看P999甚至P9999,GC停顿这类尾巴事件对极端值影响更大。实际压测场景里,G1的P999能到600毫秒以上,ZGC的P999只有几十毫秒,这种差距在线上大促场景会被进一步放大。

5.4 灰度发布与回滚预案

压测通过后,我没有直接全量切流量,而是按三阶段灰度推进。第一阶段,只切一台真实业务机器,观察24小时,重点关注两个数据:RSS是否稳定、P99是否维持在目标内。第二阶段,把集群里20%的机器切到ZGC,确认灰度机器上的业务指标与压测一致。第三阶段,剩余机器在一个业务低峰期全部切换。

每一阶段都保留回滚手段,回滚就是把JVM参数恢复G1配置,重启服务然后切流量。ZGC切换是可以回滚的,因为ZGC本身不改变业务代码,配置文件改回来即可。但要注意,回滚的机器要有充足的堆内存余量,因为G1在经历一段ZGC运行后,重新启动时堆需要重新预热。

6. 踩坑实录:文档不会告诉你的细节

6.1 直接内存的回收与DisableExplicitGC的冲突

这个坑我们在定位阶段就发现了,但直到ZGC上线之后才彻底意识到它的严重性。很多中大型Java服务为了防止业务代码里手动调用System.gc()造成停顿,会加上-XX:+DisableExplicitGC。这个配置对G1来说,减少了无谓的Full GC,确实是有效的。但它的副作用是,System.gc()无法触发堆外DirectBuffer的Cleaner清理。

切换ZGC之后,问题变得更隐蔽了。ZGC本身没有传统意义上的完整Stop-The-World Full GC阶段,它靠并发GC周期来回收,这本来是一件好事,但也意味着原本依赖"Full GC清理DirectBuffer"的机制在这里没有对应物。如果你的服务有大量直接内存分配,同时开着DisableExplicitGC,那堆外内存的回收只能靠ZGC周期处理Cleaner。ZGC对Cleaner的处理发生在GC周期的并发标记阶段,但并不会像G1的Full GC那样"集中且彻底"。

这个坑最终的解法不是关闭ZGC,而是从代码层面消除对DirectByteBuffer的高频依赖,改用池化缓冲区。同时把DisableExplicitGC从参数里去掉,因为ZGC的停顿时延特征已经完全不需要防System.gc()了,开着反而阻碍了潜在的手动触发回收通道。

6.2 ZGC在低核数机器上的CPU开销陷阱

ZGC的CPU开销主要来自并发标记和并发转移,它会默认根据CPU核数计算GC线程数量。在我们的8核机器上,ZGC默认并发GC线程数是3到4个,加上JIT编译线程、业务线程本身,CPU在峰值时会逼近85%。一开始我们在压测环境里用的是4核机器做验证,发现P99数据好但CPU始终维持在95%以上,逼得我们只能把ConcGCThreads从自动调低到1,才勉强稳定。

这里有一个容易被忽略的经验:ZGC适合核数充足的服务,如果核数少于8且业务CPU基准负载已经超过60%,一定要主动控制ConcGCThreads。我在实际调参时遵循一个简单原则:为ZGC预留至少2个核心的余量,例如8核机器最多给ConcGCThreads配2,16核机器最多配3,之后再通过压测微调。

另外,ZGC的读屏障对CPU的消耗是持续的,不像G1那样只集中在GC阶段。这意味着即使没有GC发生,ZGC的CPU基线也会比G1高3%到5%。在下单决策时,这个成本要算清楚。

6.3 容器内存限制与透明大页的隐性干扰

容器环境还有一个提前量要给足:ZGC对物理内存的占用比G1更"大方"。原因是ZGC需要预留转移空间,做并发转移时需要把存活对象复制到新的Region,这段时间内新旧对象是同时存在的。在我们压测里,Xmx8g的ZGC在流量高峰时,RSS峰值能达到10G左右,比Xmx还高,这在G1里极少出现。如果你用容器的限制是12G,那10G虽然没有超,但已经非常接近危险线了。

透明大页这个坑,我们在压测中也撞到了。第一次压测没关THP,稳定运行半小时后,JVM日志里出现了一堆异常指针信息,服务响应也出现了间歇性的长尾延迟。搜索社区反馈后关闭THP再压测,现象彻底消失。这里建议所有ZGC上线前把THP统一关闭,改成普通4K页即可,ZGC并不依赖大页来获得性能。

6.4 监控与日志配置经验

ZGC的监控体系比G1清晰,前提是你知道看哪些指标。首先是GC日志,用-Xlog配好之后,里面每一行都包含GC原因、暂停时间、并发阶段耗时、堆使用变化。其次是RSS监控,这个要通过容器或者操作系统指标看,JVM自身不提供。最后是JFR相关事件,比如jdk.GCPhasePause,可以直接看暂停时间。

我自己的经验是,把ZGC日志接入到日志平台,然后做一个关键字告警,重点关注两个指标:一个是"ZGC Pause"最大值是否超过10ms,另一个是"Allocation Rate"是否有异常尖峰。这些指标比单纯盯着CPU或堆使用率更能反映GC健康状态。

补充一个工具技巧:切换到ZGC之后,之前基于G1的很多调优参数,比如-XX:MaxGCPauseMillis、-XX:G1NewSizePercent,这些统统不再生效,如果还在用旧的JVM参数列表,启动时会直接报Unrecognized VM option。上线前务必将JVM参数清单整体过一遍,去掉G1专属参数。

7. 复盘与体会

经历了这次从堆外内存排查到ZGC落地的全过程,我个人最深的体会是:调优的本质是围绕延迟和资源做平衡,每个收集器都有自己的代价,ZGC不是银弹。它在延迟上的收益我们确实吃到了,但也付出了更高的CPU占用和更严谨的内存规划。如果你的服务核数少、CPU负载已经很高,或者业务对吞吐的敏感度大于延迟,ZGC并不一定比G1更好。

另外,代码层面的问题永远无法用GC参数弥补。我们这次最根本的收益其实来自消灭了高频DirectByteBuffer分配,ZGC的引入只是把延迟指标又压了一截。如果一开始就换ZGC而不管堆外分配,大概率还是会遇到RSS缓慢上涨,只不过上涨的速度慢了一些,问题依旧存在。

最后分享一个小技巧。这次调优之后,我把排查过程中用到的命令和参数整理成了一套checklist,每次新服务上线之前都会对照着检查一遍:堆外内存是否有不受控的分配、是否开启了NMT、GC日志是否进入监控平台、容器内存限制和Xmx之间是否预留了足够的余量、THP是否关闭。这套checklist后来帮我们在另一个消息推送服务上节省了两天排查时间。可以说,工具和参数都是暂时的,但一套可靠的排查方法论,才是调优最值得沉淀的部分。

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

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

立即咨询