公司动态
JVM 进程内存持续增长排查手册
JVM 进程内存持续增长排查手册适用场景Java 进程设置了-Xmx但 Linux RSS、容器内存或监控曲线远高于-Xmx并且内存随运行时间逐步升高。本文基于abtrader-c2b-provider-luxury的一次真实排查整理环境为 JDK 8、G1 GC、Spring Boot 2.2.2。1. 排查目标面对“JVM 只配置了 1G为什么进程用了 3G”时不要一开始就假设是 Java Heap 泄漏。首先要把进程内存拆开Java进程RSS ├── Java Heap ├── Metaspace / Compressed Class Space ├── Code Cache ├── DirectByteBuffer / Netty Direct Memory ├── Thread Stack ├── G1/JVM内部结构 ├── Java Agent及native library ├── glibc arena/native malloc └── 文件映射和其他共享/私有页面最终需要回答四个问题监控中的内存是不是单个 Java PID 的真实 RSS增长来自 Heap、Metaspace、Direct Memory、线程还是其他 native memory是一次高峰后保留高水位还是持续泄漏哪个类、ClassLoader、缓存或 native 组件持有内存2. 线上操作风险分级操作风险建议ps、/proc/PID/status、smaps_rollup低可直接执行jstat低可连续采样间隔建议5~10秒jcmd VM.flags、VM.command_line、GC.heap_info低可直接执行jcmd VM.classloader_stats中类数量巨大时输出较大低峰执行jcmd GC.class_histogram中到高可能进入安全点并造成停顿摘流或低峰执行jcmd GC.heap_dump、jmap -dump:live高可能Full GC并长时间STW必须摘流、检查磁盘重启并开启NMT需要变更灰度实例执行Heap Dump、Histogram和命令行可能包含订单、用户、token、URL等敏感信息文件必须放在受控目录并通过安全渠道传输。3. 第一阶段确认监控口径3.1 查看单个PID的真实内存ps -o pid,rss,vsz,nlwp,etime,cmd -p PID字段解释RSS实际驻留物理内存默认单位KiB。VSZ虚拟地址空间包含只预留、未实际驻留的地址不能当成真实物理内存。NLWP线程数量。ETIME进程运行时间。单位换算MiB RSS(KiB) / 1024 GiB RSS(KiB) / 1024 / 1024辅助确认cat /proc/PID/status | egrep VmRSS|VmSize|VmData|VmStk|VmExe|VmLib|Threads cat /proc/PID/smaps_rollup3.2 排除线程RSS重复计算使用以下命令查看线程时每一行可能重复显示整个进程的RSSps -eLf ps -T -p PID top -H -p PID不要把线程行的RSS相加。3.3 核对监控图的维度确认监控图是以下哪一种单个PID的RSS单个容器的working set单个Pod中所有进程多个Pod/实例求和或平均宿主机总内存包含page cache的容器指标。如果单PID RSS是3GiB而图表显示15GiB优先检查是否聚合了约5个实例不要直接拿15GiB分析单个JVM。4. 第二阶段获取JVM真实参数不要只看启动脚本因为变量、容器配置和启动平台可能改变最终参数。jcmd PID VM.command_line jcmd PID VM.flags tr \0 /proc/PID/cmdline重点记录-Xms / InitialHeapSize -Xmx / MaxHeapSize -Xss / ThreadStackSize -XX:ReservedCodeCacheSize -XX:MaxDirectMemorySize -XX:MaxMetaspaceSize GC类型 Java Agent NativeMemoryTracking必须记住-Xmx只限制Java Heap不限制整个Java进程RSS。5. 第三阶段先把内存分流到具体区域5.1 查看Heap和Metaspacejcmd PID GC.heap_info重点看heap total / used Metaspace used / capacity / committed / reserved class space used / capacity / committed / reserved概念区别used真正被对象或类元数据使用。committedJVM已经提交、可以立即使用的内存。reserved保留的虚拟地址空间不等于RSS。分流判断Heap used高继续看GC后Old区基线和Heap Dump。Metaspace/Class Space异常大立即转向类加载和ClassLoader排查。两者都不大但RSS很高继续查Direct Memory、线程和native memory。5.2 观察GC后的Heap基线jstat -gcutil PID 10000 12 jstat -gccause PID 10000 12-gcutil主要字段EEden使用率。OOld区使用率。MMetaspace使用率。该百分比基于当前容量不等于绝对MiB。CCSCompressed Class Space使用率。YGC/YGCTYoung GC次数和累计耗时。FGC/FGCTFull GC次数和累计耗时。分析时不要只看单点要计算时间窗口内的增量Young GC频率 ΔYGC / 采样秒数 GC时间占比 ΔYGCT / 采样秒数 平均YGC停顿 ΔYGCT / ΔYGC判断GC后Old区最低点持续升高存在长期存活对象增长考虑Heap Dump。Old区上下波动但最低点稳定Heap对象大概率能正常回收。YGC频率很高存在高对象分配速率检查重复解析、序列化、临时集合、脚本编译。FGC频繁已经进入严重内存压力需优先摘流或重启风险实例。5.3 为什么GC后RSS不一定下降对象被GC回收后内存通常先留在JVM已提交区域中复用不一定归还操作系统。特别是-Xms -Xmx时Heap没有缩容需求。因此判断对象是否回收要看GC前后Heap/Old使用量不能只看Linux RSS。6. Metaspace异常时的专项排查6.1 观察类加载速度jstat -class PID 10000 12字段Loaded进程生命周期累计加载类数。Unloaded累计卸载类数。Bytes累计类数据量。近似当前存活类数量Live Classes ≈ Loaded - Unloaded采样窗口内净增长 ΔLoaded - ΔUnloaded 净增长速度 净增长 / 采样秒数如果应用稳定运行后仍每秒净增几十、几百个类通常存在动态代理、脚本编译、反射Accessor、字节码增强或ClassLoader问题。6.2 确认类卸载开关jcmd PID VM.flags -all | egrep ClassUnloading|ClassUnloadingWithConcurrentMark|InitiatingHeapOccupancyPercent如果下面两项为true说明JVM允许卸载类ClassUnloadingtrue ClassUnloadingWithConcurrentMarktrue此时类仍持续净增长通常不是JVM关闭了卸载而是类生成速度超过卸载速度定义这些类的ClassLoader仍然存活Class、MethodHandle、ThreadLocal、缓存或agent仍在引用类/表达式。6.3 查看ClassLoader占用低峰或摘流执行jcmd PID VM.classloader_stats OUTPUT_DIR/classloader-stats.txt重点看ClassLoader总数是否异常哪个ClassLoader的Classes最多哪个ClassLoader的ChunkSz/BlockSz最大是否出现大量重复的ClassLoader unsafe anonymous classes是否达到几十万Aviator、Groovy、CGLIB、ByteBuddy、JDK Proxy、agent等动态类来源。判断ClassLoader归属很重要。例如动态类集中在Spring Boot主ClassLoader优先查应用内脚本/代理。集中在AgentClassLoader优先查Java Agent。大量一次性URLClassLoader优先查ClassLoader创建与关闭。6.4 获取对象和类直方图低峰或摘流执行jcmd PID GC.class_histogram OUTPUT_DIR/histogram.txt优先关注java.lang.Class java.lang.ClassLoader ThreadLocal / ThreadLocalMap.Entry 动态脚本类名前缀 字节码工具包对象 表达式对象 ClassLoader对象不要只看单个对象的shallow bytes。一个java.lang.Class对象本身可能只有几十或上百字节但它对应的元数据、常量池、方法、Code Cache等主要位于Heap之外。如果每个动态类的名字都不同Histogram可能出现几十万行每行一个实例。这是重要信号。7. Heap异常时的专项排查7.1 先比较两次Histogramjcmd PID GC.class_histogram OUTPUT_DIR/histo-a.txt在相同流量下运行一段时间或内存再次上涨后jcmd PID GC.class_histogram OUTPUT_DIR/histo-b.txt比较对象数量和bytes增量优先检查业务DTO、订单对象、策略对象HashMap$Node、ConcurrentHashMap$NodeString、char[]、byte[]队列、Future、线程池任务缓存条目ThreadLocal值。7.2 Heap Dump执行前确认df -h OUTPUT_DIR ps -o pid,rss,vsz,nlwp,etime,cmd -p PID当前Xmx1G时建议至少预留2~3GiB磁盘。摘流后采集jcmd PID GC.heap_dump OUTPUT_DIR/luxury.hprofJDK 8也可以jmap -dump:live,formatb,fileOUTPUT_DIR/luxury-live.hprof PIDMAT分析顺序Leak Suspects Report。Dominator Tree按Retained Heap降序。ClassLoader Explorer。对异常对象查看Path to GC Roots。排除weak/soft reference后确认真正强引用链。对比两份Dump或两份Histogram避免只凭“现在谁最大”下结论。8. Direct Memory与Netty专项排查通过JMX或监控读取java.nio:typeBufferPool,namedirect字段MemoryUsedTotalCapacityCountSpring/Micrometer环境也可查看对应的buffer pool指标。判断Heap稳定、Metaspace稳定、Direct BufferPool持续增长查Netty、Redisson、Lettuce、NIO。Direct Buffer数量稳定但RSS高可能是Netty池化、高水位保留或native allocator碎片。Heap Histogram中的DirectByteBuffer只是Heap上的包装对象不能完整代表Netty池化allocator的全部native bytes。9. 线程与native allocator排查9.1 线程栈粗算线程栈上限约等于 NLWP × Xss例如335 × 512KiB ≈ 167.5MiB实际RSS还受栈页面触碰、guard page和线程本地native结构影响。查看线程名分布jstack PID OUTPUT_DIR/jstack.txt重点检查Tomcat、业务线程池、Hystrix、Netty EventLoop、数据库池、监控agent和重复创建的线程。9.2 NMTNMT必须在JVM启动时开启-XX:NativeMemoryTrackingsummary -XX:UnlockDiagnosticVMOptions -XX:PrintNMTStatistics稳定后建立基线jcmd PID VM.native_memory baseline高峰或内存增长后jcmd PID VM.native_memory summary.diff scaleMB jcmd PID VM.native_memory summary scaleMB重点看Java Heap Class Thread Code GC Compiler Internal Arena Chunk如果未在启动时开启会返回Native memory tracking is not enabledNMT主要跟踪HotSpot/JVM自身内存第三方agent或native library的所有malloc不一定能完整归类所以还要对照cat /proc/PID/smaps_rollup pmap -x PID | tail -n 110. Java Agent的排查方法取得完整参数和版本tr \0 /proc/PID/cmdline unzip -p AGENT_JAR META-INF/MANIFEST.MF | egrep -i version|build如果配置了agent扩展目录还要列出实际加载的扩展jar。不要因为命令行存在-javaagent就直接归因。需要比较VM.classloader_stats中AgentClassLoader的类数量Histogram中agent/ByteBuddy对象数量关闭agent的同流量对照实例agent版本、已知问题和扩展实现。11. 本次luxury案例的完整证据链11.1 初始现象JVM主要参数-Xms1024M -Xmx1024M -Xss512K -XX:ReservedCodeCacheSize512M -XX:UseG1GC单PID数据RSS 3147172KiB ≈ 3.00GiB VSZ 6519372KiB ≈ 6.22GiB NLWP 335因此确认单个JVM真实RSS约3GiB监控图中的14~15GiB不是该PID的RSS口径。11.2 内存分区定位GC.heap_infoHeap used ≈ 872MiB Metaspace used ≈ 1.145GiB Metaspace committed ≈ 2.042GiB Compressed Class used ≈ 313MiB Compressed Class committed≈ 378MiB结论3GiB RSS主要由1GiB Heap和异常巨大的Metaspace/Class Space解释不需要先假设Direct Memory泄漏。11.3 GC压力约20秒窗口YGC 972 - 1074增加102次 YGCT 61.413 - 71.217增加9.804秒 Old 92.69% - 94.61%即每秒约5.1次Young GC窗口内约49%的时间消耗在Young GC说明存在极高的对象分配/晋升压力。11.4 类持续增长jstat -class约20秒窗口Loaded: 1,068,653 - 1,071,652增加2,999 Unloaded: 523,142 - 524,873增加1,731 净增加存活类1,268约63.4个/秒类卸载开关均为true所以问题不是JVM关闭了类卸载。11.5 ClassLoader定位主应用ClassLoader13591 normal classes 521069 unsafe anonymous classesOpenTelemetryAgentClassLoader: 3512 classes 234 unsafe anonymous classes ExtensionClassLoader: 5 classes结论五十多万个匿名类来自主应用路径OpenTelemetry不是主体。11.6 Histogram定位523074 个 Script_* 类 546239 个 java.lang.Class 1120847 个 Aviator VariableMeta 1120843 个 AviatorJavaType 896686 个 Aviator Variable 597795 个 Aviator Env 448357 个 LambdaFunctionBootstrap 298864 个 LambdaFunction 448503 个 ThreadLocal 305069 个 ThreadLocalMap.EntryScript_*计数器最大达到约1,104,824说明进程生命周期内至少生成过约110万个脚本类其中约52万个仍然存活。11.7 代码与依赖源码对应业务代码AviatorEvaluator.execute(express, paramMap); if (log.isDebugEnabled()) { AviatorEvaluator.compile(express); }Aviator 5.2.6默认cachedfalseexecute(express, env)每次重新编译compile(express)也每次重新编译每次编译生成Script_timestamp_counter类DEBUG开启时同一次请求可能编译两次。Aviator 5.2.6的lambda实现还有ThreadLocalLambdaFunction引用链和Histogram中的大量ThreadLocal、LambdaFunction对象相吻合。官方AviatorScript 5.3.3发布说明明确包含“Fixed memory leak in lambda function caching #494 #481”。相关链接AviatorScript 5.3.3多次编译导致Compressed class space OOMLambda ThreadLocal cache问题最终根因高QPS请求 - Aviator每请求无缓存编译动态脚本 - 持续生成Script匿名类和大量表达式对象 - lambda/ThreadLocal延长对象和类生命周期 - Heap分配率、YGC、Metaspace、Compressed Class Space持续增长 - 进程RSS逐步升高12. 本次问题的修复方案12.1 紧急止血在任何表达式执行前初始化有界LRU缓存PostConstruct public void initAviator() { AviatorEvaluator.getInstance() .useLRUExpressionCache(256); }每个请求只编译/获取一次ExpressionExpression compiled AviatorEvaluator.compile(express, true); Object aviatorResult compiled.execute(paramMap); if (log.isDebugEnabled()) { ListString paramNames compiled.getVariableNames(); }这会把每个请求生成脚本类降低为每个不同脚本文本首次使用时生成脚本类12.2 LRU容量选择超过容量时会淘汰最久未访问的表达式被淘汰脚本下次访问会重新编译。如果活跃脚本数大于容量会产生淘汰 - 重编译 - 再淘汰因此容量应根据数据库中有效脚本去重数量确定并留1.2~2倍余量。完整字符串是默认缓存key空格、注释、换行变化也会产生新key。监控AviatorEvaluator.getInstance().getExpressionCacheSize()如果cache size长期等于上限同时jstat -class中的Loaded仍快速增加说明容量太小、脚本文本不稳定或还有其他无缓存编译路径。12.3 永久修复Aviator至少升级到5.3.3并完整回归脚本语法、函数、返回值和性能。升级后仍要缓存稳定脚本避免每请求编译。更优方案是在策略加载/刷新时按“脚本ID版本”预编译在请求中直接执行Expression。策略下线时显式移除对应Expression生命周期由业务管理。发布后滚动重启旧实例。已经加载的几十万个类不能依赖在线GC快速恢复。不要先通过MaxMetaspaceSize掩盖问题。它只能作为修复完成后的保险丝否则会更早触发OutOfMemoryError: Metaspace OutOfMemoryError: Compressed class space13. 修复后的灰度验收选择一个灰度实例与旧实例保持尽可能相同的流量。记录ps -o pid,rss,vsz,nlwp,etime,cmd -p PID jcmd PID GC.heap_info jstat -gcutil PID 10000 12 jstat -class PID 10000 12建议在以下时间点采样启动后5分钟30分钟2小时业务高峰后策略刷新前后运行24小时后。通过标准表达式缓存预热后cache size进入平台且不超过容量。Loaded启动阶段增长预热后净增长速度接近稳定不再每秒增长几十个脚本类。Metaspace used在预热后进入平台。Compressed Class Space不再持续单调增长。YGC频率和YGCT占比明显低于旧实例。Old区GC后基线稳定。RSS在预热/高峰后形成稳定平台不再随运行天数持续爬升。接口结果、超时率、P95/P99、CPU没有回归。注意Metaspace committed和RSS不一定立即回落所以新旧版本最好使用重启后的实例做同时间窗口对比。14. 快速决策表观测结果优先方向下一步单PID RSS正常监控图很高监控口径查Pod/宿主机/多实例聚合Heap/Old GC后基线持续升高堆内引用Histogram、Heap Dump、MATMetaspace和Loaded持续增长动态类/ClassLoaderjstat -class、VM.classloader_stats大量Script_*类脚本重复编译有界缓存、预编译、升级脚本引擎Heap稳定、Direct持续增长Direct MemoryJMX BufferPool、Netty/Redis客户端Heap/Metaspace/Direct稳定RSS增长native memoryNMT、smaps、agent、glibc arena线程数持续增长线程泄漏jstack、线程名分组、线程池配置RSS上涨后稳定不回落高水位保留看used而非只看committed/RSSFull GC频繁严重内存压力摘流、dump、尽快修复或重启15. 可直接复制的采集清单15.1 第一批低风险ps -o pid,rss,vsz,nlwp,etime,cmd -p PID cat /proc/PID/status | egrep VmRSS|VmSize|VmData|VmStk|VmExe|VmLib|Threads cat /proc/PID/smaps_rollup jcmd PID VM.command_line jcmd PID VM.flags jcmd PID GC.heap_info jstat -gcutil PID 10000 12 jstat -class PID 10000 1215.2 第二批低峰执行jcmd PID VM.classloader_stats OUTPUT_DIR/classloader-stats.txt jstack PID OUTPUT_DIR/jstack.txt15.3 第三批摘流执行jcmd PID GC.class_histogram OUTPUT_DIR/histogram.txt jcmd PID GC.heap_dump OUTPUT_DIR/heap.hprof15.4 下次重启增加-XX:NativeMemoryTrackingsummary -XX:UnlockDiagnosticVMOptions -XX:PrintNMTStatistics然后jcmd PID VM.native_memory baseline jcmd PID VM.native_memory summary.diff scaleMB16. 常见误区认为Xmx1G代表进程最多1G。把VSZ当成实际内存。把线程视图里的RSS相加。看到RSS不下降就认为GC没有回收。只看Heap Dump不看Metaspace、Direct和线程。只看一份Histogram不做时间差分。看到Java Agent就直接归因没有对比ClassLoader数据。用MaxMetaspaceSize代替修复动态类泄漏。启用无界表达式缓存把重复编译问题变成无界缓存问题。LRU容量小于活跃工作集造成持续缓存抖动。修复后不重启旧实例导致历史高水位干扰判断。在生产高峰直接执行Heap Dump或Histogram。17. 事故记录模板服务 环境 实例/IP/Pod PID JDK版本 进程启动时间 监控指标名称和聚合维度 单PID RSS VSZ 线程数 Xms/Xmx/Xss GC类型 Code Cache上限 Direct Memory上限 Java Agent Heap used/committed Metaspace used/committed Class Space used/committed Direct Buffer MemoryUsed/Count 10分钟YGC增量 10分钟YGCT增量 Old区GC后最低点 10分钟Loaded增量 10分钟Unloaded增量 存活类净增长 最大ClassLoader 异常类名前缀 Histogram Top对象 GC Root持有者 根因 临时止血 永久修复 灰度结果 回滚条件按照这套顺序通常可以先在10~20分钟内把问题分流到Heap、Metaspace、Direct、线程或native memory再决定是否承担Heap Dump的线上风险。