一文看懂JDK12中新版垃圾回收器-Shenandoah GC
目录
3.2. GC 疏散阶段(Concurrent Evacuation)
3.3. GC 更新引用阶段(Concurrent Update References)
3.4. GC更新引用阶段(Final Update Refs )
1. 简介
Shenandoah是一款concurrent及parallel的垃圾收集器;跟ZGC一样也是面向low-pause-time的垃圾收集器,不过ZGC是基于colored pointers来实现,而Shenandoah GC是基于brooks pointers来实现。
其实低停顿的GC,业界早就出现,只不过Java比较晚
Azul的Zing中C4 GC 土豪选择
oracle中的HotSpot ZGC JDK11的选择
R大说ZGC说抄袭Azul的,两者是等价的。
不过今天主要是探究一下另一款追求低停顿时间的GC回收器Shenandoah GC
2. 流程
Shenandoah 主要目标是99.9%停顿小于10ms,暂停与堆大小无关
其工作流程如下图所示
|
上面的阶段大致如下
- Init Mark 启动并发标记。它为堆和应用程序线程准备并发标记,然后扫描根集。这是流程中的第一个暂停,最主要的消耗是根集扫描。因此,其持续时间取决于根集大小。
- Concurrent Marking 遍历堆,并跟踪可访问的对象(三色标记对象)。此阶段与应用程序一起运行,其持续时间取决于活动对象的数量和堆中对象关系的结构。由于应用程序可以在此阶段自由分配新数据,因此在并发标记期间堆占用率会上升。这里主要运用的是SATB(snapshot-at-the-beginning)
- Final Mark 通过清理所有等待的标记,更新队列,重新扫描根设置三个并发的来完成标记。(这里主要是处理一些SATB没有处理完的引用)它还通过确定要撤离的区域(收集集合),预先疏散一些根来初始化疏散,并且通常为下一阶段准备运行时间。这项工作的一部分可以在Concurrent Cleanup阶段同时完成。这是周期中的第二个暂停,这里消耗最主要的时间是清理队列并扫描根集。
- Concurrent Cleanup 回收立即垃圾区域。 即在并发标记之后检测到的没有活动对象的区域。
- Concurrent Evacuation 将对象集合从集合集复制到其他区域。这是与其他OpenJDK GC的主要区别。此阶段再次与应用程序一起运行,因此应用程序可以自由分配。其持续时间取决于为流程选择的集合集的大小。
- Init Update Refs 初始化更新引用阶段。除了确保所有GC和应用程序线程都已完成疏散,然后为下一阶段准备GC之外,它几乎没有任何作用。这是周期中的第三次暂停,最短暂停。
- Concurrent Update References 遍历堆,并更新对并发撤离期间移动的对象的引用。 这是与其他OpenJDK GC的主要区别。 它的持续时间取决于堆中的对象数,但不取决于对象图结构,因为它会线性扫描堆。此阶段与应用程序同时运行。
- Final Update Refs 通过重新更新现有根集来完成更新引用阶段。它还从集合集中回收区域,因为现在堆没有对它们的(陈旧)对象的引用。这是循环中的最后一次暂停,其持续时间取决于根集的大小。
- Concurrent Cleanup 回收集合集区域,现在没有引用。
四次暂停主要都是取决于GC root大小,而非堆大小
3. 实现
一般情况下如果我们要实现,并***况下的对象移动,不可避免要解决一个问题,新对象和老对象同时存在,如何写入问题
两个线程写,会造成两个对象属性不一致问题
如果要解决的话,我们Java选手大概写法会如下所
class VersionUpdater<T, V> {
final AtomicReference<T> ref = ...;
void writeValue(V value) {
do {
T oldObj = ref.get();
T newObj = copy(oldObj);
newObj.set(value);
} while (!ref.compareAndSet(oldObj, newObj));
}
}
但是每次写入就copy个对象出来存在浪费,Shenendoah有更好的idea
通常,JDK对象头有2个字分配给它们(类名和用于锁定的标记字,前向指针等)。Shenendoah增加了第三个词叫做间接指针。对象的所有引用都必须通过此指针。这允许移动对象而不更新对它的所有引用,这意味着可以在Java线程同时运行时更新活动对象。只有在使用Shenendoah GC时才会添加这个额外的指针。
在64位系统上,指针占8字节大小(后面会有汇编例子)真实在代码中使用的无符号整型替代大小
3.1. GC 前
fwd ptr会指向自己
3.2. GC 疏散阶段(Concurrent Evacuation)
会在另一块区域创建个新对象,这里和之前GC算法一样,不同点在于,老的fwd ptr会指向副本对象
在疏散期间,也就是新建个对象期间,会原子性更新fwd ptr,通过cas更新指向
stub evacuate(obj) {
if (in-collection-set(obj) && // target is in from-space
fwd-ptrs-to-self(obj)) { // no copy yet
copy = copy(obj);
CAS(fwd-ptr-addr(obj), obj, copy);
}
}
3.3. GC 更新引用阶段(Concurrent Update References)
可以看出有两个引用已经直接指向新对象了,新对象中的x,y值可以被两条引用线修改,老对象已经不再被访问了。
这是使用了Barriers,这个注意区别内存屏障 这里解释一下barriers,当write,access时候发现还没有copy对象到to区域,那么就会插入一个屏障,去copy对象去to区域,保证读写一定是to区域新对象
SlowPath
stub Write(val, obj, offset) {
if (evac-in-progress && // in evacuation phase
in-collection-set(obj) && // target is in from-space
fwd-ptrs-to-self(obj)) { // no copy yet
val copy = copy(obj);
*(copy + offset) = val; // actual write
if (CAS(fwd-ptr-addr(obj), obj, copy)) {
return; // success!
}
}
obj = fwd-ptr(obj); // write to actual copy
*(obj + offset) = val; // actual write
}
FastPath
# read the thread-local flag
movzbl 0x3d8(%r15),%r11d # flag = *(TLS + 0x3d8)
# if that flag is set, then...
test %r11d,%r11d # if (flag) ...
jne OMG-EVAC-ENABLED
# make sure we have the to-copy
mov -0x8(%rbp),%r10 # obj = *(obj - 8)
# store into to-copy r10 at offset 0x30
mov %r10,0x30(%r10) # *(obj + 0x30) = r10
保证在疏散阶段,写操作一定发生在新对象中
新值也可以被两条引用线所访问
Barriers也帮助选择读新对象中的数据
Read Barriers: Implementation
# read barrier: dereference via fwdptr
mov -0x8(%r10),%r10 # obj = *(obj - 8)
# heap read!
mov 0x30(%r10),%r10d # val = *(obj + 0x30)
最后就是把老对象的引用全部导入新对象。
允许和应用线程并发的执行
3.4. GC更新引用阶段(Final Update Refs )
这里就是根据现有GC Root再更新一次引用(更新那些在上一个阶段还没完全更新完的引用),然后顺带回收内存,这个执行之后,就是全面的回收内存了
4. 对象比较怎么办?
如果这时候if(a1==a2)会发生什么?
在Shenandoah GC情况下,如果比较对象,则如下所示
# compare the ptrs; if equal, good!
cmp %rcx,%rdx # if (a1 == a2) ...
je EQUALS
# false negative? have to compare to-copy:
mov -0x8(%rcx),%rcx # a1 = *(a1 - 8)
mov -0x8(%rdx),%rdx # a2 = *(a2 - 8)
# compare again:
cmp %rcx,%rdx # if (a1 == a2) ...
5. 适用场景
超大内存!超大内存!超大内存!
重要的话说三遍
使用Shenandoah GC,可能会导致吞吐量降低,为啥会降低,GC回收线程与业务线程的切换。Brooks indirection pointer算法带来的问题(读写可能被插入了barriers,ZGC的LVB据说比这个优秀)但是不会显著干扰。
Shenandoah GC是并发GC,逻辑上是不存在分代的。不存在所谓的young/old区
并发GC根本上是为了玩追赶游戏,应用一边在分配内存,GC一边在收集,如果GC收集的速度可以跟上应用分配的速度,那么一切就是完美。一旦GC跟不上了,垃圾也就渐渐堆积,最终空间彻底耗尽的时候,应用请求只能暂停等一等,等GC跟上来。
所以,对于一个并发GC来说,能够尽快回收出越多空间,就能够应付越高的应用内存分配速率,从而更好地保持GC以完美的并发模式工作。
如果遇见非常高的对象分配速率的话会跟不上,目前唯一有效的“调优”方式就是增大整个GC堆的大小来让GC有更大的喘息空间,这也就是为什么适用于超大内存的使用场景了。
所以Shenandoah GC在allocation failure的情况下有一些优雅的方式解决
- Pacing(
<10 ms
)
ShenandoahPacing参数默认开启,Pacer用于在gc不够快的时候去stall正在分配对象的线程,当gc速度跟上来了就解除对这些线程的stall;stall不是无期限的,有个ShenandoahPacingMaxDelay(
单位毫秒
)参数可以设置,一旦超过该值allocation就会产生。当allocation压力大的时候,Pacer就无能为力了,这个时候就会进入下一个step
- Degenerated GC(
<100 ms
)
ShenandoahDegeneratedGC参数默认开启,在这个Degenerated cycle,Shenandoah使用的线程数取之于ParallelGCThreads而非ConcCGThreads
- Full GC(
>100 ms
)
当Degenerated GC之后还没有足够的内存,则进入Full GC cycle,它会尽可能地进行compact然后释放内存以确保不发生OOM
可以看出如果最后实在没办法,发生FGC,停顿时间将大大的增大。
6. 性能对比
这种追求低停顿的方法是牺牲了cpu计算的,原因上文有写,可能会造成吞吐量的下降
7. 大神猜测
除了上述那个“调优”方法-增大内存来解决这个问题,其实还存在一种,这个不是开发者能控制的,那就是分代。Shenandoah GC如果项目不死的话,最后发展成分代的可能性极大。
分代收集,也就是是划分young/old区,其目的是为了提高GC能够应付的应用内存分配速率
对于一个并发GC来说,能够尽快回收出越多空间,就能够应付越高的应用内存分配速率!
针对上述的Shenandoah GC流程,分析一下如果,Shenandoah GC进化成分代收集之后,会有哪些优化?
在之前GC算法种,回收young区的算法都复制算法,它的开销只跟活对象的多少(live data set)有关系,而跟它所管理的堆空间的大小没关系。根据大多数对象生命周期很短的规则区看,在young区基本上99%的对象都是一次就死,存活只有1%,并行处理的话大概100-200ms就能解决
但是如果是存活对象占这个区域比例很高的话,那么可达性分析所消耗的时间将会特别差!
现在Shenandoah GC是不分代,那么扫描GC根的时间是一样的,但是后面可达性分析是不一样的。虽然这时候是并发执行也就是上述的Concurrent Marking阶段。这个阶段消耗时间会比分代收集young区时间长(因为存在长时间存活对象)那么导致回收的时间变成!
这也一来,根据能够尽快回收出越多空间,就能够应付越高的应用内存分配速率这个条件反推,原先的应付内存分配速率是小于分代收集的。
那么有人会问,那么进入old区的对象怎么回收呢?
目前来说只收集old的只有CMS的concurrent collection是这个模式,其他回收old区域,都会触发收集整个堆GC,也就是FGC。
8. 附1
8.1. 三色标记法