如何理解CMS回收器降低gc停顿时间

不管采用什么回收算法,垃圾回收都会包含两个大的过程:标记、回收。

  • 标记:标记的目的就是识别出哪些对象是垃圾了。这个阶段按道理一定是STW的,否则就标记不出所有的垃圾对象,因为如果有并发,一边标记、一边会有垃圾产生,这个理论上永远标记不完。
  • 回收:就是将标记出的垃圾对象回收,腾出内存。这个阶段分回收算法。这个看算法,比如标记-清理算法,在清理阶段是不需要STW的,因为清理阶段只是标记内存可重用就好了。而标记-整理我理解是需要的,因为整理的时候会改变对象的内存地址,同理复制算法也是。

分代收集的理论基础:标记-清除、标记-整理、复制这些垃圾回收算法各自有各自的优缺点,找不到一个完美的收集算法。那么就在不同的场景使用不同的算法,来发挥收集算法的有点,避免其缺点。在程序运行过程中,根据对象的特点,大概可以分为两类:

  • 朝不保夕的对象,即从产生到变成垃圾时间是比较短的,比如局部对象。这种对象经过一次gc后,有非常大的概率被回收掉,回收效果非常可观。
  • 驻留内存时间比较长的,比如全局对象,如连接池对象等。ps:其实还有一种特殊情况,就是占用空间特别大的对象。

jvm的分代收集就是将整个堆划分成多个两个区域:分别用来存储朝不保夕的对象和长时间驻留内存的对象

  • 年轻代:这个区域用来存储朝不保夕的对象,因为这种对象存活时间比较段,所以这个区域一次gc后,大量垃圾被回收,存活下来的对象比较少。所以这是比较适合用复制算法的。但是复制算法需要将内存一份为二,平时只能使用一半,另一半用来回收复制存活对象。所以jvm将新生代进一步分为三部分:eden、from survivor、to survivor。
    • 新建对象的时候,总是在eden去分配内存。
    • 发生gc时,就将eden区存活的对象都copy到to survivor区,整个eden区就清空了。ps:from survivor区的作用啥是?这个没太get到。平时from和to只用一个yong gc的时候将eden和from的copy到to,然后from相当于就空闲了,后面使用的就是eden和to,下次gc的时候就将eden和原来的to的内存copy到from,也就是说from 和to来回切换,这比直接是使用一半、空闲一半用于gc,内存效率要更高
  • 老年代:存储驻留内存时间比较长的对象,或者内存占用量比较大的对象。常驻内存对象不适合用复制算法:因为每次gc复制的时候都会将它复制一次,浪费时间;大对象不适合复制算法,因为占用内存大,复制代价也就大。所以老年代更适合标记-清理、标记-整理算法。
    • 如何判断一个对象是驻留时间长的对象:jvm给每个对象都分配了一个分代年龄,每经历一次gc后,这个对象还存活着的时候,该对象分代年龄+1,当达到阈值的时候,将对象搬到老年代(默认是15,有参数可配置)
    • 如何判断一个对象是占用内存比较大的对象:参数控制,当对象占用内存超过这个参数阈值的时候,就认为是大对象,直接放到老年代。

cms垃圾回收器

cms:Concurrent Mark Sweep,降低停顿的思路:将标记和回收两个阶段进一步分细,只是在最必要的阶段进行STW。

ps:明确一下:SWT指的停顿啥?是指业务线程停顿下来,gc线程独享cpu来执行垃圾回收。
首先它是一个老年代收集器,所以他肯定不适合复制算法,所以它采用的是标记-清除算法。为了减少停顿,将收集过程分段,在必要的时候STW就好。

CMS垃圾回收的过程

CMS垃圾回收过程主要分为如下4个步骤:

其中初始标记和重新标记需要STW,其他都是和用户进程并发运行的。

ps:为什么这么分阶段可以降低STW??

标记阶段

如前述,要想让标记阶段绝对准确,能够标记处所有的垃圾对象,那标记阶段就只能是STW。CMS将标记阶段进一步拆分成三步,在标记的准确性和停顿时间做了一个均衡。

这个均衡就是:初始标记和重新标记阶段STW,并发标记阶段不STW,减少标记过程中的停顿,但代价就是并发标记阶段会产生垃圾(这部分称之为浮动垃圾),CMS是无法处理浮动垃圾的,只能下次gc的时候再处理。

1. 初始标记

