性能调优 12. 垃圾收集器ParNew&CMS与底层三色标记算法详解

1. 垃圾收集算法


在这里插入图片描述

1.1. 分代收集理论


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

‌‌‌  比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择标记-清除或标记-整理算法进行垃圾收集。注意,标记-清除或标记-整理算法会比复制算法慢10倍以上

‌‌‌  所以,真正垃圾收集算法,只有复制算法,标记整理算法,标记清除算法。这三种算法都是基于分代收集理论划分区域来选择使用。复制算法和标记整理算法区别主要是是否在同一页面空间。

1.2. 标记-复制算法


‌‌‌  1. 为了解决效率问题,复制收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

‌‌‌  2. 年轻代使用的算法,划分的两块内存就好比年轻代Eden区和Survivor区。

‌‌‌  3. 具体垃圾收集器有具体实现。一般垃圾收集器的标记-复制算法会找非垃圾对象(存活对象,比如用到可达性算法,HotSpot虚拟机等就是这样),很少会找垃圾对象。

在这里插入图片描述

‌‌‌  缺点

‌‌‌  1. 浪费空间需要分割,不适用于老年代。


1.3. 标记-清除算法


‌‌‌  1. 算法分为标记和清除阶段:标记存活的对象, 统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象 。它是最基础的收集算法,比较简单。

‌‌‌  2. 老年代使用的算法。

‌‌‌  缺点

‌‌‌  1. 效率问题 (如果需要标记的对象太多,效率不高)。

‌‌‌  2. 空间问题(标记清除后会产生大量不连续的碎片,没有连续空间,不好存放对象)。

在这里插入图片描述

1.4. 标记-整理算法


‌‌‌  根据老年代的特点特出的一种标记算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

‌‌‌  简单说标记存活对象,让存活对象往一边的空间挪动整理,覆盖原来空间对象就行。然后清理掉整理过存活对象边界之外的内存。

在这里插入图片描述


2. 垃圾收集器


在这里插入图片描述

1. 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。目前为止还没有最好的垃圾收集器出现,只能具体应用场景选择适合自己的垃圾收集器。

‌‌‌  2. 画实线代表能互相组合,红线代表垃圾收集器特殊情况会切换到右边垃圾收集器收集垃圾。

‌‌‌  3. 垃圾收集器演化到现在10多种。不断演化,从串行到并行,主要没有适用于任何场景的完美垃圾收集器。像Serial适合小内存,G1适合大内存8G-几百G。ZGC适用于更大内存4T好像,STW停顿可以在10ms。

‌‌‌  4. JDK1.8常用G1和G1左边图的这些垃圾收集器。G1右边的是比较新版本的垃圾收集器,主要从CMS演变过来,JDK9开始主要使用,JDK11主要使用ZGC。G1,ZGC,Shenandoah都是CMS演变过来的,Shenandoa相当于G1升级,实现了真正并发清理


2.1. Serial收集器


‌‌‌  1. 适合内存几十到几百M的垃圾回收。

‌‌‌  2. Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。这个收集器是一个单线程收集器了。它只会使用一条垃圾收集线程去完成垃圾收集工作,它在进行垃圾收集工作的时候必须暂停其他所有的用户线程( Stop The World )**,直到它收集结束。

在这里插入图片描述

‌‌‌  算法

‌‌‌  新生代采用标记-复制算法,老年代采用标记-整理算法。

‌‌‌  优点

‌‌‌  1. 它简单而高效(与其它收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。

‌‌‌  缺点

‌‌‌  1. 停止所有用户线程时间较长影响体验,单线程垃圾收集,浪费硬件资源。所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍在继续)。


2.2. Parallel Scavenge收集器


‌‌‌  1. Parallel收集器其实就是Serial收集器的多线程版本(主要区别),除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟CPU核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。

在这里插入图片描述

‌‌‌  Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点多的是用户线程的停顿时间 (提高用户体验)

