Back
Featured image of post JVM 学习笔记 08 - 低延迟垃圾收集器 Shenandoah

JVM 学习笔记 08 - 低延迟垃圾收集器 Shenandoah

JVM 学习笔记,本文讲解低延迟垃圾收集器。

导航页

上篇文章我们讲解了几款经典垃圾收集器,本文将介绍 Shenandoah 低延迟垃圾收集器。

GC 不可能是完美的,可以通过有三个评判指标:内存占用(Footprint)、吞吐量(Throughput)、延迟(Latency)。它们三者构成了「不可能三角」,一款优秀的收集器可以达成其中的两项。

其中,延迟这一项越来越重要了。我们可以容忍多用一点点硬件,靠堆积内存和 CPU 提高吞吐,对冲内存占用。然而延迟却会随着内存增长而增加,因为回收耗时显然是会堆大小成正相关的。因此新 GC 往往会看中延迟。

Serial 到 ZGC 的并发性变化

可以注意到,Shennandoah 和 ZGC 除了部分不太耗时的标记阶段,几乎都是并发的。

Shenandoah GC

值得一提,Shenandoah 是为数不多的,Oracle JDK 中没有,而 OpenJDK 中存在的 GC。开源版胜于商业版,怎么想都是罕见的情况。

Shenandoah 相比 G1,除了一致的使用 Region 堆布局、Humongous Region、根据 Decaying Average 判断价值之外,还存在一些不同之处:

  • 支持并发整理,G1 的回收阶段虽然是多线程的,但是需要暂停用户线程

  • 不使用分代收集,换而言之,没有专门的新生代 Region 或老年代 Region 存在。

  • 不再使用记忆集,摒弃了耗费大量内存和 CPU 的数据结构。改用名为「连接矩阵」(Connection Matrix)的全局数据结构记录跨 Region 引用关系,这种数据结构类似一张二维数组,若 Region N 中有 Region M 的引用,则在 [N, M] 处打上标记。

GC 过程

Shenandoah GC 的工作过程可以大致分为九个阶段:

  1. 初始标记(Initial Marking):类似 G1,标记 GC Roots 的直接引用,STW,但停顿时间和堆大小是无关的,只和 GC Roots 的数量有关。
  2. 并发标记(Concurrent Marking):遍历对象图,标记出可达对象,和用户线程并发。用时取决于对象数量和对象结构复杂度。
  3. 最终标记(Final Marking):SATB 扫描,并将回收价值高的 Region 构成回收集。会短暂停止。
  4. 并发清理(Concurrent Cleanup):用于清理,一整个区域都无存活对象的 Region(Immediate Garbage Regions)。
  5. 并发回收(Concurrent Evacuation):这里是区别于其他 GC 的核心差异。本阶段,Shenandoah 会将回收集的存活对象复制到未使用的 Region 之中。若复制时冻结用户线程,那是相当简单的;但如果两者并发运行,就变得复杂起来了。在移动对象的同时,用户线程可能仍在不停读写对象。移动对象时,需要更新所有内存中对旧地址的引用。而 Shenandoah 使用读屏障和 Brooks Pointer 的转发指针解决了这一问题(后文会详细介绍)。本阶段用时取决于回收集大小。
  6. 初始引用更新(Concurrent Update Reference):复制完毕后,需要更新旧地址到新地址,即引用更新。然而本阶段没有具体操作,只是建立了线程集合点,确保回收线程分配对应的对象。会产生非常短暂的停顿。
  7. 并发引用更新(Concurrent Update Reference):与用户线程并发。耗时取决于引用多少。与并发标记不同,本阶段无需依靠对象图,是按照内存物理地址顺序搜索,过滤出引用类型并更新。
  8. 最终引用更新(Final Update Reference):解决了堆的引用,还需要解决 GC Roots 中的。是一次 GC 过程中的最后一次停顿,耗时与 GC Roots 数量有关。
  9. 并发清理(Concurrent Cleanup):类似第 4 步。重新清除 GC 过程中产生的 Immediate Garbage Regions。

其中最重要的是 3 个并发阶段:并发清理、并发回收、并发引用更新。

下图为官方 Wiki 配图:

Brooks Pointer

接下来我们介绍 Brooks Pointer。Brooks 在 1984 年提出了使用转发指针(Forwarding Pointer, Indirection Pointer)来实现对象移动和用户程序并发的一种方法,因此得名 Brooks Pointer。

