☰
Java服务隐式内存导致OOM?从定位到治理,资源利用率提升40%
2026/10/6 9:43:06 网站建设 项目流程

1. 从一次“神秘”OOM讲起:堆内存好好的,进程却没了

做后端的人应该都有过这种经历:线上服务没改代码、没上流量高峰,监控面板上的进程却忽然消失,Kubernetes拉起一个全新的Pod,一切恢复如初,像什么都没发生过。刚开始我以为是节点资源不足被驱逐,查了一圈发现不是,节点内存充足得很。随后登录宿主机,翻到一段被OOM Killer击杀的记录,死亡的正是我们的Java服务,那个瞬间我才意识到,问题出在进程自己身上。

这个应用规格是4C8G,JVM参数是经典的-Xmx6g,GC用的是G1。从jstat看,堆内内存长期稳定在3GB左右,Old区连一半都不到,Full GC一天都没几次,按理说不应该和OOM有任何关系。但容器的RSS(Resident Set Size)曲线却非常诚实:每天下午开始爬坡,7GB、7.5GB,直到深夜被系统杀掉重启,第二天循环往复。我用脚趾头想都知道,这一定有一块内存是常规监控看不见的,它不在堆内,却确确实实属于这个进程,这就是我后来称之为“隐式内存”的东西。

所谓隐式内存,指的是应用进程里那些不在JVM堆管理范围内、不容易从标准监控里直接看到的内存占用。它们可以是Java的堆外内存、Direct Buffer、线程栈、JIT编译器产物、元数据,也可以是第三方native库自己的“私房钱”。这些内存不上jstat、不上G1的报表,但它们吃掉的物理内存一分不少。这篇文章不打算讲“内存很小、优化不了”这种空话,我把那次完整的诊断过程和治理手段拆开来讲,包括用什么工具、看什么指标、怎么一步步定位到根因,最后怎么把资源利用率提升了40%。如果你也在维护Java服务、遇到过类似“堆没事但容器总被杀”的问题,这篇文章会有用。

2. 诊断思路先于工具:为什么常规排查会失灵

2.1 堆内看多了,容易产生“安全”错觉

大部分人对Java内存的理解都在“堆”上,这很自然,面试考的是堆、日常调参调的也是堆。可实际运行中,一个JVM进程占用的物理内存远大于-Xmx指定的大小。JVM本身除了堆,还有元空间、线程栈、CodeCache、GC相关的数据结构和native内存;更麻烦的是,业务代码里随便用一下NIO、Netty、JNI、MMAP,就会在堆外产生一波新的内存占用。

我见过很多人排查内存问题只看jstat -gc,看到GC正常就转头去查代码死循环、查慢SQL、查并发升高,方向全跑偏了。因为GC是“托管”的,堆内对象一旦不可达,迟早会被回收;而堆外的内存很多是“野生”的。以Netty为例,它默认使用PooledByteBufAllocator,如果你在系统属性里关掉了池化,或者框架封装得不当,大量Direct Buffer会频繁申请、释放不了,久而久之就把进程内存吃满了。关键是这些东西在jstat上完全不可见,你必须在OS层和JVM的native内存视角去找线索。

那天我把监控面板翻到底,确认了P99响应时间没有恶化、CPU没飙、堆内回收正常之后,就下了一个判断:问题不在GC管理范围里。于是把排查方向切换到native内存。这里有个经验:当堆内数据正常但RSS持续上涨时,请先看RSS和堆峰值的差值,如果差值超过500MB且持续增长,立刻调用完整的内存诊断工具链,而不是继续在业务代码里瞎找。

2.2 一套能落地的排查清单

真正的内存诊断不是拿一个工具扫一遍就完事,而是按层次逐层缩小范围。我把工具链分成三梯队:

第一梯队:OS视角,用来回答“进程到底吃掉了多少物理内存”,核心技术是/proc/<pid>/smaps、pmap -x、ps aux里的RSS字段。注意RSS并不是一个精确数字,它会包含共享库被多个进程共享的部分,但至少能给你一个数量级上的判断。

第二梯队:JVM Native视角,用来回答“JVM自己申请了什么类型的内存”,核心技术是-XX:NativeMemoryTracking=summary加上jcmd <pid> VM.native_memory summary。NMT给了几个分类:Java Heap、Class、Thread、Code、GC、Compiler、Internal、Other、Symbol、Native Memory Tracking。其中Internal和Other就是藏污纳垢的地方。