‌‌‌  吞吐量:就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。

‌‌‌  Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。

‌‌‌  2. JDK1.8默认年轻代和老年代使用的垃圾收集器,适用于2-3G内存的垃圾回收。

‌‌‌  开启参数

‌‌‌  有两个版本,收集年轻代垃圾的Parallel Scavenge,和收集老年代垃圾的Parallel Old。
-XX:+UseParallelGC:开启年轻代的,-XX:+UseParallelOldGC:开启老年代的。

‌‌‌  Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和标记-整理算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。

‌‌‌  算法

‌‌‌  新生代采用标记-复制算法,老年代采用标记-整理算法。

‌‌‌  优点

‌‌‌  1. 多线程垃圾收集比较快,CPU吞吐量大。

‌‌‌  缺点

‌‌‌  1. 内存大情况下STW比较长,最多适用于2-3G内存大小。


2.3. ParNew收集器


‌‌‌  1. ParNew收集器跟Parallel收集器很类似,主要区别在于它可以和CMS收集器配合使用

‌‌‌  它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器配合工作

在这里插入图片描述

‌‌‌  开启参数

‌‌‌  -XX:+UseParNewGC开启。

‌‌‌  算法

‌‌‌  只能用在年轻代,采用标记-复制算法


2.4. CMS收集器


‌‌‌  1. CMS(Concurrent Mark Sweep)并发标记垃圾收集器。是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器

‌‌‌  这边并发是实现了让垃圾收集线程与用户线程(基本上)同时工作,跟前面垃圾收集器并发不一样,前面垃圾收集器并发是暂停用户线程,多条垃圾收集线程一起同时工作。

在这里插入图片描述

‌‌‌  开启参数

‌‌‌  -XX:+UseConcMarkSweepGC(old)开启,适合大内存。

‌‌‌  算法

‌‌‌  只能用在老年代,采用标记-清除算法

‌‌‌  算法整个过程分为四个步骤:

‌‌‌  初始标记: 暂停所有的其它用户线程(STW),并记录下GC Roots直接能引用的对象(比如,一个方法一开始有句代码 A a=new A() a就是GC Roots变量,new A()就是其引用的对象,当然Gc Roots变量引用的东西很多种),速度很快,跟可达性算法有关,只标记第一个Gc Roots变量引用的对象,GC Roots意思看前面章节可达性算法说明。可单线程和多线程(串行的是JDK7默认的行为。JDK8以后默认是并行的,可以通过参数-XX:+CMSParallelInitialMarkEnabled开启)

‌‌‌  并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,在可达性算法基础上使用三色标记算法标记非垃圾对象(三色标记看后面文章), 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变,存在多标和漏标问题。在重新标记阶段,CMS借助三色标记增量更新算法解决该问题

‌‌‌  重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录 (主要是处理漏标问题),这个阶段的停顿时间一般会比初始标记阶段的时间稍长(会STW),远远比并发标记阶段时间短CMS用到三色标记里的增量更新算法、做重新标记。可单线程和多线程(多个GC线程一起执行,不是让用户线程并发执行)

‌‌‌  并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理

‌‌‌  并发重置:重置本次GC过程中的标记的数据。(简单理解前面标记的对象都被重置为白色)。

‌‌‌  优点

‌‌‌  1. 并发收集,低停顿

‌‌‌  缺点

