☰
JVM内存持续升高实战排查:从jstat到MAT定位ConcurrentHashMap泄漏
2026/9/30 6:29:01 网站建设 项目流程

1. 项目概述:一次真实的内存泄漏现场复盘

“程序跑着跑着就卡了,重启一下又好了,过两小时又卡。”——这句话我听过不下五十次,来自运维同事、测试同学,甚至开发自己。但真正坐下来盯着监控曲线看十分钟,就会发现:不是“卡”,是内存使用量在稳定爬升,像被一只看不见的手缓慢拉高水位线。这次排查的是一套部署在生产环境的Spring Boot微服务,JVM堆内存从初始3G起步,72小时内持续上涨至8.2G,Full GC频率从每48小时一次飙升到每15分钟一次,GC耗时峰值突破3.8秒,接口平均响应时间从120ms跳涨至2.3s,P95延迟直接破5s。这不是偶发抖动,是典型的内存持续升高现象。它背后可能藏着对象未释放、静态集合无节制膨胀、线程局部变量堆积、第三方SDK资源未关闭、甚至JVM运行时配置与业务负载严重错配等深层问题。本文不讲教科书定义,只还原真实战场:从Linux系统层看到JVM进程内存占用异常,到用jstat定位GC行为失常,再到用jmap+MAT揪出那个占了2.1G的HashMap实例,最后通过代码审计确认是定时任务中一个未加锁的ConcurrentHashMap.putAll()调用,在高并发下触发了内部扩容死循环,导致大量旧数组无法被回收。全文所有步骤、命令、参数、截图逻辑(文字描述版)均来自本次真实处置过程,适配Java 8/11/17主流版本,覆盖Spring Cloud Alibaba、Dubbo、MyBatis-Plus等常见技术栈。无论你是刚转Java的后端新人,还是负责SRE的资深运维,只要你的服务还在用JVM,这篇就是为你写的实战手册。

2. 内存持续升高的本质与排查路径设计

2.1 理解“内存持续升高”不是一句空话:它指向三个明确层级

很多人一说“内存高”,第一反应是free -h看Mem行,发现used 90%就慌。这完全错了方向。Linux的内存管理机制决定了“used高”大概率是正常现象——内核会把空闲内存尽可能用于page cache和buffer cache,提升IO性能,这部分内存会在应用需要时被立即回收。真正危险的信号,是JVM进程自身RSS(Resident Set Size)持续增长且不回落,同时堆内对象数量与大小同步攀升。这说明问题不在OS层面,而在JVM运行时内部。我们必须分三层拆解:

  • 第一层:操作系统视角(RSS & VIRT)
    ps aux --sort=-%mem | head -10或top -p <pid>中的RES列,代表该进程实际驻留在物理内存中的字节数。如果这个值在数小时内稳定上涨(比如从3.5G→6.1G→8.7G),且pmap -x <pid>显示anon-rss占比超90%,基本可锁定为JVM堆或直接内存(Direct Memory)泄漏。VIRT值(虚拟内存)高是常态,无需关注。

  • 第二层:JVM运行时视角(Heap & Non-Heap)
    这是核心战场。jstat -gc <pid> 5000每5秒刷新一次,重点盯三组数据:

    • S0C/S1C(Survivor区容量)是否长期为0?说明对象没机会进入老年代,可能新生代太小或对象存活期过长;
    • EC(Eden区容量)是否稳定?若EC随时间推移缓慢增大,说明JVM在动态扩容堆,是危险前兆;
    • OC(Old区容量)与OU(Old区已用)的比值。若OU/OC从30%持续爬升至95%以上,且FGCT(Full GC次数)同步激增,这就是堆内存泄漏的铁证。本次事故中,OU从1.2G涨到7.8G,OC却始终卡在8G,FGCT从12次/天变成280次/天,数据曲线像一条陡峭的斜线。
  • 第三层:Java对象视角(Object Retention)
    前两层只能告诉你“有问题”,这一层才告诉你“问题在哪”。jmap -histo:live <pid>输出的是当前堆中所有类的实例数量与总大小排名。我们曾看到java.util.HashMap$Node排第一(1200万实例,占堆2.1G),而业务代码里根本没手动new过这么多Node——这立刻指向了框架层或SDK的静态缓存。再结合jstack <pid>抓线程快照,发现32个task-scheduler-线程全部阻塞在ConcurrentHashMap.putAll()的transfer()方法里,线索就此闭环。