第三梯队:动态观测,用来回答“哪里的内存分配行为最活跃”,核心技术是jstack、jcmd <pid> Thread.print、async-profiler的native轨迹分析,以及最直接的cat /proc/<pid>/smaps里看看有没有超大块匿名映射。

我当时没用上来就上复杂的JFR。排查原则应该是:先看静态分布,再做动态采样。如果一开始就开sampling,你看到的是一大堆正常分配,在噪声中找信号非常痛苦。只有先确认哪一类内存异常放大,再带着可疑目标去抓动态分配,效率才最高。

3. 实战排查:把“隐形内存”一步步揪出来

3.1 用NMT拿到第一份嫌疑名单

我们的服务是Java 11,启动脚本原本没有开NMT。这里提醒大家一个细节:NMT的开关必须加到启动参数里,并且要选对粒度。-XX:NativeMemoryTracking=summary是汇总模式,开销很低;-XX:NativeMemoryTracking=detail能看到分配调用栈,但开销大、线上慎用。

加上参数后重启Pod,等它跑上一两个小时,执行:

jcmd <pid> VM.native_memory summary

NMT输出里的关键几行长这样:

Total: reserved=7200MB, committed=6080MB - Java Heap (reserved=6144MB, committed=4096MB) - Class (reserved=1100MB, committed=94MB) - Thread (reserved=220MB, committed=220MB) - Code (reserved=260MB, committed=120MB) - GC (reserved=160MB, committed=44MB) - Internal (reserved=700MB, committed=690MB) - Other (reserved=650MB, committed=640MB)

注意:NMT的总和并不等于OS看到的RSS,NMT不统计JVM通过其他native接口直接申请的内存,而很多第三方库恰恰是绕过JVM直接向OS申请内存的。但我还是看到了异常:Java Heap committed只有4GB,可NMT显示Internal加Other已经超过了1.3GB,这明显不正常。一个纯业务服务,不应该有这么多“其他”内存。

3.2 pmap与smaps:在操作系统层面验证

NMT是JVM的“自述”,可能隐瞒也可能夸大,所以要拿OS数据来对账。我登录到节点的Pod里(当时还能进去,它没死透),直接跑:

pmap -x <pid> | sort -k3 -n -r | head -20

排最前面的几个内存段,大多是anon映射,也就是匿名内存。其中一个地址段大小异常:约2.4GB,它在一个我们完全没想到的位置。这个段对应的就是mmap出来的匿名区,不关联任何文件,JVM堆通常也在这个区域里,但这个段的大小已经超过堆本身的剩余空间。

为了确认,我又看了/proc/<pid>/smaps中这个地址段的详细内容。里面有一项Private_Dirty很大,并且没有任何文件路径,这是典型的native堆内存或者Direct ByteBuffer底层存储的形态。有一点要特别提醒:Direct ByteBuffer的底层是直接用ByteBuffer.allocateDirect()在C heap里分配的,这部分不受JVM堆控制,却由Java代码引发。换句话说,整个Java应用可能在某处疯狂申请了Direct Buffer,而常规GC日志完全无感知。

3.3 线程栈里的异常调用路径

内存有了具体归属方向之后,我已经能猜到这个服务在使用NIO相关的通信框架,毕竟2GB多的Buffer不会凭空出现。下一步是抓分配源头,最直接的办法不是去翻代码,而是先用线程栈看哪个模块在持续产生内存分配:

jcmd <pid> Thread.print > /tmp/threads.txt

在dump文件里,大量业务线程都阻塞在同一个RPC客户端调用的SocketChannel.write和epollWait上,且调用链里反复出现DirectByteBuffer的构造函数。顺着这个线索,我把排查范围锁定到了RPC框架底层基于Netty的实现上。这里说句实话:很多框架封装了Netty,但并没有做合理的缓冲区管理。例如某些版本的RPC客户端在发送大消息时,每次都会新建一个Direct Buffer而不是复用池里的Buffer,一旦业务并发上来,内存申请速度远超释放速度,就会造成“缓冲风暴”。我们的服务恰好每天下午有几个定时任务会发送大量批处理数据,这就完美解释了为什么RSS总是下午开始爬坡。

4. 隐式内存治理的实施路径

4.1 先止血:限制Direct Memory并开启池化

定位到具体框架后,第一动作不是改业务代码,而是尽快止血。止血的抓手是JVM参数里限制堆外Direct Memory的总量:

-XX:MaxDirectMemorySize=512M

