ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JVM内存持续升高实战排查:从jstat到MAT定位ConcurrentHashMap泄漏

JVM内存持续升高实战排查:从jstat到MAT定位ConcurrentHashMap泄漏 1. 项目概述一次真实的内存泄漏现场复盘“程序跑着跑着就卡了重启一下又好了过两小时又卡。”——这句话我听过不下五十次来自运维同事、测试同学甚至开发自己。但真正坐下来盯着监控曲线看十分钟就会发现不是“卡”是内存使用量在稳定爬升像被一只看不见的手缓慢拉高水位线。这次排查的是一套部署在生产环境的Spring Boot微服务JVM堆内存从初始3G起步72小时内持续上涨至8.2GFull GC频率从每48小时一次飙升到每15分钟一次GC耗时峰值突破3.8秒接口平均响应时间从120ms跳涨至2.3sP95延迟直接破5s。这不是偶发抖动是典型的内存持续升高现象。它背后可能藏着对象未释放、静态集合无节制膨胀、线程局部变量堆积、第三方SDK资源未关闭、甚至JVM运行时配置与业务负载严重错配等深层问题。本文不讲教科书定义只还原真实战场从Linux系统层看到JVM进程内存占用异常到用jstat定位GC行为失常再到用jmapMAT揪出那个占了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进程自身RSSResident Set Size持续增长且不回落同时堆内对象数量与大小同步攀升。这说明问题不在OS层面而在JVM运行时内部。我们必须分三层拆解第一层操作系统视角RSS VIRTps 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/S1CSurvivor区容量是否长期为0说明对象没机会进入老年代可能新生代太小或对象存活期过长ECEden区容量是否稳定若EC随时间推移缓慢增大说明JVM在动态扩容堆是危险前兆OCOld区容量与OUOld区已用的比值。若OU/OC从30%持续爬升至95%以上且FGCTFull GC次数同步激增这就是堆内存泄漏的铁证。本次事故中OU从1.2G涨到7.8GOC却始终卡在8GFGCT从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输出中YGCYoung GC后的EUEden区使用量是否每次都能清零若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文档。它强制要求每一步都有明确输入、输出和终止条件避免陷入“试错式排查”现象确认Input: 监控告警 / 用户反馈Output: RSS与OU双升趋势图Termination: 否则退出必须拿到至少2小时的ps aux --sort-%mem历史快照用cron每分钟记录一次以及同等时间粒度的jstat -gc pid 60000日志。没有这两份数据一切分析都是空中楼阁。范围聚焦Input: jstat/jmap初步结果Output: 3个以内高嫌疑类名 1个关键线程名Termination: 超过5个嫌疑类则回退重采样jmap -histo:live pid结果按bytes列倒序取Top 5jstack pid中搜索WAITING、BLOCKED、TIMED_WAITING状态的线程重点关注pool、scheduler、cache、listener等关键词。本次事故中java.util.HashMap$Node2.1G、com.xxx.config.CacheManager1.3G、org.apache.http.impl.conn.PoolingHttpClientConnectionManager890MB构成铁三角task-scheduler-线程栈成为钥匙。根因深挖Input: 聚焦结果Output: 具体代码行 触发条件复现脚本Termination: 无法写出复现脚本则视为未定位用jmap -dump:formatb,fileheap.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()完全暴露。方案验证Input: 修复代码Output: 修复后72小时RSS/OU平稳曲线Termination: 任一指标再次爬升则回滚修复不是改完就上线。必须在预发环境用相同流量压测72小时监控jstat -gc输出确保OU/OC比值稳定在40%-60%区间FGCT回归至5次/天。本次修复后OU稳定在3.2G±0.3GFGCT降至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 GCMajor GC周期。若设为-h1满屏表头会淹没数据设为-h100可能错过关键拐点。这是实操中摸索出的黄金比例。5000采样间隔5秒。这个值不能乱设。太短如100ms会产生大量I/O干扰JVM本身太长如30秒可能漏掉瞬时尖峰。5秒是平衡点——它略长于本次应用的平均Minor GC间隔3.2秒确保每次采样都能捕获GC事件。现在看关键字段解读S0U/S1USurvivor区使用量本次事故中S0U长期为0S1U在0.1G~0.3G间波动。这说明Survivor区几乎没发挥作用对象“出生即入老年代”。根因是-XX:MaxTenuringThreshold0在启动参数中被误配强制所有对象在Eden区满后直接晋升老年代。这是配置错误非代码问题。EC/EUEden区容量/使用量EC稳定在2GEU在每次Minor GC后从1.95G回落至0.05G证明新生代回收有效。但EU回落后的基线0.05G比正常值应0.01G高5倍说明有大量短期对象存活指向日志框架Logback的AsyncAppender缓冲区配置过大queueSize256000。OC/OU老年代容量/使用量OC恒为8GOU从1.2G线性涨至7.8G斜率0.092G/hour。这个斜率值至关重要——它等于内存泄漏速率。我们用它反推若不修复24小时后OU将达9.9G超过OC触发OutOfMemoryError。注意jstat输出的单位是KB不是MB。OU7825432表示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:formatb,fileheap_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泄漏嫌疑java.util.concurrent.ConcurrentHashMap(2.1G) —— 由com.xxx.config.CacheManager.INSTANCE持有byte[](1.3G) —— 由org.apache.http.nio.pool.AbstractNIOConnPool持有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 scaleMB查看本地内存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 prio5 os_prio0 tid0x00007f8c4c0a1000 nid0x1a2b 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输出的nid0x1a2b是线程ID的十六进制可用printf %d\n 0x1a2b转为十进制6699再用top -H -p pid查看该线程的CPU占用确认是否真在“忙等”。4. 实操过程与核心环节实现从发现问题到彻底解决4.1 第一阶段现象确认与数据采集耗时18分钟接到告警Prometheus触发jvm_memory_used_bytes{areaold} 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:formatb,fileheap_${DATE}.hprof $PID执行./mem_check.sh 1234518分钟后得到完整数据包。关键发现ps aux日志显示进程RSS从3.4G→6.7G→8.1G30分钟涨4.7Ggc_log中OU从1.2G→3.8G→7.1G斜率0.16G/10minstack_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分析机执行打开MATFile→Open Heap Dump选择文件等待解析完成约8分钟点击Reports→Leak Suspects Report报告指出ConcurrentHashMap泄漏点击Details→See stacktrace跳转到Dominator Tree在Dominator Tree中找到ConcurrentHashMap右键Path to GC Roots→with all references展开引用链定位到CacheManager.INSTANCE.cacheMap右键该Map →List objects→with incoming references查看哪些线程/对象在调用它发现所有调用都来自CacheManager.refresh()方法。此时打开IDE定位CacheManager.java第142行// CacheManager.java Line 142 public void refresh() { MapString, ConfigEntity newConfigs configDao.findAll(); // 从DB查10万条 cacheMap.putAll(newConfigs); // 问题就在这里 }审计configDao.findAll()它返回一个ArrayList但putAll()会遍历并插入。当newConfigs.size()100000ConcurrentHashMap需扩容多次每次扩容生成新table数组旧table若被其他线程引用就无法GC。而Scheduled未加锁多线程并发调用transfer()锁竞争导致线程阻塞旧table被sizeCtl字段强引用形成泄漏。实操心得MAT的OQLObject 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 { MapString, 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 LoadingCacheString, 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组件无新增依赖。发布流程编译打包生成新jar在预发环境部署用jstat -gc pid 5000监控1小时OU稳定在3.2G上线灰度10%流量观察5分钟jstack确认scheduler线程全部RUNNABLE全量发布72小时后监控曲线平稳。注意setIfAbsent的key必须全局唯一我们用cache:refresh:lock硬编码避免不同服务冲突。若有多套环境应加入spring.application.name前缀。4.4 第四阶段运行时配置优化耗时15分钟问题虽解决但JVM配置仍有隐患。本次事故暴露两个配置错误-XX:MaxTenuringThreshold0强制对象直接入老年代加剧老年代压力-Xms8g -Xmx8g堆大小固定但-XX:NewRatio2新生代:老年代1:2导致新生代仅2.67GEden区过小Minor GC过于频繁。优化后启动参数-Xms8g -Xmx8g \ -XX:NewRatio1 \ # 新生代:老年代1:1各4G -XX:MaxTenuringThreshold6 \ # 对象最多经历6次Minor GC才入老年代 -XX:UseG1GC \ # 启用G1垃圾收集器适合大堆 -XX:MaxGCPauseMillis200 \ # G1目标停顿时间200ms -XX:PrintGCDetails -XX:PrintGCDateStamps \ -Xloggc:/data/logs/gc.log \ -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles5 -XX:GCLogFileSize100MG1的优势在于它将堆划分为多个Region可优先回收垃圾最多的Region避免Full GC。MaxGCPauseMillis200让G1自动调整年轻代大小平衡吞吐与延迟。实测后Minor GC频率从每3.2秒一次降至每8.5秒一次GC总耗时下降63%。提示-XX:PrintGCDetails日志需配合-Xloggc重定向否则刷屏stdout。UseGCLogFileRotation开启日志轮转防止单个GC日志过大。5. 常见问题与排查技巧实录那些没写在文档里的坑5.1 “jmap -dump卡住不动”别急着kill先看这三件事这是最高频的求助问题。jmap -dump执行后长时间无响应并不意味着JVM卡死而是它在做三件事暂停所有应用线程STW这是必须的否则堆状态不一致。暂停时间与堆大小正相关8G堆通常需10-30秒遍历所有对象计算引用关系MAT需构建完整的对象图此步最耗时序列化写入磁盘受磁盘IO速度限制SSD通常100MB/sHDD仅30MB/s。避坑指南✅必做执行前用df -h /data确认磁盘空间 2×堆大小✅必做用iostat -x 1监控磁盘await若100ms说明IO瓶颈换SSD或调整dump路径❌禁做CtrlC中断jmap可能导致dump文件损坏MAT无法打开⚠️技巧若实在等不及可用jcmd pid VM.native_memory summary scaleMB替代它不STW能快速查看Internal、Mapped等区域占用排查DirectByteBuffer泄漏。5.2 “MAT打不开8G的dump文件”内存不够是假象配置才是关键MAT默认内存只有1G打开8G dump必然OOM。但很多人改了MemoryAnalyzer.ini的-Xmx后仍失败原因是忽略了JVM版本兼容性。正确配置步骤下载与JDK版本匹配的MATJDK8用MAT 1.10JDK11用MAT 1.12编辑MemoryAnalyzer.ini修改两处-vmargs -Xmx6g # 设为堆大小的75%8G堆设6G -XX:UseG1GC # 必须启用G1避免CMS的内存碎片问题启动MAT时添加参数-configuration /tmp/mat_config指定独立配置目录避免权限问题首次打开大dump勾选Keep unreachable objects保留不可达对象否则MAT会自动GC掉“疑似泄漏”的对象导致分析失真。实操心得若MAT仍崩溃用命令行版ParseHeapDump.sh生成index文件再用GUI打开index速度提升5倍。5.3 “jstat显示OU很高但MAT看不到大对象”你可能忽略了MetaspaceJVM内存分堆Heap和非堆Non-Heapjstat -gc只监控堆而Metaspace存储类元数据和Compressed Class Space也吃内存。本次事故中jstat -gc显示OU7.8G但MAT分析heap.hprof只看到5.2G对象差额2.6G去哪了执行jstat -gcmetacapacity pidMGCMN MGCMX MGC MC CCSMC CCSC YGC FGC 0.0 1024.0 1024.0 1024.0 1024.0 1024.0 0 0MC1024.0Metaspace容量1G但jstat -gccapacity显示MCC1024.0Compressed Class Space也1G两者相加正好2G。再用jmap -cl pid查看类加载器发现org.springframework.boot.loader.LaunchedURLClassLoader加载了12000个类远超正常值通常2000。根因是Spring Boot DevTools在生产环境未关闭它会动态重载类导致Metaspace持续膨胀。解决方案生产启动参数添加--spring.devtools.restart.enabledfalse或直接删除spring-boot-devtools依赖。提示jstat -gc的MUMetaspace使用量字段在旧版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 --whereid%1000抽样
返回列表