这个过程是需要STW,但是这个阶段不会遍历整个堆,而是只是标记出出GC Root直接引用到的那些对象,所以是非常快的。

2. 并发标记

这个阶段就是从GC Roots遍历整个堆,找到存活对象。这个过程gc线程和用户线程是并行运行的。所以在这个阶段用户线程可能产生新的垃圾,这种垃圾成为浮动垃圾,CMS在一次gc过程中是处理不了本次GC产生的浮动垃圾的。

在并发标记完整个堆后,实际上还有一个并发预清理阶段,这个阶段也是和业务线程并发执行的,所以也可以认为是在并发标记阶段。

并发预清理阶段会标记新生代晋升的对象、新分配到老年代的对象以及在并发阶段被修改了的对象。

在回收老年代的时候,除了扫描老年代以外,还需要扫描年轻代,以确认是否有年轻代对象引用了老年代,从而确定老年代对象到底是不是垃圾对象。ps:所以CMS虽然是老年代收集器,它也会去扫描年轻代的。

在并发标记阶段,gc线程和业务线程并发执行,如果在这个阶段不去做些什么事情,到了remark阶段才去全量扫描年轻代,那么remark的STW就会比较长,所以为了减少remark阶段的STW时间,在这个阶段会尽量去进行一次yong gc,然后到了remark阶段去扫描年轻代的时候,由于存活的对象比较少,扫描就会很快

CMS提供了两个参数:CMSScheduleRemarkEdenSizeThreshold(默认值2M)和CMSScheduleRemarkEdenPenetration(默认值50%)来控制是否在老年代gc前发起一次yong gc,组合起来表示的是:eden空间使用超过2M时启动可中断的并发预清理(CMS-concurrent-abortable-preclean),直到eden空间使用率达到50%时中断,进入remark阶段。

这个可终止的预清理指的就是,在这个阶段gc会等待发生一次yong gc,但是又不能一直等待,当等待超过一定时间的时候,就终止等待,进入重新标记阶段,这个时间阈值就是CMSMaxAbortablePrecleanTime参数控制(默认5s)

另外,CMS还提供了一个参数CMSScavengeBeforeRemark,设置为true时,在进入remark阶段回强制进行一次yong gc。

另外,为了让remark阶段扫描全量新生代可以多线程并发扫描,在这个预清理阶段还会将新生代划分成多个块,这样remark阶段去rescan的时候,一个线程负责扫描一些块,这样就会更快的扫描完成,这个分块的过程也是在cms-concurrent-abortable-preclean发生minor gc的时候做的,如果在进入remark前没有发生minor gc,那么就是整个年轻代就是一整块。

3.重新标记(remark)

在这个阶段回去扫描年轻代以及老年代,确认哪些对象还是存活的,由于有了前面两个阶段的准备工作,这个阶段也会很快完成。在gc 日志中,看到rescan这样的日志,它是重新标记的一个子阶段,就是去扫描堆的过程。

ps:STW的意思是阻塞业务线程,不是说gc是单线程运行,这个阶段gc线程并发执行的。

清理阶段

4.并发清理

清理标记成垃圾的对象。

 

CMS的局限性


正式由于CMS是一个使用标记-清楚算法的并发收集器,在降低停顿的同时,也会遇到的问题:

1. 老年代清理的时候,怎么判断是否有年轻代引用了老年代对象。 cms的方式就是取全量扫描新生代,为了减少新生代的扫描时间,在full gc之前,会尽量进行一次minor gc去清理年轻代(这个有参数控制)。理论:这个理论基础也是分代收集的理论基础,年轻代的对象都是朝不保夕的,一次yong gc会回收调非常多的对象,存活下来的很少,所以yong gc都采用了复制算法,而这里是认为yong gc过后存活对象少,年轻代的扫描就比较快了。
2. cms采用了标记-清理算法,这种算法本身就会产生很多内存碎片
    cms提供了参数,在经过几次full gc后,进行一次内存整理,将碎片内存进行整理。碎片整理是需要STW的,且耗时比较长

3. gc过程和业务线程并发执行,正常的业务线程运行是需要内存的,在gc的过程中就需要留一部分内存给业务线程使用,不能等到内存没了的时候,才full gc。cms提供参数当内存使用到达参数指定值的时候就进行full gc。CMS提供了参数CMSInitiatingOccupancyFraction(默认值92%),当堆使用率达到了这个值的时候,发生一次full gc,留下8%的内存给并发的时候业务线程使用;另外CMS还共了一个自适应的方式,会根据历史情况,预测来年代还需要多久会被填满,在填满之前及时进行一次垃圾回收,CMS提供了参数UseCMSInitiatingOccupancyOnly来控制是否使用这个字使用

