☰
Java堆外内存泄漏排查实录:从G1到ZGC的调优之路
2026/10/15 6:29:30 网站建设 项目流程

去年年中我们有个核心服务出过一件怪事:JVM堆内内存、GC表现都正常,但容器内存以每天几个GB的速度往上爬,重启后撑不到一周又回到高位。当时团队第一反应是“代码里哪里有对象没释放”,可堆内内存明明稳得很。后来一路从Heap Dump查到Native Memory,从G1切到ZGC,最后把根因落在堆外内存上。这个排查过程让我把Java内存模型、GC选型和参数调优这几块内容重新串了一遍,也踩了不少坑。这篇文章就记录一下整个过程,包括堆外内存的底层机制、从G1切换到ZGC的决策依据,以及几个值得警惕的实战教训。适合正在做服务性能优化、或者项目里出现过“内存莫名上涨”这类问题的Java工程师。

1. 项目背景与问题定义:为什么好好的服务会“内存失控”

1.1 从一次线上事故说起

这个出问题的服务是某订单链路中的异步文件处理模块,主要职责是把上游推送的批处理数据落盘,再异步做格式转换。架构不算复杂,部署形态是8GB堆内存、双节点,JDK用的还是相对保守的版本。事故的触发点是容器内存告警:内存使用率连续三天超过85%,而且趋势线非常规律——每次重启后从低位快速爬升,然后进入一个缓慢但持续的增长通道。

最初判断是堆内泄漏,于是我们把Heap Dump和GC日志都拉了。结果很意外:堆使用率始终在40%左右波动,老年代也没有持续增长的趋势,Major GC频率甚至很低。JVM层看起来一切正常,但操作系统的RES内存就是降不下来。这时候我们才意识到,问题很可能不在堆内,而在JVM管理之外的“灰色地带”——堆外内存。

1.2 先分清楚:堆内问题还是堆外问题

排查之前必须先把内存账算清楚。JVM进程占用的内存远不只是-Xmx指定的堆,还包括元空间、线程栈、Direct Buffer、JIT编译器代码缓存、GC内部结构,以及JNI和第三方Native库分配的内存。只看堆内指标是没有办法解释整个进程的内存占用的。

当时我们的判断路径是:

  • 堆内:老年代、年轻代、元空间指标都在可控范围内,排除常规Java对象泄漏。
  • 堆外:RES - Heap Used - Metaspace - Thread Stack的差额明显偏大,而且还在增长。
  • 快照对比:对比同一服务在重启后24小时和运行72小时的native memory差异,锁定增长源。

这一步把方向从“找哪个对象没释放”转成了“谁在频繁申请堆外内存”。这两种问题的排查工具和思路完全不同,如果一开始就盯着Heap Dump,大概率会白费力气。

1.3 设定调优目标与验收标准

问题定位是一个层面,但后续的GC调优和选型需要一个明确的验收标准。当时我们内部定的目标很简单:容器内存长期稳定,不再出现周期性爬坡;GC停顿对业务延迟的影响要控制在可接受范围;并且调优后的方案要在压测环境可复现,不能靠玄学。

这听起来像是废话,但后来我发现多数调优失败的项目都栽在目标模糊上。比如有人一上来就说“把GC时间降下去”,但降到什么程度算达标?是P99还是P999?是吞吐优先还是延迟优先?不回答这些问题,后面的每一个参数调整都失去了参照物。我们当时把业务侧的核心指标定为:在模拟高峰流量下,GC导致的暂停总时长占比不超过1%,单次STW不超过50ms。

2. 内存模型剖析:堆内、堆外与运行时数据区必须搞清的底层逻辑

2.1 JMM规范与实际调优视角的差异

Java内存模型在面试里是个抽象概念,但调优时你会发现它其实由三块组成:语言规范定义的可见性规则、JVM运行时数据区(堆、栈、元空间等)的物理布局、以及操作系统视角下的实际内存分配行为。

平时说的“内存调优”,大部分时候在操作第二层和第三层的边界。比如-Xmx限的是堆区域,但Direct Buffer、Metaspace都是独立的运行时区域;而第三层更复杂,JVM内部管理的内存池和操作系统的页缓存、mmap映射并不完全一一对应。这也是为什么只看RES或者只jstat都没意义——你必须在两个视角之间翻译。

