天天看点

JVM垃圾回收器详解:并发老生代回收并发标记之可终止预清理

作者:大数据架构师

并发标记清除之预清理

预清理指的是在并发标记结束以后,在执行再标记之前,预先做一些工作,以减少再标记的耗时。预清理的思路是针对初始标记结束到目前为止新增的对象进行并发的标记,从而减少再标记阶段的时间。预清理的主要工作如下。

1)处理Java引用:Java引用在并发标记完成后就可以处理。处理的思路是针对标记时找到的Java引用,判断引用管理的对象是否可以回收。

2)标记Survivor分区:在Mutator运行过程中新生代中的对象的引用关系可以被修改,而新生代是老生代的根之一,所以再标记时会重新以新生代为根集合进行标记,在预清理中可以执行Survivor分区作为根集合的标记。

为什么此时选择Survivor作为根集合提前处理?Eden不能在这里处理吗?因为预清理时可并发执行,Mutator是可以访问Eden的(分配对象),如果要保证访问到Eden中所有的对象,则需要对Eden加锁。而Survivor分区中的对象不会重新分配,只有读写访问,实现简单。

3)标记ModUnionTable(简称MUT):MUT记录了在老生代回收中如果发生Minor GC时晋升的对象,或者Mutator直接分配在老生代中的对象。新增对象都被认为是新增的根集合,所以需要再次标记。注意,在预清理阶段不能执行前台GC,由于预清理和Mutator并发执行,但是为了保证正确访问对象,只有在预清理主动放弃CPU的时候,Mutator才能直接在老生代中分配对象,如果Mutator不直接在老生代中分配对象,则不会与预清理线程发生竞争。在处理完MUT中的待标记对象后,MUT相应的位图会被清除。

4)标记CardTable(简称CT):在CMS的设计中,卡表记录了GC过程中老生代变化的对象,变化的对象既可以是老生代指向新生代引用关系的变化,也可以是指向老生代引用关系的变化。所以再标记需要重新对卡表进行处理。当然,在进行卡表处理时,仅仅针对已经标记过的对象(明确是活跃对象),才会再次对卡表的状态进行再标记。是否存在卡块状态为Dirty,但是对应的对象是死亡状态的情况?完全有可能,因为卡块对应的是512字节的内存,所以可能存在只有部分对象是活跃状态的情况。另外,在预清理阶段,卡块原来是Dirty状态,再处理后状态变为PreClean,这个值表示在执行Minor GC时仍然需要把该卡块中的对象作为根。

在预清理的过程中需要访问标记位图,并且在标记位图中对新增的活跃对象进行标记。同时,Mutator在老生代中直接分配对象时也需要写标记位图,执行Minor GC时如果有对象晋升,也需要写标记位图。所以在预清理的过程中需要获取BitmapLock这个锁,从而保证正确性。但是当预清理中获得BitmapLock锁以后,Mutator就无法在老生代中分配对象,所以预清理中需要在满足一定条件下主动放弃执行CPU,让Mutator获得CPU的执行机会。主动放弃CPU一般发生在执行一定任务后,例如:

1)在对Survivor分区处理时,针对分区中每一个对象处理完成后都会检查是否需要让出CPU。为了保证处理的连续性及降低代码实现的复杂性,仅仅针对根对象检查是否放弃CPU执行,在遍历对象的成员变量时并不会再次检查。所以在实际中如果一个对象有很深的对象引用关系,可能会导致Mutator等待锁的时间过长。

2)类似地,在进行MUT和CT处理时,也是针对MUT和CT中每一个对象处理完成后检查是否需要让出CPU。

3)在进行引用处理时比较特殊,由于Survivor、MUT和CT都可以在遍历时控制放弃CPU的时机,而引用处理中并未实现细粒度的放弃CPU的动作,只有在处理不同引用类型时才会检查是否需要放弃CPU的执行。所以在预清理中,引用处理可能会导致该阶段耗时较长,如果发现存在这样的情况,则可以将引用处理放在再标记阶段执行(再标记可以并行处理引用,预清理是由CMS控制线程单线程执行引用处理)。

需要指出的是,在对Survivor、引用处理、MUT和CT的处理过程中会递归处理活跃对象的成员变量,使用标记栈来保存成员变量。但是在运行过程中,标记栈可能会溢出,所以需要一个额外的机制来保证标记栈溢出时标记对象不会丢失,通常使用一个链表作为标记栈的备份安全机制。关于标记栈溢出更多的介绍可以参考后面扩展阅读中的相关内容。

对于Survivor分区处理有一个需要优化的地方,那就是当溢出发生时并不使用备份链表,而是借用MUT作为备份机制,只要保证MUT的处理发生在Survivor分区处理之后,就能保证待标记对象不丢失。

另外再提一点,CMS控制线程在放弃CPU执行的时候,Mutator能否顺利地获取CPU并得到执行呢?放弃CPU执行是通过Yield机制完成的,OS关于线程执行Yield动作后其他线程是能否获得CPU并不确定,例如线程放弃CPU后还可能再次获得CPU的执行权,所以可能出现CMS控制线程放弃CPU后,Mutator没有抢到控制权,CMS控制线程继续执行,导致Mutator长时间等待(CMS控制线程放弃CPU时释放相关锁,Mutator获得锁才能执行)的情况。