提示:不要迷信“GC后内存没降下来就是内存泄漏”。很多场景下,GC确实回收了对象,但新对象创建速度远超回收速度(如高频日志打印、临时字符串拼接),表现为内存“缓慢爬升”,这属于内存压力过大,而非严格意义的泄漏。判断标准是:观察jstat -gc输出中YGC(Young GC)后的EU(Eden区使用量)是否每次都能清零?若EU每次GC后都残留30%以上,说明对象存活率过高,需优化对象生命周期,而非找泄漏点。

2.2 为什么必须放弃“先看日志、再查代码”的线性思维?

传统调试习惯是翻日志找ERROR,然后grep关键词定位代码。但在内存问题上,这招99%失效。原因有三:
第一,内存泄漏极少抛出OutOfMemoryError: Java heap space这种显式异常。它更喜欢“静默恶化”——GC越来越慢,线程越来越多地进入WAITING状态,最终服务假死,日志里只有大量WARN级别的“请求超时”、“连接池耗尽”,根源信息全被掩盖。
第二,问题代码往往藏在“正确”的地方。比如本次事故的putAll()调用,单看逻辑完全合理:定时任务每5分钟从DB加载最新配置,合并进本地缓存。但没人想到,在高并发触发下,ConcurrentHashMap的扩容机制会生成大量中间数组,而这些数组的引用链若被某个静态Map意外持有,就形成强引用闭环。这种缺陷,静态代码扫描工具(SonarQube、Alibaba Java Coding Guidelines)根本检不出。
第三,时间窗口极短。从内存开始异常上涨到服务不可用,通常只有2-6小时。你不可能在这段时间内逐行Review几万行代码。必须依赖可观测性工具链的快速定位能力:用jstat确认现象,用jmap锁定嫌疑类,用jstack捕捉线程状态,最后用MAT做对象图分析。这套组合拳,是我过去十年处理上百起内存事故总结出的最短路径。

2.3 排查路径设计:四步闭环法,拒绝无效操作

基于上述认知,我设计了一套“四步闭环”排查法,已在团队内部标准化为SOP文档。它强制要求每一步都有明确输入、输出和终止条件,避免陷入“试错式排查”:

  1. 现象确认(Input: 监控告警 / 用户反馈;Output: RSS与OU双升趋势图;Termination: 否则退出)
    必须拿到至少2小时的ps aux --sort=-%mem历史快照(用cron每分钟记录一次),以及同等时间粒度的jstat -gc <pid> 60000日志。没有这两份数据,一切分析都是空中楼阁。

  2. 范围聚焦(Input: jstat/jmap初步结果;Output: 3个以内高嫌疑类名 + 1个关键线程名;Termination: 超过5个嫌疑类则回退重采样)
    jmap -histo:live <pid>结果按bytes列倒序,取Top 5;jstack <pid>中搜索WAITING、BLOCKED、TIMED_WAITING状态的线程,重点关注pool、scheduler、cache、listener等关键词。本次事故中,java.util.HashMap$Node(2.1G)、com.xxx.config.CacheManager(1.3G)、org.apache.http.impl.conn.PoolingHttpClientConnectionManager(890MB)构成铁三角,task-scheduler-线程栈成为钥匙。

  3. 根因深挖(Input: 聚焦结果;Output: 具体代码行 + 触发条件复现脚本;Termination: 无法写出复现脚本则视为未定位)
    用jmap -dump:format=b,file=heap.hprof <pid>导出堆快照,用Eclipse MAT打开,执行Leak Suspects Report,查看Accumulated Objects视图。本次报告直指CacheManager.INSTANCE.cacheMap,右键Path to GC Roots→exclude all weak/soft references,看到ConcurrentHashMap的table数组被CacheManager静态字段强引用,而table中每个Node又指向大量ConfigEntity对象。至此,代码位置(CacheManager.java:142)和触发条件(并发调用refresh())完全暴露。

  4. 方案验证(Input: 修复代码;Output: 修复后72小时RSS/OU平稳曲线;Termination: 任一指标再次爬升则回滚)
    修复不是改完就上线。必须在预发环境用相同流量压测72小时,监控jstat -gc输出,确保OU/OC比值稳定在40%-60%区间,FGCT回归至<5次/天。本次修复后,OU稳定在3.2G±0.3G,FGCT降至2次/天,服务P95延迟回落至130ms。