翻译的方式不复杂。堆内内存通常对应JVM的可回收对象池,增长速度相对平滑,有GC折点;堆外内存则分两块:一类是JVM自身的运行时开销,另一类是应用程序或第三方框架通过ByteBuffer.allocateDirect或JNI分配的Native内存。后者最危险,因为它不出现在堆统计里,但会真实占用系统内存。

2.2 堆外内存:为什么用、谁来用、怎么回收

堆外内存在Java生态里最常见的载体是DirectByteBuffer。它直接分配在操作系统内存中,不受堆大小控制,默认上限是-XX:MaxDirectMemorySize,如果不设置就和-Xmx一致。它的价值在于:减少数据从内核态到用户态的拷贝,在NIO、高性能网络框架里特别常见。

但DirectByteBuffer有个很关键的特性:它本身是一个很小的Java对象,躺在堆上;真正的大块内存是它引用的Native地址。回收机制也不是传统的可达性分析,而是靠Cleaner机制,挂在一个Reference链表上,由Reference Handler线程在GC时触发清理动作。

这带来一个不容易察觉的后果:如果堆内没有GC发生、或者引用链一直没有断,Cleaner可能迟迟不执行,Direct Buffer占用的Native内存就会一直挂着。看起来就像“内存泄漏”,其实只是回收被延迟或阻塞了。

2.3 堆外内存申请与释放的生命周期陷阱

堆外内存和堆内内存的最大区别在于,它的申请和释放不在同一个管控体系里。堆内对象由GC统一管理,开发者的代码只要不持有多余引用,内存一定会被回收;堆外内存则要求开发者或框架自己对生命周期负责。一旦负责的代码逻辑本身有缺陷,比如某个Buffer被放入缓存后,key的引用一直没失效,那这个Direct Buffer就可能被永久持有。

我们当时的服务里就有一个类似场景:某文件处理框架为了减少重复申请,维护了一个Buffer池,但池中的Buffer被一个常驻的队列引用,队列被堆积后一直没有释放。逻辑上这是一处“业务侧骚操作”,但从JVM视角看,堆毫无异常,Native内存却在失控增长。期间我们在Native Memory Tracking里看到malloc区域持续增加,一度怀疑是JVM自身的缺陷,最后才发现是框架层没有正确归还池化Buffer。

2.4 观测工具:从“两眼一抹黑”到精准定位

排查堆外内存时,我建议每台机器至少做三件事:

  • 开启JVM的Native Memory Tracking(-XX:NativeMemoryTracking=summary),定期对比jcmd <pid> VM.native_memory的输出;
  • 结合操作系统的/proc/<pid>/status里的VmRSS,核算JVM内部统计和系统实际内存的差额;
  • 对可疑的Direct Buffer使用jcmd或者JFR的jdk.DirectBufferStatistics事件,观察DirectBuffer数量和总容量的变化趋势。

NMT有一点要注意:它本身也有内存开销,线上开启summary级别的影响可以接受,但不要在生产环境长期开detail级别,malloc曲线会比平时胖一圈。如果是需要精细化定位的短期排查,开detail观察完再关掉,是更稳的操作。

3. 堆外内存问题排查实录:从表象到根因

3.1 现象确认:GC正常不代表内存没问题

先贴一组当时的观测数据。堆内老年代使用率从重启后的30%涨到42%后基本平稳,Full GC平均间隔接近3小时,每次GC后堆回收比例正常。一切看起来都很健康。但再看系统内存:刚重启时RES约6.2GB,运行48小时后到8.4GB,96小时后接近10GB,几乎逼近容器限额。

这里有一个很迷惑人的点:容器内存告警往往发生在RES逼近限额时,但此时在JVM里看不到任何红色指标。如果团队没有堆外内存的意识,很容易把问题导向“升级机器内存”或者“减少线程数”这类治标不治本的方案。

确认方向的关键动作是重启后立刻记录NMT快照,运行一段时间后再记录第二份,做差分比对。第一份和第二份的差值会明确告诉你增量主要发生在哪个区域。

3.2 分步排查:如何逼近堆外内存的增长源

我们的排查分四步走:

  1. 锁定增长区域。NMT差分显示malloc区域增长了接近3GB,而mmap区域相对稳定,说明问题是大量小块的Native分配。
  2. 缩小到调用方。用JFR连续采样了20分钟,抓取Unsafe.allocateMemory和DirectByteBuffer的创建栈,发现高频率调用集中在一个文件转换器的Buffer池初始化路径上。
  3. 检查池化逻辑。代码审查发现,Buffer池虽然有上限,但“归还”操作只在转换成功路径上执行,一旦解析异常就会跳过归还,异常情况下Buffer直接留在池外引用队列里。
  4. 复现验证。在压测环境注入解析异常,观察异常率与Native内存增长曲线的相关性,确认因果关系。