‌‌‌  1. 对CPU资源敏感(用户线程会和垃圾收集线程争抢资源,导致垃圾收集时间更长。

‌‌‌  2. 无法处理浮动垃圾(主要在并发标记和并发清理阶段存在又会产生新的垃圾对象或者原本非垃圾对象变为垃圾对象情况等,这种浮动垃圾只能等到下一次GC再清理了)。

‌‌‌  3. 使用标记-清算法会导致收集结束时会有大量空间碎片产生,当然通过参数
‌‌‌  -XX:+UseCMSCompactAtFullCollection可以让JVM在执行完标记清除后再做整理。

‌‌‌  4.执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完。因为用户线程还在执行,如果创建新的对象进入年轻代触发Minor GC,又有新的对象进入老年代,老年代满了又再次触发Full GC,**这时候就触发concurrent mode failure(并发模式失败),此时会进入STW,用Serial Old垃圾收集器来回收(这就是为啥特殊情况会使用Serial Old)。


‌‌‌2.4.1. CMS的相关核心参数


‌‌‌  1. -XX:+UseConcMarkSweepGC:代表启用CMS 。

‌‌‌  2. -XX:ConcGCThreads:-XX:ConcGCThreads=10代表并发的GC线程数。

‌‌‌  3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(碎片整理,也会STW)。

‌‌‌  4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次(碎片整理),默认是0,代表每次FullGC后都会压缩一次需要配合UseCMSCompactAtFullCollection参数 。

‌‌‌  5. -XX:CMSInitiatingOccupancyFraction:当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)。不会老年代完全满了才触发Full GC,为了避免并发收集失败。需要搭配开启-XX:+UseCMSInitiatingOccupancyOnly才生效。

‌‌‌  6. -XX:+UseCMSInitiatingOccupancyOnly:开启表示只使用设定的回收阈值( 配合参数-XX:CMSInitiatingOccupancyFraction设定的值使用)。如果不开启,JVM仅在第一次使用设定值,后续则会自动调整。

‌‌‌  7. -XX:+CMSScavengeBeforeRemark:在CMS GC(这边指Full GC)前启动一次Minor GC,降低CMS GC标记阶段时的开销(因为标记阶段也会对年轻代一起做标记,如果在Minor GC就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)。

‌‌‌  一般CMS的GC耗时 80%都在标记阶段在Full GC触发前触发一次Minor GC,对年轻代多余对象清理掉(减少跨代引用等,跨代引用看后面介绍),进行并发标记时候更快

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

‌‌‌  9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW,默认开启。

‌‌‌  注意

‌‌‌  1. 参数xx越多表示将来版本越不稳定。

‌‌‌  2. +和-代表启用和禁用 如-XX:+UseConcMarkSweepGC启用CMS,-XX:-UseConcMarkSweepGC禁用CMS。


3. 亿级流量电商系统如何优化JVM参数设置垃圾收集器组合(ParNew+CMS)


‌‌‌  大型电商系统后端现在一般都是拆分为多个子系统部署的,比如,商品系统,库存系统,订单系统,促销系统,会员系统等等。这里以比较核心的订单系统为例 ,估算了下,下单高峰期每台服务,每秒会产生60M的对象。

在这里插入图片描述

‌‌‌  假设当前使用的JDK8,在JDK8默认使用是Parallel垃圾收集器。

‌‌‌  为了便于理解,堆的划分比例,老年代/年老代占3分之2,年轻代/新生代占3分之1。年轻代/新生代分Eden区,S0区,S1区。比例8:1:1(实际比例可能不一样)。

‌‌‌  JVM默认有个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致默认这个8:1:1比例自动变化,如果不想让比例自动变化可以设置参数-XX:-UseAdaptiveSizePolicy 固定比例(但不管怎样实际比例还是有点误差)。

‌‌‌  参数 -XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,用法
‌‌‌  -XX:SurvivorRatio=8

‌‌‌  对于8G内存,一般是分配4G内存给JVM,正常的JVM参数配置如下:


‌‌‌  -Xms3072M -Xmx3072M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M  -XX:SurvivorRatio=8 -XX:-UseAdaptiveSizePolicy

‌‌‌  根据参考设置可知,堆初始大小和最大大小都是3072M,老年代2000多M,Eden区大小为800多M,S0和S1就都是100多M。

‌‌‌  下单这些产生的对象,都是方法的局部变量做为GCRoot,直接引用的对象。方法结束后没有引用会被回收。

