记一次线上OOM问题排查

前言

前几天,我们生产环境的管理后台出现了问题,运营给我们反映说后台无法正常登陆,无法打开,我们根据日志,查询到了后台程序出现了OutOfMemoryError异常,这个问题我们处理了很久,主要原因是由于我们优化不够造成的问题。

特此用此文章记录一下自己的处理过程。

正文

一波三折

我的处理流程大致是这样的。

首先,我在日志里使用grep指令分析了下OutOfMemoryError异常数量,发现有很多。

1
grep 'OutOfMemoryError' -A 10 -B 10 catalina.out

结果如下:

出现的记录非常多。

然后,通过top指令分析当前java进程情况。

1
top

可以看到此时管理后台java进程已经使用了2.4G内存,其进程pid10070

而后我想使用jmap指令分析下堆内存使用情况。

1
jmap -heap 10070

但是却失败了,有如下提示。

1
Error attaching to process: sun.jvm.hotspot.debugger.DebuggerException: cannot open binary file

后续经过查阅相关资料,确定是自己用户权限不足的问题导致的。

遂请运维管理员帮忙执行相关语句。

主要有三个语句。

1
2
3
jmap -heap 10070
jmap -histo 10070
jmap -dump:format=b,file=20210901Heap.dump 10070

第一条语句jmap -heap 10070用来查看当前OOM后堆内存使用情况。

第二条语句jmap -histo 10070用来查看当前OOM后堆内存对象分布情况。

第三条语句jmap -dump:format=b,file=20210901Heap.dump 10070用来导出dump文件。

不幸的是运维管理员跟我们反馈执行不成功,输出如下。

提示Unable to open socket file: target process not responding or HotSpot VM not loaded错误。

这个异常查阅相关资料后,我确定为启动用户不一致导致的问题,就是java进程是hcadmin用户启动的,而管理员用的root用户。

而后其切换到hcadmin用户,运行正常,导出数据。

接下来我们就分析OOM异常的主要原因。

进入正题

jmap -heap

首先我们先看从运维管理员拿到的第一条命令生成的数据。

1
jmap -heap 10070

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Attaching to process ID 10070, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.112-b15

using thread-local object allocation.
Parallel GC with 2 thread(s)

