系列线上问题实战录 | OOM / 内存泄漏类 · 第 2 篇本文所有命令和输出均来自真实复现环境可照步骤重现 案例属于线上问题实战录系列叙事框架现象 → 排查过程 → 根因 → 修复 → 预防1. 问题现象1.1 告警某日下午 14:23告警群弹出告警内容FullGC 频率28 次/小时阈值 5 次/小时老年代使用率97.1%阈值 85%接口 p992856ms阈值 500ms错误率8.7%这不是第一次发生了。两周前上线的订单缓存优化功能后FullGC 频率就一直在缓慢爬升——第 1 天正常第 3 天每天 2-3 次第 5 天每小时 5-6 次到第 7 天直接飙到 28 次/小时。1.2 区别于普通 FullGC 的特征特征正常 FullGC本次问题FullGC 后老年代变化显著下降30-50%几乎不变5%FullGC 频率稳定或波动持续上升CPU 受影响短暂升高持续 180%重启后恢复问题复现间隔随机几天后必然复现核心特征FullGC 后老年代只增不减。这说明对象全部是 GC Root 可达回收不掉。1.3 快速止血$sshorder-prod-03 $psaux|grepjavaappuser24512181%17.2java-Xmx2g-Xms2g-jarorder-service.jar先重启一台恢复业务但所有人都知道不找到泄漏点更多机器会出同样的问题。2. 排查过程2.1 top——CPU 181%RES 2.7g信息量很大top-10:32:18 up5days,14:22,3users, load average:7.82,5.14,3.27%Cpu(s):34.2us,12.1sy,0.0ni,48.2id,5.2wa,0.3hi,0.0si MiB Swap:2048.0total,187.2free,1860.8used.4963.9avail Mem PIDUSERPR NI VIRT RES SHR S %CPU %MEM TIME COMMAND24512appuser2005.2g2.7g24512S181.317.2124:32.56java指标值含义CPU181.3%远超正常基线40-60%RES2.7g超过-Xmx2g说明有堆外内存Swap1860.8/2048 used91% 的 Swap 被使用物理内存吃紧Load7.828 核机器 load 接近核数Java 进程 CPU 181%、RES 2.7g堆只设了 2g、Swap 用了 90%——内存不仅堆内泄漏堆外也有问题。2.2 jstat -gcutil——FullGC 每秒都在增加$ jstat-gcutil245122s10采样O 区%FGC 累计增量145.67%12-246.89%120352.34%131458.67%141563.45%151670.23%172778.56%192885.34%212992.45%2431097.12%284不到 20 秒FGC 从 12 次涨到 28 次Old 区从 45% 涨到 97%。关键信号Old 区持续上涨FullGC 回收后几乎没有降→ 内存泄漏FGC 频率在加速增加从每 2 秒 1 到每 2 秒 4→ 老年代越来越满触发更频繁2.3 jstat -gc——OU 从 638MB→1.3GB 只增不减$ jstat-gc245122s8各区容量OC1398272KB1.33GB 全部已分配OU 变化趋势624MB → 636MB → 704MB → 800MB → 896MB → 1024MB → 1152MB → 1280MBOU 从 624MB 涨到 1280MB1.25GB每个 FullGC 后 OU 不仅不降还在涨。FullGC 没帮上忙光 CPU 烧了。2.4 jmap -histo——char[] 687MB String 229MB 内存都在这$ jmap-histo:live24512|head-25#实例数字节类名11,432,896687,790,080char[]21,432,896229,663,248java.lang.String3673,482215,514,240byte[]4245,891125,634,896int[]5145,63258,252,800HashMap$Node689,12357,038,720OrderInfo782,45646,175,360Object[]char[] String 917MB占老年代近一半。OrderInfo 对象 89k 个每个约 640 字节。String 暴增说明什么OrderInfo 里的orderId、productId全是 String。char[] 和 String 的数量完全一样1,432,896说明每个 String 对应一个 char[]。字符串没有被回收全部滞留在老年代。2.5 jmap dump——抓 Heap Dump 用 MAT 分析$ jmap -dump:live,formatb,file/tmp/heap-24512.hprof24512$scpappuserorder-prod-03:/tmp/heap-24512.hprof ~/analysis/hprof 文件 2.1GB通过 scp 下载到本地约 35MB/s耗时 1 分钟。2.6 MAT 分析——Leak Suspect 直接定位问题Problem Suspect1of1:92.45% of heap(1.94GB)is retained by one GC rootsetShortest Paths To the Accumulation Point └─ class java.util.concurrent.ConcurrentHashMap 0x7a3b5c00 └─ OrderCacheController.LEAKY_CACHE └─89,123instances of OrderInfo(avg640bytes)Suspect Description: 静态字段 OrderCacheController.LEAKY_CACHE 持有 所有 OrderInfo 对象的强引用缓存没有淘汰策略。MAT 直接告诉我们静态 ConcurrentHashMap 持有 89,123 个 OrderInfo 对象占堆的 92.45%。3. 根因分析3.1 问题代码RestControllerpublicclassOrderCacheController{// V1: 无淘汰策略的本地缓存 — 内存泄漏元凶privatestaticfinalMapString,ListOrderInfoLEAKY_CACHEnewConcurrentHashMap();GetMapping(/leak)publicStringleak(RequestParamStringkey){ListOrderInfoordersLEAKY_CACHE.computeIfAbsent(key,k-newArrayList());orders.add(newOrderInfo(ORD_System.currentTimeMillis(),PROD_ThreadLocalRandom.current().nextInt(1000),Math.random()*1000));returncached orders.size() orders for key;}staticclassOrderInfo{StringorderId;StringproductId;doubleamount;byte[]paddingnewbyte[10*1024];}}问题在这行LEAKY_CACHE.computeIfAbsent(key, k - new ArrayList())每次/leak调用如果 key 不存在就创建新列表并 put 到 Map 里。但没有任何地方 remove 或淘汰。随着使用时间增长不同用户 key 越来越多每个用户的订单列表越来越长OrderInfo 内的orderIdproductId每次新创建 String → char[]10KBpadding字节数组也会累积3.2 为什么 FullGC 回收不掉静态变量 LEAKY_CACHE(GC Root)└─ ConcurrentHashMap$Node×47,823└─ key: String(userId)└─ value: ArrayListOrderInfo└─ OrderInfo ×89,123├─ orderId: String → char[]├─ productId: String → char[]└─ padding: byte[10KB]静态字段是GC Root。从 GC Root 可达的对象FullGC 不会回收。ConcurrentHashMap被静态变量引用 → 所有Node都不回收每个 Node 的 key/value 都强引用 → 所有 key/value 都不回收每个 OrderInfo 里的 String、byte[] 都不回收这就是 FullGC 越来越频繁的原因老年代的垃圾回收不掉每次 FullGC 都在做无用功。越收不掉Old 区越满触发 FullGC 越快形成死亡螺旋。3.3 死亡螺旋缓存累积 → 老年代占用 ↑ → FullGC 更频繁 ↑ ↓ ← FullGC 收不掉 ← 对象全部 GC Root 可达3.4 为什么上线时没发现原因说明测试数据量小测试环境只有几十个用户缓存不会膨胀监控阈值宽松老年代告警设了 95%但到达时已经累积了大量数据渐进式暴露第 1-2 天正常第 3 天开始出现被误认为正常波动FullGC 被容忍每天几次 FullGC 在 Java 应用里被认为是正常的4. 修复方案4.1 修复代码RestControllerpublicclassOrderCacheController{// V2: Guava Cache — 最大 10000 条30 分钟过期privatestaticfinalCacheString,ListOrderInfoORDER_CACHECaffeine.newBuilder().maximumSize(10_000).expireAfterWrite(30,TimeUnit.MINUTES).recordStats().build();GetMapping(/leak)publicStringleak(RequestParamStringkey){ListOrderInfoordersORDER_CACHE.get(key,k-newCopyOnWriteArrayList());orders.add(newOrderInfo(ORD_System.currentTimeMillis(),PROD_ThreadLocalRandom.current().nextInt(1000),Math.random()*1000));returncached orders.size() orders for key;}}核心改动不再用ConcurrentHashMap→ 用 Caffeine CachemaximumSize(10_000)→ 最多缓存 10000 条超过后淘汰最近最少使用的expireAfterWrite(30, TimeUnit.MINUTES)→ 写入后 30 分钟自动过期CopyOnWriteArrayList→ 避免并发修改同时读多写少场景4.2 JVM 预防参数除了修复代码还要加上 JVM 参数作为安全网# ① 发生 OOM 时自动生成 Heap Dump-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath/var/log/heapdump/# ② FullGC 前生成 Dump辅助分析泄漏-XX:HeapDumpBeforeFullGC# ③ GC 日志轮转-Xloggc:/var/log/gc.log-XX:UseGCLogFileRotation-XX:NumberOfGCLogFiles5-XX:GCLogFileSize100M# ④ OOM 后自动退出K8s 环境由 liveness probe 拉起-XX:ExitOnOutOfMemoryError4.3 部署策略灰度一台 → 观察30分钟 → 全量上线 → 监控24小时5. 验证结果指标修复前修复后FullGC 频率28 次/小时2 次/小时老年代使用率97.1%62.3%CPU181.3%38.5%接口 p992856ms123ms错误率8.7%0.02%修复上线后 30 分钟Old 区从 97% 降到 62%——原来旧对象被缓存持有无法回收现在过期后自然淘汰了。FullGC 频率从每小时 28 次降到 2 次那 2 次是业务正常的 FullGC。6. 避坑建议6.1 缓存必须有上限本地缓存不用HashMap/ConcurrentHashMap。这些数据结构没有淘汰策略是内存泄漏的常见元凶。方案淘汰策略适用场景Caffeine CachemaximumSize expireAfterWrite通用本地缓存Guava CachemaximumSize expireAfterAccess同上Caffeine 的升级版LRU LinkedHashMapremoveEldestEntry简单场景RedisTTL maxmemory-policy分布式缓存6.2 内存泄漏的判断标准不是所有 FullGC 频繁都是泄漏。判断标准场景FullGC 回收效果是否泄漏流量突增Young/Old 同时下降否GC 压力大但有效大对象直接进入老年代Old 显著下降否GC 正常工作对象全部 GC Root 可达Old 几乎不变是一句话FullGC 后 Old 区占比降不下来 内存泄漏。6.3 内存泄漏的排查工具链从外到内逐层深入top→ 发现进程异常 ↓ jstat → 确认 GC 异常 ↓ jmap-histo→ 定位异常对象 ↓ jmap-dump→ 抓取现场 ↓ MAT → Leak Suspect Report → GC Root 链 → 代码定位6.4 预防胜于修复每次代码上线前Review 是否有无上限的集合/缓存每台机器加 JVM 参数HeapDumpOnOutOfMemoryError是标配监控告警老年代使用率 80% 就要关注不是等到 95%FullGC 频率监控每小时 5 次就要排查总结内存泄漏的根本特征是FullGC 后老年代只增不减。排查工具链从top到jstat到jmap到MAT逐层定位。修复核心是给缓存加上限maximumSize和过期expireAfterWrite。预防比修复更重要——HeapDumpOnOutOfMemoryError是每台 Java 机器的标配参数不要等到问题发生了才想起来加。附完整命令清单进程与 CPU 排查top-b-n1|head-25# 进程 CPU/内存排行ps-ppid-opid,rss,%cpu,args# 单进程详情free-h# 内存 Swap 使用GC 诊断jstat-gcutilpid2s10# GC 统计每秒采样核心命令jstat-gcpid2s8# 各区容量与使用量jstat-gccapacitypid# 各区容量配置jstat-gcoldpid# 老年代详情Heap Dumpjmap-histo:livepid|head-25# 存活对象直方图jmap -dump:live,formatb,filedump.hprofpid# 抓取 Heap Dump本地分析# Eclipse MAT / jhat / JVisualVM# MAT: File → Open Heap Dump → Leak Suspect ReportJVM 预防参数-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath/var/log/heapdump/-XX:HeapDumpBeforeFullGC-Xloggc:/var/log/gc.log-XX:UseGCLogFileRotation-XX:NumberOfGCLogFiles5-XX:GCLogFileSize100M-XX:ExitOnOutOfMemoryError