ZGC垃圾回收器
一、简介和性能
G1
的目标是在可控的停顿时间内完成垃圾回收,所以进行了分区设计,在回收时采用部分内存回收(在YGC
时会回收所有新生代分区,在混合回收时会回收所有的新生代分区和部分老生代分区),支持的内存也可以达到几十个GB
或者上百个GB
。为了进行部分回收,G1
实现了RSet
管理对象的引用关系。基于G1
设计上的特点,导致存在以下问题:
- 停顿时间过长,通常
G1
的停顿时间要达到几十到几百毫秒;这个数字其实已经非常小了,但是我们知道垃圾回收发生导致应用程序在这几十或者几百毫秒中不能提供服务,在某些场景中,特别是对用户体验有较高要求的情况下不能满足实际需求。 - 内存利用率不高,通常引用关系的处理需要额外消耗内存,一般占整个内存的
1%~20%
左右。 - 支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于
100GB
的系统中,会因内存过大而导致停顿时间增长。
ZGC
作为新一代的垃圾回收器,在设计之初就定义了三大目标:
- 支持
TB
级内存 - 停顿时间控制在
10ms
之内 - 对程序吞吐量影响小于
15%
。
实际上目前ZGC
已经满足设计之初定义的目标,最大支持4TB
堆空间,依据实际测试的情况来看,停顿时间通常都在10ms
以下,并且垃圾回收所引起的暂停时间并不会随着内存的增大而延长。
ZGC
如何设计以达成目标?简单地说,就是ZGC
把一切能并发处理的工作都并发执行。ZGC
是在G1
的基础上发展起来的,我们知道G1
中实现了并发标记,所以标记已经不会再影响停顿时间了。
G1
中的停顿时间主要来自垃圾回收(YGC
和混合回收)阶段中的复制算法,在复制算法中,需要把对象转移到新的空间中,并且更新其他对象到这个对象的引用。实际中对象的转移涉及内存的分配和对象成员变量的复制,而对象成员变量的复制是非常耗时的。
在G1
中对象的转移都是在STW
中并行执行的,而ZGC
就是把对象的转移也并发执行,从而满足停顿时间在10ms
以下。
我们看到G1
只有在MARKING
的时候,是并发的。而ZGC
在对象的复制和压缩,复制集的选择,等很多方面都改成了并发(和应用线程同时进行)。这就是它STW
时间如此之短的秘诀。
在最新的JDK
版本中,后面2
项没有打钩的,ZGC
也把他们优化进并发步骤中,根据他们最新的测试,STW
最大停顿时间可以缩小到1MS
。
上图左边也展示了一些ZGC
里的经典特性。比如说用到了读屏障,颜色指针。是个单代的垃圾收集器(不区分年轻代和老年代,在JVMLS 2018
上,ZGC
的领队Per
大大明确表示目前的ZGC
没有分代只是为了实现简单,目前正在考虑给ZGC
添加分代支持或者是添加一个Thread-Local GC
来起到类似Young GC
的作用,还在探索中),是个部分压缩的算法(和G1
类似,有解决内存碎片的标记整理步骤)。立即的内存重用,和NUMA
友好的特性。
我们会在下文中或多或少的展开介绍这些特点。
在JDK11
中的ZGC
只支持Linux/x86_64
现在ZGC
的目标是1MS
,原来ZGC
的停顿时间和堆SIZE
无关,但是和ROOT-SET
的SIZE
有关。他们希望实现并发的线程栈扫描,这样可以使得ZGC
的停顿时间和ROOT-SET SIZE
也无关。可以把性能提升到1MS
的效果。大概和下图这么牛逼
当然我们再来看看ZGC
和G1
比有多牛逼。
在缩短延迟的同时,吞吐率也不受影响。
二、流程介绍
并发垃圾回收算法实际上是以复制算法为基础,增加了并发处理。我们先回顾一下复制算法,它可以概括为3
个阶段,分别为标记(mark
)、转移(relocate
)和重定位(remap
)。这3
个阶段分别完成的功能是:
- 标记:从根集合出发,标记活跃对象;此时内存中存在活跃对象和已死亡对象。
- 转移:把活跃对象转移(复制)到新的内存上,原来的内存空间可以回收。
- 重定位:因为对象的内存地址发生了变化,所以所有指向对象老地址的指针都要调整到对象新的地址上。
从细节角度可以分为如下步骤
- 初始标记,从根集合出发,找出根集合直接引用的活跃对象,并入栈;该步需要
STW
。 - 并发标记,根据初始标记找到的根对象,使用深度优先遍历对象的成员变量进行标记;并发标记需要解决标记过程中引用关系变化导致的漏标记问题
- 再标记和非强根并行标记,在并发标记结束后尝试终结标记动作,理论上并发标记结束后所有待标记的对象会全部完成,但是因为
GC
工作线程和应用程序线程是并发运行,所以可能存在GC
工作线程执行结束标记时,应用程序线程又有新的引用关系变化导致漏标记,所以这一步先判断是否真的结束了对象的标记,如果没有结束就还会启动并行标记,所以这一步需要STW
。另外,在该步中,还会对非强根(软应用,虚引用等)进行并行标记。 - 并发处理非强引用和非强根并发标记
- 重置转移集合中的页面,实际上第一次垃圾回收时无须处理这一步。
- 回收无效的页面,实际上在内存充足的情况下不会触发这一步。
- 并发选择对象的转移集合,转移集合中就是待回收的页面。
- 并发初始化转移集合中的每个页面,在后续重定位(也称为
Remap
)时需要的对象转移表(Forward Table
)就是在这一步初始化的。 - 转移根对象引用的对象,该步需要
STW
。 - 并发转移,把对象移动到新的页面中,这样对象所在的老的页面中所有活跃对象都被转移了,页面就可以被回收重用。
为了画图方便,把步骤5)~步骤8)放在一个并发步骤中,实际中这是4步,并且这4步是串行执行,每一步都是并发执行的。
上述步骤,你可能会看晕。我们来看简单的版本。
上述3
个蓝线代表需要STW
,灰线代表可以并发运作。
- 根集合标记(
STW
) - 并发标记
- 并发标记的同步点(
STW
,同G1
)还会处理一些非强根 - 并发-转移前的准备(上面的
4-8
步,最核心的是引用处理,非强根清理,转移集(relocation set
)的选择) - 转移,在转移集中的根对象(
STW
) - 并发转移其他在转移集中的对象
并发算法中明确地提到重定位阶段,但上面的步骤中并没有体现。在ZGC
中并没有明确这一步,重定位实际上被合并到标记阶段中,即在标记的时候如果发现对象引用到老的地址,这时会先完成重定位更新对象的引用关系,然后再标记对象。所以实质上ZGC
的并发垃圾回收中还是包含了重定位这一阶段,只不过重定位和标记阶段复用了。
思考题:有3
个步骤需要STW
的原因是什么?他们和应用线程并发有什么问题?
第二个STW
,如果不STW
,那么应用程序一直在改引用,会找不到一个时间点,把待标记对象在QUEUE
中给全部排干净。那么会一直处于并发标记阶段。
第三个STW
,在初始转移中所做的工作主要针对根集合引用的对象,如果这些对象所在的页面在转移集中,则转移这些对象;如果对象所在的页面不在转移集中,则直接调整对象的页面映射视图。第10
步中的并发标记是对所有在转移集的页面中所有活跃对象做转移,在并发转移之后的下一次垃圾回收的标记阶段完成重定位。那么我们能不能把第9
步的工作分散在第10
步和下一次垃圾回收的标记阶段进行?
要回答这个问题,需要理解读屏障,和对ZGC
的全貌有一个认识。
关于第一个STW
,也要对ZGC
的工作原理有一定的理解。我会在讲完(颜色指针怎么工作后解答这个问题)
三、堆的内存布局
ZGC
支持的最大堆内存为4T
。但是我们确需要保留更大的虚拟地址空间。如下图
因为ZGC
里需要用到3
个地址视图。分别是Marked0
、Marked1
和Remapped
,而且有趣的是这3
个视图会映射到操作系统的同一物理地址。这就是ZGC
中Color Pointers
的概念,通过Color Pointers
来区别不同的虚拟视图。
在ZGC
中常见的几个虚拟空间有[0 ~ 4TB)、[4TB ~ 8TB)、[8TB ~ 12TB)和[16TB ~ 20TB)。其中[0 ~ 4TB)对应的是Java
的堆空间;[4TB ~ 8TB)、[8TB ~ 12TB)和[16TB ~ 20TB)分别对应Marked0
、Marked1
和Remapped
这3
个视图。这几个区域有什么关系?我们先看图
mutator
就是应用线程的意思
0~4TB
的虚拟地址是ZGC
提供给应用程序使用的虚拟空间,它并不会映射到真正的物理地址。
- 操作系统管理的虚拟内存为
Marked0
、Marked1
和Remapped
这3
个空间,且它们对应同一物理空间。 - 在
ZGC
中这3
个空间在同一时间点有且仅有一个空间有效。为什么这么设计?这是利用虚拟空间换时间;这3
个空间的切换是由垃圾回收的不同阶段触发的。(下文介绍颜色指针会介绍)
应用程序可见并使用的虚拟地址为0~4TB
,经ZGC
转化,真正使用的虚拟地址为[4TB ~ 8TB)、[8TB ~ 12TB)和[16TB ~ 20TB),操作系统管理的虚拟地址也是[4TB ~ 8TB)、[8TB ~ 12TB)和[16TB ~ 20TB)。应用程序可见的虚拟地址[0 ~ 4TB)和物理内存直接的关联由ZGC
来管理。
为了细粒度地控制内存的分配,和G1
一样,ZGC
将内存划分成小的分区,在ZGC
中称为页面(page
)。ZGC
支持3
种页面,分别为小页面、中页面和大页面。其中小页面指的是2MB
的页面空间,中页面指32MB
的页面空间,大页面指受操作系统控制的大页。我们先回顾一下操作系统所支持的大页。
标准大页(huge page
)是Linux Kernel 2.6
引入的,目的是通过使用大页内存来取代传统的4KB
内存页面,以适应越来越大的系统内存,让操作系统可以支持现代硬件架构的大页面容量功能。它有两种大小:2MB
和1GB
。2MB
页块大小适合用于吉字节级的内存,1GB
页块大小适合用于太字节级别的内存;2MB
是默认的大页尺寸。
一个ZGC
的页面可能由几个不连续的操作系统页面组成。
ZGC
对于不同页面回收的策略也不同。简单地说,小页面优先回收;中页面和大页面则尽量不回收。
四、对NUMA支持
在过去,对于X86
架构的计算机,内存控制器还没有整合进CPU
,所有对内存的访问都需要通过北桥芯片来完成。X86
系统中的所有内存都可以通过CPU
进行同等访问。任何CPU
访问任何内存的速度是一致的,不必考虑不同内存地址之间的差异,这称为“统一内存访问”(Uniform Memory Access,UMA
)。UMA
系统的架构示意图如图所示。
在UMA
中,各处理器与内存单元通过互联总线进行连接,各个CPU
之间没有主从关系。之后的X86
平台经历了一场从“拼频率”到“拼核心数”的转变,越来越多的核心被尽可能地塞进了同一块芯片上,各个核心对于内存带宽的争抢访问成为瓶颈,所以人们希望能够把CPU
和内存集成在一个单元上(称为Socket
),这就是非统一内存访问(Non-Uniform Memory Access,NUMA
)。很明显,在NUMA
下,CPU
访问本地存储器的速度比访问非本地存储器快一些。下图所示是初期处理器架构示意图。
ZGC
是支持NUMA
的,在进行小页面分配时会优先从本地内存分配,当不能分配时才会从远端的内存分配。对于中页面和大页面的分配,ZGC
并没有要求从本地内存分配,而是直接交给操作系统,由操作系统找到一块能满足ZGC
页面的空间。
ZGC
这样设计的目的在于,对于小页面,存放的都是小对象,从本地内存分配速度很快,且不会造成内存使用的不平衡,而中页面和大页面因为需要的空间大,如果也优先从本地内存分配,极易造成内存使用不均衡,反而影响性能。
五、颜色指针在ZGC中的运用
颜色指针可以说是ZGC
的核心概念。因为他在指针中借了几个位出来做事情,所以它必须要求在64
位的机器上才可以工作。并且因为要求64
位的指针,也就不能支持压缩指针。(关于JVM
的压缩指针,可以自行百度其他文章)
ZGC
支持64
位系统,我们看一下ZGC
是如何使用64
位地址的。ZGC
中低42
位(第0 ~ 41
位)用于描述真正的虚拟地址(这就是上面提到的应用程序可以使用的堆空间),接着的4
位(第42 ~ 45
位)用于描述元数据,其实就是大家所说的Color Pointers
,还有1
位(第46
位)目前暂时没有使用,最高17
位(第47 ~ 63
位)固定为0
6 4 4 4 4 4 0
3 7 6 5 2 1 0
+-------------------+-+----+-----------------------------------------------+
|00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
+-------------------+-+----+-----------------------------------------------+
| | | |
| | | * 41-0 Object Offset (42-bits, 4TB address space)
| | |
| | * 45-42 Metadata Bits (4-bits) 0001 = Marked0
| | 0010 = Marked1
| | 0100 = Remapped
| | 1000 = Finalizable
| |
| * 46-46 Unused (1-bit, always zero)
|
* 63-47 Fixed (17-bits, always zero)
由于42
位地址最大的寻址空间就是4TB
,这就是ZGC
一直宣称自己最大支持4TB
内存的原因。这里还有视图的概念,Marked0
、Marked1
和Remapped
就是3
个视图,分别将第42
、43
、44
位设置为1
,就表示采用对应的视图。在ZGC
中,这4
位标记位的目的并不是用于地址寻址的,而是为了区分Marked0
、Marked1
和Remapped
这3
个视图。当然对于操作系统来说,这4
位标记位代表了不同的虚拟地址空间,而这些不同标记位指示的不同虚拟空间通过mmap
映射在同一物理地址;颜色指针能够快速实现并发标记、转移和重定位。
为什么最高位
16
个不能用?由于
X86_64
处理器硬件的限制,目前X86_64
处理器地址线只有48
条,也就是说64
位系统支持的地址空间为256TB
。为什么处理器的指令集是64
位的,但是硬件仅支持48
位的地址?最主要的原因是成本问题,即便到目前为止由48
位地址访问的256TB
的内存空间也是非常巨大的,也没有多少系统有这么大的内存,所以在设计CPU
时仅仅支持48
位地址,可以少用很多硬件
5.1 ZGC基于颜色指针的并发处理算法
ZGC
初始化之后,整个内存空间的地址视图被设置为Remapped
,当进入标记阶段时的视图转变为Marked0
(也称为M0
)或者Marked1
(也称为M1
),从标记阶段结束进入转移阶段时的视图再次设置为Remapped
。ZGC
通过视图的切换加上SATB
算法实现并发处理。具体算法如下。
1.初始化阶段
在ZGC
初始化之后,此时地址视图为Remapped
,程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动
2.标记阶段
第一次进入标记阶段时视图为M0
,在标记阶段,应用程序和标记线程并发执行,那么对象的访问可能来自标记线程和应用程序线程。
- 标记线程:它从根集合开始标记对象,在标记前先判断对象的地址视图,如果发现对象的地址视图是
M0
,说明对象是在进入标记阶段之后新分配的对象或者对象已经完成了标记(对象活跃),无须处理。如果发现对象的地址视图是Remapped
,说明对象是前一阶段分配的,而且通过根集合可达,所以把对象的地址视图从Remapped
调整为M0
。(M0表示活跃) - 应用程序线程如果创建新的对象,则对象的地址视图为
M0
。
如果应用程序线程访问对象并且对象的地址视图是Remapped
,说明对象是前一阶段分配的,按照SATB
的算法,只要把该对象的视图调整为M0
就能防止对象漏标。只标记应用线程访问到的对象还不够,实际上还需要把对象的成员变量所引用的对象都进行递归标记。如果应用线程访问对象地址视图是M0
,说明对象是在进入标记阶段之后新分配的对象或者对象已经完成了标记,无须额外处理,直接访问。
所以,在标记阶段结束之后,对象的地址视图要么是M0
(活跃),要么是Remapped
(垃圾)。这里的虚拟地址虽然不一样,但是指向的是物理内存的同一个区域
所有标记为M0
的对象放入活跃信息表
3.并发转移阶段
标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped
。
转移阶段会把活跃对象转移到新的内存中,并回收对象转移前的内存空间。在转移阶段,应用程序和标记线程并发执行,那么对象的访问可能来自转移线程和应用程序线程。
- 转移线程:转移线程仅仅根据活跃对象进行转移。当转移线程访问对象时:
如果对象在对象活跃信息表中并且视图为M0
,则转移对象,并且视图从M0
调整为Remapped
。
如果对象在对象活跃信息表中并且视图Remapped
,说明对象已经被转移,无须处理。 - 应用程序线程如果创建新的对象,则对象的地址视图为
Remapped
。
如果应用线程访问对象且不在活跃信息表中,则说明是新创建的或者对象无须转移,无须处理。
如果应用线程访问对象且在活跃信息表中且视图为Remapped
,说明对象已经被转移,无须处理。
如果应用程序线程访问在对象活跃信息表中,且视图为M0
,说明对象是标记阶段标记的活跃对象,所以需要转移对象
在对象转移以后,对象的地址视图从M0
调整为Remapped
;
注意,只把应用线程读到的对象进行转移还不够,实际上还需要把对象的成员变量所引用的对象都进行转移,ZGC
对这一实现做了优化,由转移线程完成对象成员变量的转移。
至此,ZGC
的一个垃圾回收周期中,并发标记和并发转移就结束了。
我们提到在标记阶段存在两个地址视图M0
和M1
,上面的算法过程显示只用到了一个地址视图,为什么设计成两个?简单地说是为了区别前一次标记和当前标记。
第一次垃圾回收时地址视图为M0
,假设标记了两个对象ObjA
和ObjB
,说明ObjA
和ObjB
都是活跃的,它们的地址视图都是M0
。在转移阶段,ZGC
是按照页面进行部分内存垃圾回收的,也就是说当对象所在的页面需要回收时,页面里面的对象需要被转移,如果页面不需要转移,页面里面的对象也就不需要转移。
假设ObjA
所在的页面被回收,ObjB
所在的页面在这一次垃圾回收中不会被回收。ObjA
被转移后,它的地址视图从M0
调整为Remapped
,ObjB
不会被转移,ObjB
的地址视图仍然为M0
。
那么下一次垃圾回收标记阶段开始的时候,存在两种地址视图的对象
- 地址视图为
Remapped
的对象,说明该对象在并发转移阶段被转移或者被访问过; - 地址视图为
M0
的对象,说明该对象在前一次垃圾回收的标记阶段已经被标记。
如果本次垃圾回收标记阶段仍然使用M0
这个地址视图,那么就不能区分出对象是活跃的,还是上一次垃圾回收标记过的。
所以新标记阶段使用了另外一个地址视图M1
,则标记结束后所有活跃对象的地址视图都为M1
。
此时这3
个地址视图代表的含义是:
M1
:本次垃圾回收中识别的活跃对象。M0
:前一次垃圾回收的标记阶段被标记过的活跃对象,对象在转移阶段未被转移,但是在本次垃圾回收中被识别为不活跃对象。Remapped
:前一次垃圾回收的转移阶段发生转移的对象或者是被应用程序线程访问的对象,但是在本次垃圾回收中被识别为不活跃对象。
5.2 上述过程算法演示
六、读屏障在ZGC中的运用
对应用程序线程进行标记发生在读对象时,为了触发标记动作可以设计一个读屏障,在字节码层面或者编译代码层面给读操作增加一个额外的处理即可。
读屏障由读命令触发,JVM
有3
种运行状态:解释执行、C1
优化执行和C2
优化执行。不同的运行状态,读屏障的触发代码略有不同,但它们使用的读屏障是完全一样的。
我们从最简单的解释执行看一下读屏障的实现。读屏障在解释执行时通过load
相关的字节码指令加载数据,我们直接从堆空间中加载对象的地方了解一下读屏障,其代码如下:
template <DecoratorSet decorators, typename BarrierSetT>
template <typename T>
inline oop ZBarrierSet::AccessBarrier<decorators, BarrierSetT>::oop_load_in_heap(T* addr) {
verify_decorators_absent<ON_UNKNOWN_OOP_REF>();
const oop o = Raw::oop_load_in_heap(addr);
return load_barrier_on_oop_field_preloaded(addr, o);
}
这里调用的load_barrier_on_oop_field_preloaded
就是读屏障,在对象加载完成后做额外的处理。
读屏障增加了额外的代码,所以会引起性能下降。据
Per Linda
的介绍,SPECjbb
测试表明使用读屏障之后,性能大概降低4%
左右。
上面提及了标记的一些基本概念,STW
的时候是并行的(多个标记线程一起标记ROOT
直接引用的对象),之后是并发的。读屏障可以让应用线程再使用对象的时候,知道它是不是一个GOOD COLOR
的对象,如果不是。就先要帮他恢复成GOOD COLOR
。
如果在标记的时刻,地址视图为M0
,发现他不是M0
,那么应用线程会帮助把它(如果需要转移还会帮助转移)标记为M0
,因为我可以访问到它所以它是活的。
上述的做法通过任意一次读操作,都把颜色置为灰色(活跃),从而打破并发标记中漏标的充要条件(充要条件参考我的G1详解)
回答思考题3
为什么初始标记需要STW
?
假如我们不STW
,那么应用程序和标记线程并发。CPU
可以在任意位置切换。假设现在是初始状态,地址视图为REMAPPED
。应用线程从栈中的引用读到一个堆中的对象OBJ_A
。并且要把它的引用给断开。
local1.next = obj_a
按照STAB
的设计,如果此时已经开始并发标记了,那么OBJ_A
会标记为M0
。但是此时还没开始。所以应用程序把OBj_A
读出来。准备给LOCAL1.NEXT
赋值前,切换到了标记线程。
标记线程启动,把地址视图改为M0
。然后从LOCAL1
这个跟开始扫描。发现LOCAL1.NEXT=NULL
。那么把LOCAL1
标记为M0
就结束了。
切回到应用线程,此时继续完成赋值任务。就造成了一个黑色对象指向了一个白色对象的漏标问题。白色对象会被GC
掉之后,程序会出错。
为了解决这个问题,程序会利用STW
的安全点,来防止这种应用线程做到一半的操作被切走的情况。
七、strip(条带)在ZGC中的运用
ZGC
中引入了标记条带(mark strip
)。为了让线程之间标记的时候可以互不干扰,减少竞争锁的开销。
标记栈在ZGC
中的底层实现使用的是数组,因为标记栈只有一个,所有的并发标记线程会访问这一个标记栈,所以自然会想到将这个标记栈进行划分,划分后形成多个标记条带,然后让多个并发标记线程并行地访问其中的标记条带,互不干扰。例如,Thread0
标记Strip0
中的对象,而Strip0
可能包含多个内存区域块。示意图如下图所示。
划分成多个标记条带和为并发工作线程设置相应的标记条带需要提前完成,这样才能在标记时把待标记对象直接放入相应的标记条带中,这是第一步开始标记做的工作。
ZGC
是根据对象的地址计算对象属于哪个标记条带,把地址通过哈希函数计算得到的值作为标记条带号。
这里其实有一个问题——在对象放入标记条带中存在并发问题。设想这样一种情况,有两个线程T1
和T2
,有两个标记条带Strip1
和Strip2
,里面分别存放了Obj1
和Obj2
。T1
标记Strip1
里面的对象,T2
标记Strip2
里面的对象。当T1
标记Obj1
的时候,需要把Obj1
所有的成员变量进行标记,假设发现Obj1
的成员变量按照哈希函数计算后需要放入Strip2
中,那么T1
会访问Strip2
,把该对象的成员变量指向的待标记对象入栈。同理,线程T2
也可能访问Strip1
。
这和我们前面提到的设计标记条带的目的完全不同。设计标记条带的目的是希望线程T1
只访问Strip1
,线程T2
只访问Strip2
,从而完全解决并发性问题。
为了尽可能地让标记线程之间进行并发标记,ZGC
对每个标记线程使ZMarkThreadLocalStacks
保存需要遍历的对象,当线程的本地标记栈满时,再把标记栈转入ZMarkStripSet
中。
ZMarkStripSet
中标记条带是链表的形式,所以放入的时候相当简单,直接插入新的节点到链表中,而无须分配额外的空间。
对于并发工作线程来说,所有的标记条带都没有对象,说明并发工作线程可以结束工作。因为并发工作线程是多线程执行,所以判断的条件是所有工作线程访问的条带都没有对象。对于多线程来说,可能存在有些线程待标记对象少,执行得快,而有些线程待标记对象多,执行得慢的情况,所以并发执行中需要有一种机制来进行负载均衡,让所有并发工作线程尽可能同时结束。并发标记工作线程的负载均衡是通过窃取其他线程的任务完成的,即当本线程没有可以执行的任务时,并不会立即停止线程,而是先从其他的线程窃取任务,然后执行任务。
因为应用程序线程也可能执行标记,而且应用程序线程标记后,待标记对象存放在应用程序线程的本地标记条带中,所以当并发工作线程结束标记任务后,应该将应用程序线程中的待标记对象转移到并发工作线程的标记条带中,让并发标记线程继续高速工作。因此在并发标记结束过程中设计了主动刷新机制,并发标记0
号线程把应用程序线程中的待标记对象刷新到并发线程的标记条带中。继续工作。这就要求应用程序需要能够进入到自己的安全点再去响应主动刷新,对于单个线程走进安全点暂停做别的事情,需要支持HandShake
机制。
JDK 10
中引入JEP312 Thread-Local HandShake
,该项目实现单个线程的暂停,而不是暂停所有线程。HandShake
机制也是通过VMThread
机制完成的,只不过HandShake
中指定了一个暂停的目标线程。
并发转移
因为是并发转移,所以必然会涉及应用程序线程在转移时访问待转移的对象。此时应用程序线程会先完成转移的任务,然后再访问对象,这就涉及我们之前介绍的读屏障。读屏障的流程图中,会根据页面的状态判断进行转移还是标记或者重定位操作。
思考题2的解答
为什么需要初始转移呢?反正有读屏障不能直接并发转移吗?
应用程序线程正在访问对象,在第10
步并发转移中,应用程序线程的访问是需要读屏障的,此时在读屏障中会把对象转移到新的地址。
听起来似乎可行,但是这里有一个问题,我们提到如果应用程序线程正在访问对象,通过读屏障完成转移。这个读屏障只能在第10
步中发生,所以它针对的都是在第10
步中从根集合中新产生的引用对象,这些新的对象可以通过读屏障得到正确的处理(即新产生的对象被正确地转移),但是可能存在这样一种情况,在进入第10
步之前已经通过根集合访问了对象,这时进入第10
步后,第10
步中的读屏障对于这些已经访问的对象就不起作用了,单从转移角度来说,对象仍然可以被并发转移线程正确地转移。但是从访问角度就会出现问题,此时如果有新的应用程序线程也访问这个对象,如果对象已经被转移,那么这个新应用程序线程通过读屏障访问到新对象,如果对象还未转移,那么这个新应用程序线程则会通过读屏障先转移对象再访问,结论就是两个应用程序线程一个访问老对象,一个访问新对象,如果两个应用程序线程都对对象进行修改,就会发生数据不一致,导致错误。这就是初始转移STW
解决的问题。
八、ZGC全流程动画演示
在JVM
启动后和垃圾回收发生之前,相关的地址视图会被设置为Remapped
。假定应用程序运行一段时间后,整个内存的对象关系如图
图中对象1 ~ 5
位于小页面中,对象6 ~ 11
位于中页面中。小页面位于虚拟地址的头部,中页面和大页面位于虚拟地址的尾部,本例中假设不存在大页面对象。小页面占用的空间为2MB
,中页面占用的空间为32MB
进入初始标记后,地址视图切换为M0
,然后从根集合出发,开始遍历直接引用的对象。
为了更好地突出图中的变化,统一定义虚线表示正在变化的引用关系,标记过程中的活跃对象使用深色背景。初始标记结束后,整个内存的对象关系如图
对象1
、2
和4
都被标记为活跃的。这里活跃的意思是对象1
、2
和4
的地址视图都变成M0
。
同时在初始标记结束后,在标记条带中存在指向对象1
、2
和4
的指针,标记条带将被用于并发标记。假设并发工作线程为两个,对象1
、2
和4
将被放在不同的标记条带中,分别由线程1
和线程2
并发地标记。
并发标记时,从标记条带获取对象,开始标记。
注意,在图中初始标记中仅仅把对象1
、2
和4
的地址从Remapped
变成了M0
,但是并没有记录它们所在页面的活跃对象的信息。
并发标记从对象1
、2
和4
遍历对象的成员变量,同时统计对象1
、2
和4
的信息,这些统计信息放在对象所属的页面中。
这里把这些统计信息称为标记信息,主要包括页面中存活对象的个数,对象经过内存对齐之后占用的内存大小和对象的标记位图信息。
这里没有演示应用程序并发执行的情况,如果应用程序新分配对象,一定是从一个新的页面中分配,因为对象的缓冲页面在初始标记中被清空。
如果是新页面,则不会在本轮垃圾回收中回收,所以在图中没有体现。另外,如果应用程序线程访问待标记的对象,则通过读屏障完成标记,其处理的方法和并发标记中的方法完全一样,图中也没有体现。
在并发标记结束时,所有页面中的活跃对象都被标记,同时标记条带一定为空。进入再标记阶段
再标记主要是为了处理因应用程序线程标记对象,导致仍然有待标记的对象。实际上在并发标记结束前会尝试多次主动刷新(和被动刷新,文章中没有介绍)以避免这种状况,但是这种状况不能完全避免,如果需要完全避免,必须在STW
中进行。另外,再标记还会处理部分非强根的标记。为了简化,本例中假定所有的对象在并发标记中都标记完成,也没有非强根引用。
非强引用处理之后,将重置转移集。主要是针对前一次垃圾回收过程中产生的对象地址信息表重置。在本例中因为是第一次垃圾回收所以不会涉及重置。
重置转移集之后,将回收无效的页面。如果在页面分配时内存不足,将回收预分配或者缓存页面中的页面对应的物理地址。本例中假定内存充足,不会执行本步。
在并发标记之后,一共有4
个页面被标记。在这一步中,会根据这4
个页面的统计信息选择——哪些页面可以回收。ZGC
只会选择页面中垃圾空间超过页面空间的25%
的页面,然后把所有选择到的页面根据页面中垃圾空间大小排序,根据排序结果计算是否存在一些页面在转移后导致新的页面无剩余空间,如果存在,则把这些页面也丢弃,不进行转移。
在本例中,假定有一个小页面和中页面将被转移,所以它们将进入转移集
选择转移集结束后,将对转移集中的页面初始化,初始化最终的动作是初始化对象地址转移信息表。转移信息表将存储转移完成后对象转移前后的地址。初始化转移集结束后,整个内存的对象关系如图
接着将进入初始转移阶段。进入转移阶段后,地址视图再次从M0
切换到Remapped
。初始转移从根集合出发,遍历对象,对象进行转移或者调整对象的视图。初始转移结束后,整个内存的对象关系如图
可以看到对象4
所在的页面在转移集中,所以它会被转移到新的页面中。对象4
转移之后,会在所在的页面中记录对象转移信息。对象1
和对象2
不在转移集中,所以它们不会被转移,但是它们的地址视图会从M0
调整为Remapped
。
初始转移只会针对根集合进行,结束后将进入并发转移,并发转移是根据转移集的页面进行遍历,即只会遍历转移集选择的页面。在遍历时,两个并发工作线程会根据标记信息逐个转移对象。转移过程涉及内存的复制,比较耗时,所以在转移时会把一个页面分成64
个段并发的转移。因为对象4
已经完成转移,所以并发转移会继续转移剩下的对象5
和对象8
,转移后分别称为对象5’
和对象8’
。转移后也会更新对象转移信息表。对象5
属于小页面对象,转移后也会在小页面中,对象8
属于中页面对象,所以转移中会新分配一个中页面来存储对象8
。
需要注意的是,虽然对象4’
和对象5’
都在一个新的页面中,但是对象4’
在并发转移完成后,指向的还是对象5
的地址而不会调整到对象5’
。
并发转移之后,转移集中的页面会被立即加入页面缓存供新的页面分配使用。
假设转移之后,应用程序线程在执行过程中从对象4’
访问了对象5’
,从对象8’
访问了对象9
,此时会利用读屏障对对象进行重定位,此时整个内存的对象关系如图
对象4’
将根据页面中对象地址转移信息表得到对象5
的地址为对象5’
,对象5’
的地址视图是Remapped
,所以直接调整引用关系。对象8’
访问对象9
,对象9
并未被转移,它的地址视图仍然为M0
,所以此时会先调整对象9
的地址视图从M0
到Remapped
,然后调整引用关系(指向另一个视图为remapped
的9
)。
经过一段时间的运行,因为某种原因再次触发垃圾回收。垃圾回收触发,进入初始标记后,地址视图切换为M1
,然后从根集合出发,开始遍历直接引用的对象。初始标记结束后,整个内存的对象关系如图
在新一轮的并发标记中,从标记条带开始标记。在标记时会对页面的标记信息进行复位。这里还要注意,在标记前,对象10
、对象11
和其他对象稍有区别,其他对象的地址视图为Remapped
,对象10
和对象11
的地址视图为M0
,说明它们在上一轮垃圾回收的标记阶段被识别为活跃对象,但是它们所在的页面没有在转移阶段被转移或者被访问。在标记结束后,所有活跃对象的地址视图都会调整到M1
接下来的步骤和我们介绍过的垃圾回收步骤完全相同,也不再介绍。