JVM实战(十一):Full GC导致频繁卡死的优化实战

目录

1、实战:每秒十万QPS的社交APP如何优化GC性能提升3倍?

1.1、项目背景

1.2、高并发查询导致对象快速进入老年代

1.3、老年代必然会触发频繁GC

1.4、优化前的线上系统JVM参数

1.5、频繁Full GC导致的大量内存碎片

1.6、这个案例如何进行优化?

2、垂直电商APP后台系统,如何对Full GC进行深度优化?

2.1、背景        

2.2、垂直电商APP的JVM性能问题

2.3、公司级别的JVM参数模板

2.4、如何优化每次Full GC的性能?

2.5、垂直电商APP全公司采用JVM参数模板之后的效果

3、新手工程师不合理设置JVM参数,是如何导致频繁Full GC?

3.1、问题的产生

3.2、查看GC日志

3.3、查看Metaspace内存占用情况

3.4、一个综合性的分析思路

3.5、到底是什么类不停的被加载?

3.6、为什么会频繁加载奇怪的类?

3.7、JVM创建的奇怪类有什么玄机?


1、实战:每秒十万QPS的社交APP如何优化GC性能提升3倍?

1.1、项目背景

        陌生人社交类的APP,在这种APP中操作最多的就是浏览某个陌生人的个人页面。

一般这类APP都会通过各种方式来给你推荐一些周边的陌生人,然后你可能会看到一些感兴趣的人,就会进入他/她的个人主页去看一看。个人主页里可能就包含了那个人的一些自我介绍,照片之类的东西。
        所以这类APP,在 晚上高峰期,流量最大的一个模块,其实就是个人主页模块。 会有大量的活跃用户在一个集中的时间段内频繁的访问各种个人主页数据,而且这类数据的量还通常很大,要包含很多的信息,通常一个个人主页的数据甚至可能有几MB。
        举例:一个个人主页里,可能有这个人每天发的一些心情、感悟之类的东西。那么一旦要把这个个 人主页加载出来,必然会加载出来这个人最近N多天发的一些心情感悟之类的文字,这个文字的量还是比 较多的。
         一次个人主页的查询,就会加载出来比如5MB的数据。
        而且一般在高峰期内,有可能一些活跃用户他可能会连续不断的去点击他感兴趣的人的个人主页,比如连续1个小时都在不停的点击。所以其实这类社交APP他的高峰期QPS时很高的。在当时的场景中,这个 社交APP流量最大的个人主页模块高峰期最多每秒会有10万+的QPS。
        在底层存储中,这些个人主页数据一定是 基于缓存来存放的,也就是基于Redis缓存来查询这 些个人主页数据:

1.2、高并发查询导致对象快速进入老年代

        因为这个社交APP的日活用户涨的很快,所以导致他的 高峰期QPS很快就飙升到了10万。
正是因为每秒并发量太高,这也直接导致了这个系统在高峰期的时候,年轻代的Eden区会迅速的被填满,并且频繁的触发Young GC :

        而且每次在Young GC的时候,实际上还有很多请求是没处理完毕的,没办法,因为每秒请求量太多了,所以在触发Young GC的这一瞬间,必然有很多请求是还没处理完毕的。这就导致Eden区中其实每次触发Young GC的时候,都有很多对象是需要存活下来的 :

        在高峰期时,经常会出现Young GC过后存活对象较多,在Survivor区中放不下的问题:

        此时必然会导致大量的对象快速的进入老年代中:

1.3、老年代必然会触发频繁GC

        一旦在 高并发场景下Young GC后存活对象过多,导致对象快速进入老年代,必然会频繁触发老年代的GC,对老年代进行垃 圾回收。 所以在上述社交APP高峰期高并发场景下,必然会导致个人主页服务对应的JVM频繁的发生老年代的GC :

1.4、优化前的线上系统JVM参数

  •  针对上述场景,最核心的优化点主要应该是增加机器,尽量让每台机器承载更少的并发请求,减轻压力
  • 同时,给年轻代的Survivor区域更大的内存空间,让每次Young GC后的存活对象务必停留在Survior中,别进入老年代。
        但是在这里我们先不考虑上述优化,在优化前的线上系统中,对JVM有两个比较关键的参数大家可以看一下:
