面试官最常问的垃圾回收器CMS

Hollis

共 5653字,需浏览 12分钟

 ·

2021-10-08 18:31



前言

随着互联网技术的发展,线上用户量的大量增加,性能问题变得尤为重要,我们可以通过增大JVM的各项内存来解决一部分问题,但是这样总是片面的

应该双管齐下,既要从硬件方面变得逐渐强大,底层软件方向也不能落下发展,于是乎垃圾收集器的发展也变得很重要

熟悉JVM的小伙伴应该都知道JVM的内存结构,大致分为堆、栈、本地方法栈、方法区和程序计数器,简单回忆下各个区域的作用吧

堆:用来存储对象本身的以及数组(数组引用是存放在Java栈中的)。堆是被所有线程共享的,在JVM中只有一个堆

栈:存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表、操作数栈、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用

方法返回地址(Return Address)**和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。

本地方法栈:本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的

方法区:与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。

在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。

程序计数器:每条线程都有一个独立的的程序计数器,各线程间的计数器互不影响,因此该区域是线程私有的。该内存区域是唯一一个在Java虚拟机规范中没有规定任何OOM(内存溢出:OutOfMemoryError)情况的区域。

好了,我们大概知道了分为这几大部分,堆和方法区都会涉及到垃圾回收,就会涉及到相应的垃圾回收器,就会有好有坏,或者说合适不合适,没有最好的,只有最合适的


正文

垃圾回收器都会涉及到STW的过程,不知道STW的这里科普一下,stop the world,即STW过程会停止所有的用户线程的执行,对于用户线程是卡顿的,如果卡顿时间过长,用户会明显的感受到反应迟钝

在使用APP的时候肯定会有一些卡顿的现象,这种可能有多方面原因,网速,手机配置,当然这些肯定是主要的,也有可能是服务器正在进行STW!

所以咯,对于开发人员肯定目标就是尽可能的降低STW的时间,我们今天要说的是CMS垃圾回收器,目标也会如此,尽可能的降低应用的停顿时间,这个目标对于大多数的交互式应用都是很重要的,比如web应用;之前我们学过的并行收集器组合 Parallel Scavenge + Parallel Old,是以吞吐量为目标的垃圾回收器,也是server模式下的默认垃圾收集器的配置

我们一起来看下CMS收集器的工作过程吧,大致分为七个步骤:

初始标记:为了收集应用程序的对象引用,需要暂停应用程序线程,会导致STW,该阶段完成之后应用程序再次启动

并发标记:从第一阶段到的对象引用开始,遍历所有其它的对象引用

预清理:第二阶段运行的时候,由应用程序线程产生的对象引用,以更新第二阶段的结果

可被终止的预清理:和用户线程同时执行的,承担下一阶段的足够多的工作

重新标记:上一并发的,对象引用可能会发生进一步的改变,因此呢,应用程序线程会再一次被暂停用以更新这些变化,并且在进行实际的清理之前确保一个正确的对象引用视图

并发清除:所有不再被应用的对象将从堆里清除掉,和用户线程并行

并发重置状态等待下次CMS的触发:收集器做一些收尾的工作,以便下一次GC周期能有一个干净的状态

CMS收集器其实不是完全和应用程序并发的,我们已经看到了,其中也会有STW的阶段,只是相对来说时间极其短


详细流程

初始标记:为了收集应用程序的对象引用,需要暂停应用程序线程,会导致STW,该阶段完成之后应用程序再次启动

这一步会发生STW,这一步的作用是标记存活的对象,包含两个部分:

1、老年代中的GC Roots对象,如图中的1

2、年轻代中的活着的对象引用到的老年代对象

在Java语言里,可作为GC Roots对象的包括如下几种:

1、虚拟机栈(栈桢中的本地变量表)中的引用的对象 

2、方法区中的类静态属性引用的对象 

3、方法区中的常量引用的对象 

4、本地方法栈中JNI的引用的对象

并发标记:从第一阶段到的对象引用开始,遍历所有其它的对象引用

