vlambda博客
学习文章列表

精选文章|JVM垃圾回收器CMS原理与调优


JVM垃圾回收器CMS原理与调优



 1. JVM运行原理简介

我们写好的代码,是要通过JVM才能运行的。


JVM 想要执行一个类,首先要加载类,在加载类之前,需要先编译成字节码class文件;

然后就执行类的加载过程,JVM 加载类的话,需要类加载器;

类加载器是分层级的,遵循双亲委派机制。

  • 最上层是Bootstrap ClassLoder,加载java的核心类库,加载java安装目录下的lib目录的class文件;

  • 第二层是Ext ClassLoder,加载一些java的其他类库,加载java安装目录下的lib/ext目录下的class;

  • 第三层是Application ClassLoder ,应该程序类加载器,这个类加载器是加载我们写的类;

  • 如果我们自定义类加载器的话,那就是第四层;

  • 类加载器遵循双亲委派机制,就是说,如果要加载一个类,先去为他的父类能不能加载,如果父类上面还有父类,就继续问,直到顶层。然后顶层说加载不了,就下派到子类,如果所有父类都加载不了,那就自己加载。这么做的好处是,不会重复加载一个类。

然后说一下类加载的过程,分这么几步:

  • 加载:

    类加载器去加载类;

  • 验证:

    验证阶段,主要是验证加载的字节码是否符合JVM规范,不然随便瞎写JVM也执行不了;

  • 准备:

    准备阶段,主要是给对象申请内存,然后给变量设置初始值,该设置0的设置0,该设置null的设置null;

  • 解析:

  • 初始化:

    初始化阶段主要是给变量赋值,准备阶段只是设置了初始值,这个是核心阶段,执行类的初始化,如果发现这个类的父类没有初始化,会先暂停,然后去初始化父类,也是走类加载的一套流程,直到父类加载完了,再执行子类的初始化。

●●●

永久代的话,绝对不能不设置JVM参数,使用默认JVM参数可能就给新生代分配一两百兆,永久代分配的可能也很少,一旦并发量上来,系统扛不住,永久代一般就放点类和常量池,一般给256M够了,如果给小了,可能导致频繁的Full GC,因为永久代如果满了,会触发Full GC,这个是很坑的。


类加载到永久代后,会把类交给字节码执行引擎去执行,画图。


执行这个操作是线程去执行的,每个线程都配有一个程序计数器和Java虚拟机栈。


因为Java是支持多线程的,所以必须要有程序计数器,记录这个线程执行到哪了。


Java虚拟机栈在执行每个方法的时候,都会创建一个栈帧,main方法也一样。


局部变量都放到这个栈帧中,如果这个方法执行完了,局部变量也就失效了。


这里的栈帧如果没有执行完时,其实都是GC Root,垃圾回收时,就是根据这里的局部变量的引用和永久代的引用来判断对象是否存活。


我们设置JVM参数的时候,一般都会给Java虚拟机栈 1M的大小,一个系统运行最多几百个线程,不用设置太大,浪费内存,也不能设置太小,容易溢出,特别是递归调用的时候。



 2. ParNew + CMS

 垃圾回收器运行原理

ParNew垃圾回收器

这个垃圾回收器是回收年轻代的,使用的是多线程回收,不像之前的Serial回收器使用单线程回收。


然后ParNew使用的是复制清除算法,把年轻代分为Eden区 和两个Survivor,JVM 参数默认的占比是 8:1:1,系统运行会把对象创建到Eden区,每次YoungGC 会标记存活对象,复制到Survivor0中,再次YoungGC时,再把存活对象复制到Survivor1中。系统运行期间会保证一直有一个Survivor是空着的。


Eden区的占比有时是可以调优的,如果条件有限,没有大内存的机器,然后对象创建的还特别频繁,存活的对象比较多,那就建议把Eden区比例调低一些,让Survivor大一点,宁可Young GC多一些,也不要让Survivor触发了动态年龄审核或者放不下存活对象。

如果放不下那就把这批对象扔到老年代了,Full GC是很慢的。如果是调低Eden,YoungGC会很频繁,但是YoungGC特别快,我通过jstat 看,回收100M垃圾大概也就1ms,所以,如果内存实在不够,降低Eden去比例也不是不可以。但是如果有条件的话最好的话还是加大新生代内存,毕竟YoungGC也是要Stop the World的。

精选文章|JVM垃圾回收器CMS原理与调优

●●●


老年代的垃圾回收器CMS

这个垃圾回收器是使用的 标记-清除 + 整理 算法,我们一般在JVM参数会指定这算法和整理的频率,JVM参数默认是,标记-清除,5次之后,才会去整理内存空间,让对象整齐排列。


