去哪儿网一次JVM OOM问题的深度解码与治理实践
一、现象问题
【背景】去哪儿网国内酒店稳定性保障之中间件超时演练:为保障高峰期稳定性,一阶段先对酒店历史发生过的3起Mysql/Redis存储超时引发ATP故障的场景进行模拟演练,基于这3个故障的共性都是数据层出现问题导致应用连接池被打满,所以演练将通过在应用接口访问存储时注入超时故障,同时结合压测流量,验证高并发存储超时下不触发ATP故障。
酒店基础信息查询Redis高并发超时场景演练:
演练发现:该基础信息服务在2.5倍日常峰值QPS流量、30ms超时情况下,出现内存使用率明显飙升(max 96%),几分钟后系统OOMKilled自动重启。
问题排查:需要分析OOM(Out Of Memory)内存溢出原因。
二、排查思路和过程
1、排查思路
四步法:
确认OOM类型:通过错误日志定位是堆、元空间还是其他区域问题。
分析GC日志:检查Full GC频率、老年代占用率。
内存Dump分析:使用MAT查找大对象、内存泄漏链。
代码回溯:检查对象创建位置。
2、信息收集
2.1 确认容器内存容量
仿真同线上,都是8C,10G。
2.2 确认JVM内存配置
2.2.1 JVM内存组成
JVM内存分为堆内存(Heap)和非堆内存(Non-Heap) 两大类,其中堆内存是主要的内存消耗区域,具体结构如下所示:
JVM 内存├── 堆内存(Heap)│ ├── 新生代(Young Generation)│ │ ├── Eden 区│ │ └── Survivor 区(From/To)│ └── 老年代(Old Generation)├── 非堆内存(Non-Heap)│ ├── 元空间(Metaspace)│ ├── 虚拟机栈(VM Stack)│ ├── 本地方法栈(Native Method Stack)│ └── 程序计数器(Program Counter)└── 直接内存(Direct Memory)(不受JVM堆限制,受物理内存和操作系统限制)
其中新生代、老年代堆内存占比,JDK8后,默认设置通常是:
-XX:NewRatio=2(新生代 : 老年代 = 1 : 2)
-XX:SurvivorRatio=8(Eden : Survivor = 8 : 1,每个 Survivor 占新生代的 1/10)
2.2.2 内存配置确认
① 堆内存初始和最大都是6G(设为相同值,避免运行时动态调整、过度占用系统资源),使用G1GC,GC最大停顿时间200ms,元空间和直接内存分别限制512M和1G。
-Xms6144M -Xmx6144M //-Xms是初始堆内存,-Xmx是最大堆内存-XX:+UseG1GC //启用G1收集器-XX:MaxGCPauseMillis=200//最大停顿时间-XX:+HeapDumpOnOutOfMemoryError-XX:MetaspaceSize=512m //初始元空间大小-XX:MaxMetaspaceSize=512m //防止元空间无限增长-XX:MaxDirectMemorySize=1024M② JDK版本,是JDK11。
③ 从上面配置看,并没有设置NewRatio和SurvivorRatio值,所以默认应该是NewRatio=2,SurvivorRatio=8,可以通过jinfo命令验证。
2.3 获取出现OOM时的报错日志、GC日志和堆栈信息
从上面JVM配置看,有配置-XX:+HeapDumpOnOutOfMemoryError参数,用于在发生OOM时自动生成堆转储文件(.hprof文件),但实际是OOM时,Linux内核的OOM Killer会瞬时强制终止应用进程,JVM来不及触发HeapDumpOnOutOfMemoryError机制,导致堆转储文件未生成或生成中断,以及error.log、gc.log文件都是空,没内容。
所以需要再进行演练,复现问题,抓取现场报错日志、GC日志和堆栈信息。
2.3.1 抓取时机
30ms超时故障,2.5倍日常峰值QPS流量下,出现内存飙高持续几分钟,在接近出现系统OOMKilled自动重启前,快速手动终止压测流量、停止故障,然后dump堆栈。
另外,方便前后对照,也可以在故障、压测流量前,也dump一份堆栈。
2.3.2 dump工具
借助公司Bistoury工具(集成了Alibaba开源的arthas),
使用heapdump命令dump到指定文件,类似jmap命令的heap dump功能,heapdump dump3.hprof
注意,如果是线上机器,请摘掉流量使用。
2.3.3 下载文件
Bistoury工具,提供了从机器上下载日志文件、dump文件的功能。
确认上面deapdump命令返回Heap dump file created后,操作download,同时gc.log文件也下载到本地。
3、排查过程
3.1 确认OOM类型
3.1.1 OOM常见原因和类型
OOM是 JVM 内存耗尽时抛出的错误(java.lang.OutOfMemoryError),表示 JVM 无法为新对象分配内存,且垃圾回收器也无法回收足够内存。其核心触发条件是,内存分配请求超过JVM当前可用内存上限。OOM类型有:
3.1.2 确认OOM类型
触发OOM时候,预期是在error.log里会有java.lang.OutOfMemoryError报错信息,但由于演练当时服务进行了重启,且复现时候没让实际等到OOM触发,所以没实际看到是哪种类型,可进入下面gc.log分析,直接确认是具体哪块溢出。
3.2 分析GC
3.2.1 JVM垃圾回收机制
3.2.1.1 垃圾回收作用
JVM垃圾回收(GC)是自动管理内存的机制,通过识别并回收不可达对象释放内存空间。其核心目标是:
① 防止内存泄漏:避免无用对象长期占用内存。
② 提升开发效率:开发者无需手动管理内存分配/释放。
③ 保障系统稳定性:避免因内存耗尽导致的OOM崩溃。
不可达对象:无法通过根对象引用链到达的对象。
根对象包括:
活跃线程栈的局部变量
静态变量引用的对象
JNI(Native方法)持有的对象
常量池引用对象
public void example() {Object obj = new Object(); // obj 是 GC Root,引用堆中的对象obj = null; // 引用断开,堆中对象变为不可达}方法执行时,局部变量 obj 持有堆中对象的引用,当 obj = null 时,堆中对象失去所有引用链,变为不可达,GC 触发时,该对象将被回收。
3.2.1.2 垃圾回收算法
① 基础算法
a. 标记-清除算法
分标记、清除两阶段。
优缺点:简单但产生内存碎片。
适用场景:老年代回收(CMS)。
b. 复制算法
内存分两区,存活对象复制。
优缺点:无碎片但浪费50%空间。
适用场景:新生代(Parallel Scavenge)。
c. 标记-整理算法
标记后移动存活对象到一端。
优缺点:无碎片但需STW。
STW(Stop-The-World) 是GC过程中的一种机制,指在特定阶段 暂停所有应用程序线程,仅允许垃圾回收线程运行。其核心目的是 确保内存状态的一致性,避免应用线程在 GC 过程中修改对象引用关系,导致回收错误。
适用场景:老年代(Serial Old)。
② 分代收集算法
是融合上述三种基础的算法思想,产生的针对不同情况采用不同算法的一套组合拳。
a. 新生代(Young Gen):
Eden区:新对象分配地(80%空间)
Survivor区(S0/S1):存活对象暂存区(各10%)
算法:复制算法(Minor GC)
b. 老年代(Old Gen):
晋升条件:对象经历多次GC仍存活(默认15次)
算法:标记-清除/标记-整理(Major GC)
根据对象生命周期划分堆内存为新生代和老年代,在新生代中,每次垃圾收集时都发现有大批对象可回收,只有少量存活,那就选用复制算法,因为只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记清理或标记整理算法进行回收。
3.2.1.3 垃圾收集器
主流垃圾收集器对比,
这里使用的是G1GC。
3.2.1.4 分代回收过程
GC的触发是在对象分配过程中,当一个对象在创建时,首先分配到Eden区,如果是大对象(大小超过-XX:PretenureSizeThreshold)直接进入老年代,在年轻代创建对象时,会先判断Eden区的空间是否充足,如果充足,则直接在Eden区分配内存创建对象,如果不足,则会触发一次Minor GC(也叫Young GC)。
3.2.2 gc.log分析
3.2.2.1 借助在线工具
借助https://gceasy.io/可以进行大致分析。
① 老年代分配2G,实际要5个多G
这里会有一个疑问,上面内存配置确认过是NewRatio=2,即新生代 : 老年代 = 1 : 2,堆总空间是6G,则预期新生代Allocated是2G,老年代是4G,但这里看着是刚好反着,新生代Allocated是4G,老年代是2G,这是怎么回事?
需要回顾G1GC的内存管理机制,G1GC的工作方式与传统收集器不同,会动态调整内存分配:
G1将堆划分为多个Region,每个Region可以是Eden、Survivor或Old区。G1会根据应用的行为动态调整这些Region的数量和类型,而不是固定比例。这意味着Young Generation的总大小可能并不是按照NewRatio=2来分配的,而是根据应用的需要动态调整。如果应用大量分配短期对象,G1可能会分配更多的Region给Young Generation,导致Allocated内存超过Old Generation。
从上面看,Old Generation的Peak达到了5.68GB,超过了其Allocated的1.98GB,可以确认老年代空间不足。
② 有触发fullGC但fullGC前后老年代内存使用基本没变化
YGC前后,
Full GC前后,内存变化不大,
Full GC 114次,
确认老年代空间不足,触发了Full GC,但无法回收足够内存,导致OOM。
3.2.2.2 gc.log深入分析
gc.log中有很多GC事件,每个GC事件都有详细的时间线,包括各个阶段的耗时,比如Pre Evacuate Collection Set、Evacuate Collection Set、Post Evacuate Collection Set,这些阶段分别对应垃圾回收的不同步骤:准备、转移存活对象、清理等。
GC事件启动标识、GC任务执行信息、GC阶段耗时分解、堆内存区域变化、元空间状态、GC结果统计、CPU资源消耗。
识别关键GC事件,
G1的Region数量默认是2048个,但具体数量会根据堆的大小和Region的大小计算。Region的大小通常是堆内存除以2048,但必须满足是2的幂次方,并且范围在1MB到32MB之间。如果计算出来的Region大小不符合这个范围,会自动调整到最近的2的幂次方。
这里堆内存是6G,即6144MB,RegionSize = max((InitialHeapSize + MaxHeapSize)/2 / 2048, 1M) =3M,但需满足2的幂次方(1MB ~ 32MB),计算结果非2的幂次方,则向下取最接近的2的幂次方:3MB → 2MB,最终 RegionSize = 2MB,Region数量 = 3072 个(6144MB / 2MB)。
① Old Region持续增长
Eden regions:Eden区从617个缩减到0个(垃圾回收后Eden区的目标容量是609个Region)。 (Eden区对象全部被回收或晋升)(目标容量,是G1根据历史分配速率和堆使用情况动态计算的下次Young GC前Eden区应保留的区域数,目的是为后续对象分配预留空间,避免频繁触发 GC)
Survivor regions:Survivor区从50个减少到43个(目标容量84个Region)。 (部分存活对象晋升到老年代,剩余存活对象迁移到新的Survivor区)
Old regions:老年代Region从1885个增加到1905个。 (晋升对象导致老年代占用增加,这是Young GC的正常现象)
Humongous regions:巨型对象Region无变化(162个),说明本次GC未处理超大对象。
Metaspace:元空间总容量180992K,当前使用175743K(元空间无回收行为,未变化)。
内存变化:GC前堆内存:5427M → GC后堆内存:4218M,释放了约1209M内存(堆总容量:6144M)。
分析总结:
Old Region持续增长:尽管这是Young GC,但Old Region从1885增至1905,说明老年代对象持续累积。
潜在风险:若长期如此,可能导致老年代空间耗尽,触发Full GC。
Eden区完全清空:Eden Region从617→0,表明本次Young GC有效回收了新生代对象。
正常现象:Young GC的主要目标是清理短期存活对象。
Survivor区收缩:Survivor Region从50→43,说明部分存活对象晋升到老年代,剩余对象迁移到新的Survivor区。
晋升条件:对象经历多次Young GC仍存活(由-XX:MaxTenuringThreshold控制,默认15次)。
② 触发Mixed GC,老年代Region总数减少,但总容量占堆的绝大部分
差异分析:
GC(327)是Prepare Mixed类型的Young GC,
GC(328)是Mixed GC(同时回收新生代和部分老年代,降低老年代压力)。
Prepare Mixed,意味着GC(327)Young GC可能会涉及到老年代的回收,但主要是为了准备Mixed GC。
分析总结:
Mixed GC的作用:
在Young GC(GC327)后,老年代Region持续增长(2599→2602)。Mixed GC(GC328)主动回收了16个老年代Region(2602→2586),避免老年代空间耗尽。
堆内存分布:
老年代Region总数在Mixed GC后减少,但总容量仍占堆的绝大部分(约5172MB/6144MB)。需持续监控老年代增长,避免未来触发Full GC。
Survivor区波动:
Survivor区在两次GC中先增后减(12→17→14),反映对象晋升与存活周期的动态平衡。
③ 触发Full GC,老年代Region总数减少,但总容量仍占堆的绝大部分
④ Full GC,但未释放内存
分析总结:
堆总容量6144M,GC前堆内存6141M,GC后堆内存6141M,堆内存无变化,说明Full GC未释放内存,存在内存泄漏或对象长期存活。
3.3 内存dump分析
上面gc.log分析后,确认是老年代空间不足引起堆内存溢出,接下来进一步分析有无大对象、内存泄漏链。
3.3.1 堆栈分析工具
.hprof堆栈分析可借助JProfiler工具,也可使用MAT工具,这里使用MAT工具。
① MAT安装,下载地址https://eclipse.dev/mat/。
② MAT配置,因为我们的dump文件通常是G级别的,但MAT工具启动参数默认是1024m(这里的-Xmx1024m类似java应用的JVM参数配置,直接影响MAT的运行),需要前置调整下这个大小(MemoryAnalyzer.ini文件),如调整8g,否则MAT打开6G多的.hprof文件是无法正常打开的。
3.3.2 .hprof文件分析
3.3.2.1 最大对象
.hprof文件打开成功后,Overview页饼图展示对象内存占比,
java.util.concurrent.ScheduledThreadPoolExecutor 它的所有引用对象的大小是2.5G,占比最大。
参照Leak Suspects报告,确认最有可能导致OOM的疑点:
java.util.concurrent.ScheduledThreadPoolExecutor。
3.3.2.2 是个无界队列
通过Dominator Tree查看java.util.concurrent.ScheduledThreadPoolExecutor被哪些对象引用,占79%多,
java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue 无界队列。
无界队列默认容量是Integer.MAX_VALUE,当任务提交速度超过处理速度时,队列会无限增长,最终导致内存溢出。这里需要说明线程池的工作机制,当任务到达时,如果核心线程数未满,会创建新线程处理;如果核心线程已满,任务会被放入队列;如果队列也满了,才会创建新线程直到达到最大线程数。如果队列是无界的,那么队列会一直增长,直到内存耗尽。
3.3.2.3 查看引用链
① 查看HotelAttrContext被调用的地方
右键,逐次点击Show objects by class,by incoming references,
跳转到如下页面,可以看到是AsyncCalUpdateTagsService调用的。
② AsyncCalUpdateTagsService对应更新缓存的任务
3.4 结合error.log回溯代码
佐证是处理更新缓存的代码大量抛error。
三、排查结论和优化建议
当基础信息因Redis查询超时转向下游查询并触发异步更新缓存时,由于更新任务使用无界队列的线程池,Redis超时导致更新失败的任务持续堆积在队列中,这些任务对象被队列强引用而无法被GC回收,即使触发Full GC也无法释放内存,最终因队列无限增长耗尽堆内存引发OOM。
优化建议:
a. 查询下游后,更新Redis的线程池队列是无界队列,能否优化为有界队列。
b. Redis超时故障时,更新缓存的任务请求可否丢弃处理。
四、优化跟进
优化点1:确认可以设置为有界队列,超过了丢弃。如下图,工程已优化完成并上线修改为有界队列,大小1024。
优化点2:故障时会关掉Redis,读写都关,目前线上已有sentinel开关控制。
五、优化推广
针对无界队列问题和缺失关闭Redis更新处理的降级开关问题,推进业务线各域检测是否也存在这两类风险问题,并宣贯有界队列编码规范和Redis降级开关设计考虑,统一扫除高并发超时情况下线上出这类问题和故障的风险。