JVM第九周 JVM优化案例

906 阅读14分钟

JVM优化案例

CMS内存碎片的调优

-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=5

5次Full GC后进行一次压缩,把存活对象放一起,避免内存碎片。

频繁Full GC会造成大量的内存碎片

大量的内存碎片就会提高Full GC的频率,内存空间有限,却又有很多碎片,内存没有得到充分利用。

针对CMS内存碎片的调优:

在降低了Ful GC频率之后,务必设置如下参数“-XX:+UseCMSCompactAtFullCollection- XX:CMSFullGCsBeforeCompaction=0",每次Full GC后都整理一下内存碎片。

否则如果每次Full GC过后,都造成老年代里很多内存碎片,那么必然导致下一次Full GC更快到来,因为内存碎片会导致老年代可用内存变少。

也许第一次Full GC是一小时才有,第二次Full GC也许是40分钟之后,第三次Full GC可能就是20分钟之后,要是不解决CMS内存碎片问题必然导致Full GC慢慢变得越来越频繁

-XX:+UseCMSCompactAtFullCollection 开启的时候 , CMSFullGCsBeforeCompaction 设置成 0 和 1 的区别是什么? 0 是每一次Full gc 后 都进行碎片压缩, 1 是 第一Full gc 后 不进行压缩,其余每次Full gc 后 都进行碎片压缩。

Full GC深度优化

大部分的一线工程师开发完一个系统之后,部署生产环境的时候往往就不会对JVM进行什么参数的设置,可能很多时候就是用一些默认的JVM参数。

8G机器制定的JVM参数模板:

-Xms4096M -Xmx4096M -Xmn3072M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=92 
-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0

年轻代给到3G,之所以给到3G的内存空间,就是因为让年轻代尽量大一些,进而让每个Survivor区域都达到300MB左右。

如何优化每次Full GC的性能

  • -XX:+CMSParallelInitialMarkEnabled CMS垃圾回收器的“初始标记”阶段开启多线程并发执行

    初始标记阶段,是会进行Stop the World的,会导致系统停顿,所以这个阶段开启多线程并发之后,可以尽可能优化这个阶段的性能,减少Stop the World的时间。

  • -XX:+CMSScavengeBeforeRemark CMS的重新标记阶段之前,先尽量执行一次Young GC

    CMS的重新标记也是会Stop the World的,所以如果在重新标记之前,先执行一次Young GC,就会回收掉一些年轻代里没有人引用的对象。

    所以如果先提前回收掉一些对象,那么在CMS的重新标记阶段就可以少扫描一些对象,此时就可以提升CMS的重新标记阶段的性能,减少他的耗时。

    扫描的时候整个堆,回收的时候回收自己区域(老年代)

-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

元空间GC

  • [Full GC(Metadata GC Threshold)xxxxx,xxxxx]

    频繁的Full GC,实际上是JDK1.8以后的Metadata元数据区导致的,也就是类似我们之前说的永久代

    • Metadata区域主要用来存放一些加载到JVM中的类来。

为什么会因为Metadata区域频繁的被塞满,进而触发Full GC?而且Full GC会带动CMS回收老年代,还会回收Metadata区域本身。

系统在运行过程中,不停地有类产生被加载到JVM的Metaspace区域,然后将Metaspace区域填满,自然会触发一次Full GC,Full GC会将元数据区域的垃圾回收掉,所以接下来Metaspace区域中的空间占用就变小了。

不断重复上述步骤

什么导致了类不停地被加载呢?

"-XX:TraceClassLoading -XX:TraceClassUnloading"

上面两个参数用来追踪类加载和类卸载的情况

加入这两个参数之后,我们就可以看到在Tomcat的catalina.out日志文件中,输出了一堆日志,里面显示类似如下的内容: 【Loaded sun.reflect.GeneratedSerializationConstructorAccessor from_JVM_Defined_Class】

明显可以看到,JVM在运行期间不停的加载了大量的所谓“GeneratedSerializationConstructorAccessor"类到了Metaspace区域里。

这个类是使用Java反射产生的

Method method=XXX.class.getDeclaredMethod(xx,xx);

method.invoke(target,params);

简单来说,就是通过XXX.class获取到某个类,然后通过geteDeclaredMethod获取到那个类的方法。 这个方法就是一个Method对象,接着通过Method.invoke可以去调用那个类的某个对象的方法,大概就这个意思。

在执行这种反射代码时,JVM会在你反射调用一定次数之后就动态生成一些类,就是我们之前看到的那种莫名其妙的类。

下次你再执行反射的时候,就是直接调用这些类的方法,这是JVM的一个底层优化的机制。

而这些类(Class)对象都是SoftReference(软引用)

软引用正常情况下都不会回收,只有在内存空间比较紧张的情况下才会被回收掉。

那么SoftReference对象到底在GC的时候要不要回收是通过什么公式来判断的呢?

是如下的一个公式:clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB。