但是这个默认参数不太好,这样做会有大量的内存碎片,如果某一次从年轻代晋升一个大对象,老年代居然找不到一块连续的内存,就会触发Full GC,那就坑了。


我们会把那个值调成0,就是每次CMS垃圾回收后,都会整理内存,虽然每次的回收时间会多一些,但是不会出现内存碎片。

●●●

CMS 垃圾回收分为4个步骤

  • 第一步是初始标记:

    初始标记的话,只标记GC root直接引用的对象,只有很少一部分,这个阶段需要STW,但是影响不大,这个过程特别快。

    这个过程也可以优化,JVM 有个参数是初始标记阶段多线程标记,减少STW时间,正常是单线程标记的。

  • 第二步是并发标记:

    这个阶段是不需要STW的,是和系统并行的处理,系统继续运行,然后垃圾回收线程去追踪第一步标记的GC root,这一步是很耗时的,但是不影响程序执行。

    因为在垃圾回收时是允许系统继续创建对象的,所以这个过程会有新的对象进来,也会有标记存活的但是现在变成垃圾,这些有改动的对象JVM都会记下来,等待下一步处理。

    这一步有一个缺点,并发清理时也有这个问题,就是会占用CPU资源。如果是一个4核的机器,那会占用一个CPU去垃圾回收,公式是(cpu核数 + 3)/4。所以一般CPU资源负载特别高的时候,就俩情况,要不是程序的线程太多了。要不就是频繁FullGC,导致的。

  • 第三步是重新标记:

    重新标记阶段,会把并发标记阶段有改动的对象重新标记,这一步需要STW,不过也是比较快的,因为改动的对象不会特别多,但是要比第一步慢因为要重新判断找个对象是否GC可达。

    这里也可以通过JVM参数优化,可以通过参数控制,让CMS在重新标记阶段之前尽量触发一次Young GC(尽量YoungGC是因为可能新生代可能刚刚YoungGC不久,那此时就没必要再一次YoungGC了)这样做的好处是,改动的对象中从存活变为垃圾的那部分,就被清理掉了,缩短STW时间。

    虽然YoungGC也会造成停顿,但是YoungGC一般频率是比较快的,早晚都要执行,现在执行一举两得。

  • 第四步是并发清理,并发清理是和系统并行的,不需要STW。这个阶段是清理前几个阶段标记好的垃圾。

  • 最后,我们通过JVM参数设置,每次Old GC后都重新整理内存,整理阶段会把老年代零零散散的对象排列到一起,减少内存碎片。


 3. CMS垃圾回收器调优

什么时候会有垃圾回收

在说ParNew + CMS调优之前,我们先说下JVM的几种GC:

Young GC,Old GC,Full GC。


Young GC 和 Old GC 我上面都已经说过了。


再讲下Full GC ,Full GC就是全面回收整个堆内存,包括新生代、老年代、永久带。


整个过程极其的慢,一定要减少Full GC的次数,一般出现频繁的Full GC有几种情况,我们要避免出现这几种情况:

  • 第一种是,内存分配不合理,导致Survivor放不下,或者触发了动态年龄审核机制,频繁的往老年代放对象;

  • 第二种,有内存泄漏问题,导致老年代大部分空间被占用,回收都回收不掉,导致每次新生代晋升一点点对象,就放不下了,触发Full GC;

  • 第三种,大对象,一般是代码层面的问题,创建了太多的大对象,大对象是直接放入老年代的, 大对象过多会导致频繁触发Full GC;

  • 第四种,永久代满了,触发Full GC,我们JVM参数设置256M基本够了,如果不出现代码层面的bug ,一般不会出现这种情况;

  • 第五种,有人在代码里误调用了System.gc(),写了这个方法后,如果有机会,JVM就会发生一次Full GC。不过JVM参数可以禁止这种情况,不允许主动调用,我们要加上。


一般什么情况下我们要警觉是不是频繁的Full GC了:

  • 第一种情况,CPU负载折线上升,特别高;

  • 第二种情况,系统卡死了,或者系统处理请求极慢;

  • 第三种情况,如果公司有监控系统,会报警。

如何调优?

ParNew + CMS 调优:

如果一个系统需要JVM调优,那其实说白了就是Stop the World 太久了,导致系统太卡了。

我们说的调优,其实就是减少STW的时间,让系统没有明显的卡顿现象。


需要STW的有几个地方:

YoungGC,和Old GC的两个阶段。但是YoungGC一般STW时间特别短,Old GC时间一般会是Young GC的几倍到几十倍,而且占用CPU资源严重。

所以,我们优化的重点是让系统减少Old GC的次数。最好让系统只有YoungGC,没有Old GC,更没有Full GC。


