JEP 376:ZGC:并发线程栈处理
总结
将 ZGC 线程栈处理从安全点移至并发阶段。
目标
- 从 ZGC 安全点中移除线程栈处理。
- 使栈处理变得惰性、协作、并发和增量。
- 从 ZGC 安全点中移除所有其他每个线程的根处理。
- 提供一种机制,其他 HotSpot 子系统可以通过该机制惰性地处理栈。
非目标
- 其目标并非实现对非 GC 安全点操作(如类重定义)的每线程并发处理。
成功指标
- 改进延迟所需的吞吐量成本应微不足道。
- 在典型机器上,ZGC 安全点内部耗时应少于 1 毫秒。
动机
ZGC 垃圾收集器(GC)旨在使 HotSpot 中的 GC 暂停和可扩展性问题成为过去。到目前为止,我们已经将所有与堆大小和元空间大小相关的 GC 操作从安全点操作移到了并发阶段。这些操作包括标记、重定位、引用处理、类卸载以及大多数根处理。
唯一仍然在 GC 安全点中进行的活动是根处理的一个子集和一个有时间限制的标记终止操作。这些根包括 Java 线程栈和各种其他线程根。这些根是一个问题,因为它们随着线程数量的增加而增加。在大型机器上使用多个线程时,根处理成为一个问题。
为了超越我们目前的水平,并满足在大型机器上 GC 安全点内所花费的时间不超过 1 毫秒的期望,我们必须将这种每线程处理(包括栈扫描)移到并发阶段。
在这项工作之后,基本上不会再在 ZGC 安全点操作中进行任何重要的操作。
作为该项目的一部分而构建的基础设施最终可能会被其他项目(例如 Loom 和 JFR)用于统一惰性栈处理。
描述
我们建议通过栈水印屏障来解决栈扫描问题。一个 GC 安全点将在逻辑上通过切换一个全局变量使 Java 线程栈无效。每个被置为无效的栈将被并发处理,同时跟踪尚未处理的部分。当每个线程从安全点恢复时,它会通过比较一些 epoch 计数器发现其栈已无效,因此它将安装一个栈水印以跟踪其栈扫描的状态。栈水印可以区分某个帧是否位于水印之上(假设栈向下增长),从而确定该帧不能被 Java 线程使用,因为它可能包含过期的对象引用。
在所有弹出帧或在栈的最后帧之下进行遍历的操作中(例如,栈遍历器、返回和异常),钩子会将某个栈局部地址与水印进行比较。(这个栈局部地址可以是帧指针,如果可用的话;或者对于编译后的帧,帧指针可能被优化掉了,但帧具有相对恒定的大小,则可以使用栈指针。)当地址高于水印时,将会采用慢路径来修复一个帧,具体操作是更新其中的对象引用并将水印向上移动。为了使返回操作像今天一样快速,栈水印屏障将使用经过略微修改的安全点轮询机制。新的轮询不仅会在有待处理的安全点(或者确实是线程本地握手)时采用慢路径,还会在返回到尚未修复的帧时采用慢路径。这可以通过单个条件分支为编译后的方法进行编码。
堆栈水印的一个不变量是,给定一个作为堆栈最后一帧的被调用者(callee),被调用者和调用者(caller)都会被处理。为确保这一点,在从安全点(safepoints)唤醒时安装堆栈水印状态时,调用者和被调用者都会被处理。被调用者会被“武装”起来,以便从该被调用者返回时会触发对调用者的进一步处理,并将“武装”帧移动到调用者,依此类推。因此,由帧展开或遍历触发的处理总是发生在被展开或遍历帧之上两帧的位置。这简化了必须由调用者拥有但被被调用者使用的参数的传递;调用者和被调用者的帧(以及额外的堆栈参数)都可以自由访问。
Java 线程将处理继续执行所需的最少帧数。并发 GC 线程将处理剩余的帧,确保所有线程栈和其他线程根最终都会被处理。利用堆栈水印屏障的同步将确保 Java 线程不会在 GC 处理帧时返回到该帧。
替代方案
在处理栈遍历器时,我们考虑了另一种解决方案,即在虚拟机中对象引用从栈中加载的地方广泛设置加载屏障。我们摒弃了这种方案,因为它从根本上无法保证对指向对象内部的指针进行正确的根处理。指向对象内部的指针的基指针必须始终在内部指针之后处理,而栈遍历器可能会违反这一不变量。因此,我们选择了通过栈遍历来处理整个帧(如果尚未处理)的方法。
测试
此项工作影响的主要代码路径是其他测试已经很大程度上施加压力的路径,因此使用现有的测试基础设施进行压力测试应该就足够了。