Heap Configuration:
MinHeapFreeRatio = 0
MaxHeapFreeRatio = 100
MaxHeapSize = 2051014656 (1956.0MB)
NewSize = 42991616 (41.0MB)
MaxNewSize = 683671552 (652.0MB)
OldSize = 87031808 (83.0MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
capacity = 228589568 (218.0MB)
used = 150133920 (143.17886352539062MB)
free = 78455648 (74.82113647460938MB)
65.67837776394066% used
From Space:
capacity = 227540992 (217.0MB)
used = 1238272 (1.180908203125MB)
free = 226302720 (215.819091796875MB)
0.5441973286290323% used
To Space:
capacity = 227540992 (217.0MB)
used = 0 (0.0MB)
free = 227540992 (217.0MB)
0.0% used
PS Old Generation
capacity = 1367343104 (1304.0MB)
used = 829379544 (790.9579696655273MB)
free = 537963560 (513.0420303344727MB)
60.656286017295045% used

67634 interned Strings occupying 7793656 bytes.

可以看到使用的是Parallel GC

先看下目前堆内存配置情况,NewRatio=2年轻代:老年代=1:2

可以看到MaxHeapSize=1956.0MB,老年代占2/3,老年代大小应为1304M

正好对应上面PS Old Generationcapacity

我们这儿还可以看到配置的SurvivorRatio=8,即 Eden:From:To=8:1:1

这儿可以算出Eden Space大小为 521.6M。
$$
1956 \times \frac{1}{3} \times \frac{8}{8+1+1} = 521.6
$$
但实际我们看到Heap Usage里面,Eden:From:To的比例根据capacity来说是1:1:1

这儿的原因是因为JDK 1.8 默认使用 UseParallelGC 垃圾回收器,该垃圾回收器默认启动了 AdaptiveSizePolicy(自适应大小策略),会根据GC的情况自动计算计算 EdenFromTo 区的大小,要使-XX:SurvivorRatio生效,就需要关闭该策略 -XX:-UseAdaptiveSizePolicy

PS:开启策略参数为-XX:+UseAdaptiveSizePolicy

也可以看到目前我们管理后台的JVM都是采用的默认值,未进行相关优化。

根据Heap Usage,我们可以看到年老代使用了61%,年轻代使用了66%

我们可以看到,OOM时,年轻代或老年代并没有被打满,我们继续分析下面的脚本结果。

jmap -histo

我们再来看下第二个指令生成的结果。

1
jmap -histo 10070

由于结果数据较多,我们以图片的形式来看关键的部分。

其中#instances 为实例个数, #bytes为字节大小, class name 是对应的对象类型。

B 表示byte

C 表示char

D 表示double

F 表示float

S 表示short

I 表示int

J 表示long

Z 表示boolean

[ 表示数组,如[I表示int[]

[L+类名 表示其他对象

可以看到[Cchar数组占用了382M左右,这一般是创建了大量String导致的,String底层就是由char数组构成的。

属于我们项目的一个VO类,即LateAccountVO占用了220M左右。

LateAccountVO有多处地方被使用,且里面String字段也较多,这也是[C内存占用量大的原因。

LateAccountVO主要服务于我们管理后台的一个报表功能,这个功能关联表比较多,涉及到的显示项也是比较多的。

jmap -dump

我们再来分析下下述指令产生的dump文件。

1
jmap -dump:format=b,file=20210901Heap.dump 10070

文件比较大,我们使用dump分析工具来分析一下,把后缀改成hprof以便后续处理。

我们这儿使用MAT来分析一下它。

MAT的下载地址:https://www.eclipse.org/mat/downloads.php

下载安装好后,直接打开我们的dump文件即可。

Overview页面,我们主要看这几项。

  • Histogram:内存对象直方图。

上图可以看到占用较多的也是char[]

  • Dominator Tree:对象内存占用情况树图

​ 可以看到占用内存最多的为WebappClassLoader,占用了大概11.90%

  • Top Consumers:对象内存占用扇形图

它也能帮我们找到大对象相关的一些信息。

  • Leak Suspects:内存泄漏分析

可以看到它为我们分析出了两处可能存在内存泄漏的对象。

一个是WebappClassLoader,一个是TaskThread

但这两个类是三方jar包相关的类,目前看来没法给我们提供有效帮助。

曲折过程

我们重启了线上项目,并和运维相关人员申请了hcadmin的相关权限,当然只能申请到sudo权限。

这样我们就可以在线上实时查看jvm的运行情况。

操作如下:

1
2
3
sudo su hcadmin
jmap -heap xxxx
jmap -histo xxxx | more

我们实时分析结果如下,先来看下jmap -heap,某一次的分析如下。

可以看到其实开始时jvm内存是动态分配的,不够时再尝试扩容。

申请到hcadmin的权限后,我们看了下tomcat的参数配置,如下:

可以看到未对jvm有任何优化操作。

这儿我们尝试对jvm做出如下优化:

优化点 一

我们可以设置-Xms-Xmx参数,让其它两个相等,避免jvm动态扩充内存进而导致的时间浪费,每个线程大小也可以设置下。

1
-Xms2048M -Xmx2048M -Xss256K

优化点 二

新生代、Eden SpaceSurvivor Space的比例设置下。

这儿参考最上面jmap -heap的参数,我的设置如下:

1
-XX:NewRatio=2 -XX:SurvivorRatio=6

这样年轻代占用内存为:
$$
2048 \times \frac{1}{3} = 682.7 (M)
$$
年老代占用内存为: 1365.3 M

这样分的话是参考上面OOM时的一些配置,我们先设置此配置,如果后续仍有问题,需要继续调整。

优化点 三

垃圾回收器的选择。

JDK 1.8支持的垃圾回收器及其使用场景如下表:

垃圾收集器分类作用位置使用算法特点适用场景
Serial串行运行新生代复制算法响应速度优先适用于单CPU下的client模式
ParNew并行运行新生代复制算法响应速度优先多CPU环境server模式下与CMS配合使用
Parallel并行运行新生代复制算法吞吐量优先适用于后台运算而不需要太多交互的场景
Serial Old串行运行老年代标记-压缩算法响应速度优先适用于单CPU下的client模式
Parallel Old并行运行老年代标记-压缩算法吞吐量优先适用于后台运算而不需要太多交互的场景
CMS并发运行老年代标记-清除算法响应速度优先适用于互联网或B/S业务
G1并行、并发运行新生代、老年代标记-压缩算法、复制算法响应速度优先面向服务端应用

我们的管理后台目前采用的默认的垃圾收集器。

即 Parallel + Parallel Old。

由于管理后台提供给业务方,涉及到不少的用户行为交互操作,应该以响应速度为较高优先级目标。

因此这儿我们改用组合 ParNew + CMS 的垃圾回收器策略。

由于CMS使用的是标记-清除算法,我们老年代会产生内存碎片,因此开启CMS后,需要添加压缩参数。

1
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSFullGCsBeforeCompaction=0 -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=70

其中:

  • -XX:+UseParNewGC :指定新生代使用 ParNew 垃圾收集器。
  • -XX:+UseConcMarkSweepGC : 指定老年代使用 CMS 垃圾收集器。
  • -XX:CMSFullGCsBeforeCompaction=0 : 表示CMS执行了多少次不压缩的FullGC后,来一次压缩的FullGC,这儿设置的0,表示每次FullGC都会进行内存碎片压缩。
  • -XX:+UseCMSCompactAtFullCollection :开启 CMS 的 FullGC后的压缩,默认就是开启的。
  • -XX:CMSInitiatingOccupancyFraction=70 :表示老年代空间使用到 70% 后就开始执行 CMS 收集,以确保老年代有足够的空间接纳新的对象。

我们的最终配置如下:

1
JAVA_OPTS=" -server -Xms2048M -Xmx2048M -Xss256K -XX:NewRatio=2 -XX:SurvivorRatio=6 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSFullGCsBeforeCompaction=0 -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=70 -XX:-PrintGCDetails -XX:-PrintGCTimeStamps -javaagent:/usr/local/jmx_exporter/jmx_prometheus_javaagent-0.12.0.jar=9982:/usr/local/jmx_exporter/jmx_exporter.yml"

我们需要修改tomcat下的catalina.sh文件,在linux下可以使用vi命令修改。

配置好后,我们重启应用。如下:

可以看到我们的配置已经生效了。

我们需要继续监控一段时间看看系统运行情况,及是否可能存在内存泄漏情况。

实时监控

由于我们上面的jvm参数配置只是实验性的,我们后续需要实时跟踪jvm的运行情况。

连续监控了2-3后,发现老年代内存占用已经达到了 48% 左右,这对我们来说不是正常情况。

我们通过命令分析当前内存对象分布。

1
2
jmap -heap xxxx
jmap -histo xxxx | more

我们发现String对象(char[])占用大量内存,并且发现了一个一直存在的类OjkReportAFPI

这个类是我们一个数据上报任务用的,有个定时任务,每天会执行一次。

而且我监控发现每隔一天,内存就有较大幅度占用上涨。

经过检查代码,我们跟踪这个类的相关调用方,发现了一些可能是代码导致内存膨胀(泄漏)的原因。

此段代码存在大量String拼接,建议使用StringJoiner或者StringBuilder类来处理。

另外额外发现了前同事自己封装的工具类一些流未关闭的问题。

上述都是流未关闭的部分,可能存在内存泄漏问题。

进一步分析

我们使用jstat相关指令进行分析。

1
jstat -gcutil 21729

此命令可以分析GC情况。

上图比较重要的结果就是YGC(Young GC)发生了215次,FGC (Full GC)发生了6次,并记录了GC时间等相关信息。

1
jstat -gccause 21729

gccause指令可以让我们看到最近发生的一次GC的原因,我们看到为Allocation Failure

而后我们使用了一次如下指令:

1
jmap -histo:live 21729|more

使用live指令后,在观察jvm内存使用情况,如下:

可以看到内存占用减小了,并且OjkReportAFPI对象消失了。

GC原因是Heap Inspection Initiated GC ,这是由于jmap -histo:live命令会触发FullGC

也就可以证明其实我们的OjkReportAFPI对象被分配到了老年代(或者到了年龄移动到了老年代)。其实就是大对象的分配问题。

我们这个数据上报任务,每天运行会拉取大量数据,组成一个OjkReportAFPI VOList,这个对象由于比较大,在年轻代空间不足时会直接分配到老年代。

导致我们只有经过Full GC才能回收掉此对象。

关于这一部分,我们的优化方案是将该部分改为批处理,这样每批占用内存较小,可以保证其生成在Eden Space,而不是直接进入老年代。

另外年轻代与老年代的比例也可能需要在调整优化下。

最终方案

根据上面的分析,我们最终处理过程如下:

  1. 优化代码(流未关闭的问题,String的问题,业务批量处理等);
  2. 为年轻代多分配一些内存,由于我们目前业务内存8G,目前只有我们这一个应用,因此我们又给应用多分配了一些内存,针对于年轻代。

总结

通过本次对JVM的调整,我学到了不少知识,同时发现并解决了系统中的一些问题。

还是蛮不错的一次经历。




-------------文章结束啦 ~\(≧▽≦)/~ 感谢您的阅读-------------

您的支持就是我创作的动力!

欢迎关注我的其它发布渠道