Qunar技术沙龙

去哪儿网一次JVM OOM问题的深度解码与治理实践

一、现象问题

【背景】去哪儿网国内酒店稳定性保障之中间件超时演练:为保障高峰期稳定性,一阶段先对酒店历史发生过的3起Mysql/Redis存储超时引发ATP故障的场景进行模拟演练,基于这3个故障的共性都是数据层出现问题导致应用连接池被打满,所以演练将通过在应用接口访问存储时注入超时故障,同时结合压测流量,验证高并发存储超时下不触发ATP故障。

酒店基础信息查询Redis高并发超时场景演练:

Image

演练发现:该基础信息服务在2.5倍日常峰值QPS流量、30ms超时情况下,出现内存使用率明显飙升(max 96%),几分钟后系统OOMKilled自动重启。

2.PNG
Image

问题排查:需要分析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)

3.png
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命令验证。

4.png

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),

5.png

使用heapdump命令dump到指定文件,类似jmap命令的heap dump功能,heapdump dump3.hprof

6.png

注意,如果是线上机器,请摘掉流量使用。

2.3.3 下载文件

Bistoury工具,提供了从机器上下载日志文件、dump文件的功能。

确认上面deapdump命令返回Heap dump file created后,操作download,同时gc.log文件也下载到本地。

7.png

3、排查过程

3.1 确认OOM类型

3.1.1 OOM常见原因和类型

OOM是 JVM 内存耗尽时抛出的错误(java.lang.OutOfMemoryError),表示 JVM 无法为新对象分配内存,且垃圾回收器也无法回收足够内存。其核心触发条件是,内存分配请求超过JVM当前可用内存上限。OOM类型有:

image.png

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. 标记-清除算法

分标记、清除两阶段。

8.png

优缺点:简单但产生内存碎片。

适用场景:老年代回收(CMS)。

b. 复制算法

内存分两区,存活对象复制。

9.png

优缺点:无碎片但浪费50%空间。

适用场景:新生代(Parallel Scavenge)。

c. 标记-整理算法

标记后移动存活对象到一端。

10.png

优缺点:无碎片但需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 垃圾收集器

主流垃圾收集器对比,

image.png

这里使用的是G1GC。

3.2.1.4 分代回收过程
11.png

GC的触发是在对象分配过程中,当一个对象在创建时,首先分配到Eden区,如果是大对象(大小超过-XX:PretenureSizeThreshold)直接进入老年代,在年轻代创建对象时,会先判断Eden区的空间是否充足,如果充足,则直接在Eden区分配内存创建对象,如果不足,则会触发一次Minor GC(也叫Young GC)。

Image

3.2.2 gc.log分析

3.2.2.1 借助在线工具

借助https://gceasy.io/可以进行大致分析。

① 老年代分配2G,实际要5个多G

12.png

这里会有一个疑问,上面内存配置确认过是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前后,

13.png

Full GC前后,内存变化不大,

14.png

Full GC 114次,

15.png

确认老年代空间不足,触发了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)。

16.png

① Old Region持续增长

17.png
  • 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总数减少,但总容量占堆的绝大部分

18.png

差异分析:

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总数减少,但总容量仍占堆的绝大部分

19.png

④ Full GC,但未释放内存

20.png
21.png

分析总结:

堆总容量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文件是无法正常打开的。

22.png
3.3.2 .hprof文件分析
3.3.2.1 最大对象

.hprof文件打开成功后,Overview页饼图展示对象内存占比,

java.util.concurrent.ScheduledThreadPoolExecutor 它的所有引用对象的大小是2.5G,占比最大。

23.png

参照Leak Suspects报告,确认最有可能导致OOM的疑点:

24.png

java.util.concurrent.ScheduledThreadPoolExecutor。

3.3.2.2 是个无界队列

通过Dominator Tree查看java.util.concurrent.ScheduledThreadPoolExecutor被哪些对象引用,占79%多,

25.png

java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue 无界队列。

无界队列默认容量是Integer.MAX_VALUE,当任务提交速度超过处理速度时,队列会无限增长,最终导致内存溢出。这里需要说明线程池的工作机制,当任务到达时,如果核心线程数未满,会创建新线程处理;如果核心线程已满,任务会被放入队列;如果队列也满了,才会创建新线程直到达到最大线程数。如果队列是无界的,那么队列会一直增长,直到内存耗尽。

26.png
27.png
28.png
3.3.2.3 查看引用链

① 查看HotelAttrContext被调用的地方

右键,逐次点击Show objects by class,by incoming references,

29.png

跳转到如下页面,可以看到是AsyncCalUpdateTagsService调用的。

30.png

② AsyncCalUpdateTagsService对应更新缓存的任务

31.png
32.png
33.png
34.png
35.png

3.4 结合error.log回溯代码

佐证是处理更新缓存的代码大量抛error。

36.png

三、排查结论和优化建议

  1. 当基础信息因Redis查询超时转向下游查询并触发异步更新缓存时,由于更新任务使用无界队列的线程池,Redis超时导致更新失败的任务持续堆积在队列中,这些任务对象被队列强引用而无法被GC回收,即使触发Full GC也无法释放内存,最终因队列无限增长耗尽堆内存引发OOM。

  2. 优化建议:

    a. 查询下游后,更新Redis的线程池队列是无界队列,能否优化为有界队列。

    37.png

    b. Redis超时故障时,更新缓存的任务请求可否丢弃处理。

四、优化跟进

  1. 优化点1:确认可以设置为有界队列,超过了丢弃。如下图,工程已优化完成并上线修改为有界队列,大小1024。

38.png
  1. 优化点2:故障时会关掉Redis,读写都关,目前线上已有sentinel开关控制。

五、优化推广

针对无界队列问题和缺失关闭Redis更新处理的降级开关问题,推进业务线各域检测是否也存在这两类风险问题,并宣贯有界队列编码规范和Redis降级开关设计考虑,统一扫除高并发超时情况下线上出这类问题和故障的风险。

39.png