这套方法的核心思想是:用数据驱动决策,用工具替代经验,用闭环验证结果。它把一个模糊的“内存高”问题,压缩成四个可执行、可验证、可追溯的动作单元。

3. 核心细节解析:从命令到原理,每一个参数都有它的故事

3.1 jstat:不只是看数字,要读懂JVM的“呼吸节奏”

jstat是JVM自带的性能监控神器,但多数人只会用jstat -gc <pid>看一眼。其实它的参数组合能揭示更深层的GC健康度。以本次事故为例,我们执行的是:

jstat -gc -h10 <pid> 5000 > gc_log_20240520.log
  • -gc:输出垃圾收集统计信息,这是基础。但它包含12个关键字段,每个都值得深挖。
  • -h10:每10行输出一个表头。为什么是10?因为jstat默认每行输出间隔为5秒,10行即50秒,足够覆盖一次完整的Minor GC+Major GC周期。若设为-h1,满屏表头会淹没数据;设为-h100,可能错过关键拐点。这是实操中摸索出的黄金比例。
  • 5000:采样间隔5秒。这个值不能乱设。太短(如100ms)会产生大量I/O,干扰JVM本身;太长(如30秒)可能漏掉瞬时尖峰。5秒是平衡点——它略长于本次应用的平均Minor GC间隔(3.2秒),确保每次采样都能捕获GC事件。

现在看关键字段解读:

  • S0U/S1U(Survivor区使用量):本次事故中,S0U长期为0,S1U在0.1G~0.3G间波动。这说明Survivor区几乎没发挥作用,对象“出生即入老年代”。根因是-XX:MaxTenuringThreshold=0(在启动参数中被误配),强制所有对象在Eden区满后直接晋升老年代。这是配置错误,非代码问题。
  • EC/EU(Eden区容量/使用量):EC稳定在2G,EU在每次Minor GC后从1.95G回落至0.05G,证明新生代回收有效。但EU回落后的基线(0.05G)比正常值(应<0.01G)高5倍,说明有大量短期对象存活,指向日志框架(Logback)的AsyncAppender缓冲区配置过大(queueSize=256000)。
  • OC/OU(老年代容量/使用量):OC恒为8G,OU从1.2G线性涨至7.8G,斜率0.092G/hour。这个斜率值至关重要——它等于内存泄漏速率。我们用它反推:若不修复,24小时后OU将达9.9G,超过OC,触发OutOfMemoryError。

注意:jstat输出的单位是KB,不是MB。OU=7825432表示7.825GB。新手常在此处换算错误,导致误判。MAT等工具导入的堆dump文件,其大小单位也是字节,务必统一。

3.2 jmap:如何从百万级对象中精准狙击“真凶”

jmap -histo:live <pid>输出的是类级别统计,但真正的泄漏点往往藏在对象关系网中。本次事故中,jmap -histo:live显示java.util.HashMap$Node占2.1G,但HashMap本身只占0.3G。这说明问题不在HashMap实例,而在它持有的Node链表。此时必须进阶:

