ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java服务隐式内存导致OOM?从定位到治理,资源利用率提升40%

Java服务隐式内存导致OOM?从定位到治理,资源利用率提升40% 1. 从一次“神秘”OOM讲起堆内存好好的进程却没了做后端的人应该都有过这种经历线上服务没改代码、没上流量高峰监控面板上的进程却忽然消失Kubernetes拉起一个全新的Pod一切恢复如初像什么都没发生过。刚开始我以为是节点资源不足被驱逐查了一圈发现不是节点内存充足得很。随后登录宿主机翻到一段被OOM Killer击杀的记录死亡的正是我们的Java服务那个瞬间我才意识到问题出在进程自己身上。这个应用规格是4C8GJVM参数是经典的-Xmx6gGC用的是G1。从jstat看堆内内存长期稳定在3GB左右Old区连一半都不到Full GC一天都没几次按理说不应该和OOM有任何关系。但容器的RSSResident 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:NativeMemoryTrackingsummary加上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:NativeMemoryTrackingsummary是汇总模式开销很低-XX:NativeMemoryTrackingdetail能看到分配调用栈但开销大、线上慎用。加上参数后重启Pod等它跑上一两个小时执行jcmd pid VM.native_memory summaryNMT输出里的关键几行长这样Total: reserved7200MB, committed6080MB - Java Heap (reserved6144MB, committed4096MB) - Class (reserved1100MB, committed94MB) - Thread (reserved220MB, committed220MB) - Code (reserved260MB, committed120MB) - GC (reserved160MB, committed44MB) - Internal (reserved700MB, committed690MB) - Other (reserved650MB, committed640MB)注意NMT的总和并不等于OS看到的RSSNMT不统计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:MaxDirectMemorySize512M这个参数不限制所有native内存它只限制DirectByteBuffer这类显式堆外缓冲的总量。它会在线程试图申请超过余量的Buffer时抛出OutOfMemoryError: Direct buffer memory所以不能一限就完还要配合业务侧做兜底让发送大消息的链路改为Unpooled.wrappedBuffer复用已有的字节数组或者干脆把待发送数据先序列化到一个池化的byte[]中再包装成Buffer发送。同时如果你用的Netty版本较老检查系统属性-Dio.netty.noPreferDirecttrue -Dio.netty.allocator.typepooledpooled是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的OtherInternal总committed超过1GB持续15分钟。这两个阈值不是拍脑袋定的而是基于本次故障复盘后得出的经验值。正常情况下一个堆为6G的服务RSS在7GB左右是健康线如果持续超过8GB说明堆外有问题。用这套自动化检查我们后来又在两个非核心服务里抓到了类似问题一次是日志框架里的轮转压缩使用了过多的native buffer另一次是自定义配置中心客户端在频繁建立连接时创建了大量的临时socket缓存。所以说治理不是一锤子买卖把诊断能力变成持续可观测的“体检”才真正叫治理。5. 40%的资源利用率提升是怎么算出来的5.1 从8GB Pod到5GB Pod的直观账本很多朋友看到“提升40%”就问是不是把堆内存压小了。真不是。堆内存一点没动还是6G。变化的是Pod规格和集群密度。优化前单个Pod规格8GiRSS峰值在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”是不是唯一的问题源。万一你设了512MRSS还在涨那就说明还有别的native借方必须回到NMT和pmap去找其他线索。6.3 几个值得刻意培养的诊断习惯我不建议所有人一上来就会JFR、会perf但有几个低成本高收益的习惯可以养成。第一启动参数里长期带上NMT的summary开关它日常开销大概1%以内关键时候省下重启排障的时间。第二容器里的JAVA_OPTS不要忽略堆外资源尤其是-Xmx、MaxDirectMemorySize、io.netty.allocator.type这几项要显式声明不要依赖默认值因为默认值在一个框架版本里是A换一个版本可能变成B。第三全链路的内存监控指标里永远加上RSS和NMT分类别只盯着堆使用率。我最后还想强调一个习惯每次线上事故结束后除了写复盘文档最好顺手把诊断命令和阈值固化到监控系统里。内存问题几乎不会消失它们顶多换一个框架、换一个场景再冒出来。把排查方法沉淀成工具和看板下一次遇到类似问题你就能从“摸黑找线索”变为“开着地图打怪”。这也是那次40%收益之外我觉得最值钱的产出。
返回列表