‌‌‌  下单高峰期时候,14秒就会产生800多M对象,在第14秒时候Eden区会被塞满。这时触发Minor GC,此时前13秒的对象可以被回收掉。根据当前垃圾收集器收集垃圾过程可知Minor GC会触发STW,会停止所有用户线程,此时第14秒用户线程下订单的方法还没执行完,因为STW暂停执行,则进入Eden区的对象没有被回收。 Minor GC之后存活的第14秒的这些对象(60M)进入S区(S0或者S1),这时候这批对象超过S区S0或者S1)50%。Minor GC触发后会进行对象动态年龄判断,在S区存活的60M对象满足要求,进入老年代。下单高峰期下,差不多五六分钟老年代满就会触发一次Full GC。但是这些对象其实都是垃圾对象了,在下单方法执行完就变成了垃圾对象。

‌‌‌  这种高峰期下,频繁Full GC就要进行优化

在这里插入图片描述

‌‌‌3.1. 优化方法


‌‌‌  很多优化无非就是让短期存活的对象尽量都留在Survivor里,不要进入老年代,这样在Minor GC的时候这些对象都会被回收,不会进到老年代从而导致老年代频繁Full GC。

‌‌‌  1. 减少动态年龄触发,导致对象进入老年代

‌‌‌  1.1 把S区放大,修改比例。减少对象动态年龄判断的触发,但是Minor GC触发会更频繁。 下次Minor GC时对真正垃圾对象进行回收,空出空间。可以用前面说过参数-XX:SurvivorRatio设置S区的比例。

‌‌‌  1.2. 年轻代分配更大空间,同时Eden区和S区也会一起增大,即减少了Minor GC触发,也减少对象动态年龄判断触发。可以通过前面说的参数-Xmn设置。

‌‌‌  根据上面优化方式,选择设置加大年轻代大小,添加参数-Xmn2048M,修改JVM参数如下


‌‌‌  -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M  -XX:SurvivorRatio=8 
 

‌‌‌  设置该参数后,堆划分大小为老年代1000多M,Eden区大小为1600多M,S0和S1就都是200多M。

在这里插入图片描述

2. 减小分代年龄,一般对象存活时间不会太长,不要一直占用S区

‌‌‌  对于对象年龄应该为多少才移动到老年代比较合适,本例修改参数后,一次Minor GC要间隔二三十秒,大多数对象一般在几秒内就会变为垃圾,完全可以将默认的15岁改小一点,比如改为5,那么意味着对象要经过5次Minor GC才会进入老年代,整个时间也有一两分钟了。如果对象这么长时间都没被回收,完全可以认为这些对象是会存活的比较长的对象,可以移动到老年代,而不是继续一直占用Survivor区空间。

‌‌‌  当前这个系统有哪些对象可能会长期存活躲过5次以上Minor GC最终进入老年代。无非就是那些Spring容器里的Bean,线程池对象,一些初始化缓存数据对象等,这些加起来充其量也就几十MB。

‌‌‌  添加参数设置分代年龄为5,-XX:MaxTenuringThreshold=5。修改JVM参数如下

‌‌‌  -Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M  -XX:SurvivorRatio=8 
‌‌‌  -XX:MaxTenuringThreshold=5

3. 根据情况设置大对象直接进入老年代大小

‌‌‌  对于多大的对象直接进入老年代(参数-XX:PretenureSizeThreshold),这个一般可以结合你自己系统看下有没有什么大对象生成,预估下大对象的大小,一般来说设置为1M就差不多了,很少有超过1M的大对象,这些对象一般就是你系统初始化分配的缓存对象,比如大的缓存List,Map之类的对象。

‌‌‌  添加参数设置1M大对象直接进入老年代:-XX:PretenureSizeThreshold=1M。修改JVM参数如下


‌‌‌  -Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M  -XX:SurvivorRatio=8 
‌‌‌  -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M 