所以,优化的重点就是尽量不要让对象进入老年代。如果对象进不去老年代,想Full GC都难。这是JVM调优的重点。


对象进入老年代的情况也有几种:

  • 第一种,对象经过15次YoungGC,依然是存活的,那晋升老年代;

    这个其实是可以优化的,因为如果系统1分钟或者30秒一次YoungGC,那没必要非得让对象存活十几分钟才进入老年代,一般存活个两三分钟,这个对象大概率就是要存活很久的了。

    所以,我们当时是调低了这个参数的,设置了5。不然这个对象一直存活,然后在两个Survivor里来回复制,如果这个对象小一点还好,如果这个对象挺大的,那容易触发Survivor的动态年龄审核机制,让一大批对象进入老年代。所以,该进入老年代的对象,就让他赶紧进去。

  • 第二种,Young GC后存活的对象大小超过Survivor 的50%,那就会触发动态年龄审核机制,如:1岁、2岁、3岁、4岁的对象加起来大于Survivor 的50%,那大于等于4岁的对象全部进入老年代。

  • 第三种,Young GC后存活的对象大于Survivor的大小,那这一批对象直接全部进入老年代,特别坑。

  • 第四种,大对象直接进入老年代,这个JVM参数里是可以设置的,一般我们都设置1M,大于1M的对象进入老年代,一般很少有1M的对象,一般都是个大数组,或者map。

第一种情况和第四种情况,一般是可控的。

所以想要优化的话,主要是要在Survivor的大小这块下功夫。

我们要避免动态年龄审核和Survivor放不下的情况。要想保证这点,我们就要知道,我们系统的高峰时期,JVM中每秒有多少对象新增,每次YoungGC存活了多少对象。


这就需要用 jstat 了。

首先要使用 jstat -gc PID 1000 1000;

找到JVM的PID,然后每秒打印一次JVM的内存情况,如果系统访问量比较小,每秒的增长不是很明显,那就把每次的间隔时间调大一点,比如一分钟打印一次;

通过这行命令,我们可以看到当时的内存使用情况。


比较重要的数据:

  • S0C:Survivor0 的大小

  • S1C:Survivor1 的大小

  • S0U:Survivor0 使用了多少

  • S1U:Survivor1 使用了多少

  • EC:Eden 区的大小

  • EU:Eden 区使用了多少

  • OC:老年代的大小

  • OU:老年代使用了多少

  • MC:永久代的大小

  • MU:永久代使用了多少

  • YGC:YoungGC次数

  • YGCT:YoungGC的总耗时

  • FGC:Full GC次数

  • FGCT:Full GC的总耗时

一般使用 jstat 优化,重点观察这几个指标:

  • Eden 区对象的增长速度

    上面的几列,通过一行数据是看不出来Eden 区每秒增长多少数据的,所以我们才每秒打印一次,通过上一秒和下一秒EU的数据就可以推断出每秒增长了多少。这个数据进来多打印几行,取个平均值。

  • Young GC 频率

    我们我们知道系统启动时间,用YGC的大小除也能算,但是谁没事记得系统什么时候启动的。而且如果我想看高峰时期某一段时间的呢,就看不了了。看几十天的平均值也没什么意义。

    所以这个高峰时段YoungGC的频率是通过Eden的大小,除以Eden区对象的增长速度来算的,Eden区对象增长速度,我们已经知道了。

  • Young GC 耗时

    这个YoungGC耗时,我们取平均值就行,用YGCT除以YCG,时间除以次数就是每次的耗时。如果说就像看高峰时段的,因为CPU等使用率比较高,可能会影响回收时间,也可以单独看几次的YoungGC,算出时间。

  • Young GC 后多少对象存活

    这个指标还是比较重要的,我们要确定每次存活的对象Survovir到底能不能放得下。我们要保证每次存活的对象要小于Survivor的50%,否则就会触发动态年龄审核机制。

  • 老年代对象增量速度

    老年代对象增长速度,决定了Old GC的频率。发生Old GC后,FGC那一列也会增长,FGC那一列其实是FullGC 和Old GC的总和。

    经过优化后的JVM,每次YoungGC不应该进入太多的对象,不进入或者每次进入几兆是比较好的。这个指标我们也要分多次观察,因为只看一次YoungGC晋升的大小是片面的。

    我们现在已经知道了YoungGC的频率,如果是3分钟一次,那我们就3分钟打印一次内存情况。jstat -gc PID 180000 100,取多次晋升大小的平均值就行。

    如果晋升的对象特别多,我们需要分析这些对象为什么会进入老年代,上面讲了有四种情况会晋升老年代,到底是哪种情况。是Survivor不够大?还是大对象太多了?或者有内存泄漏导致对象回收不掉,进入了老年代?

    如果是Survivor太小,我们很轻易就能看出来,如果每次Young GC后S区都是0,那说明存活的对象太多,S区放不下,都进入了老年代。

    如果S区不是0,有一部分,但是每次回收进入老年代都很多,就有可能是触发动态年龄审核,这个最好再通过GC日志看一下,通过JVM参数可以让系统打印每次GC的日志。

    如果出现内存泄漏,数据一般是这样的,发现每次FGC次数加1后,老年代并没有多少数据被回收掉,占用了很多。这就大概率是内存泄漏,导致老年代回收不掉。

    如果是大对象,数据会这样显示,发现及时没有Young GC,OU也会一直在涨,因为大对象是不用经过年轻代的直接进入老年代。如果内存泄漏和大对象的情况,我们可以用 jmap 打印一份内存快照,用MAT工具分析一下到底是什么对象特别大,通过分析出来的堆栈信息就可以定位到代码的位置。

  • Full GC 频率多高

    看这个频率和看YoungGC的频率是一样的,可以看高峰时期某几次的平均值。

    这个Full GC是很耗时的,Full GC的频率我们最好控制在一天1次或者几天一次的范围。特别是对时效性要求比较高的系统,一定要减少Full GC次数。

  • 一次Full GC 的耗时

    这个可以取平均值,也可以取某一段的。我们会发现这个Full GC的耗时是YoungGC的好多倍。


 4. 参数设置建议