传统的方法是,在对象原有内存上设置保护陷阱(Memory Proteciton Trap),一旦访问到,就会自陷中断,进入预设好的异常处理器中,再把访问转发到复制后的新对象上。这种方法虽然可行,但性能堪忧,需要系统反复在用户态和核心态之间切换。

Brooks Pointer 在原有对象布局前,新增一个指向自己的引用。这类似之前提到过的句柄池转发。但间接对象访问的缺点都相同且明显——每次访问都会带来一次额外的转向开销。

虽然此开销可以精简到只有一行汇编指令的程度。但毕竟访问对象是十分常用、频繁的操作,仍然是一笔不可忽视的成本,只是比保护陷阱好了很多。

Brooks Pointer 还存在多线程资源竞争问题。如果是并发读取,不会出现什么问题。然而如果是并发写入,将会是非常疯狂的。不妨设想以下几件事同时发生。

  1. 收集器线程复制了新的对象副本

  2. 用户线程更新对象的某几个字段

  3. 收集器线程更新转发指针

若 2 在 1 和 3 之间执行,结果将是用户线程的操作变更在旧对象上。所以在这里,需要采取同步措施。让用户线程和收集器线程之一成功,另一个等待。而 Shenandoah 实际上使用 CAS 操作保证并发时对象访问的正确性。

保证并发

用户线程的「对象访问」说得轻巧,然而很多操作,诸如读取、写入、比较、计算散列值、加锁等。无不属于对象访问,只要一个程序在运行,对象访问就非常频繁且一刻不停。Shenandoah 在刚发布时(JDK 12),为了要覆盖全部对象的访问操作,不得不同时设置读屏障和写屏障

试想以下代码:

public class User {
  public long phone;
  public int age;
}

public class Holder {
  public User user;
}

class Test {
  void handle(Holder holder) {
    var user = holder.user;
    for (;;) {
      var zip = user.zip;
      System.out.println(zip);
      user.age = 100;
    }
  }
}

在 JDK 12 的 Shenandoah 中,实际执行的语句类似这样:

void handle(Holder holder) {
  var user = RB(holder).user;
  for (;;) {
    var zip = RB(user).zip;
    System.out.println(zip);
    WB(user).age = 100;
  }
}

其中,RB() 表示读屏障,WB() 表示写屏障。在 for 循环中读一次 zip 都需要读屏障,带来的影响是非常大的。假设这段代码被频繁运行——甚至放在另一个更大的循环中执行,非常影响印象。而读是非常频繁的操作,所以 Shenandoah 这一点为人诟病,而开发者也知道这个问题。


所以,在 JDK 13 中,读、写屏障被移除,取而代之的是加载引用屏障(Load Reference Barrier)。也就是说,只有对于引用类型会有屏障检查,而对原生类型如 int,long 等没有。执行时类似这样:

void handle(Holder holder) {
  var user = LRB(holder).user;
  for (;;) {
    var zip = user.zip;
    System.out.println(zip);
    user.age = 100;
  }
}

在 JDK 14 中,还引入了自固定屏障(Self-fixing Barrier)。

JDK 13 中的加载引用屏障用伪代码表示,类似这样:

T load_reference_barrier(T* addr) {
  T obj = *addr;

  // Fast-path: accesses thread-local flag
  if (!is_gc_active()) return obj;

  // Mid-path 1: accesses small bitmap (byte per region, handful of KBs?)
  if (!is_in_collection_set(obj)) return obj;

  // Mid-path 2: accesses fwdptrs in object (entire heap?)
  T fwd = resolve_forwardee(obj);
  if (obj != fwd) return fwd;

  // Slow-path: call to runtime, once per location with non-forwarded object
  return load_reference_barrier_slowpath(obj);
}

每一次对象都会进入该方法。而若未处于 GC 状态,则 fast return。在这之后,如果是回收集引用,就会深入解析该对象。想象一段代码在 for 循环里,mid-path 2 对性能的损失是很大的,它的范围比较广。而 Self-fixing Barrier 就是解决问题的办法。基本思路是当我们解析出了对象,并发现了转发的副本的同时,更新位置。下次就会在 Mid-path 1 直接退出。