4. 并发阶段产生的浮动垃圾,本次gc是处理不了的,cms实际也是没有处理浮动垃圾。

5. 并发阶段,gc线程是要耗资源的,势必会和业务线程争抢cpu。如何均衡:cg线程数=(cpu个数+1)/4,即gc约占整体资源的25%。

6. 对于大内存的jvm回收时间长、不可控

CMS垃圾收集器无法满足软实时(Soft Real-time)特性:即让 一次GC 停顿时间能大致控制在某个阈值以内,但是又不必像实时系统那样非常严格。这也是很多业务系统都有的诉求。

在过去的 JVM 设计中,堆内存被分割成几个区域 —— Eden、Survivor、Old 的大小都是预先划分好的,gc按照分代使用不同的回收算法执行垃圾回收,一次回收会回收当前分代的所有内存,比如yong gc,会回收年轻代内存;对于major gc,会回收所有的老年代内存。这对于大内存来说,一次gc可能停顿好几秒,这对业务系统来说不可接受。随着内存越大,这种问题也就越突出,比如一个64G的jvm,老年代可能会分配32G,那么一次回收32G的内存,停顿时间基本都要到秒级。

为了解决这个问题,G1就诞生了。CMS进行GC的单位是分代,即一次要回收整个分代的内存,所有导致时间长不可控。G1的思路就是将内存划分更多更小的可以独立执行垃圾回收的Region,去控制一次GC回收多少个Region来达到软实时的目的。

ps:在cms中为了在新生代回收的时候不扫描老年代,索引引入card table记录哪些老年代对象引用了新生代的对象,从而yong gc的时候可以单独回收新生代,不必扫描老年代就可以完成新生代内存的回收。那么G1中要实现每个Region能够单独回收,在每次回收的时候就需要知道是否有其他Region中的对象引用当前Region中的对象,为了全量扫描所有的Region,G1中就搞出了Rset来记录,避免全量扫描,从而实现每个Region能够独立回收。

ps:如何判断跨代被引用对象的存活的

分代收集可以根据不用区域存储对象的特点,采用不同的收集算法,最大程度利用上算法的缺点,规避缺点。

但是不管使用哪种算法,其实第一步都是标记,识别出垃圾对象,如果不管是收集那个区域,都是从gc root开始扫描标记,这个肯定是可以标记到所有对象的(像CMS为了减少停顿不处理浮动垃圾是不同收集器的一个特例),但如果是这样,不管哪个区域的gc都全量扫描整个堆,在标记阶段又没有利用上分代的优势。特别是在yong gc的时候,一般来说年轻代要比老年代小很多,那么在yong gc的时候去全量扫描老年代,有些代价过大

yong gc的时候,如何判断是否有老年代对象引用了新生代对象?

为了避免yong gc的时候全量扫描整个老年代,从而拉长整个GC的STW,jvm引入了card-table,采用空间换时间的方式。

card-table将老年代内存分为一个一个的2^n大小的(默认512B)的区域,成为卡页,在年轻代维护了一个card-table,其实card-table就是一个key-value结构,key=卡页的首地址、value=key指定的卡页中的对象是否引用了新生代对象。这样只需要扫描一遍card-table就可以知道是否有老年代对象引用了新生代的对象,就不需要全量扫描全量的老年代了。

由于年轻代的对象本省就是朝不保夕的,15次gc后依然存活的对象才会进入老年代,老年代对象引用年轻代其实是非常少的。

ps:hotspot 虚拟机使用字节码解释器、JIT编译器、 write barrier维护 card table,当字节码解释器或者JIT编译器更新了引用,就会触发write barrier操作card table.

回收老年代的时候,如何判断是否有年轻代对象引用了老年代?

这个一般就是扫描全量年轻代了,不过CMS为了提高扫描年轻代的效率,在发起老年代gc之前,会主动发起一次yong gc。理论基础:这个理论基础也是分代收集的理论基础,年轻代的对象都是朝不保夕的,一次yong gc会回收调非常多的对象,存活下来的很少,所以yong gc都采用了复制算法,而这里是认为yong gc过后存活对象少,年轻代的扫描就比较快了。

 

参考:https://www.cnblogs.com/Leo_wl/p/5393300.html#_labelTop

 

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值