年薪20W才懂的JVM垃圾回收器:并发标记清除回收之再标记、清除

年薪20W才懂的JVM垃圾回收器:并发标记清除回收之再标记、清除

首页模拟经营空闲合并标志更新时间:2024-04-30
并发标记清除之再标记

再标记阶段是在并发阶段(并发标记、预清理、可终止预清理)后执行的,在并发阶段,Mutator会修改对象的引用关系,进而导致部分活跃对象尚未标记。解决问题的思路非常简单:重新执行一次对象标记。首先,根集合处理与初始标记处理非常类似,包含传统的根集合、新生代,除此以外,还包括MUT和CT这两个表中记录修改的对象,当然也会执行Java对象的引用及类卸载相关代码。再标记发生在STW阶段,在执行过程中多个线程并行执行。并行执行的思路对于不同的根处理略有不同,总结如下。

传统的根集合:每个线程执行一个根集合。

新生代:将Eden和Survivor划分成内存块,然后每个内存块由一个线

程执行,Eden和Survivor的划分方法在预清理中已经介绍过。

MUT和CT:将CT中Dirty的卡块合并到MUT中,CT中卡块不做任何处理(因为卡块还可能表示老生代指向新生代引用,Minor GC仍然需要卡表的状态数据),然后将内存划分为大小相同的内存块进行并行处理。每个内存块的大小通过参数CMSRescanMultiple来控制,默认值为32,表示内存块的大小为32×4KB=128KB。

Java引用本身支持并行处理,通过线程的局部链表对引用处理进行并行处理。

最后再来看一下并发执行中卡表的操作。在前面提到,为了保证标记的正确性,Mutator在修改老生代的对象时会在卡表中记录对象的首地址。但是由于在老生代回收过程中还可以继续执行Minor GC,Minor GC执行时需要把老生代到新生代的代际引用也记录在卡表中。为了保证并发标记的正确性,需要关注老生代内对象之间的引用关系,即老生代指向老生代的引用关系也是再标记的根之一。

这里就有一个问题,在卡表中要区分老生代到新生代的引入值及老生代内对象修改后的值,否则大量的不属于老生代到新生代的引用也会被遍历到。在Minor GC中使用两个值cur_youngergen和prev_youngergen分别表示老生代到新生代的引用。当老生代内对象被修改时使用另外一个值Dirty,Dirty和cur_youngergen与prev_youngergen均不相同。所以在再标记、并发预清理、可终止预清理阶段都只需要处理卡表中值为Dirty的卡块。

并发标记清除之清除

由CMS控制线程负责完成清除,清除阶段是并发执行,并且是单线程执行的。清除包含了两个动作:发现垃圾内存并将其添加到空闲列表中;在添加过程中如果发现空闲内存块可以合并,则会执行合并动作。

清除动作算法并不复杂,从[bottom, end)依次遍历内存块,当发现内存块状态为Free或者Garbage时准备合并。清除算法本质上是一个状态机,如图4-28所示。

图4-28 并发清除状态机

实际上老生代回收在合并上还有另外的考虑,在应用执行时,一方面,如果小对象过多,JVM内部可能需要不断地从大的内存块分离出小的内存块;

另一方面,如果大对象过多,JVM内部需要将小的空闲内存块合并成大的内存块。这两种诉求在应用执行时同时存在。为了提高应用执行的效率,在合并时避免将所有可以合并(只要内存块首尾地址相连就可以合并)的内存块都合并,在内存管理中增加了合并策略,只有当满足合并策略时,才可以合并内存块。如何设计合并策略呢?

显然,要设计好合并策略,需要统计不同大小内存块使用的情况,通常使用一个allocation_stats的数据结构记录当前尺寸的内存块在应用运行时真正用于分配请求的内存块。合并时会根据过去内存块使用的情况预测到下次清除之前需要使用的内存块个数,在合并时当空闲内存块的个数小于预测值时不合并。但在实现层面提供了多样化处理,下面通过一个例子简单地介绍合并策略的情况。

假设有两个内存块,分别记为A、B,其中内存块A的大小为16字,内存块B的大小为40字。这里假设两个内存块的大小是为了演示是否满足合并条件。

满足合并条件的前提是A的尾地址和B的首地址相连。合并内存块B前,空闲链表的状态如图4-29所示。

图4-29 空闲链表示意图

假设应用运行一段时间触发了老生代回收,在老生代回收中,统计到16字和40字内存块的使用情况,并预测到下一次老生代回收时,16字和40字的内存块都需要2个。当清除内存块B的时候(B可以是Free或者Garbage状态),是否需要合并A、B可以通过策略来控制,策略的参数通过FLSCoalescePolicy来设置。