4. 对于对STW敏感的场景可以更换垃圾收集器

‌‌‌  JDK8默认的垃圾回收器是-XX:+UseParallelGC(年轻代)和-XX:+UseParallelOldGC(老年代),如果内存较大超过4个G,(只是经验值),系统对停顿时间比较敏感,可以使用更换垃圾回收器,年轻代和老年代分别使用ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)垃圾收集器。

‌‌‌  添加参数设置年轻代和老年代的垃圾收集器:-XX:+UseParNewGC -XX:+UseConcMarkSweepGC。

‌‌‌  -Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M  -XX:SurvivorRatio=8 
‌‌‌  -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC 

5. 碎片整理

‌‌‌  因为使用了CMS垃圾收集器回收老年代垃圾,采用的是标记-清楚算法存在堆空间碎片,就需要进行碎片整理。采用的-XX:CMSFullGCsBeforeCompaction参数开启碎片整理,就需要分析几次Full GC后进行整理。

‌‌‌  上面优化方式配置后不是一定不会Full GC,在订单高峰期还存在一种峰值情况可能突然给你每秒5-600单到来,如果在Eden区刚好满的那一秒遇到峰值或者刚好满的前几秒遇到一个峰值等情况(峰值时候,磁盘 网络带宽 IO等各方面都遇到压力,可能原本这一秒这些订单生成对象过程本来能创建处理好,峰值下生成这些订单对象过程可能需要处理好几秒,那么就有些订单对象还被引用着Minor GC后还存活),触发Minor GC后,有100多M对象甚至200-300M对象在S区存活。因为动态年龄触发,这些对象都放入老年代。大概高峰期下每隔五六分钟出现一次这样的情况,那么快的话大概半小时到一小时之间就可能因为老年代满了触发一次Full GC(因为前面参数设置,此时老年代是1000多M来算)。

‌‌‌  但是没关系,在半小时后发生Full GC,这时候已经过了抢购的高峰期,后续可能几小时才做一次Full GC。

‌‌‌  根据这种情况,正常都是1小时或几小时才做一次FullGC,是可以每做完一次就开始碎片整理,或者两到三次之后再做一次也行。

‌‌‌  添加参数设置开启碎片整理,每三次Full GC整理一次碎片:

‌‌‌  -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=3。修改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:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=3

6. 并发失败考虑

‌‌‌  综上,只要年轻代参数设置合理,老年代CMS的参数设置基本都可以用默认值,如果考虑CMS的并发失败可以设置老年代使用空间比例多少时候触发Full GC,一般设置80%。

‌‌‌  添加参数设置比例为80%:-XX:CMSInitiatingOccupancyFraction=80。修改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:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=3 ‌‌‌  -XX:CMSInitiatingOccupancyFraction=80

4. 垃圾收集底层算法实现


4.1. 三色标记算法


‌‌‌  在并发标记的过程中,因为标记期间用户线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。漏标的问题主要引入了三色标记算法来解决。三色标记算法是基于GC Roots可达性分析算法在并发标记时候会给对象做标记。把GC Roots可达性分析遍历对象过程中遇到的对象,按照是否访问过这个条件标记成以下三种颜色:

‌‌‌  黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过(这边引用就是其成员变量的直接引用,比如A引用的对象是B,B的引用对象是C和D,C和D跟A不算引用关系)。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其它对象引用指向了黑色对象(其它的对象有引用使用了黑色对象), 无须重新扫描一遍。 算法完整执行下,黑色对象不可能直接(不经过灰色对象) 指向某个白色对象

‌‌‌  灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。

‌‌‌  白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。

在这里插入图片描述

‌‌‌  注意

‌‌‌  1. 正常三色标记在对象头的GC标记里,这样标记垃圾收集器有CMS G1,ZGC则将标记记录到对象指针上(对象引用里头)。

‌‌‌  2. 现代追踪式(使用可达性分析算法)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。