这个公式的意思就是说,“clock-timestamp”代表了一个软引用对象他有多久没被访问过了freespace代表JVM中的空闲内存空间,SoftRefLRUPolicyMSPerMB代表每一MB空闲内存空间可以允许SoftReference对象存活多久。

假设现在JVM里的空间内存空间有3000MB,SoftRefLRUPolicyMSPerMB的默认值是1000毫秒,那么就意味着,此时那些奇怪的SoftReference软引用的Class对象,可以存活3000*1000=3000秒,就是50分钟左右。

一般来说发生GC时,其实JVM内部或多或少总有一些空间内存的,所以基本上如果不是快要发生OOM内存溢出了,一般软引用也不会被回收。

所以按理说JVM应该会随着反射代码的执行,动态的创建一些奇怪的类,他们的Class对象都是软引用的,正常情况下不会被回收,但是也不应该快速增长才对。

SoftRefLRUPolicyMSPerMB假如设置为0,那就炸了!

一旦这个参数设置为0之后,直接导致clock-timestamp <= freespace *SoftRefLRUPolicyMSPerMB这个公式的右半边是0,就导致所有的软引用对象,比如JVM生成的那些奇怪的Class对象,刚创建出来就可能被一次Young GC给带着立马回收掉一些。

比如JVM好不容易给你弄出来100个奇怪的类,结果因为瞎设置软引用的参数,导致突然一次GC就给你回收掉几十个类;接着JVM在反射代码执行的过程中,就会继续创建这种奇怪的类,在JVM的机制之下,会导致这种奇怪类越来越多。 也许下一次gc又会回收掉一些奇怪的类,但是马上JVM还会继续生成这种类,最终就会导致Metaspace区域被放满了,一旦Metaspace区域被占满了,就会触发FullGC,然后回收掉很多类,接着再次重复上述循环。

SoftRefLRUPolicyMSPerMB参数不要设置为0,可以设置为1000,2000,3000或者5000毫秒。

提高这个数值,就是让反射过程中JVM自动创建的软引用的一些类的Class对象不要被随便回收,当时我们优化这个参数之后,就可以看到系统稳定运行了。

这样的话,基本Metaspace区域的内存占用是稳定的,不会来回大幅度波动了

Class对象是存放在堆区的,不是方法区,这点很多人容易犯错。类的元数据(元数据并不是类的Class对象!Class对象是加载的最终产品,类的方法代码,变量名,方法名,访问权限,返回值等等都是在方法区的)才是存在方法区的。

Class对象是放在堆中的,那么就是先分配在Eden区域,然后和普通对象一样,会经过young gc、full gc。而类的元数据信息(包括Kclass、常量、注解表、方法表、方法信息等)放在metaspace中,JVM因为优化策略会自动生成一些类,那么估计针对同一个Class多次反射而优化自动生成的类每次都不一样,比如A0001.class,A0002.class之类,放到metaspace也会占用不同的block。这里扩展一下,metaspace是由Node组成,node又由chunk组成,chunk里又分为多个block,这个block就是存放metadata的。node的生命周期和类的类加载器绑定的,如果类加载器不能被回收,那么这个block就会一直被占用。 越来越多的占用导致metaspace满了,引起full gc。

频繁Full GC

FullGC频率 :比较良好的JVM性能,应该是Full GC在几天才发生一次,或者是最多一天发生几次而已。

优化之前

  • 机器配置: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毫秒左右。

每分钟3次Young GC,每小时2次Full GC

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

参数解析:

  • “-XX:SurvivorRatio”设置为了5,Eden:Survivor1:Survivor2的比例是5:1:1

    Eden 365MB Survivor区 70MB 老年代 1GB

  • -XX:CMSInitiatingOccupancyFraction=68

    一旦老年代内存占用达到68%,也就是大概有680MB左右的对象时,就会触发一次Full GC

GC情况分析

GC情况分析:

​ 每分钟会发生3次Young GC,说明系统运行20秒就会让Eden区满,也就是产生300多MB的对象,那么每秒钟产生15MB左右的对象。

​ 20秒左右吧,Eden区满了,就会触发一个Young GC。

​ 每小时2次Full GC,老年代1G内存,30分钟一次Full GC。

​ “-XX:CMSInitiatingOccupancyFraction=68”,因此应该是在老年代有600多MB左右的对象时就会触发一次Full GC,因为1GB的老年代有68%空间占满了就会触发CMS的GC了。

​ 30分钟,老年代产生600MB对象,1分钟20MB。

GC分析结论:

每隔20秒会让300多MB的Eden区满触发一次Young GC,一次Young GC耗时50毫秒左右。

每隔30分钟会让老年代里600多MB空间占满,进而触发一次CMS的GC,一次Full GC耗时300毫秒左右。