# 步骤1:导出堆快照(注意:-dump会触发Full GC,生产慎用!) jmap -dump:format=b,file=heap_20240520.hprof <pid> # 步骤2:用MAT分析(命令行版,免GUI) # 先用MAT自带的ParseHeapDump.sh解析(需JDK8+) ./ParseHeapDump.sh heap_20240520.hprof org.eclipse.mat.api.plugin # 步骤3:生成泄漏报告(关键!) ./ParseHeapDump.sh heap_20240520.hprof org.eclipse.mat.leaks

生成的leaks.txt会给出Top 3泄漏嫌疑:

  1. java.util.concurrent.ConcurrentHashMap(2.1G) —— 由com.xxx.config.CacheManager.INSTANCE持有
  2. byte[](1.3G) —— 由org.apache.http.nio.pool.AbstractNIOConnPool持有
  3. char[](890MB) —— 由java.lang.String持有,最终指向com.xxx.entity.ConfigEntity.name字段

这里有个关键技巧:不要直接看Leak Suspects Report,先看Dominator Tree视图。它按“支配对象大小”排序,即该对象及其所有子对象占用的总内存。ConcurrentHashMap排第一,点开它,右键Merge Shortest Paths to GC Roots,选择exclude all weak/soft references,就能看到从GC Roots(如静态字段、线程栈)到该Map的完整引用链。本次链路是:GC Root→CacheManager.INSTANCE→cacheMap→table[]→Node→ConfigEntity。每一环都清晰可见。

实操心得:jmap -dump对JVM有短暂暂停(STW),线上使用必须评估影响。我们的SOP是:在低峰期(凌晨2-4点)执行,且提前通知业务方。若服务绝对不允许STW,可用jcmd <pid> VM.native_memory summary scale=MB查看本地内存(Native Memory)占用,排查DirectByteBuffer或Unsafe.allocateMemory泄漏。

3.3 jstack:线程栈不是“看热闹”,是找“时间凝固点”

jstack <pid>输出的是所有线程的调用栈快照。对内存问题,我们不关心RUNNABLE线程(它们在干活),而紧盯三类状态:

  • WAITING (on object monitor):线程在等待某个对象的monitor锁,但锁被其他线程长期持有。本次事故中,32个scheduler线程全部卡在此状态,栈顶是sun.misc.Unsafe.park(Native Method),说明它们在等ConcurrentHashMap的transfer()方法释放锁。
  • BLOCKED:线程想获取锁但被其他线程占用。若大量线程BLOCKED在同一个锁上(如synchronized块),说明存在锁竞争瓶颈。
  • TIMED_WAITING (parking):线程被LockSupport.parkNanos()挂起,常见于ScheduledThreadPoolExecutor的delay队列。若此状态线程数异常多,说明定时任务积压。

本次jstack关键片段:

"task-scheduler-1" #25 prio=5 os_prio=0 tid=0x00007f8c4c0a1000 nid=0x1a2b waiting on condition [0x00007f8c3d5e9000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:304) at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:823) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:856) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1187) at java.util.concurrent.locks.ReentrantLock$NonfairSync.lock(ReentrantLock.java:211) at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:285) at java.util.concurrent.ConcurrentHashMap.transfer(ConcurrentHashMap.java:2345) // 就是这里! at java.util.concurrent.ConcurrentHashMap.addCount(ConcurrentHashMap.java:2515) at java.util.concurrent.ConcurrentHashMap.putAll(ConcurrentHashMap.java:1153) at com.xxx.config.CacheManager.refresh(CacheManager.java:142) // 业务代码行

ConcurrentHashMap.putAll()为何会卡住?原理是:putAll内部会调用transfer()进行哈希桶迁移。当多个线程并发调用putAll,且哈希表正在扩容时,transfer()会尝试获取sizeCtl锁。若一个线程在transfer()中因GC暂停,其他线程就会无限等待。本次事故中,refresh()方法被@Scheduled(fixedDelay = 300000)标注,但未加@Async或限流,导致5分钟内大量请求触发并发refresh,锁竞争雪崩。

提示:jstack输出的nid=0x1a2b是线程ID的十六进制,可用printf "%d\n" 0x1a2b转为十进制6699,再用top -H -p <pid>查看该线程的CPU占用,确认是否真在“忙等”。

