ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java容器内存消失之谜:堆外内存排查与SysOM实战

Java容器内存消失之谜:堆外内存排查与SysOM实战 凌晨两点半群里弹出一条告警某Java服务容器内存使用率连续15分钟超过95%limit是4GB。第一反应是登录机器看TopJava进程RSS 3.7GB几乎打满。可执行 jmap -heap 后却更懵了——堆内存实际只用了800多MBOld区连600MB都不到。监控曲线显示马上要爆堆里却空空荡荡。多出来的2GB多内存到底去哪了这是Java容器环境里最经典的消失的内存问题。后来我们配合云监控2.0的指标趋势和SysOM做深度诊断把这个问题彻底查清了。这篇文章把完整的排查链路、工具原理和最终落地的优化配置整理出来给后来人省点弯路。1. 告警拉响容器内存被吃掉的第一现场1.1 现象还原监控曲线与直觉完全背离当时服务部署在容器环境里规格是4GB内存限制Java进程启动参数明确配置了-Xmx2g。按照常规理解堆上限2GB加上Metaspace、线程栈、JIT等堆外开销整体占用控制在3GB左右应该很宽裕。但云监控2.0的内存指标图上容器内存使用率从发布后就开始缓慢爬坡三天内从60%涨到95%最终触发连续告警。对比Java进程的Heap使用曲线堆占用始终在800MB到1.2GB之间波动完全没有持续上涨的趋势。也就是说监控告警的容器内存和JVM堆内存之间存在巨大差值。这就是典型的容器内存问题场景——监控指标看得见但指标指向的内存去向不明。这类问题最迷惑的地方在于它不像常见的堆内存泄漏那样可以用jstat直接观察到Old区持续增长。堆外内存的消耗分散在多个区域每个区域单看都不算大但加起来就是一个惊人的数字。我当时的排查思路是先确认堆内没有问题再逐步拆解堆外部分。1.2 常规三板斧的排查盲区遇到Java内存问题团队的第一反应通常是三板斧jstat -gcutil看GC和堆各区占用jmap -heap看堆参数与当前使用量jstack抓线程栈排查是否有线程异常这三招对堆内问题非常有效但面对堆外内存消失的场景几乎全部失灵。jmap只报告堆内数据jstat只跟踪GC行为jstack只能看到线程状态——它们统统看不到DirectByteBuffer、Metaspace、线程栈本身、JIT CodeCache这些堆外区域的实际物理内存占用。还有一个更隐蔽的坑在容器里执行free -m看到的内存统计和Cgroup限制的口径不一致。容器内看到的宿主机内存是全量的而Cgroup限制的是本容器内所有进程可以使用的物理内存上限。如果只看free的数值你很容易误判内存还够用或内存已经爆了。这就是为什么后来引入SysOM——它从内核态直接读取Cgroup统计和内存细分类目能告诉我们容器内存里每一块到底是什么。排查工具选型如果从一开始就有这个视角能省下好几个小时的无效检查。2. Java进程的内存账本真正的消失去了哪里2.1 JVM内存模型不只有堆堆外六大去向很多同学对JVM内存的理解停留在堆 栈 方法区但一个运行中的Java进程物理内存的构成远比这复杂。我按排查后的实际数据把堆外的内存去向拆成六个部分内存区域默认行为典型案例线程栈64位Linux默认1MB/线程线程池膨胀500个线程就能吃掉近500MBDirectBuffer默认上限等于-XmxNetty、gRPC等NIO框架分配堆外缓冲区Metaspace默认无上限随类加载增长动态生成代理类、热部署场景JIT CodeCache默认240MB左右大量方法编译后缓存GC辅助结构卡表、标记位图等G1区域划分较大时JNI/Native内存由Native库管理解压缩、加密库分配线程栈是最容易被忽略的一块。jstack时你关注的是线程状态和调用栈内容但一个-Xss1m的默认设置意味着每个线程独立占用1MB的进程地址空间而且这部分是物理驻留的。当业务代码里出现线程池未复用、每次请求新建线程的写法时线程数可以轻松冲到几百上千内存就以肉眼可见的速度涨。DirectBuffer同样是高频元凶。Netty的PooledDirectByteBuffer、Kafka客户端的ByteBuffer分配都会直接占堆外内存。-XX:MaxDirectMemorySize不显式设置时默认等于-Xmx的值但这只是上限约束实际占用完全取决于代码的分配和释放节奏。一旦有GC停顿导致ByteBuffer回收不及时堆外内存曲线就会走出和堆完全无关的上涨趋势。2.2 容器视角的内存统计口径RSS、Page Cache与Cgroup容器内存监控里的数值来自Cgroup对进程组的内存记账这套记账规则和Java自己统计的内存完全不是一套体系。理解下面的对应关系是解开谜题的关键。Cgroup统计的核心指标是RSSResident Set Size加Page Cache。RSS是进程实际驻留在物理内存中的匿名页——包括堆、栈、DirectBuffer、Metaspace等一切需要真正占物理内存的分配这也正是JVM各区域的物理落地。而Page Cache则是磁盘文件读写的缓存页比如JAR包读取后的缓存、日志文件写入的缓冲。Page Cache有个迷惑性它看起来占内存但在内存紧张时可以被内核回收并不等同于泄漏。可如果文件读写频繁Page Cache在Cgroup计数里涨得很高容器监控就会显示内存使用率飙升。区分可回收的Page Cache和不可回收的匿名内存是诊断容器内存问题的分水岭。而Java侧常用的-Xmx只是堆的虚拟上限JVM并不一定立即把所有堆内存提交为物理内存。很多时候-Xmx2g实际提交的物理页只有1GB监控看不到这些预留未使用的空间但代码一旦触发大量对象分配RSS会在分钟级涨上来。2.3 为什么Top显示高不一定是泄漏缓存与回收错觉很多人看到RSS高直接判定内存泄漏然后开始无休止的代码审查。这里要泼一盆冷水RSS高不等于泄漏甚至不等于真实需求。JVM在运行过程中会把堆内存主动提交并驻留特别是G1收集器会随着分配Commit Memory这些提交的内存即使当前没有存活对象也不会马上归还给操作系统。另一个经典错觉是Native内存的回收滞后。JVM堆内对象回收由GC管理但DirectBuffer的回收需要GC触发Cleaner机制。分配量大、GC频率低的场景堆外内存会持续累加直到一次Full GC才断崖式下降。监控曲线呈现锯齿状爬升时往往不是泄漏而是分配与回收的节奏错位。我见过一个服务在压测期间RSS稳定上升所有人都往泄漏方向排查最后查出来是-Xms和-Xmx都设成了2GBJVM启动就把全部堆提交了加上连接池预热和线程池扩张RSS涨到2.5GB其实是完全正常的。判断是否泄漏要看的是曲线形态和业务活动的对应关系而不是一味的数值大小。3. SysOM投入诊断从指标异常见到内存真面目3.1 SysOM是什么它凭什么能查内存SysOM是阿里巴巴开源的系统运维诊断工具集全称System Operation Maintenance专门解决云环境里指标看得见、根因摸不着的问题。它在设计上和我们常用的jstack、jmap这类用户态工具有一个根本区别拿到的是内核态第一手观测数据。诊断Agent运行在主机上通过内核观测能力直接采集Cgroup、内存节点、进程内存细分等数据。这意味着我们看到的不是Java虚拟机的自我报告而是操作系统视角下这个进程真正的物理内存行为。两者结合的诊断价值很大JVM告诉你我堆内用了900MB内核告诉你你的RSS里有400MB是页面缓存、600MB是匿名页、还有200MB在slab里。SysOM对Java问题还内置了专门的诊断场景覆盖线程分析、堆分析、GC分析和容器内存分析。实际操作中我们主要用到两块系统诊断里的内存诊断和Java诊断里的线程与堆诊断。3.2 诊断工作流先分场景再定工具SysOM的使用不是一上来就全部功能铺开。合理的路径是先看场景再选工具把问题收敛到具体某一类内存然后再做根因分析。我们团队实践下来标准流程是这样的从云监控2.0确认容器内存趋势记录上涨时间段与发布、流量事件的关系打开SysOM的系统诊断查看当前内存的细分构成——先分清匿名内存、文件缓存、slab等大类如果匿名页高进入Java诊断定位Heap、Metaspace、线程栈、DirectBuffer各自的占比如果文件缓存高检查日志写入、JAR加载等文件读写模式锁定方向后拉取对应现场数据做根因分析这套流程的核心思想是逐级收敛从容器整体下沉到进程物理内存再到内存细分类目最后到代码行为。每一步只看一个维度不被无关数据干扰。3.3 关键采集项与口径联动SysOM面板里最有价值的信息是把Cgroup的统计口径完整暴露出来。重点看这几个数值的关系memory.usage_in_bytesCgroup当前总占用对应云监控2.0的内存使用率memory.stat中的anon匿名内存包含堆、栈、直接内存等不可回收部分memory.stat中的file文件缓存理论上可回收memory.oom_control中oom_kill_disable与under_oom判断是否处于回收压力状态当一个容器内存濒临上限时如果panel显示anon占比极高说明是Java进程自身的不可回收内存增长此时JVM诊断是主线。如果file占比高则要重点排查是否有大量文件读写没有依赖Page Cache的自动回收机制。我当时在SysOM里看到的情况是匿名页约2.3GB文件缓存约300MBslab和内核占用约200MB。问题清晰收敛到Java进程匿名物理内存膨胀于是立即切到Java诊断往下拆。4. 一次真实排查的完整链路从4GB告警到1GB堆真相4.1 第一步确认堆内内存不是元凶切开之前先用jstat -gcutil确认堆内基础面。当时的数据是Young区使用了62%Old区使用了55%整体堆占用800MB左右GC频率正常。接着用jmap -heap看了参数生效情况-Xmx2g实际堆容量2GBMetaspace只有85MB远未触顶。又执行了一次jcmd GC.class_histogram确认存活对象分布没有异常聚集。判断结论很明确堆内无泄漏、无异常堆使用量跟业务负载匹配。那么2GB以上的匿名内存必然指向堆外区域。这一步的关键收获是不能只凭堆使用率不高就下结论还要确认堆外各子项的基线值。我把Metaspace、Thread、DirectBuffer的当前值都记录了下方便后续对比增长。4.2 第二步用SysOM的内核态视角抓RSS构成通过SysOM内存诊断模块拿到了进程RSS的详细拆解内存类别占用说明匿名内存2.3GB主要嫌疑区文件缓存310MB正常范围内核Slab180MB包含inode/dentry缓存共享内存40MB影响较小匿名内存2.3GB其中堆提交约为1.2GB-Xms2g导致启动即提交Metaspace约85MB剩余近1GB需要继续定位。结合线程数看当时线程数为820按1MB/线程计算约820MB。堆提交1.2GB加上线程栈820MB再加Metaspace和其余开销总和与匿名页2.3GB基本对上了。线程数820是个关键异常信号。排查代码后发现这个服务使用了一个自定义的线程池且没有设置核心线程数的上限压测流量上升时线程数随请求数无限扩张。线程栈880MB就是消失的内存最大的一块。4.3 第三步锁定DirectBuffer与线程膨胀的双重叠加线程膨胀之外SysOM的Java诊断还抓了Native内存的细分。高亮显示DirectBuffer的分配总量已达到400多MB而JVM的-XX:MaxDirectMemorySize未配置理论上限是2GB实际分配尚在安全区但趋势需要警惕。进一步用jcmd VM.native_memory复核发现Direct Buffer的committed为430MB主要用于一个基于Netty的异步上报组件。该组件的消息队列积压时会预分配大量DirectByteBuffer作为发送缓冲。到这里内存账单彻底算清堆提交约1.2GB、线程栈约820MB、DirectBuffer约430MB、Metaspace约85MB、JIT CodeCache约120MB、其余原生部分约200MB。总计约2.8GB和Cgroup匿名页2.3GB加上少量文件缓存、slab的统计基本吻合。所谓消失的内存其实是多个堆外区域无阈值约束地各自增长合在一起把容器内存顶穿了。4.4 第四步元数据区与JIT的隐性增长为什么第一个想到的是Metaspace因为它在不配置上限时默认可以无限增长。当时Metaspace只有85MB看起来不构成问题但深挖发现这个服务的类加载次数在一个月内翻了3倍——每次发布都会加载新版本类而旧的类加载器未完全卸载Metaspace的Committed空间在缓慢爬升。JIT CodeCache同样容易被忽视。这个服务启用了偏激进的JIT编译参数这也是写进启动脚本的历史遗留JDK8u191之后的默认CodeCache上限为240MB实际使用120MB。虽然当前不算危急但这类配置如果配合持续的方法内联和循环展开也可能出现缓存膨胀。这个环节给我们的教训是堆外区域没有当前没爆就不管的说法没有上限约束的Metaspace和线程栈迟早会在某个发布或流量峰值后成为下一场告警的主角。4.5 验证与善后排查结论落地之后调整了四个参数并做了灰度验证堆提交从-Xms2g调整为-Xms512m -Xmx2g减少启动即提交自定义线程池设置corePoolSize100、maximumPoolSize200拒绝策略改为调用者执行显式为Netty的ByteBuffer分配设置-XX:MaxDirectMemorySize512m设置-XX:MaxMetaspaceSize256m防止类加载器泄漏导致的无界增长重新发布后容器内存稳定在2GB左右线程数回落到200以下监控曲线平滑告警消失。验证过程中特别注意观察了DirectBuffer的锯齿曲线——分配回收节奏恢复正常未出现断崖式下降说明Cleaner机制工作正常不是靠Full GC兜底回收。5. 防止内存消失复发的实战配置与监控改进5.1 容器内JVM参数的正确写法经历过这次排障容器里跑Java的启动参数彻底成了团队标配模板。我推荐直接抄这份配置java -Xms512m -Xmx2g \ -XX:MaxDirectMemorySize512m \ -XX:MaxMetaspaceSize256m \ -XX:ReservedCodeCacheSize240m \ -XX:UseG1GC \ -XX:MaxRAMPercentage75.0 \ -jar app.jar有几个参数要重点解释。-XX:MaxRAMPercentage75.0是让JVM感知容器Cgroup限制并按比例分配堆JDK 10默认开启Container Support但很多老JDK 8u131以下版本不识别必须显式升级或用这个参数。-XX:MaxDirectMemorySize一定要显式设置否则默认等于-Xmx一旦堆外分配失控上限形同虚设。线程栈的治理不在JVM参数而在代码层。-Xss默认1MB一般够用真正要控制的是线程总数。尽量不用裸Executors而是用ThreadPoolExecutor并显式设置核心/最大线程数和有界队列。作为兜底可以加一段启动时的线程数日志超过阈值直接告警。5.2 代码层的约束DirectByteBuffer与线程池规范DirectByteBuffer要从使用方下手。Netty框架要设置io.netty.maxDirectMemory或者统一走-XX:MaxDirectMemorySize。如果用的不是Netty而是自研NIO务必配套一个缓冲池并做Release的兜底回收。线程池规范这个事必须在Code Review阶段就卡住核心线程数、最大线程数、队列长度、拒绝策略四要素缺一不可。团队内部可以定一个规矩所有线程池创建必须经工具类统一封装默认值就是maximumPoolSize 200防止某个同学图省事直接new Thread。Metaspace治理则建议从JDK的-XX:MaxMetaspaceSize和-XX:CompressedClassSpaceSize两个维度做限制同时在容器里配置自动重启兜底。注意MaxMetaspaceSize设置过小会引入频繁Full GC256MB是比较稳妥的起点但也要和团队实际类加载规模挂钩。5.3 云监控告警阈值应该怎么设告警阈值这件事吃过大亏之后才明白不能只盯一个指标。云监控2.0的容器内存使用率告警只是数据已经很难看的通知应该配合SysOM的数据做分级告警容器内存使用率超过80%观察检查匿名页/文件缓存比例容器内存使用率超过90%持续10分钟介入采集SysOM诊断快照出现OOM Kill事件立即告警拉取最近1小时内存趋势和GC日志还建议加一个指标线程数变化率。线程数短期翻倍往往是线程池失控的前哨比内存告警更早暴露问题。云监控2.0支持自定义指标采集通过JMX或者Micrometer把jvm.threads.live和process.threads上报配合相对值告警比只看绝对值灵敏得多。另外文件缓存类内存要单独看。如果业务对日志写盘量很大的服务Page Cache带来的内存占用增加是正常的不要为了瘦身盲目设置vm.drop_caches这在容器共享宿主机内核的场景里会影响其他容器得不偿失。据我所知这类内存问题在云原生化改造的过程中基本都会踩一遍尤其是从物理机迁移到容器的Java服务老一套启动参数直接搬过去几乎必出问题。SysOM的价值在于把内核态的数据完整暴露出来让消失的内存无处可藏。但工具终归是辅助真正管用的还是对JVM内存模型和Cgroup记账规则有清晰的认知——几个关键区域设上限、代码层管好线程和缓冲、监控按分级告警来这套组合拳打下来我还没见过第二个治不住的内存谜题。
返回列表