老年代为什么30分钟就会发生一个GC呢?也就是说老年代为什么会有这么多对象

  1. 可能是每次Young GC后的存活对象较多,Survivor区域太小,放不下了

  2. 也有可能是有很多长时间存活的对象太多了,都积累在老年代里,始终回收不掉,进而导致老年代很容易就达到68%的占比触发GC。

    15岁后对象会迁入老年代,迁入老年代的存活对象太多了,由于 “-XX:CMSInitiatingOccupancyFraction=68”参数配置,进而很容易触发老年代的垃圾回收。

通过jstat的观察,可以明确看到,每次Young GC过后升入老年代里的对象很少。

具体情况:

  • 一般来说,每次Young GC过后大概就存活几十MB而已,那么Survivor区域只有70MB,所以经常会触发动态年龄判断规则,导致偶尔一次Young GC过后有几十MB对象进入老年代。

  • 通过jstat追踪观察,并不是每次Young GC后都有几十MB对象进入老年代的,而是偶尔一次Young GC才会有几十MB对象进入老年代

  • 关键情况:通过jstat运行的时候就观察到,老年代里的内存占用在系统运行的时候,不知道为什么系统运行着运行着,就会突然有几百MB的对象占据在里面,大概有五六百MB的对象,一直占据在老年代中

    突然有大对象

分析:

​ 应该是系统在运行的时候,每隔一段时间就会产生一些几百兆的大对象,直接进入到老年代中,这样老年代中的对象;

​ 再配合上年轻代还偶尔会有Young GC后几十MB对象进入老年代,就很容易达到68%的内存占比,所以才会30分钟触发一次Full GC。

(关键)如何定位大对象?

利用jmap工具,通过后台jstat工具观察系统,什么时候发现老年代里突然进入了几百MB的大对象,就立马用jmap工具导出一份dump内存快照,然后我们再使用jhat或者Visual VM等工具来分析内存快照。

优化

第一步,让开发同学解决代码中的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
  • 年轻代调节大一些,Eden和Survivor空间都变大了

  • -XX:CMSInitiatingOccupancyFraction=92 比率调大一些,触发老年代GC的阈值变大了

  • -XX:PermSize=256M -XX:MaxPermSize=256M

万一系统运行使用了反射机制,可能一旦动态加载的类过多,就会频繁触发Full GC

复盘

  • (频繁Full GC)当你发现Young GC过后并不是每次都有很多存活对象进入老年代的时候,就得从别的角度考虑一下到底为什么会有那么多的对象进入老年代了(比如本案例中的大对象)

System.gc()犯的罪

“System.gc0”可不能随便瞎写,他每次执行都会指挥VM去尝试执行一次FullGC,连带年轻代、老年代、永久代都会去回收。

针对这个问题,一方面平时写代码的时候,不要自己使用“System.gc()”去随便触发GC,一方面可以在JVM参数中加入这个参数:-XX:+DisableExplicitGC

这个参数的意思就是禁止显式执行GC,不允许你来通过代码触发GC。

推荐将“-XX:+DisableExplicitGC”参数加入到自己的系统的JVM参数中,或者是加入到公司的JVM参数模板中去。

知识补充

  1. 第一次Full GC不太靠谱,通常可以忽略

  2. 启动就多次gc,一般就是启动的时候系统内置对象太多,只能考虑增加机器内存了,分配更大内存空间

  3. 问题:CMS不是扫描老年代的对象吗?那在重新标记之前进行一次YGC,YGC回收年轻代的对象,对提升重新标记的性能有所帮助吗?

    回答:CMS扫描老年代的对象是没错的,但是有的时候年轻代和老年代之间的对象有引用关系呢?是不是就会扫描到年轻代去了? 所以提前young gc一次,可以清理掉一些年轻代对象,是有助于提升CMS的重新标记阶段的性能。

    CMSScavengeBeforeRemark这个参数本意是希望在CMS GC remark之前做一次YGC,正常情况下其实是会做一次YGC的。 这个参数的好处是如果YGC比较有效果的话是能有效降低remark的时间长度,可以简单理解为如果大部分新生代的对象被回收了,那作为根的部分少了,从而提高了remark的效率

    可能年轻代的某个GCRoot,它引用了老年代的某个对象,这个对象就不能清除,所以CMS应该也要扫描年轻代GC Root,再进行一次YGC就可以减少扫描的年轻代GC链路。

  4. JVM问题排查思路总结:

    1. 分析机器情况(机器配置,堆内存大小,运行时长,FullGC次数、时间,YoungGC次数、时间)
    2. 查看具体的jvm参数配置
    3. 然后根据JVM参数配置梳理出JVM模型,每个区间的大小是多少,画出来JVM模型(考虑每个设置在申请情况下会执行GC)
    4. 结合jstat查看的GC情况,再结合JVM模型进行二次分析
    5. jmap dump内存快照,通过jhat或者Visual VM之类的工具查看具体的对象分类情况
    6. 根据分析的情况再具体到问题(Bug、或者参数设置等问题)
    7. 修复Bug,优化JVM参数