ARTICLE DETAIL

资讯详情

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

Java只配了-Xmx4g,进程为什么吃掉8GB?

Java只配了-Xmx4g,进程为什么吃掉8GB? Java只配了-Xmx4g进程为什么吃掉8GB服务器只有8GB内存Java启动参数明明写着-Xms4g -Xmx4g监控里的老年代也只用了55%。但Java进程的RSS一路涨到7.6GB最后被系统直接杀掉。应用日志里没有java.lang.OutOfMemoryError: Java heap space也没有自动生成Heap Dump。重启以后内存重新回到5GB左右。运行几个小时又开始缓慢上涨。很多人看到这里会问-Xmx不是最大内存吗我只配了4GBJava凭什么吃掉8GB问题就出在这句话。-Xmx4g限制的只是Java堆最大值不是整个Java进程的内存上限。这次事故最终发现堆内存没有泄漏真正持续增长的是一块被业务缓存长期持有的Direct Buffer。说明文中的进程号、内存数值和代码均为简化后的脱敏示例。线上执行诊断命令前请确认JDK版本、容器权限和命令影响不要在高峰期直接生成大体积Heap Dump。一、先分清三个经常被混用的数字排查Java内存时经常会同时看到Xmx VSZ / VIRT RSS / RES它们不是同一个概念。1. Xmx-Xmx4g表示Java堆允许增长到的最大值。对象通常分配在Java堆里但一个Java进程除了堆还需要元空间、代码缓存、线程栈、GC数据结构、直接内存和本地库。因此进程内存 ! Java堆2. VSZ或VIRT这是进程拥有的虚拟地址空间。JVM可能会预留很大的地址空间但预留不等于已经占用等量物理内存。所以看到VIRT 15G不代表进程真的吃掉了15GB物理内存。3. RSS或RESRSS是当前驻留在物理内存中的页面规模更接近操作系统此刻为进程承担的内存压力。这次事故里真正需要解释的是Xmx 4G RSS 7.6G中间多出来的3.6GB去了哪里。二、第一步不要看GC先从操作系统确认事实先找到Java进程PID$(pgrep-forder-service.jar|head-n1)echo$PID然后查看RSS、虚拟内存和线程数ps-opid,rss,vsz,nlwp,etime,cmd-p$PID示例输出PID RSS VSZ NLWP ELAPSED CMD 21873 7964120 15124480 812 05:42:17 java ...这里的RSS单位通常是KB。进一步查看/procgrep-EVmPeak|VmSize|VmHWM|VmRSS|RssAnon|RssFile|RssShmem|VmSwap|Threads\/proc/$PID/status示例VmPeak: 15402860 kB VmSize: 15124480 kB VmHWM: 8019324 kB VmRSS: 7964120 kB RssAnon: 7389000 kB RssFile: 575120 kB RssShmem: 0 kB VmSwap: 0 kB Threads: 812此时至少能确认三件事1. RSS确实接近8GB不是监控页面算错 2. 大部分是匿名内存不只是文件页缓存 3. 线程数明显偏高需要继续核对注意Linux手册也提醒/proc/pid/status中的部分RSS统计并非精确计费工具。它适合快速判断趋势不要拿几十MB的差异做结论。三、再确认Java堆到底用了多少先查看进程实际生效的参数jcmd$PIDVM.command_line jcmd$PIDVM.flags不要只看发布脚本。脚本里写了-Xmx4g不代表当前进程一定用了这套参数。然后查看堆信息jcmd$PIDGC.heap_info在部分JDK和垃圾收集器下也可以结合jstat-gcutil$PID10005这次事故里堆的表现大致是最大堆4096MB 已提交4096MB 实际使用约2850MB Full GC后约2300MB这说明Java堆确实没有超过4GB但操作系统看到的是整个进程。即使堆里只用了2.8GB堆外和JVM自身内存仍然可能把RSS推到8GB。四、Java进程的内存到底由什么组成可以先建立一个粗略模型Java进程内存 ≈ Java堆 元空间与压缩类空间 JIT代码缓存 线程栈与线程本地数据 Direct Buffer GC与JVM内部结构 JNI、本地库和第三方native内存 内存映射文件与共享库驻留页面 分配器碎片及其他开销其中最容易漏算的是下面几项。1. 元空间类元数据主要存放在native memory中而不是普通Java堆里。大量动态代理、反射生成类、热部署或类加载器无法回收都可能让元空间持续增长。2. 线程栈每个Java平台线程都需要栈空间和线程相关的本地数据。-Xss设置的是线程栈大小。默认值与平台有关不能把所有机器都当成固定1MB。3. Direct BufferByteBuffer.allocateDirect()以及Netty等网络框架可能使用堆外直接内存。Java堆里只保存一个很小的Buffer对象真正的数据却放在native memory中。4. JVM自身开销垃圾收集器、JIT编译器、代码缓存、符号表和内部数据结构都需要内存。5. JVM追踪不到的native分配JNI库、压缩库、数据库驱动关联的native组件以及第三方分配器也可能在堆外申请内存。所以生产内存预算不应该写成容器限制 Xmx而应该至少写成容器限制 堆预算 native预算 峰值余量如果团队里仍有人把-Xmx当成Java进程内存上限可以把这段转给他。很多“堆还没满却被杀”的事故根因就是预算里从来没有给堆外内存留位置。五、用NMT拆开HotSpot内部内存HotSpot提供了Native Memory Tracking简称NMT。它默认关闭需要在JVM启动时增加-XX:NativeMemoryTrackingsummary需要调用点级别信息时可以使用-XX:NativeMemoryTrackingdetail但detail的数据和开销都更大。Oracle文档给出的提醒是开启NMT可能带来约5%到10%的性能开销因此生产环境应先评估再决定长期打开还是只在诊断环境使用。进程启动后查看jcmd$PIDVM.native_memory summaryscaleMB你会看到类似分类Java Heap Class Thread Code GC Compiler Internal Symbol Native Memory Tracking示例中的关键结果可以简化成Total committed 约5.3GB Java Heap committed 约4.0GB Class 约220MB Thread 约360MB Code 约120MB GC及其他JVM内部内存 约600MBNMT里还有两个词必须分清reserved预留的虚拟地址空间 committed已经提交、可以实际使用的内存排查物理内存压力时不能只看到一个巨大的reserved就认定泄漏。更重要的是观察committed是否持续增长。六、建立baseline判断谁一直在涨单次快照只能告诉你当前结构不能直接证明泄漏。应用预热并进入稳定状态后建立基线jcmd$PIDVM.native_memory baseline一段时间后查看差异jcmd$PIDVM.native_memory summary.diffscaleMB如果启用了detail可以进一步使用jcmd$PIDVM.native_memory detail.diffscaleMB这次事故中基线对比显示Java HeapFull GC后能够明显回落 Class基本稳定 Code基本稳定 Thread缓慢增长 NMT总committed增长但解释不了全部RSS差值这里非常关键。NMT只追踪HotSpot内部内存并不覆盖所有第三方native代码和JDK类库产生的分配。因此出现RSS 7.6GB NMT committed 5.3GB并不意味着两个工具中有一个错了。剩余差值需要继续从Direct Buffer、JNI、本地库、内存映射和分配器碎片方向查。七、线程数很高但不要直接用线程数乘Xss当RSS前面看到Threads: 812先输出线程栈jcmd$PIDThread.print-l/tmp/thread-$PID.txt再按线程名称统计grep^/tmp/thread-$PID.txt\|sed-Es/^([^]).*/\1/\|sed-Es/-[0-9]$//\|sort|uniq-c|sort-nr|head-20同时确认实际线程栈参数jcmd$PIDVM.flags|grep-EThreadStackSize|CompilerThreadStackSize需要注意线程数 × Xss更接近栈地址空间的上限估算不等于这些页面已经全部进入RSS。判断物理内存压力要结合NMT中Thread的committed、RSS趋势和实际线程数量。这次确实发现了一个没有关闭的定时任务线程池线程数从300多涨到800多。它需要修但还不足以单独解释2GB以上的NMT缺口。八、真正的增长来自Direct Buffer应用使用了一个文件预览缓存。为了减少复制代码使用了Direct BufferprivatefinalMapString,ByteBufferpreviewCachenewConcurrentHashMap();publicvoidcache(StringfileId,byte[]content){ByteBufferbufferByteBuffer.allocateDirect(content.length);buffer.put(content);buffer.flip();previewCache.put(fileId,buffer);}问题在于缓存没有容量上限 没有过期时间 旧文件很少被移除 Direct Buffer一直被Map强引用只要Buffer仍然可达对应的直接内存就没有释放条件。如果Spring Boot已经暴露受保护的Actuator指标可以观察Direct Buffercurl-sS\http://127.0.0.1:8080/actuator/metrics/jvm.buffer.memory.used?tagid:direct也可以通过JMX查看java.nio:typeBufferPool,namedirect重点关注MemoryUsed TotalCapacity Count事故时间线上Direct Buffer大致是启动后180MB 2小时760MB 4小时1.4GB 故障前1.9GB它和RSS增长趋势几乎一致。这才是最关键的证据。-XX:MaxDirectMemorySize可以限制java.nioDirect Buffer的总量但它更适合作为容量护栏不是修复无界缓存的替代品。如果只是把上限从2GB改成4GB结果和扩大连接池一样只是让故障晚一点出现。九、为什么系统直接杀进程却没有Heap Dump容器内存限制是8GB。故障前的近似预算是Java堆提交 4.0GB Direct Buffer 1.9GB Class、Code、GC等JVM内存 0.9GB 线程及相关native内存 0.4GB 本地库、映射和其他开销 0.4GB -------------------------------- 合计 7.6GB左右一次文件预览高峰就可能继续推高Direct Buffer和页面驻留。最终超过cgroup限制进程被系统终止。这不是Java堆自己抛出的OutOfMemoryError: Java heap space因此不能期待-XX:HeapDumpOnOutOfMemoryError一定留下Heap Dump。在Kubernetes中通常需要同时检查kubectl describe podpod-namekubectl get podpod-name\-ojsonpath{.status.containerStatuses[*].lastState.terminated.reason}如果看到Reason: OOMKilled Exit Code: 137下一步应该核对容器总内存而不是只打开Heap Dump分析器。cgroup v2环境还可以查看cat/sys/fs/cgroup/memory.currentcat/sys/fs/cgroup/memory.max容器口径与单进程RSS并不完全相同但它们能回答“距离容器上限还有多远”。十、最后怎么修而不是怎么把上限调得更大这次修复分成四部分。1. 删除无界Direct Buffer缓存预览文件改为存储在有容量和过期策略的缓存中。对大文件直接使用对象存储或流式读取不再长期把完整内容放入进程内存。2. 修复线程池生命周期定时任务不再每次创建新的Executor。统一交给Spring管理并设置明确的核心线程、最大线程、队列和拒绝策略。3. 重新做进程内存预算不再使用容器8GBXmx就配8GB而是按实测数据预留native空间和峰值余量。例如容器限制8GB Java堆4GB Direct Buffer护栏1GB 其他native与线程预算1.5GB 故障与突发余量1.5GB具体比例必须根据框架、线程数、GC和流量压测结果确定不能照抄。4. 把总进程内存加入监控至少同时监控容器memory working set 进程RSS Java heap used和committed Metaspace Direct Buffer MemoryUsed 线程数 NMT committed趋势 OOMKilled和重启次数修复后连续观察一个完整业务周期堆使用随GC波动 Direct Buffer稳定在合理区间 线程数不再持续增加 RSS稳定在5.0GB到5.4GB十一、我的排查顺序遇到“Xmx没满Java进程却快把机器吃光”我会按这个顺序1. 用ps和/proc确认RSS、峰值RSS、线程数 2. 用jcmd VM.command_line确认实际启动参数 3. 用jcmd GC.heap_info确认堆使用和上限 4. 区分VSZ、RSS、reserved和committed 5. 有NMT时查看VM.native_memory summary 6. 建立baseline再看summary.diff增长分类 7. 检查线程数量、线程名称和Xss 8. 检查Direct Buffer、Netty和本地缓存 9. NMT解释不了时继续查JNI、本地库、mmap和分配器 10. 在容器中核对memory limit、OOMKilled和Exit Code 11. 按整个进程重新计算内存预算 12. 修复后观察至少一个完整业务周期这套顺序的核心只有一句话先确认哪一层在增长再决定抓Heap Dump、线程栈还是native证据。写在最后-Xmx4g只说明Java堆最多4GB它从来没有承诺Java进程最多4GB当堆没有异常、RSS却持续上涨时不要反复调GC参数也不要只盯着Heap Dump。先把进程内存拆开堆、线程、Class、Code、GC、Direct、JNI、mmap只有找到真正上涨的那一项修复才不会变成“把OOM从今天推迟到明天”。本文归入「生产环境保命清单」系列。你遇到过“堆还没满Java进程却被系统杀掉”的情况吗最后查到的是线程、Direct Buffer、本地库还是容器预算欢迎在留言区说说最终证据。系列导航上一篇《JDK装好、JAR能跑就能上生产这20项才是真正的保命符》下一篇预告《接口只慢了500ms为什么200个Tomcat线程还是被打满》关注我回复关键词保命获取「生产环境保命清单」全部文章。也可以把脱敏后的RSS趋势、NMT摘要、线程数和容器限制发来我会尽量帮你判断下一步该查哪里。请务必隐藏密码、Token、IP、域名和客户数据。
返回列表