4.2. 三色标记算法产生的多标和漏标问题


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

‌‌‌  这边多标和漏标情况,根据CMS垃圾收集器来说,不同垃圾收集器产生情况都差不多,根据CMS来了解就行。


‌‌‌4.2.1. 多标(也叫浮动垃圾)


‌‌‌  首先明白多标是多标记了对象,将垃圾对象标记为了非垃圾对象(怎样的颜色标记跟垃圾收集器的三色算法有关,这边就是黑色来说明)。

‌‌‌  这边根据CMS垃圾收集器来说CMS垃圾收集器下,浮动垃圾在并发标记和重新标记阶段和并发清理阶段会产生,因为存在用户线程一起执行导致对象引用发生各种变化,并发重置阶段不需要注意,因为只是重置标记数据。

‌‌‌  并发标记阶段产生的多标

‌‌‌  在并发标记过程中使用三色算法标记期间,应用线程还在继续跑。如果由于方法运行结束导致部分局部变量(GC Root)被销毁,这个GC Root引用的对象之前被扫描过(标记为非垃圾对象,黑色对象),那么本轮GC不会回收这部分关联引用的对象。这部分本应该回收的垃圾对象没有回收,这就是浮动垃圾

‌‌‌  至于用户线程新增对象(新增了一个对象成员变量的引用),这种本轮就不会扫描到就是漏标问题,在漏标问题解决中,借助三色标记算法提供的方法在重新标记阶段解决,这部分对象最后都会被标记为黑色对象处理(看后面文章怎么实现)。

‌‌‌  重新标记阶段产生的多标

‌‌‌  漏标问题会在重新标记借用三色标记算法提供的方法解决,因为重新标记阶段会STW没有用户线程执行,不会有对象引用变化。但是用三色标记的原始快照解决漏标,可能也存在多标问题(看后面原始快照章节)。

‌‌‌  并发清理阶段

‌‌‌  1. 跟前面并发标记阶段产生的多标一样,清理期间对象引用变化导致原本非垃圾对象变为垃圾。

‌‌‌  2. 用户线程新增对象(新增了对象成员变量的引用),这时候就直接标记为黑色对象(这部分对象期间可能也会变为垃圾,这也算是浮动垃圾)

‌‌‌  总之,浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除,不用考虑特殊解决方案


‌‌‌4.2.2. 漏标


‌‌‌  看下面代码,根据三色标记算法来说。

‌‌‌  经过初始标记GC Roots直接能引用的对象假设是A对象,接下来开始做并发标记,多个线程开始从Gc Roots直接引用的对象们开始同时扫描。一个线程分派从A对象开始扫描,如果B被扫描到。这时A的所有直接引用的对象都被扫描过了,这时候就标记为黑色,被分析完。

‌‌‌  假设扫描到B,B引用了C和D对象。这时扫描了C但是D对象还没来得及扫描到,这时候B就是灰色,还没分析完。

‌‌‌  这时D没有扫描到就是白色,没被分析,C被扫描到因为没有引用对象所以就是黑色。

‌‌‌  对于黑色和灰色对象肯定不能回收,白色对象就会回收掉。‌‌

‌‌‌  漏标:假设D还没被扫描到,此时扫描过程,B对象里头成员变量D设为null,A对象成员变量D又引用D对象。此时当前扫描的线程还没处理到D,因为D成员变量引用为null。而A是黑色,不在扫描处理,此时D对象是有被引用的,但是被当做白色对象处理需要回收掉,这种就是漏标。


‌‌‌  /**
 * 垃圾收集算法细节之三色标记
 * 为了简化例子,代码写法可能不规范,请忽略
 * Created by 诸葛老师
 */
‌‌‌  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 {
‌‌‌  }



4.2.2.1.解决方法