这个参数不限制所有native内存,它只限制DirectByteBuffer这类显式堆外缓冲的总量。它会在线程试图申请超过余量的Buffer时抛出OutOfMemoryError: Direct buffer memory,所以不能一限就完,还要配合业务侧做兜底:让发送大消息的链路改为Unpooled.wrappedBuffer复用已有的字节数组,或者干脆把待发送数据先序列化到一个池化的byte[]中,再包装成Buffer发送。

同时,如果你用的Netty版本较老,检查系统属性:

-Dio.netty.noPreferDirect=true -Dio.netty.allocator.type=pooled

pooled是Netty的默认选择,但有些框架会通过配置文件改成unpooled。如果确实是unpooled,改成pooled后,会显著减少Buffer的反复申请。这里有一个必须知道的事实:池化之后,Netty会保留一部分native内存作为缓存,这部分内存的占用是“稳定且可控”的,不会像无界模式那样无限上涨。

4.2 治本:从“随手申请”改成“池化复用”

参数只能按住总量,治本还是要把代码里的Buffer申请方式改掉。以我们定位到的发送大消息场景为例,原始逻辑大概是每次构造一个新的ByteBuf,写入数据,然后由底层框架发送。改成池化方案时,最稳妥的做法是用PooledByteBufAllocator.DEFAULT去申请:

