前言
前几天,我们生产环境的管理后台出现了问题,运营给我们反映说后台无法正常登陆,无法打开,我们根据日志,查询到了后台程序出现了OutOfMemoryError
异常,这个问题我们处理了很久,主要原因是由于我们优化不够造成的问题。
特此用此文章记录一下自己的处理过程。
正文
一波三折
我的处理流程大致是这样的。
首先,我在日志里使用grep
指令分析了下OutOfMemoryError
异常数量,发现有很多。
1 | grep 'OutOfMemoryError' -A 10 -B 10 catalina.out |
结果如下:
出现的记录非常多。
然后,通过top
指令分析当前java
进程情况。
1 | top |
可以看到此时管理后台java
进程已经使用了2.4G
内存,其进程pid
为10070
。
而后我想使用jmap
指令分析下堆内存使用情况。
1 | jmap -heap 10070 |
但是却失败了,有如下提示。
1 | Error attaching to process: sun.jvm.hotspot.debugger.DebuggerException: cannot open binary file |
后续经过查阅相关资料,确定是自己用户权限不足的问题导致的。
遂请运维管理员帮忙执行相关语句。
主要有三个语句。
1 | jmap -heap 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 | Attaching to process ID 10070, please wait... |
可以看到使用的是Parallel GC
。
先看下目前堆内存配置情况,NewRatio=2
即年轻代:老年代=1:2
。
可以看到MaxHeapSize=1956.0MB
,老年代占2/3
,老年代大小应为1304M
。
正好对应上面PS Old Generation
的capacity
。
我们这儿还可以看到配置的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
的情况自动计算计算 Eden
、From
和 To
区的大小,要使-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
+类名 表示其他对象
可以看到[C
即char
数组占用了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 | sudo su hcadmin |
我们实时分析结果如下,先来看下jmap -heap
,某一次的分析如下。
可以看到其实开始时jvm
内存是动态分配的,不够时再尝试扩容。
申请到hcadmin
的权限后,我们看了下tomcat
的参数配置,如下:
可以看到未对jvm
有任何优化操作。
这儿我们尝试对jvm
做出如下优化:
优化点 一
我们可以设置-Xms
和-Xmx
参数,让其它两个相等,避免jvm
动态扩充内存进而导致的时间浪费,每个线程大小也可以设置下。
1 | -Xms2048M -Xmx2048M -Xss256K |
优化点 二
新生代、Eden Space
和Survivor 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 | jmap -heap xxxx |
我们发现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
,而不是直接进入老年代。
另外年轻代与老年代的比例也可能需要在调整优化下。
最终方案
根据上面的分析,我们最终处理过程如下:
- 优化代码(流未关闭的问题,String的问题,业务批量处理等);
- 为年轻代多分配一些内存,由于我们目前业务内存8G,目前只有我们这一个应用,因此我们又给应用多分配了一些内存,针对于年轻代。
总结
通过本次对JVM的调整,我学到了不少知识,同时发现并解决了系统中的一些问题。
还是蛮不错的一次经历。