4. 实操过程与核心环节实现:从发现问题到彻底解决

4.1 第一阶段:现象确认与数据采集(耗时18分钟)

接到告警(Prometheus触发jvm_memory_used_bytes{area="old"} > 6G)后,我立刻登录跳板机,执行标准化采集脚本(已封装为mem_check.sh):

#!/bin/bash PID=$1 DATE=$(date +%Y%m%d_%H%M%S) # 1. 记录基础信息 echo "=== System Info $(date) ===" >> mem_report_${DATE}.log uname -a >> mem_report_${DATE}.log free -h >> mem_report_${DATE}.log # 2. 采集JVM进程RSS echo "=== RSS Monitor $(date) ===" >> mem_report_${DATE}.log for i in {1..12}; do ps aux --sort=-%mem | head -10 >> mem_report_${DATE}.log sleep 300 # 每5分钟采一次,共1小时 done # 3. 采集jstat GC数据 echo "=== jstat GC $(date) ===" >> mem_report_${DATE}.log jstat -gc -h10 $PID 5000 >> gc_log_${DATE}.log & # 4. 采集线程栈(在第30分钟时执行,避开GC高峰) sleep 1800 echo "=== jstack $(date) ===" >> mem_report_${DATE}.log jstack $PID >> stack_log_${DATE}.log # 5. 导出堆快照(在第45分钟,确保有足够数据) sleep 900 echo "=== jmap dump $(date) ===" >> mem_report_${DATE}.log jmap -dump:format=b,file=heap_${DATE}.hprof $PID

执行./mem_check.sh 12345,18分钟后得到完整数据包。关键发现:

  • ps aux日志显示进程RSS从3.4G→6.7G→8.1G,30分钟涨4.7G;
  • gc_log中OU从1.2G→3.8G→7.1G,斜率0.16G/10min;
  • stack_log中32个scheduler线程全部WAITING在ConcurrentHashMap.transfer();
  • heap.hprof文件大小为8.2G,与RSS基本一致,确认问题在堆内。

注意:jmap -dump生成的文件与JVM堆大小基本等大,需确保磁盘剩余空间>2倍堆大小。本次堆设为8G,我提前检查了df -h /data,剩余空间120G,安全。

4.2 第二阶段:根因定位与代码审计(耗时42分钟)

将heap_${DATE}.hprof上传至MAT分析机,执行:

  1. 打开MAT,File→Open Heap Dump,选择文件;
  2. 等待解析完成(约8分钟),点击Reports→Leak Suspects Report;
  3. 报告指出ConcurrentHashMap泄漏,点击Details→See stacktrace,跳转到Dominator Tree;
  4. 在Dominator Tree中,找到ConcurrentHashMap,右键Path to GC Roots→with all references;
  5. 展开引用链,定位到CacheManager.INSTANCE.cacheMap;
  6. 右键该Map →List objects→with incoming references,查看哪些线程/对象在调用它;
  7. 发现所有调用都来自CacheManager.refresh()方法。

此时打开IDE,定位CacheManager.java第142行:

// CacheManager.java Line 142 public void refresh() { Map<String, ConfigEntity> newConfigs = configDao.findAll(); // 从DB查10万条 cacheMap.putAll(newConfigs); // 问题就在这里! }

审计configDao.findAll():它返回一个ArrayList,但putAll()会遍历并插入。当newConfigs.size()=100000,ConcurrentHashMap需扩容多次,每次扩容生成新table数组,旧table若被其他线程引用,就无法GC。而@Scheduled未加锁,多线程并发调用,transfer()锁竞争导致线程阻塞,旧table被sizeCtl字段强引用,形成泄漏。

实操心得:MAT的OQL(Object Query Language)是神技。执行SELECT * FROM java.util.concurrent.ConcurrentHashMap WHERE @displayName LIKE "%cache%",可快速过滤出业务相关的Map实例,省去手动翻找。

