垃圾回收器原理
# 1. G1垃圾回收器原理
G1垃圾回收有两种方式:
1、年轻代回收(Young GC)
2、混合回收(Mixed GC)
# 年轻代回收
年轻代回收只扫描年轻代对象(Eden + Survivor),所以从GC Root到年轻代的对象或者年轻代对象引用了其他年轻代的对象都很容易扫描出来。
这里就存在一个问题,年轻代回收只扫描年轻代对象(Eden + Survivor),如果有老年代中的对象引用了年轻代中的对象,我们又如何知道呢?
比如上图中,E对象被对象引用了,那么显然在垃圾回收时E对象是不应该被回收的。
方案1:从GC Root开始,扫描所有对象,如果年轻代对象在引用链上,就标记为存活。
重新扫描一遍GC Root关联的所有对象,包括老年代的。这个方案显然不可行,需要遍历引用链上所有对象,效率太低。
方案2:维护一个详细的表,记录哪个对象被哪个老年代引用了。在年轻代中被引用的对象,不进行回收。
如上图中,通过引用详情表记录F和E对象分别被A和B对象引用了。问题:如果对象太多这张表会占用很大的内存空间。存在错标的情况
方案2的第一次优化:只记录Region被哪些对象引用了。这种引用详情表称为记忆集 RememberedSet(简称RS或RSet):是一种记录了从非收集区域对象引用收集区域对象的这些关系的数据结构。扫描时将记忆集中的对象也加入到GC Root中,就可以根据引用链判断哪些对象需要回收了。
问题:如果区域中引用对象很多,还是占用很多内存。
方案2的第二次优化:将所有区域中的内存按一定大小划分成很多个块,每个块进行编号。记忆集中只记录对块的引用关系。如果一个块中有多个对象,只需要引用一次,减少了内存开销。
每一个Region都拥有一个自己的卡表,如果产生了跨代引用(老年代引用年轻代),此时这个Region对应的卡表上就会将字节内容进行修改,JDK8源码中0代表被引用了称为脏卡。这样就可以标记出当前Region被老年代中的哪些部分引用了。那么要生成记忆集就比较简单了,只需要遍历整个卡表,找到所有脏卡。
那么怎么样去维护这个卡表呢?或者说怎么知道A对F引用了?
JVM使用写屏障(Write Barrier)技术,在执行引用关系建立的代码时,可以在代码前和代码后插入一段指令,从而维护卡表。
记忆集中不会记录新生代到新生代的引用,同一个Region中的引用也不会记录。
记忆集的生成流程分为以下几个步骤:
1、通过写屏障获得引用变更的信息。
2、将引用关系记录到卡表中,并记录到一个脏卡队列中。
3、JVM中会由Refinement 线程定期从脏卡队列中获取数据,生成记忆集。不直接写入记忆集的原因是避免过多线程并发访问记忆集。
# 执行流程:
更详细的分析下年轻代回收的步骤,整个过程是STW的:
1、Root扫描,将所有的静态变量、局部变量扫描出来。
2、处理脏卡队列中的没有处理完的信息,更新记忆集的数据,此阶段完成后,记忆集中包含了所有老年代对当前Region的引用关系。
3、标记存活对象。记忆集中的对象会加入到GC Root对象集合中,在GC Root引用链上的对象也会被标记为存活对象。
4、根据设定的最大停顿时间,选择本次收集的区域,称之为回收集合Collection Set。
5、复制对象:将标记出来的对象复制到新的区中,将年龄加1,如果年龄到达15则晋升到老年代。老的区域内存直接清空。
6、处理软、弱、虚、终结器引用,以及JNI中的弱引用。
G1年轻代回收核心技术
1、卡表 Card Table
每一个Region都拥有一个自己的卡表,卡表是一个字节数组,如果产生了跨代引用(老年代引用年轻代),G1会将卡表上引用对象所在的位置字节内容进行修改为0, 称为脏卡。卡表的主要作用是生成记忆集。
卡表会占用一定的内存空间,堆大小是1G时,卡表大小为1G = 1024 MB / 512 = 2MB
2、记忆集 RememberedSet(简称RS或RSet)
每一个Region都拥有一个自己的记忆集,如果产生了跨代引用,记忆集中会记录引用对象所在的卡表位置。标记阶段将记忆集中的对象加入GC ROOT集合中一起扫描,就可以将被引用的对象标记为存活。
3、写屏障 Write Barrier
G1使用写屏障技术,在执行引用关系建立的代码执行后插入一段指令,完成卡表的维护工作。
会损失一部分的性能,大约在5%~10%之间。
# 混合回收
多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值(默认45%)时会触发混合回收MixedGC。
混合回收会由年轻代回收之后或者大对象分配之后触发,混合回收会回收 整个年轻代 + 部分老年代。
老年代很多时候会有大量对象,要标记出所有存活对象耗时较长,所以整个标记过程要尽量能做到和用户线程并行执行。
混合回收的步骤:
1、初始标记,STW,采用三色标记法标记从GC Root可直达的对象。
2、并发标记,并发执行,对存活对象进行标记。
3、最终标记,STW,处理SATB相关的对象标记。
4、清理,STW,如果区域中没有任何存活对象就直接清理。
5、转移,将存活对象复制到别的区域。
# 初始标记
初始标记会暂停所有用户线程,只标记从GC Root可直达的对象,所以停顿时间不会太长。采用三色标记法进行标记,三色标记法在原有双色标记(黑也就是1代表存活,白0代表可回收)增加了一种灰色,采用队列的方式保存标记为灰色的对象。
黑色:存活,当前对象在GC Root引用链上,同时他引用的其他对象也都已经标记完成。
灰色:待处理,当前对象在GC Root引用链上,他引用的其他对象还未标记完成。
白色:可回收,不在GC Root引用链上。
初始所有对象都是默认为白色,初始值为0:
三色标记中的黑色和白色是使用位图(bitmap)来实现的,比如8个字节使用1个bit来标识标记的内容,黑色为1,白色为0,灰色不会体现在位图中,会单独放入一个队列中。如果对象超过8个字节,仅仅使用第一个bit位处理。
将GC Root可以直到的对象D标记,D没有其他引用对象,所以直接标记为为黑色:
接下来将B对象标记,由于B关联了A和C,而A和C没有标记完成,所以B是待处理状态,将B送入灰色队列。
# 并发标记
接下来进入并发标记阶段,继续进行未完成的标记任务。此阶段和用户线程并发执行。
从灰色队列中获取尚未完成标记的对象B。标记B关联的A和C对象,由于A和C对象并未引用其他对象,可以直接标记成黑色,而B也完成了所有引用对象的标记,也标记为黑色。
最后从队列获取C对象,标记为黑色,E也标记为黑色。所以剩余对象F就是白色,可回收。
最后从队列获取C对象,标记为黑色,E也标记为黑色。所以剩余对象F就是白色,可回收。
三色标记存在一个比较严重的问题,由于用户线程可能同时在修改对象的引用关系,就会出现错标的情况,比如:
这个案例中正常情况下,B和C都会被标记成黑色。但是在BC标记前,用户线程执行了 B.c = null;将B到C的引用去除了。
同时执行了A.c = c; 添加了A到C的引用。此时会出现严重问题,C是白色可回收一旦回收代码中再去使用对象会造成重大问题。
如果接着处理B:
B在GC引用链上,没有引用任何对象,所以B标记为黑色:
这样C虽然在引用链上,但是被回收了。
G1为了解决这个问题,使用了SATB技术(Snapshot At The Beginning, 初始快照)。SATB技术是这样处理的:
1、标记开始时创建一个快照,记录当前所有对象,标记过程中新生成的对象直接标记为黑色。
2、采用前置写屏障技术,在引用赋值前比如B.c = null之前,将之前引用的对象c放入SATB待处理队列中。SATB队列每个线程都有一个,最终会汇总到一个大的SATB队列中。
最终队列处理完之后,C和F就可以完成标记了。
SATB的缺点是在本轮清理时可能会将不存活的对象标记成存活对象,产生了一些所谓的浮动垃圾,等到下一轮清理时才能回收。比如图中的E对象。
SATB练习题
C和E对象会被加入SATB队列中,最终被标记为存活。
转移的步骤如下:
1、根据最终标记的结果,可以计算出每一个区域的垃圾对象占用内存大小,根据停顿时间,选择转移效率最高(垃圾对象最多)的几个区域。
2、转移时先转移GC Root直接引用的对象,然后再转移其他对象。
先转移A对象:
接下来转移B对象:
3、回收老的区域,如果外部有其他区域对象引用了转移对象,也需要重新设置引用关系。
多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值(默认45%)时会触发混合回收MixedGC。
混合回收会由年轻代回收之后或者大对象分配之后触发,混合回收会回收 整个年轻代 + 部分老年代。
老年代很多时候会有大量对象,要标记出所有存活对象耗时较长,所以整个标记过程要尽量能做到和用户线程并行执行。
混合回收的步骤:
1、初始标记,STW,采用三色标记法标记从GC Root可直达的对象。
2、并发标记,并发执行,对存活对象进行标记。
3、最终标记,STW,处理SATB相关的对象标记。
4、清理,STW,如果区域中没有任何存活对象就直接清理。
5、转移,将存活对象复制到别的区域。
# 2. ZGC原理
ZGC 是一种可扩展的低延迟垃圾回收器。ZGC 在垃圾回收过程中,STW的时间不会超过一毫秒,适合需要低延迟的应用。支持几百兆到16TB 的堆大小,堆大小对STW的时间基本没有影响。
在G1垃圾回收器中,STW时间的主要来源是在转移阶段:
1、初始标记,STW,采用三色标记法标记从GC Root可直达的对象。 STW时间极短
2、并发标记,并发执行,对存活对象进行标记。
3、最终标记,STW,处理SATB相关的对象标记。 STW时间极短
4、清理,STW,如果区域中没有任何存活对象就直接清理。 STW时间极短5、转移,将存活对象复制到别的区域。 STW时间较长
# G1转移时需要停顿的主要原因
在转移时,能不能让用户线程和GC线程同时工作呢?考虑下面的问题:
转移完之后,需要将A对对象的引用更改为新对象的引用。但是在更改前,执行A.c.count = 2,此时更改的是转移前对象中的属性
更改引用之后, A引用了转移之后的对象,此时获取A.c.count发现属性值依然是1。这样就产生了问题,所以G1为了解决问题,在转移过程中需要进行用户线程的停止。ZGC和Shenandoah解决了这个问题,让转移过程也能够并发执行。
在ZGC中,使用了读屏障Load Barrier技术,来实现转移后对象的获取。当获取一个对象引用时,会触发读后的屏障指令,如果对象指向的不是转移后的对象,用户线程会将引用指向转移后的对象。
f变量一开始指向转移前的对象:
通过读后屏障指令,判断如果是转移前的对象,就改写指针内容,指向转移后的对象。
这样对f.count进行赋值操作,操作的就是转移后的对象了:
那么ZGC是如何判断对象是转移前还是转移后的呢?它主要使用了着色指针(Colored Pointers)。
# 着色指针(Colored Pointers)
着色指针将原来的8字节保存地址的指针拆分成了三部分:
1、最低的44位,用于表示对象的地址,所以最多能表示16TB的内存空间。
2、中间4位是颜色位,每一位只能存放0或者1,并且同一时间只有其中一位是1。
终结位:只能通过终结器访问
重映射位(Remap):转移完之后,对象的引用关系已经完成变更。
Marked0和Marked1:标记可达对象
3、16位未使用
访问对象引用时,使用的是对象的地址。在64位虚拟机中,是8个字节可以表示接近无限的内存空间。所以一般内存中对象,高几位都是0没有使用。着色指针就是利用了这多余的几位,存储了状态信息。
正常应用程序使用8个字节去进行对象的访问,现在只使用了44位,不会产生问题吗?
应用程序使用的对象地址,只是虚拟内存,操作系统会将虚拟内存转换成物理内存。而ZGC通过操作系统更改了这层逻辑。所以不管颜色位变成多少,指针指向的都是同一个对象。
在ZGC中,与G1垃圾回收器一样将堆内存划分成很多个区域,这些内存区域被称之为Zpage。
Zpage分成三类大中小,管控粒度比G1更细,这样更容易去控制停顿时间。
小区域:2M,只能保存256KB内的对象。
中区域:32M,保存256KB – 4M的对象。
大区域:只保存一个大于4M的对象。
# 初始标记阶段
标记Gc Roots引用的对象为存活对象数量不多,所以停顿时间非常短。
初始阶段会标记GC Roots直接关联的对象,对引用这些对象的指针上的marked0位标记为1:
# 并发标记阶段
遍历所有对象,标记可以到达的每一个对象是否存活,用户线程使用读屏障,如果发现对象没有完成标记也会帮忙进行标记。
# 并发处理阶段
选择需要转移的Zpage,并创建转移表,用于记录转移前对象和转移后对象地址。
# 转移开始阶段
转移GC Root直接关联的对象,不转移的对象remapped值设置成1,避免重复进行判断。
如下1和2不转移,将remapped置为1:
接下来开始转移:
# 并发转移阶段
将剩余对象转移到新的ZPage中,转移之后将两个对象的地址记入转移映射表。
转移完之后,转移前的Zpage就可以清空了,转移表需要保留下来。
此时,如果用户线程访问4对象引用的5对象,会通过读屏障,将4对5的引用进行重置,修改为对5的引用,同时将remap标记为1代表已经重新映射完成。
并发转移阶段结束之后,这一轮的垃圾回收就结束了,但其实并没有完成所有指针的重映射工作,这个工作会放到下一阶段,与下一阶段的标记阶段一起完成(因为都需要遍历整个对象图)。
# 第二次垃圾回收的初始标记阶段
第二次垃圾回收的初始标记阶段,沿着GC Root标记对象。这一次会使用marked1,因为marked0是上一次垃圾回收了。这样可以很容易区分出是这一次垃圾回收的标记阶段还是上一次垃圾回收的。
如果Marked0为1代表上一轮的重映射还没有完成,先完成重映射从转移表中找到老对象转移后的新对象,再进行标记。如果Remap为1,只需要进行标记。
将转移映射表删除,释放内存空间。
# 并发问题
如果用户线程在帮忙转移时,GC线程也发现这个对象需要复制,那么就会去尝试写入转移映射表,如果发现映射表中已经有相同的老对象,直接放弃。
# 分代ZGC的设计
在JDK21之后,ZGC设计了年轻代和老年代,这样可以让大部分对象在年轻代回收,减少老年代的扫描次数,同样可以提升一定的性能。同时,年轻代和老年代的垃圾回收可以并行执行。
分代之后的着色指针将原来的8字节保存地址的指针拆分成了三部分:
1、46位用来表示对象地址,最多可以表示64TB的地址空间。
2、中间的12位为颜色位。
3、最低4位和最高2位未使用
整个分代之后的读写屏障、着色指针的移位使用都变的异常复杂,仅作了解即可。
# 总结 - ZGC核心技术:
1、着色指针(Colored Pointers)
着色指针将原来的8字节保存地址的指针拆分成了三部分,不仅能保存对象的地址,还可以保存当前对象所属的状态。
不支持32位系统、不支持指针压缩
2、读屏障(Load Barrier)
在获取对象引用判断对象所属状态,如果所属状态和当前GC阶段的颜色状态不一致,由用户线程完成本阶段的工作。
会损失一部分的性能,大约在5%~10%之间。
# 3. ShenandoahGC原理
ShenandoahGC和ZGC不同, ShenandoahGC很多是使用了G1源代码改造而成,所以在很多算法、数据结构的定义上,与G1十分相像,而ZGC是完全重新开发的一套内容。
1、ShenandoahGC的区域定义与G1是一样的。
2、没有着色指针,通过修改对象头的设计来完成并发转移过程的实现。
3、ShenandoahGC有两个版本,1.0版本存在于JDK8和JDK11中,后续的JDK版本中均使用2.0版本。
# 1.0版本
如果转移阶段未完成,此时转移前的对象和转移后的对象都会存活。如果用户去访问数据,需要使用转移后的数据。 ShenandoahGC使用了读前屏障,根据对象的前向指针来获取到转移后的对象并读取。
写入数据时会使用写前屏障,判断Mark Word中的GC状态,如果GC状态为0证明没有处于GC过程中,直接写入,如果不为0则根据GC状态值确认当前处于垃圾回收的哪个阶段,让用户线程执行垃圾回收相关的任务。
1.0版本的缺点:
1、对象内存大大增加,每个对象都需要增加8个字节的前向指针,基本上会占用5% - 10%的空间。
2、读屏障中加入了复杂的指令,影响使用效率。
# 2.0版本
2.0版本优化了前向指针的位置,仅转移阶段将其放入了Mark Word中。
# ShenandoahGC的执行流程
# 并发转移阶段 – 并发问题
如果用户线程在帮忙转移时,ShenandoahGC线程也发现这个对象需要复制,那么就会去尝试写入前向指针,使用了类似CAS的方式来实现,只有一个线程能成功修改,其他线程会放弃转移的操作。