JVM深入解析四之垃圾收集器

    技术2022-07-11  122

     

    垃圾收集算法

    当前虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

    1.标记-复制算法

    它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的 内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对 内存区间的一半进行回收。

    2.标记-清除算法

    分为“标记”和“清除”阶段:标记存活的对象, 统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

    这种垃圾收集算法会带来两个明显的问题:

    效率问题 (如果需要标记的对象太多,效率不高)空间问题(标记清除后会产生大量不连续的碎片)

    3.标记-整理算法

    根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但多了一步把存活的对象整理到一块,释放出连续的空间。

    垃圾收集器

    如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。目前没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

    1.Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)

    Serial(串行)收集器是最基本、历史最悠久的垃圾收集器,也是一个单线程收集器。它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。它的新生代采用复制算法,老年代采用标记-整理算法。它的特点就是简单而高效(与其他收集器的单线程相比),而它的老年代(Serial Old)是作为CMS收集器的后备方案。

    Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5 以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。

    2. Parallel Scavenge收集器(-XX:+UseParallelGC,-XX:+UseParallelOldGC)

    Parallel收集器其实就是Serial收集器的多线程版本,是JDK8默认的新生代和老年代收集器。默认的收集线程数跟cpu核数相同,也可以用参数(XX:ParallelGCThreads)指定收集线程数,一般不推荐修改。它的关注点是吞吐量(高效率的利用CPU),所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。

    3. ParNew收集器(-XX:+UseParNewGC)

    ParNew收集器就是Parallel收集器的升级版。主要区别在于它是年轻代垃圾收集器,除了Serial收集器外,只有它能与CMS收集器配合工作。

    4.CMS收集器(-XX:+UseConcMarkSweepGC(old))

    CMS(Concurrent Mark Sweep)收集器是采用标记-清除算法实现的老年代垃圾收集器, 是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程 (基本上)同时工作。

    通过上图可知,它的运作过程分为五个步骤:

    初始标记: 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快。并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法做重新标记。并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理。并发重置:重置本次GC过程中的标记数据。

    通过cms垃圾收集器的运作过程我们可以知道它的特点是并发收集、低停顿。而它的缺点也是显而易见的:

    对CPU资源敏感(会和服务抢资源);无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并 发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收

    让我们来了解一下CMS的相关核心参数:

    1. -XX:+UseConcMarkSweepGC:启用cms

    2. -XX:ConcGCThreads:并发的GC线程数

    3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)

    4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一 次

    5. -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)

    6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整

    7. -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时80%都在标记阶段

    8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW

    9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

    这里有一个建议JVM调优参数

    ‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8 ‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC ‐XX:CMSInitiatingOccupancyFraction=92 ‐XX:+UseCMSCompactAtFullCollection ‐XX:CMSFullGCsBeforeCompaction=0

    5.G1收集器(-XX:+UseG1GC)

    G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC 停顿时间要求的同时,还具备高吞吐量性能特征。它的回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。

    G1的堆内存分配图

    从上图可看出,G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region,一般Region大小等于堆大小除以2048,可以用参数"XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。G1保留了年轻代和老年代的概念,但不再是物理隔阂了,默认年轻代对堆内存的占比是5%,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多 的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。Region的区域功能可能会动态变化。G1垃圾收集器对于对象唯一不同的是对大对象的处理Humongous区,如果一个对象超过了一个Region大小的50%,就会被放 入Humongous中。Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开 销。Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。

    G1收集器运作过程图

    由上图可知,G1垃圾收集器跟CMS垃圾收集器很相像,其实G1也是从CMS演变而来:

    初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;并发标记(Concurrent Marking):同CMS的并发标记最终标记(Remark,STW):同CMS的重新标记筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期 望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划

    G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字 Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回 收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收 方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。

    G1被视为JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:

    并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短StopThe-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式 让java程序继续执行。分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部 上来看是基于“复制”算法实现的。可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了 追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"XX:MaxGCPauseMillis"指定)内完成垃圾收集。

    这里需要注意:如果把G1停顿时间设置的特别短,那么当G1还没有怎么回收就停止导致垃圾收集不了,那么就应用运行时间一长最终占满堆引发 Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

    G1垃圾收集分类

    当然,G1垃圾收集器跟别的垃圾收集器还有一点不同就是多了一个MixedGC

    YoungGC:YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时 间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GCMixedGC:老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的 Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做 MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够 的空region能够承载拷贝对象就会触发一次Full GCFull GC:停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这 个过程是非常耗时的。(Shenandoah优化成多线程收集了)

    当然,G1垃圾收集器这么强大功能,那么它的核心参数也少不了:

    -XX:+UseG1GC:使用G1收集器

    -XX:ParallelGCThreads:指定GC工作的线程数量

    -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区

    -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)

    -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)

    -XX:G1MaxNewSizePercent:新生代内存最大空间

    -XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个 年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代

    -XX:MaxTenuringThreshold:最大年龄阈值(默认15)

    -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合 收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能 就要触发MixedGC了

    -XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这 个值,存活对象过多,回收的的意义不大。

    -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一 会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。

    -XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都 是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清 理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立 即停止混合回收,意味着本次混合回收就结束了。

    那么什么场景适合使用G1呢?

    1. 50%以上的堆被存活对象占用

    2. 对象分配和晋升的速度变化非常大

    3. 垃圾回收时间特别长,超过1秒

    4. 8GB以上的堆内存(建议值)

    5. 停顿时间是500ms以内

    6.ZGC收集器(-XX:+UseZGC)

    ZGC是一款JDK 11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于是Azul System公司开发的 C4(Concurrent Continuously Compacting Collector) 收集器。

    ZGC运作过程图:

    由上图ZGC的运作过程大致可划分为以下四个大的阶段:

    并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新染色指针中的Marked 0、 Marked 1标志位。并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障)所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。ZGC的颜色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢, 一旦重分配集中某个Region的存活对象都复制完毕 后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉, 因为可能还有访问在使用这个转发表。并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。

    ZGC的目标

    支持TB量级的堆。我们生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有JAVA应用的需求了吧。最大GC停顿时间不超10ms。目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的。奠定未来GC特性的基础。最糟糕的情况下吞吐量会降低15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。另外,Oracle官方提到了它最大的优点是:它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下。

    不分代(暂时)

    单代,即ZGC「没有分代」。我们知道以前的垃圾回收器之所以分代,是因为源于“「大部分对象朝生夕死」”的假设,事实上大部分系统的对象分配行为也确实符合这个假设。

    那么为什么ZGC就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单可用的单代版本,后续会优化。

    ZGC内存布局

    ZGC收集器是一款基于Region内存布局的, 暂时不设分代的, 使用了读屏障、 颜色指针等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器。

    ZGC的Region可以具有如图3-19所示的大、 中、 小三类容量:

    小型Region(Small Region) : 容量固定为2MB, 用于放置小于256KB的小对象。中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段, 稍后会介绍到)的, 因为复制一个大对象的代价非常高昂。

    颜色指针

    Colored Pointers,即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中。

    每个对象有一个64位指针,这64位被分为:

    18位:预留给以后使用;1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);1位:Marked1标识;1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;42位:对象的地址(所以它可以支持2^42=4T内存)

    为什么有2个mark标记?

    每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。

    GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。

    GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。

    通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。

    颜色指针有什么优势?

    1.一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。

    2.颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。

    3.颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

    ZGC触发时机

    定时触发,默认为不使用,可通过ZCollectionInterval参数配置。预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用。分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC(耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间)。主动触发,(默认开启,可通过ZProactive参数配置) 距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发。

    垃圾收集底层算法

    三色标记

    把Gcroots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以 下三种颜色:

    黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过 灰色对象) 指向某个白色对象。灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。

    多标与漏标

    在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

    多标:在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过 (被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动 垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。 另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分 对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。

    漏标:多标我们还可以接受,无非产生点浮动垃圾,可以在gc下一次回收时再回收,那么如果漏标的话,那么程序就会出现重大bug,首先我们来看一段代码

    /** * 垃圾收集算法细节之三色标记 * 为了简化例子,代码写法可能不规范,请忽略 * */ public class ThreeColorRemark { public static void main(String[] args) { A a = new A(); //开始做并发标记 D d = a.b.d; // 1.读 a.b.d = null; // 2.写 a.d = d; // 3.写 } } class A { B b = new B(); D d = null; } class B { C c = new C(); D d = new D(); } class C {} class D {}

    通过以上代码,我们可以分析出A实例化了B,B又分别实例化了C,D。如果这样正常走的话,那么扫描完ABCD都应该为黑色,但是a.b.d=null,把B中的引用给置空了,又把d赋值给了A中的d,那么在在main方法运行时,扫描到a.b.d=null,开始进行垃圾回收,那么d已经给回收掉了,a.d=d不满足了,所以就造成了漏标。

    程序出现重大bug肯定是我们不能忍受的,那么处理漏标情况我们怎么办呢,有两种解决方案:

    增量更新(Incremental Update):当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用保存下来, 等并发扫描结束之后,再将黑色对象变为灰色对象,等待gc的下一次扫描。原始快照(Snapshot At The Beginning,SATB):当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用保存下来,在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,将白色对象直接标记为黑色,这么做可能会产生浮动垃圾

    写屏障与读屏障

    以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。

    给某个对象的成员变量赋值时,其底层代码大概长这样:

    /** *@param field 某对象的成员变量,如 a.b.d *@param new_value 新值,如 null */ void oop_field_store(oop * field, oop new_value) { * field = new_value; // 赋值操作 }

    所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):

    void oop_field_store(oop * field, oop new_value) { pre_write_barrier(field); // 写屏障‐写前操作 * field = new_value; post_write_barrier(field, value); // 写屏障‐写后操作 }

    写屏障实现SATB,当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用 对象D记录下来:

    void pre_write_barrier(oop * field) { oop old_value = * field; // 获取旧值 remark_set.add(old_value); // 记录原来的引用对象 }

    写屏障实现增量更新,当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D 记录下来:

    void post_write_barrier(oop * field, oop new_value) { remark_set.add(new_value); // 记录新引用的对象 }

    读屏障

    oop oop_field_load(oop * field) { pre_load_barrier(field); // 读屏障‐读取前操作 3 return *field; }

    读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:

    void pre_load_barrier(oop * field) { oop old_value = * field; remark_set.add(old_value); // 记录读取到的对象 }

    现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色 集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可 以是广度/深度遍历等等。 对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

    CMS:写屏障 + 增量更新G1,Shenandoah:写屏障 + SATBZGC:读屏障

    工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并 发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

    为什么G1用SATB?CMS用增量更新?

    SATB不做深度扫描,只标记,下次再回收,而增量更新会再次深度扫描。G1有很多region,做增量更新会比CMS代价高,SATB效率比增量更新高

    记忆集与卡表

    记忆集(Remember Set):在发生跨代引用时,在新生代引入记录集,避免在垃圾收集时去年轻代、老年代全部扫描找寻

    卡表:卡表与记忆集的关系,可以类比为Java语言中HashMap与Map的关系。以字节数组(CARD_TABLE[ ])中每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”,一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0,Hotspot使用写屏障维护卡表状态。

    安全点与安全区域

    安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。这些特定的安全点位置主要有以下几种:

    方法返回之前调用某个方法之后抛出异常的位置循环的末尾

    安全区域又是什么?

    Safe Point 是对正在执行的线程设定的。如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。因此 JVM 引入了 Safe Region。Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。

     

    Processed: 0.033, SQL: 9