这一步最有价值的经验是:堆外内存泄漏不会像堆内泄漏那样稳定地涨,而是和“异常分支”高度相关。平时流量正常时不涨,一有异常波动就快速爬升。排查时要特别关注那些“只在出问题时才走”的代码路径。

3.3 根因修复与验证

修复本身不复杂:把Buffer的释放逻辑挪到finally块中,并增加一个针对池外Buffer的监控指标。上线后观察了两周,RES稳定在6.4GB左右,增长曲线消失。同时把MaxDirectMemorySize调整到3GB,使得即使出现未预期的Direct Buffer分配,也不会无限制蚕食系统内存。

这个案例的教训不在于某段代码写错了,而在于Java应用的内存视图不能只看堆。NIO、网络框架、序列化组件、异步文件处理,都可能成为堆外内存的重度使用者。如果大家遇到“GC正常但容器内存告警”的情况,我建议直接按这个思路排查,而不是浪费时间反复查堆内对象。

3.4 为什么说“GC调优前先查内存泄漏”

在开始调整GC参数之前,必须确保内存不泄漏。原因很简单:如果存在泄漏,堆内或堆外的内存总量会持续增长,此时任何GC参数调优都只能延缓OOM,不能解决问题。而反过来,一个健康的应用做GC调优才有意义。

我们当时的经历就是一个反面教材:先花了一周调G1参数,试图通过增大-XX:G1HeapRegionSize和调整-XX:MaxGCPauseMillis来改善“内存不足”,结果毫无用处。根源不在GC,而在堆外。想清楚这个顺序之后,整个项目的节奏快了很多。

4. 从G1到ZGC:一次延迟导向的GC选型

4.1 G1的痛点:吞吐不错,但延迟毛刺难压

堆外问题解决之后,GC调优才真正开始。我们的服务对延迟要求不低:订单链路里的异步文件处理虽然不直接阻塞用户请求,但后续依赖它的消息推送有P99 200ms的硬性要求。G1在8GB堆、大量并发处理线程的场景下,Mixed GC已经很努力了,但每隔一段时间还是会出现一次上百毫秒的STW。在高峰流量下,这种毛刺会传导到下游,造成消息推送超时。

G1的STW主要来自并发标记完成后的清理阶段和转移阶段(Evacuation),当堆被填得比较满、或者RSet维护开销变大时,停顿会明显上升。你可以把它理解成一个定期需要大扫除的仓库:平时整理很勤快,但总有几次要把所有货物挪一遍位置,这段时间里库房就不对外营业。

4.2 ZGC的工作原理:为什么STW能压到极低

ZGC的核心目标是把STW时间压到极短,且与堆大小基本无关。它的思路是把GC的大部分工作改成并发执行,包括标记、转移、重定位。关键技术是染色指针(Colored Pointer)和读屏障(Load Barrier),通过指针上的元数据位来记录对象状态,让业务线程在访问对象时能感知并配合GC转移。

这个方案也有代价:ZGC在吞吐量上一般不如G1,高CPU核心数下并发GC线程会占用一定的计算资源;而且染色指针依赖64位系统,Stack上需要额外的空间来维护转发记录,大堆场景下内存占用也会略高。所以ZGC不是“万能更好”,它适合的是延迟敏感、堆较大、CPU有余量的服务。

4.3 选型判断:不是“ZGC更先进就用ZGC”

做选型的时候,我们对照业务的实际特征列了一个表:

业务特征我们的情况G1的适配度ZGC的适配度
堆大小8GB中等非常合适
延迟要求(P99)200ms以内偶发毛刺稳定可控
吞吐要求中等,允许CPU换延迟较好略低,但可以接受
堆内对象分配速率较高,约1-2GB/s压力明显并发转移缓解
运维复杂度团队有JFR和NMT基础低稍高,但可接受

结合这个表,我们的结论是切到ZGC。这里明确说一句:如果服务是CPU密集型的、延迟要求又不太苛刻,保留G1并做精细调优往往是性价比更高的方案。ZGC不是银弹,它所依赖的读屏障、并发转移在低CPU余量场景下反而会成为新的瓶颈。

