目录

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. 三色标记法


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,暂停与堆大小无关

其工作流程如下图所示

GC(3) Pause Init Mark 0.771ms

GC(3) Concurrent marking 76480M->77212M(102400M) 633.213ms

GC(3) Pause Final Mark 1.821ms

GC(3) Concurrent cleanup 77224M->66592M(102400M) 3.112ms

GC(3) Concurrent evacuation 66592M->75640M(102400M) 405.312ms

GC(3) Pause Init Update Refs 0.084ms

GC(3) Concurrent update references  75700M->76424M(102400M) 354.341ms

GC(3) Pause Final Update Refs 0.409ms

GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms

上面的阶段大致如下

 

  1. Init Mark 启动并发标记。它为堆和应用程序线程准备并发标记,然后扫描根集。这是流程中的第一个暂停,最主要的消耗是根集扫描。因此,其持续时间取决于根集大小。
     
  2. Concurrent Marking 遍历堆,并跟踪可访问的对象(三色标记对象)。此阶段与应用程序一起运行,其持续时间取决于活动对象的数量和堆中对象关系的结构。由于应用程序可以在此阶段自由分配新数据,因此在并发标记期间堆占用率会上升。这里主要运用的是SATB(snapshot-at-the-beginning)
     
  3. Final Mark 通过清理所有等待的标记,更新队列,重新扫描根设置三个并发的来完成标记。(这里主要是处理一些SATB没有处理完的引用)它还通过确定要撤离的区域(收集集合),预先疏散一些根来初始化疏散,并且通常为下一阶段准备运行时间。这项工作的一部分可以在Concurrent Cleanup阶段同时完成。这是周期中的第二个暂停,这里消耗最主要的时间是清理队列并扫描根集。 
     
  4. Concurrent Cleanup 回收立即垃圾区域。 即在并发标记之后检测到的没有活动对象的区域。
     
  5. Concurrent Evacuation 将对象集合从集合集复制到其他区域。这是与其他OpenJDK GC的主要区别。此阶段再次与应用程序一起运行,因此应用程序可以自由分配。其持续时间取决于为流程选择的集合集的大小。
     
  6. Init Update Refs 初始化更新引用阶段。除了确保所有GC和应用程序线程都已完成疏散,然后为下一阶段准备GC之外,它几乎没有任何作用。这是周期中的第三次暂停,最短暂停。
     
  7. Concurrent Update References 遍历堆,并更新对并发撤离期间移动的对象的引用。 这是与其他OpenJDK GC的主要区别。 它的持续时间取决于堆中的对象数,但不取决于对象图结构,因为它会线性扫描堆。此阶段与应用程序同时运行。
     
  8. Final Update Refs 通过重新更新现有根集来完成更新引用阶段。它还从集合集中回收区域,因为现在堆没有对它们的(陈旧)对象的引用。这是循环中的最后一次暂停,其持续时间取决于根集的大小。
     
  9. Concurrent Cleanup  回收集合集区域,现在没有引用。

 

四次暂停主要都是取决于GC root大小,而非堆大小 

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. 三色标记法