4核8G机器我们一般要怎么配置JVM参数

4核8G的机器,那么给JVM的内存一般会到4G,剩下几个G会留点空余给操作系统之类的来使用:

  • -Xss1M:

    线程Java虚拟机栈1M,那么JVM里如果有几百个线程大概会有几百M;

  • -XX:PermSize=256M-XX:MaxPermSize=256M :永久代256M;

  • -Xms3072M -Xmx3072M -Xmn2048M:

    堆内存我们可以给3G,新生代2G,老年代1G;

  • -XX:SurvivorRatio=8:Eden和S区的比例;

  • -XX:MaxTenuringThreshold=5:

    对象连续躲过5次垃圾回收后会自动升入老年代(默认是15次);

  • XX:PretenureSizeThreshold=1M:

    大对象可以直接进入老年代;

  • -XX:+UseParNewGC:

    新生代试用ParNew 垃圾回收期;

  • -XX:+UseConcMarkSweepGC:

    老年代使用CMS垃圾回收器;

  • -XX:+UseCMSCompactAtFullCollectio

    -XX:CMSFullGCsBeforeCompaction=0:

    设置的0次Full GC之后才会进行一次压缩操作;

  • -XX:+CMSParallelInitialMarkEnabled:

    这个参数会在CMS垃圾回收器的“初始标记”阶段开启多线程并发执行;

  • -XX:+CMSScavengeBeforeRemark:

    在CMS的重新标记阶段之前,先尽量执行一次Young GC。

以上参数优化点

增加了新生代和老年代比例,增大新生代,增大了S区。


普通业务系统,明显大部分对象都是短生存周期的,根本不应该频繁进入老年代,也没必要给老年代维持过大的内存空间,首先得先让对象尽量留在新生代里。


通过 jstat -gc PID 1000 1000 命令打印JVM内存使用大小,确定每次Minor GC存活对象进入S区的大小,判断S区内存大小设置的是否合适


增大了新生代和Survivor区大小,避免Minor GC后对象放不下Survivor进入老年代,或者是动态年龄判定之后进入老年代,给新生代里的Survivor充足的空间。


  • 减小XX:MaxTenuringThreshold值

    躲过15次GC都几分钟了,一个对象几分钟都不能被回收,说明肯定是系统里类似用@Service、@Controller之类的注解标注的那种需要长期存活的核心业务逻辑组件。

    说一个对象如果躲过5次Minor GC,在新生代里停留超过1分钟了,那么他就应该进入老年代,别在新生代里占着内存了。

  • 设置-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0(默认为5)

    默认5次Full GC之后才会进行一次压缩操作。Full GC的过程中,每一次Full GC之后都会产生大量的内存碎片,随着一次一次Full GC导致老年代产生更多的内存碎片,连续可用内存越来越少,触发下一次FUll GC的速度就会越快。设为0每次Full GC后都整理一下内存碎片。

  • -XX:+CMSParallelInitialMarkEnabled

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

  • -XX:+CMSScavengeBeforeRemark

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

    那么在CMS的重新标记阶段就可以少扫描一些对象,此时就可以提升CMS的重新标记阶段的性能,减少耗时。


文|古木

得物技术

携手走向技术的云端