‌‌‌  漏标的问题有两种解决方案: 增量更新(Incremental Update) 或者原始快照(Snapshot At The Beginning,SATB)。这两种解决方式都有借助到CPU的写屏障(代码级的屏障,时机分别是在赋值后和赋值前)

‌‌‌  CMS垃圾收集器在重新标记阶段会使用增量更新解决漏标,重新标记阶段会STW不用担心对象引用变化。


4.2.2.1.1. 增量更新

‌‌‌  并发标记阶段,当黑色对象插入新的引用指向白色对象时(注意触发时机是赋值新的引用后), 就将这个新插入的引用记录下来放到集合(简单理解存有源头对象的引用,新增对象的引用数据)黑色对象一旦新插入了,指向白色对象的引用之后, 总之它就会变成灰色对象(在重新标记阶段就知道需要重新扫描这对象了)。等并发标记扫描结束之后,在重新标记阶段(STW) 再将这些灰色对象, 重新扫描一次,这样就能扫描到白色对象,最终将其标记为黑色(或者说非垃圾对象)**,这样就能在本轮GC存活,在下一次GC做处理。

‌‌‌  CMS使用的模式来解决漏标


4.2.2.1.2. 原始快照

‌‌‌  就是当灰色对象删除指向白色对象的引用关系时(注意触发时机在赋值null操作前,这边删除就是将引用赋值为null), 就将这个要删除老的引用记录放到集合(简单理解存有要删除对象老的引用)。在并发标记扫描结束之后,在重新标记阶段(STW),再扫描集合中记录的引用对象,直接标记为黑色(也许在这阶段白色对象真的都没对象引用它,但是原始快照就是将它标记为黑色,这时候就是浮动垃圾。没关系就让这种对象在本轮GC清理中能存活下来,待下一轮GC的时候重新扫描,清理掉)。

‌‌‌  G1垃圾收集器使用的模式来解决漏标


4.3. 读写屏障


‌‌‌  增量更新(Incremental Update) 或者原始快照(Snapshot At The Beginning,SATB)。这两种解决方式,都是要借助到写屏障实现效果(时机分别是在赋值后和赋值前)。注意这边主要是借助写屏障达到原理中的记录对象引用的功能

‌‌‌  对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

‌‌‌  **1. CMS:写屏障 + 增量更新(这边加意思是,比如增量更新原理中记录引用关系需要借助写屏障)。

‌‌‌  2. G1,Shenandoah:写屏障 + SATB。

‌‌‌  3. ZGC:读屏障。

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


4.3.1. 写屏障


‌‌‌  所谓的写屏障,其实就是指在赋值操作前后加入一些处理(可以参考AOP实现的概念),类似在代码级别插入屏障跟并发编程中使用到硬件级的内存屏障有区别。

‌‌‌  原始快照就是在删除对象成员变量引用时候,将其引用赋值为null。在赋值前记录对象原本的引用。

‌‌‌  增量更新就是在对象成员变量新增引用时候,赋值成功后记录赋值成功后对象的引用。

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


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

‌‌‌  写屏障代码:赋值前后做一些操作,比如把引用放到集合。

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


‌‌‌  比如openjdk1.8的hotspot底层,用到写屏障代码,C++实现是这样:

在这里插入图片描述


‌‌‌4.3.1.1. 原始快照(SATB)中屏障的用法

‌‌‌  原始快照借用写屏障记录删除前的对象间的引用。

‌‌‌  比如,当对象B的成员变量D的引用删除,此时成员变量D的引用赋值为null,可以利用写屏障,在赋值前记录下来:

这段代码结合上面写屏障伪代码来看。


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

‌‌‌

‌‌‌  比如openjdk1.8的hotspot底层,SATB写屏障代码,C++实现是这样:

‌‌‌  看代码可以看到用到队列,先存放旧的引用。这样做因为写操作比较频繁,写屏障需要将旧引用存放到集合中,通过异步方式,先丢到队列,后台有个后台线程去处理这个队列,将队列旧引用存放到集合,不影响赋值操作效率。