ByteBuf buf = PooledByteBufAllocator.DEFAULT.directBuffer(estimatedSize); try { // 写入Biz-Header,写Body channel.writeAndFlush(buf); } catch (Exception e) { buf.release(); throw e; }

注意,writeAndFlush成功之后,Netty内部通常会帮你release掉引用计数;但如果你在写入前有任何异常路径,必须自己release,否则会引入新的“隐式泄漏”。这块我觉得很多人容易忽略:池化内存的显著特征是需要“归还”的,不归还的池化对象和泄漏的native内存没有区别,同样会让RSS慢慢上涨。因此,如果代码里没有良好的引用计数管理和异常处理,切换到池化反而可能从小泄漏变成大泄漏。

还有一类隐式内存来自线程上下文中的ThreadLocal缓存。Netty的FastThreadLocal、各种线程池里的ThreadLocalMap,都会在持有对象引用后阻止GC回收。尤其是在多个线程复用的场景下,一个不小心把一个超大对象存进ThreadLocal,那内存会一直挂在线程上,直到线程池销毁。这个也属于“看不见”的内存行为,排查起来比Direct Buffer更隐蔽,因为它在堆内,只是引用链异常。我们的服务虽然没栽在这上面,但后来做代码审计时发现了一个类似的隐患,所以一并改成显式的try-finally remove。

4.3 常态巡检:把诊断动作沉淀为自动化脚本

那次治理做完后,我把诊断步骤总结成一套巡检脚本,定时在预发环境跑。脚本做的事情不复杂,但非常有用:每30分钟采集一次RSS、NMT的Internal和Other值、DirectBuffer占用、线程数,写入我们的监控系统。设置两个自动告警阈值:

  • RSS /-Xmx比值超过1.4持续15分钟;
  • NMT的Other+Internal总committed超过1GB持续15分钟。

这两个阈值不是拍脑袋定的,而是基于本次故障复盘后得出的经验值。正常情况下,一个堆为6G的服务,RSS在7GB左右是健康线;如果持续超过8GB,说明堆外有问题。用这套自动化检查,我们后来又在两个非核心服务里抓到了类似问题,一次是日志框架里的轮转压缩使用了过多的native buffer,另一次是自定义配置中心客户端在频繁建立连接时创建了大量的临时socket缓存。所以说,治理不是一锤子买卖,把诊断能力变成持续可观测的“体检”,才真正叫治理。

5. 40%的资源利用率提升是怎么算出来的

5.1 从8GB Pod到5GB Pod的直观账本

很多朋友看到“提升40%”就问是不是把堆内存压小了。真不是。堆内存一点没动,还是6G。变化的是Pod规格和集群密度。

优化前:单个Pod规格8Gi,RSS峰值在7.6Gi附近,时常逼近OOM上限,没有冗余空间,也不能再低配部署。优化后:RSS稳定在5.2Gi。按照业界常见的预留策略(比如宿主机必须保证每个Pod有至少10%的剩余内存),8Gi规格几乎打满,5.2Gi则可以在5Gi规格的Pod里生存。理论上同样一台物理机(假设32Gi可用,不计系统开销),优化前只能稳定放3个8Gi实例,优化后可以放5个5Gi规格实例,部署密度提升了大约60%。但实际要考虑CPU、网络、磁盘IO的相互影响,密度不可能线性拉满,所以最终我们在生产环境销毁了几个重复资源,整体资源的实际利用率提升约40%。这是按真实收益算的,不是PPT里的数字。

还有一个容易忽视的收益:GC暂停时间变短了。因为堆外内存大量积压时,Old区虽然没满,但JVM在做Mixed GC和并发标记阶段时,需要扫描的root区域也会包含一部分引用,特别是从Direct Buffer到Java对象之间的引用,GC开销同样有。治理完,同样的业务量,G1的Remark平均时间下降了15%,P99响应时间也稳定了。

5.2 把这套方法搬到你项目里的步骤

不是只有大厂才有必要做隐式内存治理。如果你的Java服务用了Netty、gRPC、Kafka Client、HBase Client这类底层NIO组件,并且部署在容器里,就有必要做一遍排查。我建议至少按照下面五步走:

第一步:拉出RSS的90天曲线,找出上涨周期和规律,确认是否存在“缓慢爬坡”而非瞬时尖峰。隐式内存问题多数是漏,不是爆,曲线特征倾向于线性增长。

第二步:给服务开NMT的summary模式,运行至少24小时,拿到JVM的分类内存报表,看Internal、Other、Class三类是否异常。

第三步:用pmap -x核对RSS总量与NMT total的差距,如果超过1GB,基本可以断定有第三方native内存或者未纳入NMT统计的Direct Buffer。

第四步:结合发布记录和业务调用链,找到内存上涨和业务任务之间的对应关系。这一步最消耗时间,需要具备对框架内部机制的了解。例如定时任务、批量导入、连接池重建,都可能成为触发点。

第五步:根据定位结果做参数限制、框架配置调整或代码池化,再观察3天RSS曲线,确认坡度是否变平。

这套流程没有使用任何商业化黑科技,用的全是JDK自带的工具和Linux系统文件。很多人对内存问题的理解停留在“调-XX参数”,我觉得真正的功夫在现场定位的过程。“内存诊断”不是我发明的新名词,它就是一套系统性的观察、悬丝诊脉、对账、收敛的方法论。

6. 常见问题与避坑记录

6.1 误区一:RSS涨了,立刻去查堆内泄漏

RSS上涨的原因非常多,最常见的原因里堆内对象增长只占一小部分。我见过有人为了查RSS上涨,花了一周时间分析heap dump,结果发现堆里全是正常对象,最后用math确认问题在堆外。建议先做“断舍离”。如果系统里存在jstat正常但RSS持续上涨的情况,优先看native,不要被heap dump的惯性带偏。做heap dump本身会造成进程停顿甚至直接OOM,线上千万要慎重。

6.2 误区二:设置了MaxDirectMemorySize就万事大吉

MaxDirectMemorySize只对遵守该上限的DirectBuffer分配器生效。有些native库直接通过malloc或mmap申请内存,这个参数管不到。比如JNA、OpenSSL、一些自定义C扩展,它们的分配完全绕过了JVM参数。所以设置了参数之后,还要配合RSS观测,确认“显式DirectBuffer”是不是唯一的问题源。万一你设了512M,RSS还在涨,那就说明还有别的native借方,必须回到NMT和pmap去找其他线索。

6.3 几个值得刻意培养的诊断习惯

我不建议所有人一上来就会JFR、会perf,但有几个低成本高收益的习惯可以养成。第一,启动参数里长期带上NMT的summary开关,它日常开销大概1%以内,关键时候省下重启排障的时间。第二,容器里的JAVA_OPTS不要忽略堆外资源,尤其是-Xmx、MaxDirectMemorySize、io.netty.allocator.type这几项要显式声明,不要依赖默认值,因为默认值在一个框架版本里是A,换一个版本可能变成B。第三,全链路的内存监控指标里永远加上RSS和NMT分类,别只盯着堆使用率。

我最后还想强调一个习惯:每次线上事故结束后,除了写复盘文档,最好顺手把诊断命令和阈值固化到监控系统里。内存问题几乎不会消失,它们顶多换一个框架、换一个场景再冒出来。把排查方法沉淀成工具和看板,下一次遇到类似问题,你就能从“摸黑找线索”变为“开着地图打怪”。这也是那次40%收益之外,我觉得最值钱的产出。

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

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

立即咨询