4.4 切换前的准备清单

ZGC切换不是改一个-XX:+UseZGC参数那么简单,至少要做三件事:

  • 确认JDK版本。ZGC在JDK 15之后进入生产可用,但不同版本功能差异较大;在我们常用版本上分代模式已趋于成熟,建议至少使用支持分代ZGC的版本。
  • 关闭或调整透明大页(THP)。多数Linux发行版默认开启THP,它会放大内存分配时的延迟波动,对ZGC这类依赖并发操作的GC影响尤其明显。建议echo never > /sys/kernel/mm/transparent_hugepage/enabled,或者在启动参数中配合相关内存配置使用。
  • 开启GC日志和JFR,记录切换前的基线数据。没有基线数据,上线后出了性能波动你根本说不清楚是GC的问题还是业务流量变化。

5. ZGC落地参数与容量规划实战

5.1 核心参数配置与解释

我们最终采用的ZGC启动参数大致如下:

-Xms8g -Xmx8g -XX:+UseZGC -XX:SoftMaxHeapSize=7g -XX:ConcGCThreads=4 -XX:ZCollectionInterval=30 -XX:ZUncommitDelay=300

逐个解释一下理由。-Xms和-Xmx设成相同值,是为了避免JVM在启动阶段频繁扩容堆,减少不必要的内存抖动。SoftMaxHeapSize设成7g,是给ZGC一个“舒适区”:堆可以在8g以内浮动,但GC会倾向于把堆控制在7g左右,避免堆长期涨满导致回收压力。ConcGCThreads默认会根据CPU核数自动算,但我们这个服务在同一台物理机上有多个容器,自动计算的值可能抢占其他容器的CPU,手动限成4个线程更可控。ZCollectionInterval=30是让ZGC即使没有立即回收压力,也至少每30秒做一次周期性的GC动作,避免空闲场景下内存迟迟不释放——这在配合SoftMaxHeapSize时是很有用的。ZUncommitDelay=300是指堆内存释放回操作系统之前的延迟时间,300秒意味着短时间的内存峰值不会导致频繁向OS归还和重新申请内存。

这套参数里最容易踩坑的是SoftMaxHeapSize。如果你设得太小,ZGC会频繁回收,导致CPU消耗上升;设得太大又等于没设。我们压测后觉得7g与8g的组合比较均衡,但这依赖具体业务,别直接抄。

5.2 容量规划的测算思路

容量规划不能只看-Xmx,要结合分配速率和存活率来推导。当时我们做了这样的估算:假设单节点QPS约为5000,每个请求处理过程中累计创建约200KB的临时对象,那么每秒的年轻代分配速率大约是5000乘以200KB,即约1GB/s。再假设存活率仅5%,那么每秒钟会往老年代晋升约50MB对象。G1面对这种分配速率时,Mixed GC需要频繁处理大量的小Region,STW自然压不下来。

再看ZGC:由于转移阶段是并发的,它能承受更高的分配速率,但堆的大小必须给足余量。实际上我们后来按2倍速率跑压测,即分配速率大约2GB/s,8GB堆依然能维持在30秒左右的GC周期,P999延迟也没有超过40ms。这说明容量规划的底线是:分配速率不能长时间高于堆容量的四分之一,否则无论哪个GC都会很吃力。

5.3 压测数据对比:变化不是一星半点

切换前后,我们在相同的压测流量下记录了GC指标和业务延迟:

指标G1(调优后)ZGC
平均STW38ms约0.5ms
最大STW210ms约4ms
P99业务延迟173ms121ms
P999业务延迟310ms167ms
CPU开销基准约高8%-10%

ZGC在延迟上的收益非常直观。STW从平均38ms降到亚毫秒级,最大停顿从210ms锐减到4ms左右,业务侧P999直接改善了45%。代价是CPU高了约8%,换来的是延迟稳定性。

不过要特别说明:这个CPU增量在我们场景可以接受,因为服务本身不是CPU饱和的。如果你的服务CPU使用率常年超过70%,ZGC带来的并发GC线程和读屏障开销可能让平均延迟变好,但吞吐下降会更明显,需要重新评估。

5.4 上线后的观察清单