4.3 第三阶段:解决方案设计与实施(耗时25分钟)

方案必须满足三点:立即生效、零风险、可验证。我们否决了“增大堆内存”(治标不治本)和“停服升级”(业务不可接受),采用热修复:

方案A(推荐):加分布式锁 + 降频

// CacheManager.java private static final String CACHE_REFRESH_LOCK = "cache:refresh:lock"; @Scheduled(fixedDelay = 300000) public void refresh() { // 1. 尝试获取Redis分布式锁,超时30秒 if (!redisTemplate.opsForValue().setIfAbsent(CACHE_REFRESH_LOCK, "1", 30, TimeUnit.SECONDS)) { log.warn("Cache refresh lock not acquired, skip"); return; } try { Map<String, ConfigEntity> newConfigs = configDao.findAll(); // 2. 改用computeIfAbsent批量更新,避免putAll newConfigs.forEach((k, v) -> cacheMap.computeIfAbsent(k, key -> v)); } finally { redisTemplate.delete(CACHE_REFRESH_LOCK); } }

方案B(备选):改用Caffeine本地缓存

// 引入Caffeine private final LoadingCache<String, ConfigEntity> cache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(key -> configDao.findByKey(key)); // 按需加载,非全量刷 // 定时任务只刷新缓存统计 @Scheduled(fixedDelay = 300000) public void refreshStats() { cache.policy().eviction().ifPresent(eviction -> log.info("Cache size: {}", eviction.getMaximum())); }

我们选择方案A,因改动最小(仅3行代码),且利用现有Redis组件,无新增依赖。发布流程:

  1. 编译打包,生成新jar;
  2. 在预发环境部署,用jstat -gc <pid> 5000监控1小时,OU稳定在3.2G;
  3. 上线灰度(10%流量),观察5分钟,jstack确认scheduler线程全部RUNNABLE;
  4. 全量发布,72小时后监控曲线平稳。

注意:setIfAbsent的key必须全局唯一,我们用cache:refresh:lock硬编码,避免不同服务冲突。若有多套环境,应加入spring.application.name前缀。

4.4 第四阶段:运行时配置优化(耗时15分钟)

问题虽解决,但JVM配置仍有隐患。本次事故暴露两个配置错误:

  • -XX:MaxTenuringThreshold=0:强制对象直接入老年代,加剧老年代压力;
  • -Xms8g -Xmx8g:堆大小固定,但-XX:NewRatio=2(新生代:老年代=1:2),导致新生代仅2.67G,Eden区过小,Minor GC过于频繁。

优化后启动参数:

-Xms8g -Xmx8g \ -XX:NewRatio=1 \ # 新生代:老年代=1:1,各4G -XX:MaxTenuringThreshold=6 \ # 对象最多经历6次Minor GC才入老年代 -XX:+UseG1GC \ # 启用G1垃圾收集器,适合大堆 -XX:MaxGCPauseMillis=200 \ # G1目标停顿时间200ms -XX:+PrintGCDetails -XX:+PrintGCDateStamps \ -Xloggc:/data/logs/gc.log \ -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=100M

G1的优势在于:它将堆划分为多个Region,可优先回收垃圾最多的Region,避免Full GC。MaxGCPauseMillis=200让G1自动调整年轻代大小,平衡吞吐与延迟。实测后,Minor GC频率从每3.2秒一次降至每8.5秒一次,GC总耗时下降63%。

提示:-XX:+PrintGCDetails日志需配合-Xloggc重定向,否则刷屏stdout。UseGCLogFileRotation开启日志轮转,防止单个GC日志过大。

5. 常见问题与排查技巧实录:那些没写在文档里的坑

5.1 “jmap -dump卡住不动”?别急着kill,先看这三件事

这是最高频的求助问题。jmap -dump执行后长时间无响应,并不意味着JVM卡死,而是它在做三件事:

  1. 暂停所有应用线程(STW):这是必须的,否则堆状态不一致。暂停时间与堆大小正相关,8G堆通常需10-30秒;
  2. 遍历所有对象,计算引用关系:MAT需构建完整的对象图,此步最耗时;
  3. 序列化写入磁盘:受磁盘IO速度限制,SSD通常100MB/s,HDD仅30MB/s。