T load_reference_barrier(T* addr) {
  T obj = *addr;

  // Fast-path: accesses thread-local flag
  if (!is_gc_active()) return obj;

  // Mid-path 1: accesses small bitmap (byte per region, handful of KBs?)
  if (!is_in_collection_set(obj)) return obj;

  // Mid-path 2: accesses fwdptrs in objects (entire heap?)
  T fwd = resolve_forwardee(obj);

  if (obj != fwd) {
    // Can do the update here
    CAS(addr, fwd, obj);
    return fwd;
  }

  // Slow-path: call to runtime, once per location with non-forwarded object
  fwd = load_reference_barrier_slowpath(obj);
  // Can do the update here
  CAS(addr, fwd, obj);
  return fwd;
}

同时,上述方法可以简化,我们不必再使用 mid-path 2 来检查,而是对每次通过 mid-path 的 LFB 都执行 load_reference_barrier_slowpath

T load_reference_barrier(T* addr) {
  T obj = *addr;

  // Fast-path: accesses thread-local flag
  if (!is_gc_active()) return obj;

  // Mid-path 1: accesses small bitmap (byte per region, handful of KBs?)
  if (!is_in_collection_set(obj)) return obj;

  // Slow-path: call to runtime, once per non-updated location
  return load_reference_barrier_slowpath(obj, addr); // Update is actually here
}

虽然在代码上看起来,直接 call to runtime 会耗费时间。但 mid-path 2 往往比 call to runtime 更加耗时。

消除转发指针

可能你会觉得作为 Shenandoah 的基本要求,Brooks 转发指针是必须的。然而实际上并非如此。

Hotspot VM 中常见的对象布局为:

0   --- mark-word
8   --- class-word
16  --- field 1
24  --- field 2
32  --- field 3

每个部分都是一个堆字(Heap Word)。根据系统架构不同而不同,如常见的 32 和 64 位。

mark-word 就是对象 header。之前提到过,它的用途不被特定,可以用于锁、GC 年龄等。

第二个字为类指针保留,这是一个指向 HotSpot VM 类定义的指针。

Array 还会有额外的字用于储存数组长度。后面才是真正的内容。

当使用 Shenandoah GC 时,还会额外增加

-8   --- forward pointer

然而这会带来额外的内存消耗以及转发调用,尽管如之前所说,转发操作只需要一行汇编。

在 JDK 13 加载引用屏障基础上,消除转发指针变得容易。在并发回收时,拷贝对象的原本(from-space)也不被允许任何访问。这意味着原本可以被随意修改,比如用于储存转发指针,而不是保留额外的字。现在的结构将类似于:

0   --- [mark-word] or [forward pointer]
8   --- class-word
16  --- field 1
24  --- field 2
32  --- field 3

在 load reference barrier 方法被调用时,可以执行对应的解码操作:

oop decode_forwarding(oop obj) {
  mark m = obj->load_mark();
  if ((m & 0b11) == 0b11) {
    return (oop) (m & ~0b11);
  } else {
    return obj;
  }
}

这解决了内存问题,并带来了额外收获:

  • 增加吞吐,在 GC 触发前分配更多对象
  • 对象更紧密,可以更好的利用 CPU 缓存
  • 内部实现变得更简单了,维护起来更容易

Stack Watermark

栈水位(Stack Watermark)用于减缓为数不多的暂停环节的影响,即初始标记和最终标记。

该技术于 JDK 17 中实现,用于 ZGC 和 Shanendoah GC。

在 GC 开始时需要扫描堆栈用于后续处理。较小的程序堆栈一般不深。然而,大项目时常到达 300 多的堆栈是不足为奇的。此时就该 Stack Watermark 了。

核心思路是,可以边扫描边执行。原本 GC 时,会将所有阵栈都锁定,再统一扫描。但 GC 不可能一瞬扫描完,需要时间运行。通过设置 Watermark,可以实现 GC 线程和用户线程的协调。Watermark 区分哪些部分可以安全执行,哪些部分不能。

通过该优化,Shanendoah GC 实现了惊人的亚毫秒级 GC 暂停。

VersionInit markFinal mark
11421μs421\,\mu s1294μs1294\,\mu s
16321μs321\,\mu s704μs704\,\mu s
1763μs63\,\mu s328μs328\,\mu s

本文内容就先到这里,下节继续讲解 ZGC。