-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=5
CMS垃圾回收器默认是采用标记-清理算法,所以是会造成大量的内存碎片的。
什么叫内存碎片?比如现在老年代内存里有一些垃圾对象。

        然后CMS垃圾回收器一次垃圾回收过后,回收掉了一些垃圾对象,此时可能内存里看起来跟下面这样:

        上面那个红圈的地方,因为回收掉了一个对象,所以那里出现了一个内存碎片。虽然这里是空白内存,但是假如此时你要是要分配一个对象比较大,没法再上面红圈处放进去呢?那么红圈的那个内存碎片不就没任何意义了?

所以CMS正常垃圾回收,因为使用标记-清理算法,所以必然导致大量的内存碎片。
所以“-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=5”两个参数的含义,就是在5次Full GC之后会触发一次Compaction操作,也就是 压缩操作这个操作会把存活对象放到紧邻在一起,避免大量的内存碎片

1.5、频繁Full GC导致的大量内存碎片

        所以这就直接导致 在这5次Full GC的过程中,每一次Full GC之后都会产生大量的内存碎片。
大量的内存碎片会导致很多问题,其中一个问题,就是提高Full GC的频 率。
为什么呢?
        触发老年代GC的一个非常重要的条件,就是 Young GC后的存活对象无法放入Survivior,就要放入老年代。但是此时老年代假设也没足够内存放这些对象了,就必须触发Full GC 了。
        假设如下图所示,一次Full GC过后,老年代中有一部分内存里都是大量的内存碎片,没法放入完整的一些大对象了,只有部分内存是连续可用的内存空间:

        这时,随着大量对象快速进入老年代,会导致一旦老年代的那块连续可用内存满了,此时很多内存碎片是无法放入更多对象的,就会立马触发下一次Full GC

  • 比如老年代有2G的内存,其中1.5G是连续可用内存,0.5G是很多内存碎片
  • 本来老年代如果都是连续空内存的话,那么可能可以对象占用到将近2G才会触发Full GC。
  • 结果现在就是对象占用到了1.5G就需要触发Full GC了,剩下0.5G是没法放任何对象的。
所以这就会导 致随着一次一次Full GC导致老年代产生更多的内存碎片,连续可用内存越来越少,触发下一次FUll GC的速度就会 越快。直到几次Full GC之后,才会触发一次Compaction操作去整理内存碎片。

1.6、这个案例如何进行优化?

        用jstat分析一下各个机器上的jvm的运行状况,判断出来每次Young GC 后存活对象有多少,然后就是 增加Survivor区的内存,避免对象快速进入 老年代。另外一个,在当时对那个系统优化之后, 增加了年轻代和Survivor区的大 小,但还是会慢慢的有对象进入老年代里,毕竟系统负载很
高,彻底让对象不进入老年代也很难做到。所以当时调优过后每小时还是会有一次Full GC。
所以当时 第二个参数的优化就是针对CMS内存碎片问题的
在降低了Full GC频率之后, 务必设置如下参数“-XX:+UseCMSCompactAtFullCollection -
XX:CMSFullGCsBeforeCompaction=0”,每次Full GC后都整理一下内存碎 片。
否则如果每次Full GC过后,都造成老年代里很多内存碎片,那么必然导致下一次Full GC更快到来,因为内存碎片会导致老年代可用内存变少。
        也许第一次Full GC是一小时才有,第二次Full GC也许是40分钟之后,第三次Full GC可能就是20分钟之后,要是不解决CMS内存碎片问题,必然导致Full GC慢慢变得越来越频繁。

2、垂直电商APP后台系统,如何对Full GC进行深度优化?

2.1、背景        

        一个垂直电商公司,做的一个垂直电商APP主要是一些细分领域的电商业务。

  • 比如说有的APP专门做消费分期类的电商业务,在他们的APP里你主要是进行购物,然后可以分期付费。
  • 还有的APP专门是做服装定制的,他可能是会在APP里让你选购商品,然后有人上门给你定制化的测量身体,然后给你做定制的衬衫或者西装之类的。
  • 还有的APP他是做时尚潮流服饰的,就是专门售卖针对年轻人的一些潮牌、设计师的品牌等。
         这个垂直电商APP大致注册 用户量数百万的规 模,而且 每日活跃用户数量也就几十万 ,每 天APP的整体请求量也就小几千万 的级别。 高峰期的QPS也就每秒数百请求 罢了。
         这个APP虽然不大,但是同样有JVM相关的性能问题,而且也需要一些细致的优化才可以。