在这里插入图片描述


4.3.1.2. 增量更新中写屏障的实现

‌‌‌  增量更新借用写屏障记录新增对象引用后,新增的对象引用。

‌‌‌  比如,当对象A的成员变量D,此时新增引用赋值为new D(),可以利用写屏障,在赋值后记录新的引用:

这段代码结合上面写屏障伪代码来看。

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


4.3.2. 读屏障


‌‌‌  读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取到对象引用时,就会执行这段代码。

‌‌‌  读操作读取到某个对象的成员变量引用时,其底层伪代码大概长这样:


‌‌‌  oop oop_field_load(oop* field) {
    return *field;
‌‌‌  }

‌‌‌  读屏障代码:在读到对象引用返回时候做些操作。


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


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



5. 记忆集与卡表


5.1. 跨代引用

‌‌‌  是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。


5.1. 1. 跨代引用大规模扫描问题


‌‌‌  Minor GC时,进行可达性分析算法扫描查找非垃圾对象时,理想就是扫描新生代就行。因为GC Roots直接引用对象肯定存在老年代的对象,如果新生代对象存在被老年代对象引用(跨代引用一种)。那么就要扫描所有GC Roots引用对象是老年代的对象,才能扫描到被老年代跨代引用的新生代对象,才能完全找出新生代是非垃圾对象的对象,效率就低下。为解决这个问题引入记忆集。


5.2. 记忆集


‌‌‌  记忆集就是为了解决跨代引用大规模扫描问题。在新生代可以引入记忆集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),在新生代开辟一小块空间用来存储一个记忆集,总之能借助记忆集中信息扫描到新生代对象 ,避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题

‌‌‌  垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。


5.3. 卡表和卡页


‌‌‌  hotspot使用一种叫做卡表(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比为Java语言中HashMap与Map的关系

‌‌‌  卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素是一个字节(比如0100 0000)。通过该字节能找到对应卡页的起始地址,该字节元素还有位做为标识位标记对应卡页是不是脏

结构如下

在这里插入图片描述

‌‌‌  卡页

‌‌‌  根据老年代来说,将老年代的内存分成一页页小格子就是卡页(hotSpot使用的卡页划分内存大小是512字节,一般来说,卡页大小都是以2的N次幂的字节数)。根据卡表知道对应卡页的起始地址,这个起始地址开始的512字节大小内存就是卡页大小,这个内存大小肯定包含多个对象,只要有一个对象的字段存在跨代引用,这个卡页就是脏的。其对应的卡表的元素,其中对应标识位就变成1,表示该卡页变脏,否则为0。

‌‌‌  GC时,除了扫描GC Roots关联的对象,还会扫描脏卡页中的对象,这样解决跨代引用大规模扫描。

‌‌‌  卡表的维护

‌‌‌  卡表变脏实现就是通过写屏障实现,很简单比如有个老年代对象有字段赋值引用年轻代对象,赋值操作后,就把这个老年代对象对应卡页变脏,卡表标识位记录下。


6. 面试题


1. 写屏障里头,为啥记录引用时候,不直接放到集合中,需要类似队列数据结构里头?


‌‌‌  1. 因为集合可能存在满情况,使用队列暂存数据。

‌‌‌  2. 写的操作频繁,不想在写屏障操作时候影响写操作执行效率,异步缓冲处理,用后台线程去处理引用放到集合。


‌‌‌2. 对于漏标,为什么G1用原始快照而CMS用增量更新?


‌‌‌  我的理解:原始快照相对增量更新效率会高(当然原始快照可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。

‌‌‌  G1有很多分区,扫描就存在很多跨代引用(存在大量跨代引用大规模扫描),即使每个Eden区都有卡表用增量更新会深度扫描对象,借助Eden区的卡表可能效率也会低。用原始快照简单标记下对象,下次GC再全部扫描会快点。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值