java 九 (二) 实战
案例实战:新手工程师不合理设置JVM参数,是如何导致频繁Full GC的
问题的产生
一般中大型公司都是接入类似Zabbix、OpenFalcon或者公司自研的一些监控系统的,监控系统一般都做的很好,可以让你的系统直接接入进去,然后在上面可以看到每台机器的CPU、磁盘、内存、网络的一些负载。而且可以看到你的JVM的内存使用波动折线图,还有你的JVM GC发生的频率折线图。包括如果你自己上报某个业务指标,也可以在监控系统里看到。而且一般都会针对线上运行的机器和系统设置一些报警,比如说,你可以设置如果10分钟内发现一个系统的JVM发生了超过3次Full GC,就必须发送报警给你,可以发送给你的短信、邮箱或者是钉钉之类的IM工具。
类似这样的监控系统不在我们的专栏范畴内,建议大家自己可以去查阅资料,其实基于我们讲解的命令行工具,比如jstat,你可以通过linux上的一些命令,让jstat自动对jvm进行监控,把监控结果可以输出到机器的某个文件里去。然后第二天你就可以去查阅那个文件,也可以看到那台机器的jvm的一些gc统计。所以说,没有可视化工具,用最简单的命令行工具,其实同样可以起到类似的效果。
所以那天那个工程师设置了一个JVM参数之后,直接导致线上频繁接到JVM的Full GC的报警,大家就很奇怪了,于是就开始排查那个系统了。
查看GC日志
之前已经给大家讲解过如何在启动系统的时候让他输出GC日志,所以一旦发现报警,直接登录到线上机器,然后就看到对应的GC日志了。此时我们看到在GC日志中有大量的Full GC的记录。
那么是为什么导致的Full GC呢?
在日志里,看到了一个“Metadata GC Threshold”的字样,类似于如下日志:
【Full GC(Metadata GC Threshold)xxxxx, xxxxx】
从这里就知道,这频繁的Full GC,实际上是JDK 1.8以后的Metadata元数据区导致的,也就是类似我们之前说的永久代。
这个Metadata区域一般是放一些加载到JVM里去的类的。
所以此时就很奇怪了,为什么会因为Metadata区域频繁的被塞满,进而触发Full GC?而且Full GC大家都知道,会带动CMS回收老年代,还会回收Metadata区域本身。
我们先看看下图:
查看Metaspace内存占用情况
接着我们当然是想看一看 Metaspace区域的内存占用情况了,简单点你可以通过jstat来观察,如果有监控系统,他会给你展示出来一个Metaspace内存区域占用的波动曲线图,类似下面这种。
看起来Metaspace区域的内存呈现一个波动的状态,他总是会先不断增加,达到一个顶点之后,就会把Metaspace区域给占满,然后自然就会触发一次Full GC,Full GC会带着Metaspace区域的垃圾回收,所以接下来Metaspace区域的内存占用又变得很小了。
一个综合性的分析思路
看到这里,相信大家肯定有一点感觉了,这个很明显是系统在运行过程中,不停的有新的类产生被加载到Metaspace区域里去,然后不停的把Metaspace区域占满,接着触发一次Full GC回收掉Metaspace区域中的部分类。然后这个过程反复的不断的循环,进而造成Metaspace区域反复被占满,然后反复导致Full GC的发生,如下图所示。
到底是什么类不停的被加载
接着我们就有点奇怪了,到底是什么类不停的被加载到JVM的Metaspace区域里去?这个时候就需要在JVM启动参数中加入如下两个参数了:
"-XX:TraceClassLoading -XX:TraceClassUnloading"
这两个参数,顾名思义,就是追踪类加载和类卸载的情况,他会通过日志打印出来JVM中加载了哪些类,卸载了哪些类。加入这两个参数之后,我们就可以看到在Tomcat的catalina.out日志文件中,输出了一堆日志,里面显示类似如下的内容:
【Loaded sun.reflect.GeneratedSerializationConstructorAccessor from __JVM_Defined_Class】
明显可以看到,JVM在运行期间不停的加载了大量的所谓“GeneratedSerializationConstructorAccessor”类到了Metaspace区域里去,如下图所示:
相信就是因为JVM运行期间不停的加载这种奇怪的类,然后不停的把Metaspace区域占满,才会引发不停的执行Full GC的。
这是一个非常实用的技巧,各位同学一定要掌握,频繁Full GC不光是老年代触发的,有时候也会因为Metaspace区域的类太多而触发。
为什么会频繁加载奇怪的类
接着遇到类似这种问题,我们就应该找一下Google或者是百度了,当然推荐是用Google。你完全可以看看那种不停加载的类,到底是什么类,是你自己写的类?还是说JDK内置的类?
比如上面的那个类,如果你查阅一些资料,很容易就会搞明白,那个类大概是在你使用Java中的反射时加载的,所谓反射代码类似如下所示。
Method method = XXX.class.getDeclaredMethod(xx,xx);
method.invoke(target,params);
友情提示一下,反射是Java中最最基础的一个概念,不懂的朋友自己查一下资料。
简单来说,就是通过XXX.class获取到某个类,然后通过geteDeclaredMethod获取到那个类的方法。
这个方法就是一个Method对象,接着通过Method.invoke可以去调用那个类的某个对象的方法,大概就这个意思。
在执行这种反射代码时,JVM会在你反射调用一定次数之后就动态生成一些类,就是我们之前看到的那种莫名其妙的类
下次你再执行反射的时候,就是直接调用这些类的方法,这是JVM的一个底层优化的机制。
看到这里,有的小伙伴是不是有点蒙?其实这倒无所谓,这段话看的蒙丝毫不影响你进行JVM优化的
你只要记住一个结论:如果你在代码里大量用了类似上面的反射的东西,那么JVM就是会动态的去生成一些类放入Metaspace区域里的。
所以上面看到的那些奇怪的类,就是由于不停的执行反射的代码才生成的,如下图所示。
JVM创建的奇怪类有什么玄机
那么接下来我们就很奇怪一件事情,就是JVM为什么要不停的创建那些奇怪的类然后放入Metaspace中去?
其实这就要从一个点入手来分析一下了,因为上面说的那种JVM自己创建的奇怪的类,他们的Class对象都是SoftReference,也就是软引用的。
大家可千万别说连类的Class是什么都没听说过?简单来说,每个类其实本身自己也是一个对象,就是一个Class对象,一个Class对象就代表了一个类。同时这个Class对象代表的类,可以派生出来很多实例对象。
举例来说,Class Student,这就是一个类,他本身是由一个Class类型的对象表示的。
但是如果你走一个Student student = new Student(),这就是实例化了这个Student类的一个对象,这是一个Student类型的实例对象。
所以我们这里所说的Class对象,就是JVM在发射过程中动态生成的类的Class对象,他们都是SoftReference软引用的。
所谓的软引用,最早我们再一篇文章里说过,正常情况下不会回收,但是如果内存比较紧张的时候就会回收这些对象。
那么SoftReference对象到底在GC的时候要不要回收是通过什么公式来判断的呢?
是如下的一个公式:clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB。
这个公式的意思就是说,“clock - timestamp”代表了一个软引用对象他有多久没被访问过了,freespace代表JVM中的空闲内存空间,SoftRefLRUPolicyMSPerMB代表每一MB空闲内存空间可以允许SoftReference对象存活多久。
举个例子,假如说现在JVM创建了一大堆的奇怪的类出来,这些类本身的Class对象都是被SoftReference软引用的。
然后现在JVM里的空间内存空间有3000MB,SoftRefLRUPolicyMSPerMB的默认值是1000毫秒,那么就意味着,此时那些奇怪的SoftReference软引用的Class对象,可以存活3000 * 1000 = 3000秒,就是50分钟左右。
当然上面都是举例而已,大家都知道,一般来说发生GC时,其实JVM内部或多或少总有一些空间内存的,所以基本上如果不是快要发生OOM内存溢出了,一般软引用也不会被回收。
所以大家就知道了,按理说JVM应该会随着反射代码的执行,动态的创建一些奇怪的类,他们的Class对象都是软引用的,正常情况下不会被回收,但是也不应该快速增长才对。
为什么JVM创建的奇怪的类会不停的变多
那么究竟为什么JVM创建的那些奇怪的类会不停的变多呢?
原因很简单,因为文章开头那个新手工程师不知道从哪里扒出来了SoftRefLRUPolicyMSPerMB这个JVM启动参数,他直接把这个参数设置为0了。
他想的是,一旦这个参数设置为0,任何软引用对象就可以尽快释放掉,不用留存,尽量给内存释放空间出来,这样不就可以提高内存利用效率了么?
真是想的很傻很天真。
实际上一旦这个参数设置为0之后,直接导致clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB这个公式的右半边是0,就导致所有的软引用对象,比如JVM生成的那些奇怪的Class对象,刚创建出来就可能被一次Young GC给带着立马回收掉一些。
如下图所示。
比如JVM好不容易给你弄出来100个奇怪的类,结果因为你瞎设置软引用的参数,导致突然一次GC就给你回收掉几十个类
接着JVM在反射代码执行的过程中,就会继续创建这种奇怪的类,在JVM的机制之下,会导致这种奇怪类越来越多。
也许下一次gc又会回收掉一些奇怪的类,但是马上JVM还会继续生成这种类,最终就会导致Metaspace区域被放满了,一旦Metaspace区域被占满了,就会触发Full GC,然后回收掉很多类,接着再次重复上述循环,如下图所示。
其实很多人会有一个疑问,到底为什么软引用的类因为错误的参数设置被快速回收之后,就会导致JVM不停创建更多的新的类呢?
其实大家不用去扣这里的细节,这里有大量的底层JDK源码的实现,异常复杂,要真的说清楚,得好几篇文章才能讲清楚JDK底层源码的这些细节。
大家只要记住这个结论,明白这个道理就好。
如何解决这个问题
虽然底层JDK的一些实现细节我们没分析,但是大致梳理出来了一个思路,大家也很清楚问题所在和原因了
解决方案很简单。在有大量反射代码的场景下,大家只要把
-XX:SoftRefLRUPolicyMSPerMB=0
这个参数设置大一些即可,千万别让一些新手同学设置为0,可以设置个1000,2000,3000,或者5000毫秒,都可以。提高这个数值,就是让反射过程中JVM自动创建的软引用的一些类的Class对象不要被随便回收,当时我们优化这个参数之后,就可以看到系统稳定运行了。
基本上Metaspace区域的内存占用是稳定的,不会来回大幅度波动了。
案例实战:一次线上系统每天数十次Full GC导致频繁卡死的优化实战!
未优化前的JVM性能分析
通过监控平台+jstat工具分析,直接得出当时没优化过的系统的JVM性能表现大致如下:
机器配置:2核4G
JVM堆内存大小:2G
系统运行时间:6天
系统运行6天内发生的Full GC次数和耗时:250次,70多秒
系统运行6天内发生的Young GC次数和耗时:2.6万次,1400秒
综合分析一下,就可以知道,大致来说每天会发生40多次Full GC,平均每小时2次,每次Full GC在300毫秒左右;
每天会发生4000多次Young GC,每分钟会发生3次,每次Young GC在50毫秒左右。
上述数据对任何一个线上系统,用jstat可以轻松看出来,因为jstat显示出来的Full GC和Young GC的次数都是系统启动以来的总次数,耗时都是所有GC加起来的总耗时,所以直接可以拿到上述数据,略微分析一下就知道具体情况了。
基本看起来,这个系统的性能是相当差了,每分钟3次Young GC,每小时2次Full GC,这种系统是必须得进行优化的。
未优化前的线上JVM参数
下面是未优化前的线上JVM参数,大致如下:
-Xms1536M -Xmx1536M -Xmn512M -Xss256K -XX:SurvivorRatio=5 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=68 -XX:+CMSParallelRemarkEnabled -XX:+UseCMSInitiatingOccupancyOnly -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC
其实基本上跟我们之前看到的参数没多大的不同,一个4G的机器上,给JVM的堆内存是设置了1.5G的大小,其中新生代是给了512M,老年代是1G。
比较关键的是“-XX:SurvivorRatio”设置为了5,也就是说,Eden:Survivor1:Survivor2的比例是5:1:1
所以此时Eden区域大致为365M,每个Survivor区域大致为70MB。
而且这里有一个非常关键的参数,那就是“-XX:CMSInitiatingOccupancyFraction”参数设置为了68
所以一旦老年代内存占用达到68%,也就是大概有680MB左右的对象时,就会触发一次Full GC。
此时整个系统的内存模型图如下所示:
根据线上系统的GC情况倒推运行内存模型
接着我们可以根据系统的内存模型以及GC情况,直接根据学习过的知识推导出系统运行时的内存模型了。
首先我们知道每分钟会发生3次Young GC,说明系统运行20秒就会让Eden区满,也就是产生300多MB的对象,平均下来系统每秒钟会产生15~20MB的对象,如下图所示。
所以20秒左右就会导致Eden区满,然后触发一次Young GC。
接着我们根据每小时2次Full GC推断出,30分钟会触发一次Full GC
根据
-XX:CMSInitiatingOccupancyFraction=68
参数的设置,应该是在老年代有 600多MB 左右的对象时大概就会触发一次Full GC,因为1GB的老年代有68%空间占满了就会触发CMS的GC了。
所以系统运行30分钟就会导致老年代里有600多MB的对象,进而触发CMS垃圾回收器对老年代进行GC,如下图。
所以基本上我们就能根据推导出的运行内存模型得出一个结论:
每隔20秒会让300多MB的Eden区满触发一次Young GC,一次Young GC耗时50毫秒左右。
每隔30分钟会让老年代里600多MB空间占满,进而触发一次CMS的GC,一次Full GC耗时300毫秒左右。
但是到这里大家先暂停一下,有的朋友可能立马会推断了,他会说,是不是因为Survivor区域太小了,导致Young GC后的存活对象太多放不下,就一直有对象流入老年代,进而导致30分钟后触发Full GC?
实际上仅仅只是分析到这里,绝对不能草率下这个判断的。
因为老年代里为什么有那么多的对象?有可能是每次Young GC后的存活对象较多,Survivor区域太小,放不下了
也有可能是有很多长时间存活的对象太多了,都积累在老年代里,始终回收不掉,进而导致老年代很容易就达到68%的占比触发GC。
所以仅仅分析到这里,绝对不能轻易下结论。
老年代里到底为什么会有那么多的对象?
分析到这里,说句实话,仅仅根据可视化监控和推论是绝对没法往下分析了,因为我们并不知道老年代里到底为什么会有那么多的对象
此时就完全可以用jstat在高峰期观察一下JVM实际运行的情况。
通过jstat的观察,我们当时可以明确看到,每次Young GC过后升入老年代里的对象很少
一般来说,每次Young GC过后大概就存活几十MB而已,那么Survivor区域因为就70MB,所以经常会触发动态年龄判断规则,导致偶尔一次Young GC过后有几十MB对象进入老年代。
我们看下图的图示。
因此分析到这里很奇怪,因为通过jstat追踪观察,并不是每次Young GC后都有几十MB对象进入老年代的,而是偶尔一次Young GC才会有几十MB对象进入老年代,记住,是偶尔一次!
所以正常来说,应该不至于30分钟就导致老年代占用空间达到68%。那么老年代里到底为什么有那么多对象呢?
这个时候我们通过jstat运行的时候就观察到一个现象,就是老年代里的内存占用在系统运行的时候,不知道为什么系统运行着运行着,就会突然有几百MB的对象占据在里面,大概有五六百MB的对象,一直占据在老年代中, 大家看下图。
正是因为系统运行的时候,不知道为什么突然有有几百MB对象进入老年代中,所以才导致Young GC偶尔一次让几十MB对象升入老年代,平均30分钟左右就会触发一次Full GC!!!
那么我们就很奇怪了,为什么系统运行着会突然有几百MB的对象进入老年代?
答案已经呼之欲出了,大对象!
一定是系统运行的时候,每隔一段时间就会突然产生几百MB的大对象,直接进入老年代,不会走年轻代的Eden区域。
然后再配合上年轻代还偶尔会有Young GC后几十MB对象进入老年代,所以才会30分钟触发一次Full GC!
大家看如下图所示。
定位系统的大对象
分析到这里,就很简单了,我们只需要采用之前给大家介绍的jmap工具,通过后台jstat工具观察系统,什么时候发现老年代里突然进入了几百MB的大对象,就立马用jmap工具导出一份dump内存快照。
接着可以采用之前说过的jhat,或者是Visual VM之类的可视化工具来分析dump内存快照
关于Visual VM之类的工具,大家自行百度即可,非常简单易用,其实本质就是让你分析导出的内存快照。
通过内存快照的分析,直接定位出来那个几百MB的大对象,就是几个Map之类的数据结构,这是什么东西?直接让负责写那个系统代码的几个同学分析了一下,明显是从数据库里查出来的!
因为那个系统仅仅就是操作数据库而已,不存在别的什么特殊操作。
然后这个时候也没太好的办法了,直接笨办法,几个人地毯式排查这个系统的所有SQL语句,结果还真的有一个人发现,自己的一个SQL居然在某种特殊的场景下,会类似如下所示:
select * from tbl。
这是啥意思?就是没有where条件!没有where条件,就代表这个SQL可能会把表中几十万条数据直接全部查出来!
正是因为这个代码层面的bug,导致了每隔一段时间系统会搞出几个上百MB的大对象,这些对象是会全部直接进入老年代的!
然后过一会儿随着偶尔几次Young GC有几十MB对象进入老年代,所以平均几十分钟就会触发一次Full GC!!!
针对本案例的JVM和代码优化
其实分析到这里,这个案例如何优化已经呼之欲出了!非常简单,分成两步走
第一步,让开发同学解决代码中的bug,避免一些极端情况下SQL语句里不拼接where条件,务必要拼接上where条件,不允许查询表中全部数据。彻底解决那个时不时有几百MB对象进入老年代的问题。
第二步,年轻代明显过小,Survivor区域空间不够,因为每次Young GC后存活对象在几十MB左右,如果Survivor就70MB很容易触发动态年龄判定,让对象进入老年代中。所以直接调整JVM参数如下:
-Xms1536M -Xmx1536M -Xmn1024M -Xss256K -XX:SurvivorRatio=5 -XX:PermSize=256M
-XX:MaxPermSize=256M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92 -XX:+CMSParallelRemarkEnabled
-XX:+UseCMSInitiatingOccupancyOnly -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC
直接把年轻代空间调整为700MB左右,每个Surivor是150MB左右,此时Young GC过后就几十MG存活对象,一般不会进入老年代。
反之老年代就留500MB左右就足够了,因为一般不会有对象进入老年代。
而且调整了参数
-XX:CMSInitiatingOccupancyFraction=92
避免老年代仅仅占用68%就触发GC,现在必须要占用到92%才会触发GC。
最后,就是主动设置了永久代大小为256MB,因为如果不主动设置会导致默认永久代就在几十MB的样子,很容易导致万一系统运行时候采用了反射之类的机制,可能一旦动态加载的类过多,就会频繁触发Full GC。
这几个步骤优化完毕之后,线上系统基本上表现就非常好了,基本上每分钟大概发生一次Young GC,一次在几十毫秒;
Full GC几乎就很少,大概可能要运行至少10天才会发生一次,一次就耗时几百毫秒而已,频率很低。
案例实战:电商大促活动下,严重Full GC导致系统直接卡死的优化实战
案例是这样,有一次一个新系统上线,平时都还算正常,结果有一次大促活动的时候,这个系统就直接卡死不动了
大家注意,是直接卡死不动!也就是说,所有请求到这个系统就直接卡住无法处理,无论如何重启这个系统都没任何效果。
这个时候我们当然会想,是不是按照之前的思路,一点一点去分析JVM的GC问题,考虑是不是过于频繁的GC问题导致了系统被卡死?
那当然是会按照之前的思路去分析的,首先使用jstat去看一下系统运行情况,令人吃惊的事情是:JVM几乎每秒都执行一次Full GC,每次都耗时几百毫秒。
我们当时就惊呆了,为什么每秒都有一次Full GC?
结果更加令人吃惊的事情还在后面:我们通过jstat看了一下JVM各个内存区域的使用量,基本都没什么问题,年轻代对象增长并不快,老年代才占用了不到10%的空间,永久代也就使用了20%左右的空间
那。。。为什么会频繁触发Full GC呢?
这个时候我们立马想到了一个问题,是不是有人在系统里写了一行致命的代码:System.gc()
这个“System.gc()”可不能随便瞎写,他每次执行都会指挥JVM去尝试执行一次Full GC,连带年轻代、老年代、永久代都会去回收。
所以我们立马找到那个系统负责开发的同学,让他排查了一下自己的代码。
其实说是排查,无非就是在开发IDE中开启代码全局搜索找一下是否有“System.gc()”,结果没想到还真的找到了!
所以致命的问题就在这里,当时这个同学写这行代码想的特别的天真和可爱,出发点也是好的
他是这么考虑的,大家可以看看他当时的思路:他在代码里经常会一下子加载出来一大批数据,一旦请求处理完毕之后,他就觉得,一大批数据就废弃不用了,占据内存太多了,完全可以主动用“System.gc()”代码触发一次GC,把他们回收掉!
结果呢,平时系统运行时,访问量很低,基本还不会出大乱子!但是在大促活动的时候,访问量一高,立马由“System.gc()”代码频繁触发了Full GC,导致了这个系统直接被卡死了!
这个案例很短,也很简单,发在周末,让大家轻松看一看,不太烧脑,这周之前的几个案例,都略微有点烧脑,而且都在4000字以上,甚至需要大家看两遍才能理解其中的内容和精髓
所以,针对这个问题,一方面大家平时写代码的时候,不要自己使用“System.gc()”去随便触发GC,一方面可以在JVM参数中加入这个参数:-XX:+DisableExplicitGC。这个参数的意思就是禁止显式执行GC,不允许你来通过代码触发GC。
所以推荐大家将
-XX:+DisableExplicitGC
参数加入到自己的系统的JVM参数中,或者是加入到公司的JVM参数模板中去。避免有的开发工程师好心办坏事,代码中频繁触发GC就不好了。