2.2、垂直电商APP的JVM性能问题

        默认的JVM参数绝对是系统负载逐渐增高的时候一个最大的问
        如果你不设置-Xmx、-Xms之类的堆内存大小的话,你启动一个系统,可能默认就给你几百MB的堆内存大小,新生代和老年代可能都是几百MB的样子。
        所以当时这个垂直电商APP的很多后台系统,基本都是用的默认JVM参数部署启动的,前期是没什么问题,但是中后期开始,当有一定用户量,有一定负载了,此时就会出现一些问题了。
        
        新生代内存过小,会导致Survivor区域内存过小,同时Eden区域也很小。Eden区域过小,自然会导致频繁的触发Young GC,Survivor区域过小,自然会导致经常在Young GC之后存活对象其实也没多少,但就是Survivor区域放不下。此时必然会导致对象经常进入老年代中,因此也必然会导致老年代过一段时间就放满了,然后就会触发Full GC。
        所以当时这个垂直电商APP的各个系统通过jstat分析JVM GC之后发现,基本上高峰期的时候,Full GC每小时都会发生好几次。 Full GC一般在正常情况下,都是以天为单位发生的,比如每天发生一次,或者是几天发生一次Full GC 。要是 每小时都发生几次Full GC,那么就会导致系统每小时都卡顿好几次。这个时候必然是不行 的。

2.3、公司级别的JVM参数模板

         默认的JVM参数,可能一台8G内存的机器上,JVM堆内存就分配了几百MB。
下面我们可以来看看当时我和那位朋友一起定制出来的适合他们公司的JVM参数模板:
-Xms4096M -Xmx4096M -Xmn3072M -Xss1M -XX:PermSize=256M
-XX:MaxPermSize=256M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFaction=92 -XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
为什么如此定制JVM参数模板呢?
  • 首先,8G的机器上给JVM堆内存分配4G差不多了,一般别让JVM堆内存把机器内存给占满
  • 然后,年轻代3G,因为让年轻代尽量大一些,进而让每个Survivor区域都达到300MB左右
        根据当时对这个业务系统的分析,假设用默认的JVM参数,可能年轻代就几百MB的内存,Survivor区域就几十MB的内存。那么每次垃圾回收过后存活对象可能会有几十MB,这是因为在垃圾回收的一瞬间可能有部分请求没处理完毕,此时会有几十MB对象是存活的,所以 很容易触发动态年龄判定规则,让部分对象进入老年代。
        在分析过后,给年 轻代更大内存空间,让Survivor空间更大,这样在Young GC的时候,这一瞬间可能有部分请求没处理完毕,有几十MB的存活对象,这个时候在几百MB的Survivor空间中可以轻松放下,绝对不会进老年 代。
        基本上都是在每次Young GC过后存活几MB~几十MB的对象,所以此时在这个参数模板下,
都可以抗住。只要把内存分配完毕,那么对象进入老年代的速度是极慢极慢的,经过这个参数模板在朋友公司全部系统的重新部署和上线,各个 团队通过jstat观察,基本上发现各个系统的Full GC都变成了几天才会发生一 次。
        此时在参数模板里还 会加入Compaction相关的参数,保证每次Full GC之后都会执行一次压缩,解决内存碎片的 问题。

2.4、如何优化每次Full GC的性能?

        当时做优化时还调整的另外两个参数,这个两个参数可以帮助优化FUll GC的性能,把每次Full GC的时间进一步降低一些。
  • 一个参数是“-XX:+CMSParallelInitialMarkEnabled”,这个参数会在CMS垃圾回收器的“初始标记”阶段开启多线程并发执行。
    • 初始标记阶段,是会进行Stop the World的,会导致系统停顿,所以这个阶段开启多线程并发之后,可以尽可能优化这个阶段的性能,减少Stop the World的时间。
  • 外一个参数是“-XX:+CMSScavengeBeforeRemark”,这个参数会在CMS的重新标记阶段之前,先尽量执行一次Young GC
    • 这样做有什么作用呢?因为CMS的重新标记也是会Stop the World的,所以如果在重新标记之前,先执行一次Young GC,就会回收掉一些年轻代里没有人引用的对象。所以如果先提前回收掉一些对象,那么在CMS的重新标记阶段就可以少扫描一些对象,此时就可以提升CMS的重新标记阶段的性能,减少他的耗时。
所以当时在JVM参数模板中,同样加入了这两个参数:
-Xms4096M -Xmx4096M -Xmn3072M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:+UseParNewGC -
XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=92 -XX:+UseCMSCompactAtFullCollection -
XX:CMSFullGCsBeforeCompaction=0 -XX:+CMSParallelInitialMarkEnabled -XX:+CMSScavengeBeforeRemark

