简单聊聊G1垃圾回收算法整个流程 --- 理论篇 -- 下
- 软实时性
- 预测转移时间
- 预测可信度
- GC 暂停处理的调度
- 并发标记中的暂停处理
- 分代 G1 GC 模式
- 不同点
- 新生代区域
- 分代对象转移
- 具体转移流程
- 分代选择回收集合
- 设置最大新生代区域数
- GC的切换
- GC执行的时机
- 总结
上一篇 文章我们简单看了一下G1整个垃圾回收流程,但是关于G1如何计算区域回收价值和G1在分代模式下的工作流程这块,由于篇幅限制没有进行说明,本文主要针对这两块内容进行补齐。
软实时性
在 G1GC 中,用户可以设置如下 3 个值:
- 可用内存上限
- GC 暂停时间上限
- GC 单位时间
设置可用内存上限是为了避免内存被过度占用。就算是为了实现软实时性,也不能让 GC 完全占用内存。
设置GC暂停时间上限 指定的是执行 GC 所导致的用户线程的最大暂停时间。这个最大暂停时间并不包含 G1 GC 的并发处理时间。在多处理器环境下,G1 GC 的并发处理时间可以理解成平均分配给用户线程的负载。
CMS垃圾回收算法目标就是缩短用户线程的最大暂停时间,所以其适合使用在注重响应时间的Web服务器后端程序中。
有一个权宜之计可以避免 GC 暂停时间超过指定上限,那就是频繁地执行暂停时间较短的 GC。虽然这样做确实可以缩短 GC 暂停时间,但是 用户线程 的执行也会频繁地被 GC 打断,从而导致 用户线程 几乎无法正常执行。
要想避免这个问题,需要指定 GC 单位时间。指定该项后,G1GC 将在每个单位时间内遵守 GC 的暂停时间上限。如果 GC 暂停时间上限是 1 秒,而 GC 单位时间是 3 秒,就意味着在任意 3 秒的时间段内,GC 的暂停时间不可以超过 1 秒。
a、b、c、d 分别代表一个 GC 单位时间,GC 只在 c 中超过了暂停时间上限。
G1 GC 会努力实现软实时性。软实时性的定义是 GC 单位时间内 GC 暂停时间超过上限的次数在用户的容忍范围之内。因此,尽管在上图中,GC 单位时间 c 内的 GC 暂停时间超过了上限,但是只要用户认为可以接受,就算是实现了软实时性。
预测转移时间
要想在 GC 暂停时间上限之内完成转移,就需要选择可以在这个时间范围内完成转移的回收集合。在往回收集合中添加区域时,要先预测一下该区域的转移时间,如果超过了 GC 暂停时间上限,就不再添加该区域,并终止回收集合的选择。
在 G1GC 中,由 GC 导致的用户线程的暂停时间称为消耗。转移回收集合的消耗,等于扫描转移专用记忆集合中的卡片时的消耗与对象转移时的消耗之和。
具体公式如下所示:
公式中各个数值的含义如下:
- cs : 回收集合
- V (cs) : 转移回收集合 ( cs ) 的消耗
- V fixed : 固定消耗
- U : 扫描脏卡片的平均消耗
- d : 转移开始时的脏卡数量
- S : 查找卡片内指向回收集合的引用的消耗
- r :区域
- rsSize : 区域的转移专用记忆集合中的卡片总数
- C : 对象转移时每个字节的消耗
- liveBytes : 区域内存活对象的总字节数 (大概的值)
V ( cs ) 表示某个回收集合 ( cs ) 的转移时间。V fixed 表示转移过程中的固定消耗,主要是选择和释放回收集合时的消耗。
V fixed ,U ,S ,C 这几个值的大小受实现方法、运行平台以及应用程序特性等各种环境因素的影响,是可变的。因此可以先根据经验设置一些初始值,再通过测量各自的实际处理时间来进行修正,以提高精度
liveBytes 使用上一篇文章讲到的prev_marked_bytes值,对于在并发标记结束后被分配的对象,即使是死亡对象,也要将其当做存活对象来计数。因此liveBytes并非精确值,只是大概的值。
S * rsSize ( r ) + C * liveBytes ( r ) 是一个区域的转移消耗。累加求和操作是回收集合内所有区域的转移消耗的总和。U * d 是在转移回收阶段对转移专用记忆集合维护线程未处理完的脏卡片进行扫描的消耗。这些消耗再加上 V fixed ,就是总体的消耗了。
在理解了公式中各个参数含义后,我们可以把公式翻译成下面的大白话模样:
转移回收集合内所有存活对象的总消耗 =
固定消耗 +
扫描剩余脏卡的总消耗 (脏卡数量 * 扫描每个脏卡的平均消耗) +
转移回收集合内所有存活对象的消耗 =
依次扫描回收集合内每个区域,并计算每个区域转移消耗 =
遍历该区域转移专用记忆集合中每个脏卡,并找出所有指向当前回收区域的引用的总消耗 + 转移当前区域内所有存活对象的总消耗
预测可信度
用户可以通过对消耗的预测值设置预测可信度来调整暂停时间。
预测可信度是一个百分数。如果预测可信度设置为 120%,GC 暂停时间会在消耗预测值的基础上上浮 20%。相反,如果设置为 80%,会在预测值的基础上下浮 20%。预测可信度越高,用户的暂停时间就越短;相反,预测可信度越低,用户的暂停时间就越长。
GC 暂停处理的调度
GC 暂停处理必须在合适的时机进行。这是为了遵守本文一开始提到的规则:
- 在 GC 单位时间内不得超过 GC 暂停时间的上限
当堆内空间充足时,可以根据需要扩展堆,从而延迟转移处理。而且,转移处理并不一定发生在并发标记完全结束之后。因此,即使并发标记过程中的暂停处理(根扫描等)延迟开始,也不会产生致命的问题。通过这些可知:在一般情况下(除了堆内空间紧缺时),GC 暂停处理发生的时机是可以调度的。
G1GC 中有一个队列名为调度队列,其中的元素是暂停处理的开始时间和结束时间的组合。G1GC 使用这个队列来高效地调度 GC 的暂停处理任务。调度队列中保存了最近一次暂停处理的开始时间和结束时间(队列的元素)。调度队列中元素个数是有上限的,如果添加元素时超过上限,队列头部中最早添加的元素就会被删除。
调度程序会基于调度队列中的信息来决定下次 GC 暂停的适当时机。如下图所示:
如果像图中②这样执行,GC 单位时间内总的 GC 暂停时间会超过上限。但是如果像③这样指定了合适的 GC 暂停时机 Z,GC 单位时间内总的 GC 暂停时间就不会超过上限了。
上图中①的 X 表示下次 GC 暂停处理的预测暂停时间。调度程序会计算 X 的开始时刻。
首先,假定 X 会像图中②一样立即开始执行,由此计算出 GC 单位时间内总的 GC 暂停时间(包含 X),并检查它是否超过用户指定的 GC 暂停时间上限。如果没有超过上限,则认为 X 可以立即开始执行;相反,如果超过了上限,则需要延迟执行 X。GC 暂停时间上限和总的GC 暂停时间的差用 Y 来表示。
图中③将 X 的开始时刻向后延迟了 Y,延迟后的开始时刻用 Z 表示。这样,GC 单位时间内总的 GC 暂停时间就刚好等于 GC 暂停时间上限。换句话说,Z 就是执行 X 的合适时机。
需要注意的是,调度程序会保证在任意选取的 GC 单位时间内,总的GC 暂停时间不会超过用户指定的 GC 暂停时间上限。假设 GC 单位时间是 3 秒,GC 暂停时间上限是 1 秒,那么就要像:
- “第 0 秒到第 3 秒内的GC 暂停时间不超过 1 秒”
- “第 0.0001 秒到第 3.0001 秒内的 GC 暂停时间不超过 1 秒”
- “第 0.0003 秒到第 3.0002 秒内的 GC 暂停时间不超过 1秒”
这些情况一样,在无论从哪个时刻开始的 3 秒内,GC 暂停时间都不会超过上限哪怕 1 秒。下图展示了实际发生过的 GC 暂停的时间片段。
GC 单位时间 a、b、c 中任何一个的总 GC 暂停时间都不超过暂停时间上限。
观察一下 GC 单位时间 a、b、c 范围内总的 GC 暂停时间,可以发现GC 暂停处理的确没有超过 GC 暂停时间上限。
当然,在 GC 的预测时间不准确或堆内空间不足等导致 GC 必须提前开始时,GC 暂停处理还是会超出暂停时间上限。
并发标记中的暂停处理
并发标记中的暂停处理阶段也会以上文中所讲的方法,按照合适的间隔执行。具体来说,需要在以下 3 个步骤中执行暂停处理:
- 初始标记阶段
- 最终标记阶段
- 收尾工作
但是,这些步骤的暂停时间不像转移中的暂停时间一样可控,如果暂停时间本身就超过了 GC 暂停时间上限,就不能遵守 GC 暂停时间上限了。
在调度 GC 的暂停时机时,需要预测暂停时间。一开始需要根据经验来设置并发标记中暂停处理的预测暂停时间。然后可以根据测算出的实际暂停时间,并结合过去的执行结果来提高预测暂停时间的精确度。
分代 G1 GC 模式
G1GC 中存在“纯 G1GC 模式”(pure garbage-first mode)和“分代 G1GC模式”(generational garbage-first mode)两种模式。前面介绍的内容都是关于纯 G1GC 模式的。本节开始,我们将介绍分代 G1GC 模式。
实际上,OpenJDK 虽然实现了纯 G1GC 模式,但是并没有将这种模式开放给用户。用户们使用的都是分代 G1GC 模式。
不同点
和纯 G1GC 模式相比,分代 G1GC 模式主要有以下两个不同点。
- 区域是分代的
- 回收集合的选择是分代的
在分代 G1 GC 模式中,区域被分为新生代区域和老年代区域两类。和其他分代 GC 算法一样,分代 G1 GC 的对象也保存了自身在各次转移中存活下来的次数。新生代区域用来存放新生代对象,老年代区域用来存放老年代对象。
另外,分代 G1 GC 模式也分为新生代 GC 和老年代 GC 。G1 GC 中的新生代 GC 称为完全新生代 GC(fully-young collection),老年代 GC称为部分新生代 GC(partially-young collection)。
完全新生代 GC 和部分新生代 GC 的主要区别在于回收集合的选择:
- 完全新生代 GC 将所有新生代区域选入回收集合
- 部分新生代 GC 将所有新生代区域,以及一部分老年代区域选入回收集合。
这里需要注意的是,所有的新生代区域都会被选入回收集合。这一点非常重要,请务必牢记。
新生代区域
新生代区域可以进一步分为以下两类:
- 创建区域
- 存活区域
创建区域用来存放刚刚生成、一次也没有被转移过的对象。存活区域用来存放被转移过至少一次的对象。
另外,转移专用写屏障不会应用在新生代区域的对象上。因此,即使新生代区域的对象存在对其他区域对象的引用,被引用区域的转移专用记忆集合中也不会记录引用方的卡片。
对于新生代区域 A 中对象 a 对老年代区域 B 中对象 b 的引用,转移专用写屏障是无效的,所以转移专用记忆集合 B 不会记录这次引用(左图)。而对于来自老年代区域对象的引用,转移专用写屏障仍然有效(右图)。
但是,为什么新生代区域间不使用转移专用写屏障也可以呢?
- 我们先回顾一下转移专用记忆集合的作用。转移专用记忆集合维护的是区域之间的引用关系,因此在转移时无须扫描整个区域就能找到待转移对象所在区域的存活对象。
- 而在分代 G1GC 模式中,所有的新生代区域都会被选入回收集合,因此在转移新生代区域时所有对象的引用都会被检查。
- 即使被引用区域的转移专用记忆集合中记录了来自新生代区域的引用,这些记录也都是重复的信息。
- 因此,转移专用记忆集合中不会记录来自新生代区域的引用。
这里可以再次回顾上一篇文章中贴出的对象转移过程伪代码:
1: def evacuate_obj(ref):
# 拿到被引用对象的地址
2: from = *ref
# 如果被引用对象没有被标记,说明是死亡对象,则无需转移
3: if not is_marked(from):
4: return from
# 如果被引用对象设置了转发标记,说明此时对象已经完成了转移
5: if from.forwarded:
# 在被引用对象的转移记忆集合中,重新添加引用方所在区域对自己的引用关系,如果有需要的话
6: add_reference(ref, from.forwarded)
# 返回被引用对象转移后的新地址
7: return from.forwarding
8:
# 为被引用对象分配新的内存空间,然后copy过去
9: to = allocate($free_region, from.size)
10: copy_data(new, from, from.size)
11:
# 设置被引用对象所处旧位置的对象头的转发标记和转发指针
12: from.forwarding = to
13: from.forwarded = True
14:
# 遍历被引用对象引用的子对象列表
15: for child in children(to):
# 如果子对象所处区域属于回收集合,则将子对象添加到转移队列中
16: if is_into_collection_set(*child):
17: enqueue($evacuate_queue, child)
18: else:
# 在子对象所在区域的转移记忆集合中,重新添加引用方所在区域对自己的引用关系
19: add_reference(child, *child)
20:
# 这里相当于在新的区域重新创建了对象,所以在被引用对象的转移记忆集合中,重新添加引用方所在区域对自己的引用关系
21: add_reference(ref, to)
22:
# 返回对象转移后新的地址
23: return to
被加入引用队列后,后续被处理的流程:
1: def evacuate():
2: while $evacuate_queue != Null:
3: ref = dequeue($evacuate_queue)
4: *ref = evacuate_obj(ref)
- 如果新生代区域C中的对象c1被新生代区域A中的对象a1所引用,此时对象转移存在两种情况:
- 如果先转移c1,然后c1旧地址会设置转发标记和转发地址,在转移对象a1的时候,遍历到子对象c1时,发现子对象也位于回收区域内,则加入引用队列等待稍后处理,同时将对象a1新的引用赋值给引用方,然后返回对象a1新的地址。
- 等到后面子对象c1从引用队列取出处理时,发现其已经被转移过了,此时会更新a1指向c1的引用关系,然后返回c1对象新的地址。
- 如果先转移a1, 遍历到子对象c1时,发现子对象也位于回收区域内,则加入引用队列等待稍后处理,同时将对象a1新的引用赋值给引用方,然后返回对象a1新的地址。
- 等到后面子对象c1从引用队列取出处理时,将c1转移到新区域后,此时会更新a1指向c1的引用关系,然后返回c1对象新的地址。
- 如果先转移c1,然后c1旧地址会设置转发标记和转发地址,在转移对象a1的时候,遍历到子对象c1时,发现子对象也位于回收区域内,则加入引用队列等待稍后处理,同时将对象a1新的引用赋值给引用方,然后返回对象a1新的地址。
所以当跨区域引用对应的引用方区域和被引用方区域都位于回收集合中时,此时就无需在被引用方的转移专用记忆集合中添加引用方所在卡片了,这也是为什么这里新生代区域无需使用写屏障的原因了。
分代对象转移
存活对象保存了自己被转移的次数,这个次数称为对象的年龄。转移时对象的年龄如果低于阈值,对象就会被转移到存活区域,否则就会被转移到老年代区域。将对象转移到老年代区域的行为称为晋升。
如果转移的目标区域满了,垃圾回收器就会选择一个空闲的区域,把它修改成存活区域或者老年代区域之后,作为转移的目标区域使用。
对象被转移到存活区域之后,即使该对象引用了回收集合以外的区域,也不需要记录在转移专用记忆集合中。关于这一点的原因,上一节末尾已经说明了原因。相反,往老年代区域转移对象时就必须要记录。因为老年代区域并非每次都会被选入回收集合。
具体转移流程
我们来看一下完全新生代 GC 的执行过程。
完全新生代 GC 不会选择老年代区域,而是将所有新生代区域都选入回收集合,然后转移回收集合内的存活对象。晋升的对象会被转移到老年代区域,其余对象则被转移到存活区域。
部分新生代 GC 则是除了所有新生代区域外,还会选择一部分老年代区域进入回收集合。除了回收集合的选择方式,部分新生代 GC 和完全新生代 GC 的执行过程是一样的。
分代选择回收集合
刚才介绍过,在回收集合的选择方式上,完全新生代 GC 和部分新生代GC 有所不同。完全新生代 GC 会选择所有新生代区域,而部分新生代GC 会选择所有新生代区域和一部分老年代区域。
分代 GC 的理论基础是大多数对象是“朝生夕死”的。因此,分代 G1GC模式也是通过选择回收集合的方式来确保总是优先转移新生代区域,从而积极地释放年轻对象的内存空间。
不过,选择全部新生代区域的做法可能会打破软实时性。如果新生代区域数太多,就有可能无法遵守用户设置的 GC 暂停时间上限。要想避免这个问题,分代 G1GC 模式就需要计算出合理的最大新生代区域数。
设置最大新生代区域数
完全新生代 GC 和部分新生代 GC 关于最大新生代区域数的计算方法是不一样的。
完全新生代 GC 的最大新生代区域数是在遵守 GC 暂停时间上限的前提下,尽量设置较大的值。即根据过去的转移时间记录,预测出单个新生代区域转移所需的大概时间,然后基于这个时间计算出刚好不超过 GC暂停时间上限的最大新生代区域数。完全新生代 GC 的名字由来就是“尽可能完全地转移新生代区域”。
而部分新生代 GC 的最大新生代区域数是在遵守 GC 单位时间的前提下,尽量设置较小的值:
- 首先,采用软实时性一节中介绍的方法计算出下次能够进行 GC 暂停处理的时机。
- 然后,预测出在这个“时机”之前大概能回收多少个区域,并以此作为新生代区域的最大数目。
- 当预测值命中时,达到最大新生代区域数的时机,刚好就是下次能够进行 GC 暂停处理的时机,因此能够遵守 GC 单位时间。
- 另外,因为最大新生代区域数设置的是最小值,所以被选入回收集合的新生代区域数也是最少的。
- 这样一来,距离 GC 暂停时间上限很可能还有一段时间,就可以往回收集合里添加一些老年代区域。
部分新生代 GC 的名字由来就是“尽可能少地转移新生代区域”。
最大新生代区域数的设置发生在并发标记结束之后。
GC的切换
垃圾回收器在选择 GC 算法时,通常会选择部分新生代 GC,只有在使用完全新生代 GC 效率更高时才会切换为完全新生代 GC。切换的时间点和设置最大新生代区域数时一样,都是在并发标记结束之后。
首先,参考并发标记中标记出的死亡对象个数,预测出下次部分新生代GC 的转移效率。然后,根据过去的完全新生代 GC 的转移效率,预测出下次完全新生代 GC 的转移效率。如果预测出完全新生代 GC 的转移效率更高,则切换为完全新生代 GC。
GC执行的时机
当新生代区域数达到上限时,会触发转移的执行。换句话说,通过调节最大新生代区域数,可以控制转移执行的时机。
当转移完成并通过以下 4 项检查之后,会开始执行并发标记:
- 不在并发标记执行过程中
- 并发标记的结果已被上次转移使用完
- 已经使用了一定量的堆内存(默认是全部堆内存的 45% 以上)
- 相比上次转移完成之后,堆内存的使用量有所增加
其中第二步是为了避免重复地并发标记。如果有并发标记的结果尚未在转移过程中被使用,则不会开始并发标记。
需要注意的是,并发标记过程中的所有暂停处理都需要遵守程序对于GC 暂停处理的调度,以适当的时间间隔来执行。
总结
GC 的各种处理之间关系非常复杂,这里我们用一张图来总结一下。下图展示了 mutator (用户线程) 和 GC 的执行关系示例:
图中并列的箭头表示可能会并行执行的处理
从上图中可以看出,转移专用记忆集合维护线程和 mutator(用户线程) 在大多数时间中是并发执行的,但是在存活对象计数时,转移专用记忆集合维护线程也是暂停的。
- 并发计数阶段是和用户线程并行执行的,到此时为止所有存活对象都已经被标记出来了,G1后续会按照当前时刻快照进行筛选回收,所以即使此刻用户线程又更改了引用关系,也不会有什么影响,所以可以停止掉记忆集合线程。
- 当进入后续筛选回收阶段时,还是处于STW状态,相当于是以最终标记这一刻的快照为准,进行筛选加对象转移,所以以上论述都是为了证明此刻停掉记忆集合线程没有什么关系,用户在计数阶段变更的引用关系,会在下一波GC时再被处理。
还有一点需要注意,那就是转移处理可能发生在并发标记中暂停处理以外的所有时刻。比如在并发标记阶段或者存活对象计数的过程中,都可能执行转移。
下面对G1垃圾回收算法的优缺点进行总结:
优点:
- 首先,G1GC 具备软实时性,这是一个很大的优点。对于要求软实时性的应用程序,可以由用户控制 GC 暂停时间。
- 其次,它能够充分发挥高配置机器的性能,大幅缩减 GC 暂停时间,这一点也值得表扬。虽然考虑到算法,总有一些必须要暂停的阶段,但这些阶段也可以通过尽可能地并行执行,来进一步缩短暂停时间。
- 再次,它通过将写屏障的处理粒度由对象粒度改为更粗的卡片粒度,降低了写屏障发生的频率。这也是缩短暂停时间的一个手段。
- 另外,因为有转移,所以区域内不会产生内存碎片。由此可以提高引用的局部性和对象存储空间分配的速度。
- 与其他具备软实时性的 GC 相比,G1GC 的吞吐量保持在较高水平。近年,很多具备软实时性的 GC 会通过频繁地执行“以对象为单位进行复制”这种更细粒度的暂停处理来缩短 GC 暂停时间,从而达成软实时性。因此,无论如何它们的吞吐量都是下降的。另外,这些 GC 中死亡对象的回收处理可能存在延迟,因此内存的使用效率也不高。
- 而 G1GC 以区域这种较粗的粒度来频繁地执行用户指定时间内的暂停处理,所以暂停时间会稍微长一些,它的吞吐量也会高一些。此外,通过在转移时选择合适的回收集合,还能够更高效地回收死亡对象。
缺点:
- G1 GC 的适用对象被限定为“搭载多核处理器、拥有大容量内存的机器”。在多数环境下,我们并不能发挥出它的性能。适用环境受限可以说是它的一个缺点。
- 另外在转移时,尽管区域内不会出现碎片化,但是会出现以区域为单位(整个堆)的碎片化。和普通的 GC 复制算法相比,这一点算是缺点。