在这种情况下,更好的处理方式是重新设计Mutator和CMS控制线程的交互方式,例如使用通知/等待机制,但实现较为复杂。在JVM实现中直接让CMS控制线程在放弃CPU后再睡眠一段时间(睡眠时间通过参数CMSYieldSleepCount控制,默认是0,表示不睡眠)。如果遇到在并发执行阶段Mutator长时间等待的情况,则可以设置该参数让Mutator获得执行权。

并发标记清除之可终止预清理

可终止预清理指的是在执行过程中如果发现内存压力比较大,会主动终止执行,直接进入再标记阶段。可终止预清理阶段的可终止指的是当Eden内存使用到一定程度时(通过参数CMSScheduleRemarkSamplingRatio控制,默认值是50,表示Eden使用超过50%),不再继续执行预清理阶段,直接转入再标记阶段。其主要原因是Eden剩余空间不多,而可终止预清理虽然是并发执行的,但是是单线程执行,速度比较慢。如果继续执行预清理,可能导致新生代因为内存不足触发老生代回收,而这样的老生代回收可能会终止当前正在执行的回收,所以引入了可终止预清理。

可终止预清理和预清理阶段完全共享代码。主要区别如下:

1)通过不同的参数控制处理的源,默认情况下,预清理执行引用处理、MUT和CT的处理;可终止预清理执行Survivor、MUT和CT的处理。

2)可终止预清理会额外判断是否需要终止,如果需要终止,则直接进入再标记阶段。

预清理和可终止预清理执行的工作可以通过参数修改,其中预清理阶段使用参数CMSPrecleanRefLists1(默认为true)和CMSPrecleanSurvivors1(默认为false)控制引用处理和Survivor的执行;

可终止预清理阶段使用参数CMSPrecleanRefLists2(默认为false)和CMSPrecleanSurvivors2(默认为true)控制引用处理和Survivor的执行。

如果遇到预清理阶段引用处理时间过长的情况,则可以将CMSPrecleanRefLists1也设置为false,则可跳过引用处理。

MUT和CT在预清理和可终止预清理阶段都有处理。在预清理阶段,不会主动终止MUT和CT的处理;而在可终止预清理阶段,MUT和CT的处理都会尝试主动让出CPU,并且也都会主动检查是否需要终止执行。

另外,在MUT的处理中还进行了额外的优化,主要是为了控制执行的时间,在这两个阶段都会控制处理的对象数量。以下两种情况会主动终止MUT的处理。

1)MUT的处理在放弃次数不超过3次(可以通过参数CMSPrecleanIter控制,默认值为3)的情况下还会继续重试执行MUT。

2)当MUT处理卡块的个数小于1000(可以通过参数CMSPrecleanThreshold控制,默认值为1000),或者每次MUT处理卡块的个数没有出现递减并且达到一定程度时会主动终止(通过参数CMSPrecleanDenominator和CMSPrecleanNumerator控制数量变化的程度,默认值分别是3和2,表示最新一次MUT处理的个数大于上一次MUT处理个数的2/3)。

再来分析一下在进行CT处理时卡块被设置为PreClean的正确性。CMS控制线程在预清理和可终止预清理阶段都会将老生代的卡块设置为PreClean。而Mutator也有可能修改对象的引用关系并设置卡块的值,Mutator会将卡块的值修改为Dirty。因为CMS控制线程和Mutator都可能修改同一卡块,所以存在竞争问题。那么在修改卡块时是否需要加锁?

如何设计才能保证算法的正确性?下面通过一个简单的例子来说明CMS是如何解决这个问题的。假设Mutator(记为T1)修改老生代中对象的引用关系(记为Write Heap,简写为Wh),需要写卡块(记为Write Dirty,简写为Wd),可以抽象为先写堆再写卡块;CMS控制线程(记为T2)正在执行预清理或可终止预清理,对卡块为Dirty的进行重新标记,当标记时先将卡块修改为PreClean(记为WritePreClean,简写为Wp),再读对象(记为Read,简写为R),可以抽象为先写卡块再读堆。T1和T2交互执行,可能有以下6种执行顺序。

1)如果T2先于T1执行,那么整个执行顺序为Wp→R→Wh→Wd,最后卡块的结果为Dirty,并发预清理和可终止预清理正确执行,同时下一次的MinorGC也正确执行。

2)如果T1先于T2执行,那么整个执行顺序为Wh→Wd→Wp→R,最后卡块的结果为PreClean,并发预清理和可终止预清理正确执行。对于Minor GC需要稍微增强,在执行Minor GC处理时,除了要把Dirty看作代际引用之外,也要把PreClean看作代际引用,以保证对象标记的正确性(在4.2节中提到PreClean也是根的原因)。

3)如果T1和T2交互执行,T1修改引用关系,T2修改卡块为PreClean,T1修改写卡块为Dirty,T2再读对象。整个执行顺序为Wh→Wp→Wd→R,卡块最后的状态为Dirty,T2读到的是修改后的对象,对象会被正确地再标记。

