免责声明:我不是 GC 专家/作家;下面写的所有内容都会发生变化,其中一些可能过于简单化。请谨慎对待。
我只会谈论Shenandoah,因为我认为我理解它;这不是分代 GC。
这里实际上有两个阶段:Mark 和 Compact。我要在这里强烈强调两者都是并发,并且确实在您的应用程序运行时发生(带有一些非常短的 STW 事件)。
现在是细节。我已经解释了一些事情here,但是因为这个答案与某种不同的问题有关;我会在这里解释更多。我认为遍历活动对象图对您来说不是什么新闻,毕竟您正在阅读一本关于GC 的书。正如该答案所解释的,当应用程序完全停止(也称为安全点)时,识别活动对象很容易。没有人改变你脚下的任何东西,地板是僵硬的,你控制着一切。并行收集器执行此操作。
真正痛苦的方法是同时做事。 Shenandoah 采用了一种称为Snapshot at the beginning 的算法(那本书解释了它AFAIK),简称为SATB。基本上这个算法是这样实现的:“我将开始同时扫描对象图(从 GC 根),如果有任何变化在我扫描时,我不会改变堆,但会记录这些变化并在以后处理”。
您需要提问的第一部分是:当我扫描时。这是如何实现的?好吧,在执行concurrent mark 之前,有一个STW event 称为Initial Mark。在该阶段完成的一件事是设置一个标志,表明并发标记已经开始。稍后,在执行代码时,会检查该标志(Shenandoah 因此在解释器中使用了更改)。在伪代码中:
if(!concurrentMarkingActive) {
// do whatever you were doing and alter the heap
} else {
// shenandoah magic
}
在机器代码中可能如下所示:
test %r11, %r11 (test concurrentMarkingActive flag)
jne // concurrent marking is currently active
现在 GC 知道何时发生并发标记。
但是并发标记是如何实现的。当堆本身发生突变(不稳定)时,如何扫描堆?你脚下的地板增加了更多的洞,也将它们移除。
这就是“雪兰多魔法”。对堆的更改被“拦截”而不是直接持久化。因此,如果 GC 在这个时间点执行并发标记,并且应用程序代码尝试改变堆,则这些更改会记录在每个线程 SATB queues(开头的快照)中。当并发标记结束时,这些队列将被清空(通过称为 Final Mark 的 STW event)并再次分析那些被清空的更改(现在记住在 STW event 下)。
当这个阶段Final Mark结束时,GC 知道什么是活着的,因此什么是隐含的垃圾。
接下来是压缩阶段。 Shenandoah 现在应该将活动对象移动到不同的区域(以紧凑的方式)并将当前区域标记为我们可以再次分配的区域。当然,在一个简单的STW phase 中,这很容易:移动对象,更新指向它的引用。完毕。当你必须同时进行...
您不能将对象简单地移动到不同的区域并然后一一更新您的引用。想想看,假设这是我们拥有的第一个状态:
refA, refB
|
---------
| i = 0 |
| j = 0 |
---------
此实例有两个引用:refA 和 refB。我们创建这个对象的副本:
refA, refB
|
--------- ---------
| i = 0 | | i = 0 |
| j = 0 | | j = 0 |
--------- ---------
我们创建了一个副本,但尚未更新任何参考。我们现在移动一个引用以指向副本:
refA refB
| |
--------- ---------
| i = 0 | | i = 0 |
| j = 0 | | j = 0 |
--------- ---------
现在有趣的部分是:ThreadA 是 refA.i = 5,而 ThreadB 是 refB.j = 6,所以你的状态变成了:
refA refB
| |
--------- ---------
| i = 5 | | i = 0 |
| j = 0 | | j = 6 |
--------- ---------
你现在如何合并这些对象?老实说 - 我不知道这是否可能,这也不是Shenandoah 采取的路线。
相反,Shenandoah 的解决方案做了一件非常有趣的事情,恕我直言。为每个实例添加一个额外指针,也称为转发指针:
refA, refB
|
fwdPointer1
|
---------
| i = 0 |
| j = 0 |
---------
refA 和refB 指向fwdPointer1,而fwdPointer1 指向真实对象。现在让我们创建副本:
refA, refB
|
fwdPointer1 fwdPointer2
| |
--------- ---------
| i = 0 | | i = 0 |
| j = 0 | | j = 0 |
--------- ---------
现在,我们要切换所有引用(refA 和 refB)指向副本。如果您仔细观察,这只需要更改一个指针 - fwdPointer1。让fwdPointer1 指向fwdPointer2,你就完成了。这意味着与refA 和refB 的两个(在此设置中)相比,只有一个更改。这里更大的胜利是您无需扫描堆并找出指向您的实例的引用。
有没有办法自动更新引用?当然:AtomicReference(至少在 java 中)。这里的想法几乎相同,我们通过CAS(比较和交换)原子地更改fwdPointer1,如下所示:
refA, refB
|
fwdPointer1 ---- fwdPointer2
|
--------- ---------
| i = 0 | | i = 0 |
| j = 0 | | j = 0 |
--------- ---------
所以,refA 和 refB 指向 fwdPointer1,它现在指向我们创建的副本。通过单个CAS 操作,我们同时将所有引用切换到新创建的副本。
然后,GC 可以简单地(同时)更新所有引用 refA 和 refB 以指向 fwdPointer2。最后有这个:
refA, refB
|
fwdPointer1 ---- fwdPointer2
|
--------- ---------
| i = 0 | | i = 0 |
| j = 0 | | j = 0 |
--------- ---------
所以,左边的对象现在是垃圾:没有指向它的引用。
但是,我们需要了解其中的弊端,没有免费的午餐。
-
首先,很明显:Shenandoah 添加了一个机器头,堆中的每个实例(进一步阅读,因为这是错误的;但更容易理解)。
-
这些副本中的每一个都将在新区域中生成一个额外的对象,因此在某些时候将至少有两个相同对象的副本(Shenandoah 需要额外的空间才能正常工作)。
-
当ThreadA 执行refA.i = 5(来自上一个示例)时,它如何知道它是否应该尝试创建副本,写入该副本和CAS forwarding pointer 与简单地写入目的?请记住,这是同时发生的。与concurrentMarkingActive 标志相同的解决方案。有一个标志isEvacuationToADifferentRegionActive(不是实际名称)。如果该标志是true => Shenandoah Magic,否则只需按原样进行写入。
如果你真的理解了最后一点,你的自然问题应该是:
“等一下!这是否意味着Shenandoah 对isEvacuationToADifferentRegionActive 执行if/else 对实例的EACH AND SINGLE 写入 - 是原语还是引用?这是否意味着必须通过@ 访问每个读取987654392@?”
答案曾经是 是;但情况发生了变化:via this issue(尽管我说它听起来比实际情况要糟糕得多)。现在他们对整个对象使用Load 屏障,更多细节here。他们没有在每次写入时设置屏障(即if/else 对标志)并通过forwarding pointer 为每次读取取消引用,而是移至load barrier。基本上只有在加载对象时才这样做if/else。由于写入它意味着首先读取,因此它们保持“空间不变”。显然这更简单,更好,更容易优化。万岁!
还记得forwarding pointer吗?好吧,它已经不存在了。我不了解它的整个荣耀的细节(还),但它必须与使用 mark word 和 from space 的可能性有关,因为添加了负载屏障,因此不再使用。很多more details here。一旦我了解了它在内部的真正运作方式,就会更新帖子。
G1 与Shenandoah 并没有太大的不同,但魔鬼在细节中。例如,G1 中的Compact 阶段始终是STW 事件。 G1 总是 世代相传 - 即使你想要或不想要(Shenandoah 可以 有点像 - 有一个设置来控制它)等等。