0:表示即使A、B满足合并的前提(地址相连)也不会合并。该参数值会导致内存碎片较多,要慎重使用,适用于应用对象分布比较均匀的场景。

1:表示A、B满足合并的前提,同时要求A和B对应的空闲链表中空闲内存块的个数均超过预测值时才会尝试合并B。在该例中,由于40字的链表中只有一个空闲块,低于预测值2,不满足合并条件,B将被加入空闲链表中。该参数值会导致内存碎片较多,要慎重使用。

2:表示A、B满足合并的前提,要求A对应的空闲链表中空闲内存块的个数超过预测值时才会尝试合并B。在该例中由于16字的链表中有3个空闲块,超过预测值,满足合并条件,A和B可以被合并。在A和B合并时,A对应的链表空闲内存块的个数变成了2,如果后续再要从该链表中合并内存块时就不满足预测值。该参数值是JVM默认的值,由于清除阶段是从左到右执行的,执行合并时仅仅判断左侧的内存块更容易实现合并。

3:表示A、B满足合并的前提,要求A和B对应的空闲链表中空闲内存块的个数有一个超过预测值时就会尝试合并B。在该例中,由于16字的链表中有3个空闲块,超过预测值,满足合并条件,A和B可以被合并。该参数值和默认值相比可以合并更多的内存块,但效果有限。

4:表示只要A、B满足合并的前提,就会合并A和B,不用考虑分配的效率。该参数值可以尽可能多地合并内存块,但对内存分配效率会有一定的影响。

实际工作中常使用默认值2或者激进的合并策略4,读者可以根据应用运行的情况来选择相应的参数值。

另外,JVM还对最大空闲内存块的合并做了特殊处理,原因是最大空闲内存块越大,满足应用分配请求的概率就更高。所以,当遇到最大空闲时尽可能地合并。其具体的实现如下:

1)找到第一个最大的空闲内存块。

2)根据该空闲内存块的地址向前计算一个阈值,当空闲块的地址落在阈值之后的地址空间时,总是合并空闲块,而不考虑合并策略。阈值的计算公式如下:假设Offset是最大空间块A距离内存起始地址O的偏移量,即Offset=A-O;阈值threshold=Offset×FLSLar-gestBlockCoalesceProximity O,内存块落在[threshold, A)之间时都会强制进行合并。

FLSLargestBlockCoalesceProximity的默认值为0.99。在实际生成中,如果合并策略选择1、2、3,当发现遇到内存碎片化导致无法响应内存分配时,可以设置该值,将其值变小,可以有效地提高最大空闲内存块合并的概率。

最后,再对清除的并发操作做一些提示。清除操作和Mutator并发执行,而Mutator可以在清除执行期间从老生代中申请内存并初始化对象。两者之间的同步通过FreeListLock这个锁来保证,即只有得到FreeListLock这个锁的线程才能访问老生代。要进行清除,必须获得锁,当Mutator需要分配内存时,必须等待清除阶段释放锁。为了保证Mutator的执行,在清除阶段会执行Yield动作。具体方法是在每处理完一个内存块之前都先检查是否需要放弃执行,如果需要,则放弃CPU的占用。在放弃CPU占用时会先释放锁,从而使Mutator得到执行。

但是两者并发执行可能会存在一个问题,那就是Mutator从老生代分配了内存,但是尚未完成初始化,就被清除线程抢占了CPU重新执行。对于这种情况,清除线程在实现中需要做额外的处理。主要原因是在进行清除工作时需要访问元数据获取对象内存的大小,而尚未完成初始化的对象的元数据信息并不存在,无法正确获取。具体的方法是:对于已经申请但尚未完成初始化的内存块,当分配时,在标记位图中做特殊的标记。

普通对象在标记位图中仅仅标记对象的首地址对应的位图,对于已经分配但尚未完成初始化的对象,对对象的首地址、第二个字和最后一个字对应的位图都进行设置。因为对象的大小最小为3字,所以通过上述方法可以将两者区分开来。正常对象标记位图中前两位为10,已经分配但尚未完成初始化动作的对象标记位图中前两位为11,继续查找标记位图,直到遇到标记1,该地址就是未初始化对象的尾地址,通过这样的方式就可以获得尚未初始化的对象的大小。

本文给大家讲解的内容是JVM垃圾回收器详解:并发标记清除回收,并发的老生代回收-并发标记清除之再标记、清除
  1. 下篇文章给大家讲解的内容是JVM垃圾回收器详解:并发标记清除回收,并发的老生代回收-并发标记清除之内存空间调整、复位、并发算法难点、Full GC
  2. 感谢大家的支持!
查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved