26_JVM 调优案例
2698字约9分钟
2024-08-10
1、频繁 Full GC 导致的大量内存碎片
在社交类 APP
中,特别是陌生人社交类型,到了晚上访问用户信息的查询量比较大
虽然用户主页数据可以放在缓存中间件,对数据库压力不大,但是如果 QPS
比较高,不合理的 JVM
参数设置还是会导致系统卡顿的
在高并发场景下 Young GC
后存活对象过多,导致对象快速进入老年代,必然会触发老年代的 GC
,如下图所示
针对上述场景,最核心的优化点应该是增加机器,让每台机器承受更少的并发请求,减轻压力
同时给年轻代的 Survivor
区更大的内存空间,让每次 Young GC
后的存活对象尽量留在 Survivor
中,别进入老年代
这里我们先不考虑上述优化,我们来看两个参数:-XX:+UseCMSCompactAtFullCollection
、-XX:CMSFullGCsBeforeCompaction=5
参数设置的 5
次 Full GC
之后才会进行一次压缩操作,解决内存碎片问题
这会导致每一次 Full GC
之后都会产生大量的内存碎片,可用连续内存减少,进一步提高了 Full GC
的频率
针对这个案例,使用 jstat
工具分析一下 jvm
的运行情况,每次 Young GC
后存活对象有多少,增加 Survivor
区大小,避免对象快速进入老年代
另外,系统负载很高,始终还是会慢慢的有对象进入老年代,需要调整下参数 -XX:+UseCMSCompactAtFullCollection
、-XX:CMSFullGCsBeforeCompaction=0
每次 Full GC
之后都整理一下内存碎片
2、大对象频繁进入老年代
系统运行中频繁出现 Full GC
,通过 jstat
观察平时 Young GC
每次就几十 MB
对象进入老年代,偶尔一次 Young GC
会突然几百 MB
对象进入老年代
像这样第一反应是大对象直接进入老年代了
使用 jmap
工具导出一份 dump
内存快照,查看是什么对象这么大
后面发现是从数据库查询出来的数据,存在 select * from table
这样的 SQL
,表的数据量也比较大
这是认为引起的频繁 Full GC
,优化这种不带条件查询的 SQL
3、System.gc() 频繁触发 Full GC
系统在一次升级之后,系统 Full GC
变得十分频繁,通过 jstat
工具观察内存使用情况
发现年轻代、老年代都只有几十 MB
的时候就会发生 Full GC
,几乎是每秒一次 Full GC
这种情况很可能是写了这样的代码:System.gc()
,每次执行都会让 JVM
去尝试执行一次 Full GC
,连带年轻代、老年代、永久代回收
写这行代码的人出发点可能是这样的,代码里经常会加载出来一大批数据,请求处理完成之后就觉得,一大批数据不用了挺占内存的,主动 System.gc()
代码触发一次 GC
,将它们都回收掉
系统平时运行时,访问量较低,问题也不大,如果访问量一高,频繁主动触发的 Full GC
,能让系统一直处于卡死状态
4、jxl 引发的 Full GC
在开源世界中,Excel
有两套比较有影响的 API
可供使用,一个是 POI
,一个是 jExcelAPI
项目初期的 Excel
文件导入导出就使用了 jExcelAPI
,系统在预生产环境中测试发现,每次导入数据的时候就会出现 Full GC
,造成了系统的卡顿,貌似还导入失败
通过 Excel
导入这个点开始排查,发现 jxl
读取 Excel
手动调用了 System.gc()
,想想还是有些离谱的,一个开源的工具,默认调用了 System.gc()
// 使用最后会调用 close(),最终源码发现,如果没显示设置 gcDisabled 为 true,那么就会调用 System.gc() 代码
finally {
try {
workbook.close();
} catch (Exception e) {
e.printStackTrace();
}
}
// workbook.close(); 对应源码如下
void close(boolean cs) throws IOException, JxlWriteException {
jxl.write.biff.CompoundFile cf = new jxl.write.biff.CompoundFile(this.data, this.data.getPosition(), this.outputStream, this.readCompoundFile);
cf.write();
this.outputStream.flush();
this.data.close();
if (cs) {
this.outputStream.close();
}
this.data = null;
if (!this.workbookSettings.getGCDisabled()) {
System.gc();
}
}
通过手动关闭调用 System.gc();
解决了这个问题
WorkbookSettings wbs=new WorkbookSettings();
// 设置 gcDisabled 为 true
wbs.setGCDisabled(true);
Workbook wb = Workbook.getWorkbook(excelFile,wbs);
5、本地缓存导致的内存泄露
系统上线后,高峰期访问量突然增大,系统的 CPU
使用率飙升,由于 CPU
使用率太高,导致系统几乎陷入卡死的状态,无法处理任何请求
CPU
负载过高的常见场景:
1、系统创建了大量的线程,这些线程同时并发执行,工作负载很重,过多的线程同时并发运行就会导致机器的
CPU
负载过高2、机器上运行的
JVM
在频繁的Full GC
,Full GC
是非常耗费CPU
资源的
目前系统情况是频繁的 Full GC
,那我们初步排查下频繁 Full GC
的问题:
1、内存分配不合理,导致对象频繁进入老年代,进而引发频繁的
Full GC
2、存在内存泄露等问题,大量对象塞满了老年代,稍微有一些对象进入老年代就会引发
Full GC
3、永久代里的类太多,触发了
Full GC
(像反射代码运行时,会在永久代增加类信息,错误的设置软引用存活时间会频繁触发Full GC
,-XX:SoftRefLRUPolicyMSPerMB=0
)4、错误的执行
System.gc()
代码导致(可以配置jvm
参数禁用手动gc
)
目前来看,最有可能的是老年代里驻留了大量的对象,我们可以借助 MAT
内存分析工具去分析
最后找到原因,发现是在系统中做了一个 JVM
本地缓存,把很多数据都加载进去,然后提供查询服务直接返回数据
但是因为限制本地缓存的大小,并且没有使用 LRU
之类的算法定期淘汰掉一些缓存里的数据,导致缓存在内存里的对象越来越多,从而造成了内存泄露
6、错误的 JVM 软引用参数设置
某天团队一个工程师可能心血来潮,网上看到某个 JVM
参数,觉得非常好,当天上线系统时,自作主张的设置了一个 JVM
参数
监控系统开始告警,有大量 Full GC
日志记录,日志显示 Metadata GC Threshold
字样
从日志中我们看到频繁的 Full GC
是由于 Metadata
元数据区导致的,也就是之前说过的永久代
通过分析发现,系统运行过程中,不断有新的类产生被加载到 Metaspace
区域,直到 Metasapce
区域被占满,接着触发一次 Full GC
回收掉 Metaspace
区域中的部分类
在代码中使用了反射,JVM
动态的生成一些类,上述过程反复不断循环,造成 Metaspace
区域反复被占满,然后反复导致 Full GC
最后发现 JVM
中多了 -XX:SoftRefLRUPolicyMSPerMB=0
参数,这个参数表示每一 MB
空闲内存空间可以允许 SoftReference
对象存活多久
对象能存活时间计算公式 clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB
SoftRefLRUPolicyMSPerMB
设置为 0
,公式左边直接为 0
,你通过反射刚创建出来的类可能就被一次 Young GC
顺带着回收掉了,然后又不停的创建
7、大数据量 Excel 文件导出
项目开发末期,开始开发系统报表模块内容,开发时没有什么问题,导入导出一切正常
报表模块内容开发完成后发布到预生产环境,预生产环境的数据与生产环境是完全一样的,也就是说数据量比较大
第一版代码如下,从数据中心查询数据,然后循环处理数据,然后问题来了,selectOrderListFromDataCenter()
查询回来的数据有几十万
数据量太大,根本不是 Full 不 Full GC 的问题,是直接把 JVM 干崩溃了,得了,优化代码
// 从数据中心查询订单数据
List<Order> orders = selectOrderListFromDataCenter();
// 数据处理后的报表内容
List<OderReport> oderReports = new ArrayList<>();
// 循环处理数据
for (Order order : orders) {
OderReport oderReport = new OderReport();
oderReports.add(oderReport);
}
// 导出 excel
有一点需要注意,这里查询数据我没写条件,实际上在查询过程中是有条件的,并不是说直接把整张表数据都查出来
我优化了一点,先查询总数据条数,然后处理了下循环次数,分页去查询数据回来处理,比如几十万的数据,我每次只查询 2000 条数据回来处理
看到这里,感觉数据量太大也没啥问题,慢慢分页查询处理就是了
问题出在我太年轻了,第二层 for 循环中 OderReport oderReport = new OderReport(); 不停的创建局部变量 oderReport
导致 Young GC 时新生代中的订单数据内容都存在引用,自然无法回收,导致无法继续分配内存,JVM 直接崩溃,得了,继续优化代码
Integer count = selectOrderCountFromDataCenter();
// 从数据中心查询订单数据
List<Order> orders = selectOrderListFromDataCenter();
for (int i = 0; i < count/pageSize; i++) {
// 数据处理后的报表内容
List<OderReport> oderReports = new ArrayList<>();
// 循环处理数据
for (Order order : orders) {
OderReport oderReport = new OderReport();
oderReports.add(oderReport);
}
}
// 导出 excel
第三版本代码,将 oderReport 局部变量的定义放在了 for 循环之外,这样发生 Young GC 时也可以顺利回收掉没有引用的对象
不出意外还是出意外的了,导出 excel 的工具类是一次性导出的,也就是把所有数据处理好然后去导出
结果还是数据量太大,JVM 直接奔溃了,得了,继续优化
Integer count = selectOrderCountFromDataCenter();
// 从数据中心查询订单数据
List<Order> orders = selectOrderListFromDataCenter();
OderReport oderReport;
for (int i = 0; i < count/pageSize; i++) {
// 数据处理后的报表内容
List<OderReport> oderReports = new ArrayList<>();
// 循环处理数据
for (Order order : orders) {
oderReport = new OderReport();
oderReports.add(oderReport);
}
}
// 导出 excel
到了这里不成功都不行了,我们将每次循环处理好的数据追加到本地 excel 文件,完美解决了大数据量 excel 文件导出的问题
// 从数据中心查询数据总条数
Integer count = selectOrderCountFromDataCenter();
// 从数据中心查询订单数据
List<Order> orders = selectOrderListFromDataCenter();
OderReport oderReport;
for (int i = 0; i < count/pageSize; i++) {
// 数据处理后的报表内容
List<OderReport> oderReports = new ArrayList<>();
// 循环处理数据
for (Order order : orders) {
oderReport = new OderReport();
oderReports.add(oderReport);
}
// 数据追加到本地 excel 文件
orders = selectOrderListFromDataCenter();
}