从初始标记阶段标记的对象中找出所有还存活的对象,因为是和用户线程并发运行的,在运行期间会有新生代的对象晋升到老年代,或者说直接分配到老年代

对于这些对象都需要重新标记,否则会有一些对象被遗漏的情况,为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续则只需要扫描这些Dirty Card的对象就可以了,不需要扫描整个老年代了

并发标记阶段只是将引用发生改变的Card标记为Dirty状态,不负责清理

由于这个阶段是和用户线程并发的,可能会导致concurrent mode failure

预清理:第二阶段运行的时候,由应用程序线程产生的对象引用,以更新第二阶段的结果

我们从上一阶段其实没有标记出老年代的所有存活对象,是因为标记的同时应用程序也会改变一些对象的引用,这个阶段主要就是用来处理前一阶段因为引用关系改变导致没有标记到的存活对象的

新生代已经发现的引用,比如在并发阶段,在Eden去分配了一个A对象,A对象引用了一个老年代对象B,在这个阶段会标记对象B为活跃对象

在并发标记阶段,如果老年代有对象内部引用发生变化,而把所在的Card都标记为Dirty,通过扫描这些,重新标记那些在并发阶段引用被更新的对象(晋升到老年代的对象、原来在老年代的对象)

可被终止的预清理:和用户线程同时执行的,承担下一阶段的足够多的工作

该阶段发生的前提是新生代的Eden的内存使用量大于参数CMSScheduleRemarkEdenSizeThreshold,默认是2M,如果新生代的对象太少,这个阶段没必要执行,直接执行下一阶段重新标记即可

存在的价值:尽最大的努力去处理那些在并发阶段被应用线程更新的老年代对象,这样在暂停的重新标记阶段就可以少处理一些,暂停时间会相应的降低些