同时由于卡块状态为Dirty,因此再标记中还会再处理一次卡块对应的对象,相当于额外多执行一次标记动作,但是正确性没有问题。

4)如果T1和T2交互执行,T1修改引用关系,T2修改卡块为PreClean并读对象,T1最后修改写卡块为Dirty。整个执行顺序为Wh→Wp→R→Wd,卡块最后的状态为Dirty,T2读到的是修改后的对象,对象会被正确地再标记,会额外多执行一次卡表的标记动作。

5)如果T1和T2交互执行,T2修改卡块为PreClean,T1修改引用关系,T1修改写卡块为Dirty,T2再读对象。整个执行顺序为Wp→Wh→Wd→R,卡块最后的状态为Dirty,T2读到的是修改后的对象,对象会被正确地再标记,会额外多执行一次卡表的标记动作。

6)如果T1和T2交互执行,T2修改卡块为PreClean并读对象,T1修改引用关系,T1最后修改写卡块为Dirty。整个执行顺序为Wp→Wh→R→Wd,卡块最后的状态为Dirty,T2读到的是修改后的对象,对象会被正确地再标记,会额外多执行一次卡表的标记动作。

另外,预清理阶段和可终止预清理阶段除了做上述标记工作以外,还可能做一些其他的工作(依赖于参数CMSEdenChunksRecordAlways的设置,该参数的默认值是true,表示不需要预清理做额外的工作)。在执行再标记的时候,需要重新把新生代作为老生代的根进行标记,为了加速再标记的执行,会将Eden划分为成大小尽量相同的内存块(chunk),由多个线程并行执行对象的标记,内存块的大小可以通过参数CMSSamplingGrain来控制(默认值是16K[3]字)。

但是直接按照大小对Eden进行划分会存在一个问题,那就是每个chunk的第一字不是对象的首地址,所以需要额外的数据结构辅助(例如BOT)找到对象的首地址,然后在遍历对象时根据对象的首地址开始进行标记。使用辅助结构需要额外的内存消耗及时间来查找对象,所以在CMS中提供了另外一种实现,即使用了一个额外的数组记录每个chunk中第一个对象的首地址,而数组中元素的更新策略有两种,通过参数CMSEdenChunksRecordAlways来控制。

1)当参数设置为true时,在进行对象分配的时候判断对象是否跨chunk,如果对象进入下一个chunk,则直接更新数组;该参数为true时会降低Mutator对象分配的效率。

2)当参数设置为false时,在进行对象分配时并不记录,而是在预清理或可终止预清理阶段在遍历对象时对Eden进行采样,如果发现Eden当前可用的地址处于一个新的chunk中,则更新数组。这样的方式虽然不影响对象分配的效率,但是数组记录对象并不均匀,数组元素之间的地址跨度可能比较大(依赖于应用运行的情况)。

另外,在实现时对于是否启动抽样还要提供额外的参数控制,当满足下面的条件时才会真正启动抽样,公式为

JVM垃圾回收器详解:并发老生代回收并发标记之可终止预清理

其中CMSScheduleRemarkSamplingRatio的默认值为5,CMSScheduleRemarkEden-Penetration的默认值为50,表示Eden使用的内存低于1/10容量时才会启动抽样。如果无法成功启动抽样,在执行再标记时性能可能受损,整个Eden会被一个线程处理(当然JVM内部有多线程的任务均衡机制来解决负载不均衡的问题)。

根据笔者个人经验,在CMS的实现这一部分逻辑中存在一个小问题,通常不建议读者对这几个参数做修改,直接使用默认配置即可。

在可终止预清理阶段还提供了以下几种通过参数主动终止执行的控制。

参数CMSMaxAbortablePrecleanLoops控制预清理主动执行的次数,在一次预清理处理中处理Survivor、MUT和CT时都可以主动终止。该参数表示如果遇到主动终止,则判断是否需要再次进入预清理工作。该参数的默认值为0,表示不使用该方式控制是否主动终止。

参数CMSMaxAbortablePrecleanTime控制预清理主动执行的总体时间,当进行多次预清理处理时,总体执行的时间不能超过该阈值。该参数的默认值为5000,表示最大允许该阶段执行5秒,超过5秒会立即进入再标记。

参数CMSAbortablePrecleanMinWorkPerIteration用于控制可终止预清理的效率,要求一次预清理处理至少处理一定数量的对象,当低于该阈值时,暂时进入休眠状态,休眠时间通过参数CMSAbortablePrecleanWaitMillis来控制。这两个参数的默认值都是100,表示一次预清理工作的处理对象少于100个时会休眠100毫秒。

这几个参数只有在很少的情况下才会被使用到,一般的程序员无须关心这些参数。

本文给大家讲解的内容是JVM垃圾回收器详解:并发标记清除回收,并发的老生代回收-并发标记清除之预清理、可终止预清理

  1. 下篇文章给大家讲解的内容是JVM垃圾回收器详解:并发标记清除回收,并发的老生代回收-并发标记清除之再标记、清除
  2. 感谢大家的支持!

继续阅读