ZGC上线后不能只看业务延迟和GC日志,还有几个值得观测的指标:

  • jstat -gcutil在ZGC下的输出语义和G1不同,不能照搬原来的经验值来判断“健不健康”。
  • JFR里的jdk.ZGCStatistics、jdk.ZGCPauses事件要持续看,关注并发周期的耗时和堆使用率曲线。
  • 容器的CPU限流情况要盯住,ZGC并发阶段如果和业务高峰撞在一起,可能出现瞬时CPU飙高。
  • 观察SoftMaxHeapSize是否被频繁突破,如果频繁突破说明堆容量规划不够,需要调整业务分配速率或堆大小。

6. 常见问题与排查技巧实录

6.1 现象速查表

我把这次实战里遇到的典型问题和解决方案整理成一个速查表,后续再碰类似问题可以直接对照:

现象可能原因排查方向解决建议
GC正常,容器RES持续上涨堆外内存或框架层Native分配NMT差分、JFR采样Unsafe.allocateMemory修复Buffer池释放逻辑,设MaxDirectMemorySize上限
P99正常,P999毛刺大偶发STW过长GC日志查看具体阶段耗时评估切ZGC或调整G1的G1MixedGCLiveThresholdPercent
切ZGC后CPU涨幅超预期并发GC线程数过多、THP开启查看ConcGCThreads,检查THP状态手动限制GC线程,关闭THP
ZGC堆始终接近上限SoftMaxHeapSize设置不合理观察ZCollectionInterval和分配率提高堆容量或适当调高SoftMax
频繁向OS归还又申请内存ZUncommitDelay太短观察native内存曲线调大ZUncommitDelay
jmap -heap在ZGC下输出异常ZGC管理结构不同用JFR或jcmd VM.info代替不要依赖旧工具做新GC的日常监控

6.2 几个会误导人的细节

第一,System.gc()在堆外内存回收上的“妙用”是很多老文章推荐的,但实际代价极高。如果你依赖System.gc()来触发Cleaner清理DirectBuffer,等于强迫JVM做一次全堆扫描和可能的Full GC,这在ZGC下会破坏低延迟特性。正确做法是找到泄漏源,而不是用Full GC去兜底。

第二,ZGC对Linux内核版本和系统架构有要求,32位系统、或者内核版本过旧的机器是跑不起来的。上线前先在预发环境用java -XX:+UseZGC -version验证一遍,避免当天发布才发现启动就崩。

第三,不要迷信“JDK越新越好”。ZGC在较新版本中分代模式已经很成熟,但如果你依赖的第三方库版本较老,需要先做兼容性验证。我们升级JDK时因为一个序列化框架的老版本不兼容新JDK,被迫先在老JDK上跑非分代ZGC,效果仍然不错,但少了一些新特性的红利。

6.3 排障工具的组合用法

单独开NMT、单独看JFR、单独用pmap都有盲区。我的习惯是三层配合:

  • jfr记录GC事件、DirectBuffer统计、线程分配速率,定位“方向上是谁的问题”;
  • jcmd VM.native_memory差分定位Native内存的区域归属;
  • /proc/<pid>/smaps查看地址空间里的匿名映射体量,辅助确认堆外内存的物理占用。

这三层组合基本上能在半小时内把“GC正常内存上涨”类问题缩小到具体模块。大多数团队卡住的环节其实不在工具上,而是没有“堆外内存也是Java内存管理的一部分”这个意识。

7. 结语:一些实操后的个人体会

这次项目前后折腾了一个多月,最大的体会是:Java调优的核心不是死记参数,而是理解内存和GC底层的机制,然后在具体场景里做取舍。堆外内存问题让我重新审视了JVM的边界——所谓Java管理的内存,从来不只是-Xmx那一块。而从G1切到ZGC,表面上只是换了个GC实现,实际上涉及的是对延迟和吞吐权重的一次重新排序。

如果大家正在经历类似的问题,我的建议是:先花时间搞清楚内存增长的真正来源,再动手调GC参数;调优过程中每改一个参数都记录前后基线,别相信“感觉快了”这种判断;ZGC确实好用,但必须结合你自己的分配速率和CPU余量来评估。最后分享一个小技巧:在切换GC之后,保留一段新旧GC并列运行(一个节点用G1、一个节点用ZGC)的灰度期,用真实流量对比一段时间,远比压测环境的数据更能说明问题。这个做法帮我们避免了一次潜在的发布事故,我个人认为是整个项目里最值回票价的一个决策。

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

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

立即咨询