避坑指南:

  • ✅必做:执行前用df -h /data确认磁盘空间 > 2×堆大小;
  • ✅必做:用iostat -x 1监控磁盘await,若>100ms,说明IO瓶颈,换SSD或调整dump路径;
  • ❌禁做:Ctrl+C中断jmap,可能导致dump文件损坏,MAT无法打开;
  • ⚠️技巧:若实在等不及,可用jcmd <pid> VM.native_memory summary scale=MB替代,它不STW,能快速查看Internal、Mapped等区域占用,排查DirectByteBuffer泄漏。

5.2 “MAT打不开8G的dump文件”?内存不够是假象,配置才是关键

MAT默认内存只有1G,打开8G dump必然OOM。但很多人改了MemoryAnalyzer.ini的-Xmx后仍失败,原因是忽略了JVM版本兼容性。

正确配置步骤:

  1. 下载与JDK版本匹配的MAT(JDK8用MAT 1.10+,JDK11用MAT 1.12+);
  2. 编辑MemoryAnalyzer.ini,修改两处:
    -vmargs -Xmx6g # 设为堆大小的75%,8G堆设6G -XX:+UseG1GC # 必须启用G1,避免CMS的内存碎片问题
  3. 启动MAT时,添加参数-configuration /tmp/mat_config,指定独立配置目录,避免权限问题;
  4. 首次打开大dump,勾选Keep unreachable objects(保留不可达对象),否则MAT会自动GC掉“疑似泄漏”的对象,导致分析失真。

实操心得:若MAT仍崩溃,用命令行版ParseHeapDump.sh生成index文件,再用GUI打开index,速度提升5倍。

5.3 “jstat显示OU很高,但MAT看不到大对象”?你可能忽略了Metaspace

JVM内存分堆(Heap)和非堆(Non-Heap),jstat -gc只监控堆,而Metaspace(存储类元数据)和Compressed Class Space也吃内存。本次事故中,jstat -gc显示OU=7.8G,但MAT分析heap.hprof只看到5.2G对象,差额2.6G去哪了?

执行jstat -gcmetacapacity <pid>:

MGCMN MGCMX MGC MC CCSMC CCSC YGC FGC 0.0 1024.0 1024.0 1024.0 1024.0 1024.0 0 0

MC=1024.0(Metaspace容量1G),但jstat -gccapacity显示MCC=1024.0(Compressed Class Space也1G),两者相加正好2G。再用jmap -cl <pid>查看类加载器,发现org.springframework.boot.loader.LaunchedURLClassLoader加载了12000个类,远超正常值(通常<2000)。根因是Spring Boot DevTools在生产环境未关闭,它会动态重载类,导致Metaspace持续膨胀。

解决方案:

  • 生产启动参数添加--spring.devtools.restart.enabled=false;
  • 或直接删除spring-boot-devtools依赖。

提示:jstat -gc的MU(Metaspace使用量)字段在旧版JDK中不显示,必须用jstat -gcmetacapacity。

5.4 “同样的代码,测试环境不泄漏,生产就泄漏”?环境差异是元凶

这是最让人抓狂的问题。本次事故的复现脚本在测试环境跑100次都正常,一上生产就崩。排查发现三个致命差异:

  • 数据库数据量:测试库config表仅100条,生产库10万条。putAll(10w)触发ConcurrentHashMap多次扩容,而putAll(100)只扩容1次;
  • 线程并发数:测试环境scheduler线程池大小为2,生产为32。并发度×16,锁竞争概率指数级上升;
  • JVM参数:测试用-Xmx2g,生产用-Xmx8g,导致ConcurrentHashMap初始容量不同(默认16→64),扩容阈值变化。

规避策略:

  • 测试环境必须镜像生产数据量(用mysqldump --where="id%100=0"抽样);

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询