目录
1. 简介
2. 流程
3. 实现
3.1. GC 前
3.2. GC 疏散阶段(Concurrent Evacuation)
3.3. GC 更新引用阶段(Concurrent Update References)
3.4. GC更新引用阶段(Final Update Refs )
4. 对象比较怎么办?
5. 适用场景
6. 性能对比
7. 大神猜测
8. 附1
8.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
Shenandoah 主要目标是99.9%停顿小于10ms,暂停与堆大小无关
其工作流程如下图所示
|
上面的阶段大致如下
四次暂停主要都是取决于GC root大小,而非堆大小
GC Root
一般情况下如果我们要实现,并发情况下的对象移动,不可避免要解决一个问题,新对象和老对象同时存在,如何写入问题
两个线程写,会造成两个对象属性不一致问题
如果要解决的话,我们Java选手大概写法会如下所
class VersionUpdater {
final AtomicReference 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字节大小(后面会有汇编例子)真实在代码中使用的无符号整型替代大小
fwd ptr会指向自己
会在另一块区域创建个新对象,这里和之前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);
}
}
可以看出有两个引用已经直接指向新对象了,新对象中的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)
最后就是把老对象的引用全部导入新对象。
允许和应用线程并发的执行
这里就是根据现有GC Root再更新一次引用(更新那些在上一个阶段还没完全更新完的引用),然后顺带回收内存,这个执行之后,就是全面的回收内存了
如果这时候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) ...
超大内存!超大内存!超大内存!
重要的话说三遍
使用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的情况下有一些优雅的方式解决
<10 ms
)
ShenandoahPacing参数默认开启,Pacer用于在gc不够快的时候去stall正在分配对象的线程,当gc速度跟上来了就解除对这些线程的stall;stall不是无期限的,有个ShenandoahPacingMaxDelay(
单位毫秒
)参数可以设置,一旦超过该值allocation就会产生。当allocation压力大的时候,Pacer就无能为力了,这个时候就会进入下一个step
<100 ms
)
ShenandoahDegeneratedGC参数默认开启,在这个Degenerated cycle,Shenandoah使用的线程数取之于ParallelGCThreads而非ConcCGThreads
>100 ms
)
当Degenerated GC之后还没有足够的内存,则进入Full GC cycle,它会尽可能地进行compact然后释放内存以确保不发生OOM
可以看出如果最后实在没办法,发生FGC,停顿时间将大大的增大。
这种追求低停顿的方法是牺牲了cpu计算的,原因上文有写,可能会造成吞吐量的下降
除了上述那个“调优”方法-增大内存来解决这个问题,其实还存在一种,这个不是开发者能控制的,那就是分代。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。