2.5、垂直电商APP全公司采用JVM参数模板之后的效果

         采用jstat观察JVM GC情况,基 本上各个系统的Young GC都在几分钟一次,或者十几分钟一
次,每次耗时就几十毫秒而 已。 Full GC基本都在几天一次,每次耗时在几百毫秒的 样子。

3、新手工程师不合理设置JVM参数,是如何导致频繁Full GC?

3.1、问题的产生

        因为一般中大型公司都是接入类似Zabbix、OpenFalcon或者公司自研的一些监控系统的,监控系统一般都做的很好,可以让你的系统直接接入进去,然后在上面可以看到每台机器的CPU、磁盘、内存、网络的一些负载。 而且可以看到你的JVM的内存使用波动折线图,还有你的JVM GC发生的频率折线图。包括如果你自己上报某个业务指标,也可以在监 控系统里看到。
        而且一般都会针对线上运行的机器和系统设置一些报警,比如说,你可以设置如果10分钟内发现一个系统的JVM发生了超过3次Full GC,就必须发送报警给你,可以发送给你的短信、邮箱或者是钉钉之类的IM工具。

3.2、查看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区域本身:

3.3、查看Metaspace内存占用情况

       然后,看一看Metaspace区域的内存占用情况,可以通过jstat来观察,如果有监控系统,他会给你展示出来一个 Metaspace内存区域占用的波动曲线图 ,类似下面这种。

        看起来Metaspace区域的内存呈现一个波动的状态,他总是会先不断增加,达到一个顶点之后,就会把Metaspace区域给占满,然后自然就会触发一次Full GC,Full GC会带着Metaspace区域的垃圾回收,所以接下来Metaspace区域的内存占用又变得很小了。

3.4、一个综合性的分析思路

        很明显是系 统在运行过程中,不停的有新的类产生被加载到Metaspace区域里去,然后不
停的把Metaspace区域占满,接着触发一次Full GC回收掉Metaspace区域中的 部分类。 然后这个过程反复的不断的循环,进而造成Metaspace区域反复被占满,然后反复导致Full GC的发生:

3.5、到底是什么类不停的被加载?

        这时, 需要在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区域的类太多而触发。

3.6、为什么会频繁加载奇怪的类?

       那个类大概是在你使用Java中的反射时加载的,所谓反射代码类似如下所示。
Method method = XXX.class.getDeclaredMethod(xx,xx);
method.invoke(target,params);
         通过XXX.class获取到某个类,然后通过geteDeclaredMethod获取到那个类的方法。这个方法就是一个Method对象,接着通过Method.invoke可以去调用那个类的某个对象的方法。
        在 执行这种反射代码时,JVM会在你反射调用一定次数之后就动态生成一些类,就是我们之前看到的那种莫名其妙的 类。下次你再执行反射的时候,就是直接调用这些类的方法,这是JVM的一个底层优化的机制。
你只要记住一个结论:
        如 果你在代码里大量用了类似上面的反射的东西,那么JVM就是会动态的去生成一些类放入Metaspace区域里的。
        所以上面看到的那些奇怪的类,就是由于不停的执行反射的代码才生成的

3.7、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对象都是软引用的,正常情况下不会被回收,但是也不应该快速增长才对。
9、为什么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底层源码的这些细节。
10、如何解决这个问题?
        虽然底层JDK的一些实现细节我们没分析,但是大致梳理出来了一个思路,大家也很清楚问题所在和原因了解决方案很简单。在有大量反射代码的场景下,大家只要把
-XX:SoftRefLRUPolicyMSPerMB=0
这个参数设置大一些即可,千万别让一些新手同学设置为0,可以设置个1000,2000,3000,或者5000毫秒,都可以。
        提高这个数值,就是让反射过程中JVM自动创建的软引用的一些类的Class对象不要被随便回收,当时我们优化这个参数之后,就可以看到系统稳定运行了。 基本上Metaspace区域的内存占用是稳定的,不会来回大幅度波动了。
11、今日思考题
结合昨天的内容, 大家思考一下这个线上事故的本质是什么?
其实说白了不是JVM的问题,是人的问题。
大家可以考虑一下,如果你是公司的架构师,是否应该严格审核各个系统的生产环境JVM参数?
比如完全可以推行一套JVM参数模板,如果有人要做定制的JVM优化,是不是应该先在测试环
试一下,然后还得交给你们高级别的架构师来审核?如果有人审核,那么就不会发生类似之类的血案了。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值