这个阶段属于尝试着去承担下一个阶段的部分工作,这个阶段持续的时间依赖比较多的因素,这个阶段属于重复做相同的事情直到相应的条件满足(次数、工作量、持续时间等

这个阶段主要是循环做两件事:

1、处理From和To区的对象,标记可达的老年代对象

2、扫描处理Dirty Card中的对象

当然也肯定不会一直循环下去,就像上面说的,这里打断循环的条件有三个:

最大循环次数的设置CMSMaxAbortablePrecleanLoops,默认是0;执行的时间达到阈值CMSMaxAbortablePrecleanTime,默认是5秒;还有一个就是新生代Eden区的内存使用率达到阈值CMSScheduleRemarkEdenPenetration,默认是50%

重新标记:上一并发的,对象引用可能会发生进一步的改变,因此呢,应用程序线程会再一次被暂停用以更新这些变化,并且在进行实际的清理之前确保一个正确的对象引用视图

这一阶段是会发生STW的,这个阶段的目标是完成标记整个老年代的所有存活的对象,进行最后的整理

这个阶段重新标记的内存范围是整个堆,年轻代和老年代都包含

为什么要扫描新生代呢?对于老年代中的对象,如果被新生代中的对象引用就会被视为存活的对象,即使新生代中的对象不可达了,也会使用这些不可达的对象当做GC Roots来扫描老年代,因此这里要扫描新生代

由于之前的预处理阶段是和用户线程并发执行的,这时候可能年轻代的对象和老年代的引用发生了很多改变,这时remark阶段可能要花比较多的时间处理这些改变,会导致STW,所以通常CMS尽量运行Final Remark这一阶段的时候年轻代保持足够的干净

这也解释了上一阶段可被终止的预清理的重要性

并发清除:所有不再被应用的对象将从堆里清除掉,和用户线程并行

通过上面这五个阶段的标记,老年代所有存活的对象已经被标记,并且现在要通过Garbage Collector采用清扫的方式回收这些不可用的对象

这个阶段则主要是清理那些未被标记的对象,回收相应的空间

由于这一并发清楚阶段也是和用户线程同时运行,伴随着用户线程的运行自然还会有一些新的垃圾的不断的产生,这一部分垃圾出现在标记过程之后,CMS自然就无法在这次回收过程处理掉这些垃圾,只能等待下一次的GC的时候才可以清理掉

这一部分被称为浮动垃圾

并发重置状态等待下次CMS的触发:收集器做一些收尾的工作,以便下一次GC周期能有一个干净的状态


注意细节

随着互联网技术的发展,线上用户量的大量增加,性能问题变得尤为重要,我们可以通过增大JVM的各项内存来解决一部分问题,但是这样总是片面的

减少remark阶段停顿

一般CMS的GC耗时80%都在remark阶段,如果发现remark阶段停顿时间很长,可以尝试添加该参数:-XX:+CMSScavengeBeforeRemark。

在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销

内存碎片问题

CMS是基于标记-清除算法的,CMS只会删除无用对象,不会对内存做压缩,会造成内存碎片,这时候我们需要这个参数:-XX:CMSFullGCsBeforeCompaction=n

意思是说在上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做压缩。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入full GC的时候都会做压缩。 如果把CMSFullGCsBeforeCompaction配置为10,就会让上面说的第一个条件变成每隔10次真正的full GC才做一次压缩。

concurrent mode failure

这个异常发生在cms正在回收的时候。执行CMS GC的过程中,同时业务线程也在运行,当年轻带空间满了,执行ygc时,需要将存活的对象放入到老年代,而此时老年代空间不足,这时CMS还没有机会回收老年带产生的,或者在做Minor GC的时候,新生代救助空间放不下,需要放入老年代,而老年代也放不下而产生的。

设置cms触发时机有两个参数:-XX:+UseCMSInitiatingOccupancyOnly和-XX:CMSInitiatingOccupancyFraction=70

-XX:CMSInitiatingOccupancyFraction=70 是指设定CMS在对内存占用率达到70%的时候开始GC。

-XX:+UseCMSInitiatingOccupancyOnly如果不指定, 只是用设定的回收阈值CMSInitiatingOccupancyFraction,则JVM仅在第一次使用设定值,后续则自动调整会导致上面的那个参数不起作用。

为什么要有这两个参数?

由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

CMS前五个阶段都是标记存活对象的,除了”初始标记”和”重新标记”阶段会stop the word ,其它三个阶段都是与用户线程一起跑的,就会出现这样的情况gc线程正在标记存活对象,用户线程同时向老年代提升新的对象,清理工作还没有开始,old gen已经没有空间容纳更多对象了,这时候就会导致concurrent mode failure, 然后就会使用串行收集器回收老年代的垃圾,导致停顿的时间非常长。

CMSInitiatingOccupancyFraction参数要设置一个合理的值,设置大了,会增加concurrent mode failure发生的频率,设置的小了,又会增加CMS频率,所以要根据应用的运行情况来选取一个合理的值。如果发现这两个参数设置大了会导致full gc,设置小了会导致频繁的CMS GC,说明你的老年代空间过小,应该增加老年代空间的大小了。

promotion failed

在进行Minor GC时,Survivor Space放不下,对象只能放入老年代,而此时老年代也放不下造成的,多数是由于老年带有足够的空闲空间,但是由于碎片较多,新生代要转移到老年带的对象比较大,找不到一段连续区域存放这个对象导致的。


总结

1、CMS收集器只能收集老年代,其以吞吐量为代价换取收集速度

2、一共分为七个步骤,其中初始标价和重新标技是STW的,CMS的大部分时间都花费在重新标记阶段,CMS无法解决浮动垃圾问题

3、由于CMS的收集线程和用户线程并发,可能在收集过程中出现concurrent mode failure,解决方法就是让CMS尽早的进行GC,在一定次数的Full GC之后让CMS对内存做一次压缩,减少内存碎片,防止年轻代对象晋升到老年代时因为内存碎片问题导致晋升失败

有道无术,术可成;有术无道,止于术

欢迎大家关注Java之道公众号


好文章,我在看❤️

浏览 8
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报