解析 ‘Stop-The-World (STW) Phase 2’:深入 1ms 以内的 GC 暂停中,哪些逻辑是必须串行执行的?

各位技术同仁,下午好!

今天,我们将深入探讨一个在Java虚拟机(JVM)垃圾回收(GC)领域既关键又充满挑战的议题:Stop-The-World (STW) Phase 2。特别是,我们将聚焦于那些现代低延迟GC算法中,被严格控制在 1ms 以内 的STW暂停里,究竟有哪些逻辑是必须串行执行的。

在高性能应用中,GC暂停时间是衡量系统响应能力和用户体验的关键指标。开发者们无不渴望将GC暂停降至最低,最好是完全消除。然而,即使是最先进的并发垃圾收集器,也无法完全避免STW暂停。它们通过精巧的设计,将绝大部分工作转移到与应用线程并发执行,但总有一些核心操作,为了保证堆的一致性和GC的正确性,仍然需要短暂地“停下世界”。

本次讲座的目标,就是揭开这些“不可避免的串行逻辑”的神秘面纱,理解它们为何存在,以及现代GC如何极致地压缩它们的执行时间。


第一部分:STW暂停的本质与GC循环中的阶段划分

1.1 STW暂停:为何是必需品?

Stop-The-World (STW),顾名思义,是指GC暂停所有应用线程,使它们无法执行任何代码,直到GC操作完成并允许应用线程恢复执行。这就像是给整个JVM世界按下了暂停键。

为什么需要STW?核心原因在于一致性。GC需要在一个稳定的、不被应用程序线程修改的堆快照上执行某些关键操作,例如:

  • 识别GC根 (GC Roots): 确定哪些对象是活动对象(可达对象)的起点。如果在识别过程中,应用程序线程正在修改对象的引用关系,那么识别结果将是不准确的,可能导致活动对象被错误回收,或垃圾对象被错误保留。
  • 处理引用对象 (Reference Objects):SoftReferenceWeakReferencePhantomReference等。这些引用的语义通常在GC周期内被触发,需要在GC决定对象存活状态时进行处理。
  • 卸载类 (Class Unloading): 确定哪些类不再被任何地方引用,可以安全地从方法区卸载。这同样需要一个全局一致的视图。

没有STW,GC将面临一个“活生生”的、不断变化的堆,这使得准确而安全地执行某些操作变得极其困难,甚至不可能。

1.2 GC循环中的并发与暂停阶段

现代的垃圾收集器,如G1、ZGC和Shenandoah,都采用了分代和/或区域化的设计,并大量利用并发执行来减少STW时间。一个典型的GC周期通常包含以下几个大阶段:

  • 初始标记 (Initial Mark): 通常是一个短STW阶段,用于标记从GC根直接可达的对象。
  • 并发标记 (Concurrent Marking): GC线程与应用线程并发运行,遍历堆,标记所有可达对象。
  • 再标记 (Remark): 通常是一个短STW阶段,用于处理并发标记期间的堆变化,并完成一些收尾工作。
  • 并发清除/复制/整理 (Concurrent Sweeping/Copying/Compacting): GC线程与应用线程并发运行,回收垃圾空间或移动对象。

在这些阶段中,我们今天要深入探讨的,正是那些被标记为“短STW阶段”中的 Phase 2 所承载的逻辑。

1.3 STW Phase 1 (Safepoint) 与 Phase 2 的界定

在JVM内部,当GC需要进入STW状态时,它会首先触发一个 Safepoint 请求。

  • STW Phase 1 (Safepoint Request/Synchronization): 这个阶段主要是等待所有应用线程到达一个安全点(Safepoint)。安全点是JVM中预定义的程序执行位置,在这些位置上,线程的执行状态(如栈帧、寄存器)是已知的,并且可以被GC安全地检查。这个过程本身也可能耗费一些时间,尤其是在有大量线程或线程执行长循环的情况下。一旦所有线程都到达安全点,它们就会暂停执行。
  • STW Phase 2 (Actual GC Work): 一旦所有应用线程都暂停在安全点,GC就可以开始执行它必须在STW状态下完成的具体任务了。我们今天讨论的“必须串行执行的逻辑”,主要就发生在这个 Phase 2 中。

值得注意的是,即使在Phase 2中,许多任务也可以通过并行化(使用多个GC线程)来加速。但我们关注的是那些即使并行化也存在底层串行瓶颈,或本质上就需要单一协调者来完成的逻辑


第二部分:1ms以内暂停的挑战与现代GC的策略

2.1 为何1ms如此重要?

在现代应用中,1毫秒的暂停时间是一个极具挑战性的目标,但对于许多场景而言又是至关重要的:

  • 用户体验: 对于交互式应用,100ms以上的暂停就可能被用户感知。1ms的暂停几乎是用户无感的。
  • 高频交易/实时系统: 在金融交易、工业控制等领域,微秒级的延迟都可能导致巨大损失。
  • 微服务架构: 服务间调用链路复杂,任何一个服务的长暂停都可能导致整个调用链超时或雪崩。

因此,追求极致的低延迟GC,将STW暂停控制在1ms以内,是现代JVM设计和优化的一大热点。

2.2 传统GC与现代GC在STW上的对比

GC收集器 主要特点 STW暂停时间 Phase 2 典型工作 适用场景
Serial 单线程GC 长(数十毫秒到秒) 所有GC工作 小型应用,客户端JVM
Parallel 多线程GC,吞吐量优先 长(数十毫秒到秒) 所有GC工作 服务端,可容忍长暂停
CMS 并发标记和清除 中(几十到几百毫秒) 初始标记,再标记 服务端,对延迟有要求但能容忍CMS的碎片问题
G1 分区、并发、可预测暂停 短(数毫秒到数十毫秒,目标是可配置) 初始标记,再标记 服务端,大堆,对延迟有要求
ZGC/Shenandoah 着色指针、读屏障、并发整理 极短(亚毫秒级,通常 < 1ms) 初始标记,重定位停止 服务端,超大堆,对延迟要求极高

可以看到,从G1开始,GC的设计理念就转向了“可预测的暂停时间”,并力求将其控制在较低水平。而ZGC和Shenandoah更是将STW暂停推向了亚毫秒(Sub-millisecond)级别。

2.3 现代GC如何实现极致的STW缩短?

为了将STW暂停时间压缩到1ms以内,现代GC采用了多种复杂且精巧的技术:

  1. 最大化并发执行: 将绝大部分标记、清除、整理工作转移到与应用线程并发执行。这是最根本的策略。
  2. 屏障技术 (Barriers):
    • 写屏障 (Write Barriers): 在应用程序修改对象引用时插入代码,记录旧引用或新引用,帮助GC追踪堆变化。例如G1的SATB (Snapshot-At-The-Beginning)
    • 读屏障 (Read Barriers): 在应用程序读取对象引用时插入代码,帮助GC在对象移动时进行转发。例如ZGC和Shenandoah的读屏障。
      这些屏障使得GC能够在并发阶段追踪到应用线程对堆的修改,从而减少STW阶段需要处理的“不一致性”。
  3. 着色指针 (Colored Pointers): ZGC和Shenandoah的核心技术。通过在指针中编码GC状态信息(如是否已标记、是否已重定位),省去了在对象头中存储GC元数据的开销,并允许GC线程在并发阶段修改指针指向,而应用线程通过读屏障感知并转发。
  4. 区域化与增量处理: G1将堆划分为多个区域,可以增量地回收部分区域,实现可预测的暂停。
  5. 并发整理 (Concurrent Compaction): ZGC和Shenandoah甚至可以在不暂停应用线程的情况下进行堆的整理和对象的移动。

尽管有这些先进技术,但某些操作的本质决定了它们仍然需要在STW Phase 2中,至少在某个关键协调点上,进行串行执行。接下来,我们将详细剖析这些逻辑。


第三部分:STW Phase 2中必须串行执行的逻辑:深入剖析

在1ms以内的STW暂停中,GC线程并非完全串行执行所有操作。相反,它会尽量并行化。但即便如此,仍然存在一些固有的串行瓶颈需要全局一致性判断的逻辑,它们是无法被完全并行化或并发化的。我们将逐一分析这些核心任务。

3.1 核心任务一:根集合扫描 (Root Scanning)

GC根 (GC Roots) 是GC算法判断对象是否存活的起点。任何从GC根可达的对象都被认为是存活的,不能被回收。

常见的GC根包括:

  • 虚拟机栈中的引用 (Stack Frames): 正在执行的方法中的局部变量、参数等。
  • 本地方法栈中的引用 (Native Method Stacks): JNI(Java Native Interface)方法中的引用。
  • 方法区中的静态变量和常量 (Method Area): 类静态字段、字符串常量池中的引用等。
  • JNI句柄表中的引用 (JNI Handle Table): 本地代码持有的Java对象引用。
  • Monitor持有的引用: 锁对象中可能持有的引用。
  • 活跃线程的Thread对象本身。
  • JVM内部数据结构: 如系统类加载器、重要的内部类等。

3.1.1 为什么根集合扫描必须在STW中进行?

根集合是应用程序线程直接可访问并修改的。如果在扫描过程中,应用程序线程正在:

  • 修改栈帧: 调用新方法、返回方法,导致局部变量的引用发生变化。
  • 修改静态变量: 改变静态字段指向的对象。
  • 修改JNI句柄: 增删JNI引用。

那么,GC扫描到的根集合就可能是不完整或不准确的。例如,一个对象在扫描开始时是可达的,但在扫描过程中,其唯一的GC根引用被应用程序线程置空,GC可能错误地将其标记为存活。反之,一个新创建的对象可能在扫描前不可达,但扫描过程中被某个GC根引用,GC可能漏掉它。

为了获取一个精确且一致的根集合快照,暂停所有应用线程是不可避免的。

3.1.2 并行化与串行瓶颈

尽管根集合扫描需要STW,但其内部操作是可以高度并行化的:

  • 并行扫描不同类型的根: 不同的GC线程可以同时扫描不同的线程栈、不同的静态字段区域、不同的JNI句柄表等。
  • 并行扫描线程栈: JVM通常会为每个Java线程生成一个描述其栈帧布局的OopMap(或类似结构),GC线程可以根据这些OopMap并行地扫描每个线程的栈。

然而,启动和协调这些并行扫描任务,以及在某些情况下更新全局GC根状态(例如,将所有活动根添加到GC工作队列),仍然可能涉及串行协调点。尤其是在非常短的STW暂停中,这些协调点的开销会变得相对显著。

示例代码:GC根的抽象概念

在Java层面,我们通常不直接操作GC根,但可以理解其概念:

public class GCRootExample {
    // 1. 静态变量 (方法区中的GC根)
    private static Object staticFieldRoot = new Object();

    // 2. JNI引用 (通过JNI与C/C++交互时,C/C++代码可能持有Java对象的引用)
    // 通常通过JNIEnv->NewGlobalRef()或NewLocalRef()创建,这些引用需要GC特殊处理。
    // 这里无法直接用Java代码模拟JNI句柄,但可以理解其作用。

    public void methodA(Object paramRoot) {
        // 3. 虚拟机栈中的局部变量 (栈帧中的GC根)
        Object localVarRoot = new Object();
        System.out.println("Inside methodA: " + paramRoot + ", " + localVarRoot);

        // 创建一个匿名内部类,其this引用也是一种GC根的来源(间接通过外部对象)
        Runnable r = new Runnable() {
            @Override
            public void run() {
                // 这个匿名内部类会捕获外部的this引用,如果外部对象是GC根,这里也会间接保持可达
                System.out.println("Runnable accessing staticFieldRoot: " + staticFieldRoot);
            }
        };
        r.run();
    }

    public static void main(String[] args) throws InterruptedException {
        GCRootExample example = new GCRootExample();
        example.methodA(new Object()); // methodA中的paramRoot和localVarRoot是GC根

        // 另一个线程的栈帧也是GC根的来源
        Thread t = new Thread(() -> {
            Object threadLocalRoot = new Object();
            System.out.println("Inside new thread: " + threadLocalRoot);
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        t.start();
        t.join();

        // 此时,methodA和新线程的栈帧已清空,但staticFieldRoot依然是GC根
        System.out.println("After methods, staticFieldRoot still exists: " + staticFieldRoot);

        // 触发一次GC,理论上除了staticFieldRoot和GC内部对象,其他都可回收
        System.gc();
    }
}

STW Phase 2的根集合扫描中,GC会遍历所有这些被认为是GC根的位置,找到它们直接引用的对象,并将这些对象标记为存活的起始点。

3.2 核心任务二:初始标记与再标记 (Initial Mark & Remark)

在许多并发GC中,标记阶段被分解为多个子阶段,其中一些是STW的。

3.2.1 初始标记 (Initial Mark)

这是GC循环的第一个STW阶段,其任务非常简单:标记所有从GC根直接可达的对象。这个阶段通常非常短,因为它只扫描根集合引用的第一层对象。

  • 为什么需要STW? 原因同根集合扫描,为了获取一个一致的根集合快照,并根据这个快照标记初始的可达对象。
  • 串行瓶颈: 根集合的最终聚合和处理,以及将初始标记结果写入GC内部数据结构(如标记位图),可能包含串行部分。但由于其工作量小,通常极快。

3.2.2 再标记 (Remark)

再标记 (Remark) 阶段通常是STW Phase 2中工作量较大且复杂的一个环节,尤其是在G1这样的收集器中。它发生在并发标记阶段之后,主要目的是:

  1. 处理并发标记期间的引用变化: 并发标记阶段,应用程序线程仍在运行,可能会创建新对象、修改引用关系,或者将原本不可达的对象变得可达,反之亦然。再标记阶段需要解决这些并发修改带来的“漏标”或“错标”问题。
  2. 处理引用对象 (Reference Objects):SoftReferenceWeakReferencePhantomReferenceFinalizerReference等。
  3. 类卸载 (Class Unloading) 准备: 确定哪些类可以被卸载。
  4. 字符串去重 (String Deduplication) 准备: 如果启用。
  5. 其他内部数据结构的清理与维护。

3.2.3 为什么再标记需要STW?

  • 精确处理引用变化: 尽管写屏障(如G1的SATB)会记录并发期间的引用变化,但在再标记阶段,GC需要一个稳定的环境来最终处理这些记录,确保所有在并发标记阶段被漏掉的活动对象都能被正确标记。如果应用程序线程仍在修改引用,处理这些记录将变得不准确。
  • 引用对象语义: Reference对象的处理涉及到对象可达性的最终判断,并可能将Reference对象加入到对应的ReferenceQueue中。这些操作必须在一个一致的堆状态下完成,以避免数据竞争和逻辑错误。
  • 类卸载的准确性: 确定一个类是否可卸载需要知道所有实例、所有子类、所有类加载器引用是否都已不可达。这同样需要一个全局一致的堆视图。

3.2.4 再标记阶段的串行瓶颈

虽然再标记的大部分工作(如扫描SATB缓冲区)可以并行执行,但以下部分仍可能存在串行瓶颈或需要串行协调:

  1. 处理全局的写屏障缓冲区列表: 每个线程都有自己的写屏障缓冲区。在再标记阶段,GC会遍历所有线程的缓冲区,并处理其中记录的引用变化。尽管处理每个缓冲区可以并行,但收集所有线程的缓冲区列表以及最终将所有处理结果合并到全局标记状态,可能存在串行协调点。
  2. Reference对象的最终处理: 虽然Reference对象的扫描可以并行,但将它们加入到对应的ReferenceQueue中,以及处理FinalizerReference(将它们添加到Finalizer队列),可能需要对共享的队列结构进行串行访问和更新。特别是Finalizer的注册,这是一个关键的串行操作。
  3. StringTableSymbolTable的清理: 这些是JVM内部共享的数据结构。清理过期条目需要对这些表进行修改,通常需要某种形式的全局锁或串行访问,以确保数据一致性。
  4. 类卸载的最终决策: 尽管在并发阶段可以初步识别候选类,但最终决定一个类是否可以卸载,以及执行卸载操作,通常需要在STW中进行,因为它涉及修改JVM的类加载器层次结构和元数据。

3.3 核心任务三:引用处理 (Reference Processing)

Java中的java.lang.ref包提供了软引用(SoftReference)、弱引用(WeakReference)和虚引用(PhantomReference),以及用于终结处理的FinalizerReference。这些引用允许程序在不同程度上与GC进行交互。

引用类型 可达性级别 GC行为 常见用途
强引用 强可达 只要有强引用,对象永不回收 绝大多数对象引用
软引用 软可达 内存不足时回收 缓存
弱引用 弱可达 下次GC时回收 弱引用Map、监控对象生命周期
虚引用 虚可达 对象被回收前通知,用于资源清理 外部资源清理(如Direct ByteBuffer的cleaner)
终结器引用 终结可达 对象被回收前,执行finalize()方法 遗留代码,不推荐使用

3.3.1 为什么引用处理需要STW?

引用处理的核心在于根据对象的最终可达性来决定引用的行为。

  • 判断对象可达性: 在GC确定哪些对象是垃圾之前,它不能处理Reference对象。这个判断过程发生在STW阶段,以保证判断结果的准确性。
  • 队列操作: 当一个Reference对象(例如WeakReference)的referent(它引用的对象)变得不可达时,这个Reference对象本身可能会被添加到与之关联的ReferenceQueue中。应用程序线程通常会从这个队列中取出Reference对象并进行处理(例如清除缓存)。如果在应用程序线程仍在运行的同时进行这些队列操作,可能会导致竞争条件和不一致状态。GC需要保证在它将Reference对象加入队列时,应用程序线程不会同时尝试访问或修改这些队列。
  • Finalizer的特殊性: FinalizerReference的处理更为特殊。当其referent变得可达但又没有被其他强引用或软弱引用直接引用时,GC会将该对象加入到Finalizer队列,由一个单独的Finalizer线程去执行其finalize()方法。这个加入队列的操作需要一个稳定的堆状态。

3.3.2 引用处理的串行瓶颈

尽管GC线程可以并行地扫描堆中的Reference对象,并对每个Reference对象的可达性进行判断,但将这些Reference对象添加到对应的ReferenceQueue,以及将需要执行finalize()方法的对象加入到Finalizer队列中,这些操作通常会涉及到对共享数据结构的更新,从而引入串行瓶颈。

示例代码:WeakReference 的使用

import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;

public class WeakReferenceExample {
    public static void main(String[] args) throws InterruptedException {
        ReferenceQueue<MyObject> queue = new ReferenceQueue<>();
        MyObject obj = new MyObject("原始对象");
        WeakReference<MyObject> weakRef = new WeakReference<>(obj, queue);

        System.out.println("Weak reference created, referent: " + weakRef.get());

        // 将强引用置空,使MyObject对象变得弱可达或不可达
        obj = null;
        System.out.println("Strong reference cleared, referent: " + weakRef.get());

        // 提示GC执行,希望回收MyObject
        System.gc();
        Thread.sleep(100); // 留出时间让GC和ReferenceQueue处理

        // 再次检查弱引用,此时可能已被回收
        System.out.println("After GC, referent: " + weakRef.get());

        // 检查ReferenceQueue,看弱引用是否已被加入
        if (queue.poll() == weakRef) {
            System.out.println("Weak reference was enqueued to ReferenceQueue.");
        } else {
            System.out.println("Weak reference not yet enqueued or already processed.");
        }

        // 再次触发GC,确保清理
        System.gc();
        Thread.sleep(100);
        if (queue.poll() == weakRef) {
            System.out.println("Weak reference was enqueued to ReferenceQueue.");
        } else {
            System.out.println("Weak reference not yet enqueued or already processed (second check).");
        }
    }

    static class MyObject {
        String name;
        MyObject(String name) {
            this.name = name;
            System.out.println(name + " created.");
        }

        @Override
        protected void finalize() throws Throwable {
            super.finalize();
            System.out.println(name + " finalized.");
        }

        @Override
        public String toString() {
            return "MyObject{" + "name='" + name + ''' + '}';
        }
    }
}

STW Phase 2中,GC会:

  1. 判断obj是否已不可达。
  2. 如果不可达,则将weakRef这个WeakReference对象加入到queue中。
  3. 如果MyObjectfinalize()方法且符合条件,还会将其加入Finalizer队列。

这些对队列的更新操作,在保证原子性和一致性方面,往往需要串行化或使用细粒度锁。

3.4 核心任务四:类卸载 (Class Unloading)

当一个类不再被任何地方引用时,它可以被安全地从方法区卸载,释放其占用的内存。

3.4.1 为什么类卸载需要STW?

一个类被认为是可卸载的,需要满足以下三个条件:

  1. 该类的所有实例都已被回收(堆中不存在该类的任何对象)。
  2. 加载该类的ClassLoader已被回收。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法通过任何途径访问该类。

这些条件涉及到对整个堆和方法区的全局可达性分析。如果应用程序线程在GC判断和卸载类的过程中修改了类加载器引用、类实例引用或Class对象引用,就可能导致:

  • 错误卸载: 正在使用的类被卸载,导致NoClassDefFoundErrorClassNotFoundException
  • 未能卸载: 应该被卸载的类没有被卸载,导致方法区内存泄漏。

因此,为了确保类卸载的正确性和安全性,必须在STW暂停中进行最终的判断和执行。

3.4.2 类卸载的串行瓶颈

  • 全局可达性分析的最终确认: 尽管并发GC可以在并发阶段识别出潜在的候选卸载类,但最终确认这些类是否真的满足卸载条件,需要一个全局一致的快照。
  • 修改JVM内部数据结构: 类卸载不仅仅是释放内存,它还涉及到从JVM的内部类加载器哈希表、符号表、常量池等数据结构中移除该类的元数据。这些全局共享数据结构的修改通常需要串行化或全局锁来保证一致性。

3.5 核心任务五:内部数据结构清理与维护

JVM维护着许多重要的内部数据结构,它们也可能在GC的STW阶段进行清理和维护。

3.5.1 字符串去重 (String Deduplication)

从Java 8u20开始,G1 GC引入了字符串去重功能。它通过在GC过程中扫描堆中的字符串对象,如果发现内容相同的字符串,就只保留一个,将其他字符串对象的内部字符数组引用指向保留的那个,从而节省内存。

  • 为什么需要STW? 字符串去重涉及到修改java.lang.String对象的内部字段(value字段),将其指向共享的字符数组。这个修改操作必须在应用程序线程不访问这些字符串对象的时候进行,以避免数据竞争。此外,去重过程需要构建一个全局的字符串哈希表来查找重复项,这个过程也需要一个稳定的堆视图。
  • 串行瓶颈: 即使扫描和比较字符串可以并行,但更新String对象的内部引用(value字段),以及对全局字符串去重哈希表(如果使用)的维护,都可能引入串行协调点。

3.5.2 符号表 (SymbolTable) 和其他内部表清理

JVM的SymbolTable存储着所有已加载类、方法、字段的名称,以及字符串常量池中的字符串。这些符号在JVM内部被广泛使用。在类卸载后,对应的符号也可能变得不可达并需要清理。

  • 为什么需要STW? SymbolTable是JVM的核心内部数据结构,其修改必须在安全的、无并发访问的环境下进行,以避免损坏JVM的内部状态。
  • 串行瓶颈: 清理和维护SymbolTable通常涉及对哈希表或树结构的遍历和修改,这些操作在全局共享的结构上往往需要串行化。

3.5.3 JNI句柄表 (JNI Handle Table) 清理

JNI允许本地代码持有Java对象的引用。这些引用存储在JNI句柄表中。当这些引用不再被本地代码使用时,需要由GC来清理。

  • 为什么需要STW? 确定JNI句柄是否仍然活跃需要根集合扫描,并且清理句柄表涉及对全局结构的修改。

3.6 核心任务六:并发阶段的收尾与验证

即使是高度并发的GC,在并发阶段结束后,也可能需要一个短暂的STW暂停来执行最终的收尾工作和验证,确保整个GC周期的完整性和正确性。

  • 确保所有GC线程完成: 确认所有并发GC线程都已完成其分配的任务,并安全地停止。这通常通过一个全局的协调机制来实现。
  • 状态切换: 将GC的状态从一个阶段(如并发标记)切换到下一个阶段(如并发清理),这可能涉及更新GC的全局状态变量,需要原子操作或全局锁。
  • 检查不一致性: 在极少数情况下,并发GC可能会遇到一些无法自动解决的边缘情况或不一致性。STW阶段提供了一个机会来检查这些潜在问题,并进行必要的修复或回滚。

这些协调和状态切换操作,尽管本身耗时可能极短,但由于其全局性和对JVM核心状态的修改,往往需要串行执行。

3.7 针对特定低延迟GC的考量:ZGC与Shenandoah

ZGC和Shenandoah是JVM中追求极致低延迟的代表。它们将大多数传统的STW工作(如并发标记、并发整理)都并发化了。它们是如何做到这一点的,以及它们极短的STW阶段中具体做了什么?

3.7.1 ZGC的STW阶段

ZGC的目标是将暂停时间控制在亚毫秒级别,且与堆大小无关。它主要有两个STW阶段:

  1. Initial Mark (初始标记):

    • 工作内容: 扫描GC根集合(如线程栈、JNI引用、静态字段),将它们直接引用的对象标记为“已标记(Marked)”状态。
    • 为什么STW? 同上述根集合扫描的原因,需要一个一致的根集合快照。
    • 极致短的原因: ZGC利用着色指针,在标记对象时直接修改指针中的标记位,而非修改对象头。这使得标记操作非常高效。它也利用了读屏障,使得在并发阶段,即使根集合中的引用被修改,也能被GC感知。
  2. Relocation Stop (重定位停止):

    • 工作内容: 在并发重定位(Compaction)阶段之后,ZGC需要更新所有GC根中指向被移动对象的引用。因为应用线程在并发重定位期间可能持有旧的引用,读屏障会帮助它们转发到新地址。但在根集合中,这些引用必须被最终更新。
    • 为什么STW? 根集合的更新必须在一个稳定的状态下进行,以确保所有根都指向正确的、重定位后的地址。
    • 极致短的原因: ZGC的读屏障在并发重定位期间已经处理了大部分的引用更新转发。Relocation Stop只是一个最终的、对根集合的检查和修正。这个阶段通常只涉及对少量活动根的重新映射。

3.7.2 Shenandoah的STW阶段

Shenandoah同样致力于实现亚毫秒级的暂停时间,且与堆大小无关。它也有几个短暂的STW阶段:

  1. Initial Mark (初始标记):

    • 工作内容: 类似于ZGC,扫描GC根集合,标记初始可达对象。
    • 为什么STW? 同样是为了根集合的一致性快照。
    • 极致短的原因: Shenandoah也使用读屏障和着色指针来高效标记。
  2. Final Mark (最终标记):

    • 工作内容: 处理并发标记期间发生的堆变化(通过读屏障日志),处理Reference对象,以及进行类卸载的准备。
    • 为什么STW? 与G1的Remark阶段类似,需要一个稳定状态来最终处理并发期间的引用变化、引用队列操作和类卸载的判断。
    • 极致短的原因: Shenandoah的读屏障比G1的写屏障更强大,能够更有效地追踪堆变化。因此,Final Mark需要处理的“不一致性”通常更少。
  3. Relocation Stop (重定位停止):

    • 工作内容: 更新GC根中指向被移动对象的引用。
    • 为什么STW? 同ZGC的Relocation Stop,确保根集合的最终一致性。
    • 极致短的原因: Shenandoah的读屏障和转发表机制使得并发重定位非常高效,Relocation Stop只是一个对根集合的最终修正。

总结 ZGC/Shenandoah 的 STW 串行逻辑:

对于ZGC和Shenandoah这样的收集器,它们将大部分复杂且耗时的GC工作并发化后,其1ms以内的STW暂停主要集中在:

  1. 根集合的扫描和初始标记: 确保从应用程序线程视角看,所有活跃对象的起点都被准确识别。这是任何GC都无法避免的。
  2. 根集合的重映射: 在并发整理(对象移动)之后,确保所有GC根指向对象的最新地址。
  3. 少量全局状态的更新和协调: 例如,完成并发阶段的最终确认,更新GC内部状态机。
  4. Reference对象的最终入队和Finalizer的注册。

这些操作之所以能做到1ms以内,是因为它们被设计得极其精简,只处理必要的核心逻辑,并且在JVM的底层实现中被高度优化,大量利用了多核并行处理能力。


第四部分:并行化与串行执行的界限

在STW Phase 2中,GC引擎会尽可能地并行化任务。例如:

  • 根集合扫描: 多个GC线程可以同时扫描不同的线程栈、JNI句柄表或静态字段区。
  • 写屏障缓冲区处理 (G1的Remark阶段): 每个GC线程可以处理一部分写屏障缓冲区。
  • 标记位图更新: 多个线程可以并行更新不同的堆区域的标记位图。

然而,并行化并非万能。存在以下几种情况,使得串行执行成为必需:

  1. 全局一致性状态的更新: 当修改涉及整个JVM的全局状态(如GC状态机、类加载器链表、全局符号表等)时,为了避免数据损坏和竞争条件,通常需要一个全局锁或串行执行。
  2. 单一协调者模式: 某些任务本质上需要一个单一的“主宰者”来做出最终决策或执行最终的协调。例如,将对象添加到Finalizer队列,或最终决定一个类是否可以卸载。
  3. 资源争用: 即使任务可以并行,如果它们需要访问相同的共享资源(如某个核心数据结构),并且锁的粒度不够细,或者获取/释放锁的开销过大,也可能导致串行瓶颈。
  4. 算法本身的限制: 某些GC算法的特定步骤可能就是设计为串行的,或者其并行化的收益不抵其实现的复杂性。

因此,现代低延迟GC的设计,就是在并发和并行之间找到最佳平衡点,尽可能地将工作推到并发和并行阶段,只将那些绝对需要全局一致性快照难以进一步分解的逻辑保留在STW的串行协调点中。


第五部分:优化策略与工程实践

理解了STW Phase 2中必须串行执行的逻辑,我们就能更有效地进行GC优化:

  1. 应用程序层面减少GC压力:

    • 减少对象分配速率: 避免在热点代码路径中创建大量短生命周期的对象。
    • 优化对象生命周期: 尽可能使对象在年轻代被回收,避免晋升到老年代。
    • 使用对象池: 对于高频创建和销毁的特定类型对象,可以考虑对象池(但要注意复杂性)。
    • 避免过多的JNI调用和全局JNI引用: JNI引用是GC根,过多会增加根集合扫描的负担。
    • 合理使用java.lang.ref 虽然它们有助于内存管理,但过度使用或不当使用也可能增加GC的复杂性。
  2. JVM参数调优:

    • 选择合适的GC收集器: 根据应用对吞吐量和延迟的需求,选择G1、ZGC或Shenandoah。
      • XX:+UseG1GC
      • XX:+UseZGC
      • XX:+UseShenandoahGC
    • 配置GC线程数: XX:ParallelGCThreadsXX:ConcGCThreads。适当增加GC线程数可以加速STW阶段的并行任务。
    • 调整堆大小: XmsXmx。过小的堆会频繁GC,过大的堆可能导致单次GC时间变长(尽管现代GC努力避免)。
    • G1特有参数: XX:MaxGCPauseMillis(G1的目标暂停时间),XX:G1HeapWastePercentXX:G1NewSizePercent等。
    • ZGC/Shenandoah特有参数: 通常这些收集器的调优参数较少,因为它们的设计目标就是“开箱即用”的低延迟。
  3. 监控与分析GC日志:

    • 启用详细GC日志:XX:+PrintGCDetails XX:+PrintGCDateStamps XX:+PrintGCTImeStamps XX:+PrintGCApplicationStoppedTime
    • 使用GC日志分析工具(如GCViewer、GCEasy)来可视化GC行为,识别长暂停的原因,并针对性地进行优化。特别关注_GC_pausePause Young (Initial Mark)Pause RemarkPause Relocation等事件的持续时间。

结束语

通过今天的深入探讨,我们理解了即使在现代低延迟GC(如ZGC和Shenandoah)将STW暂停时间压缩到1毫秒以内时,仍然存在一些核心且不可避免的串行逻辑。这些逻辑主要集中在根集合的扫描与重映射、引用对象的最终处理、类卸载的决策以及JVM内部关键数据结构的维护上。它们之所以必须串行执行,是为了确保在对堆和JVM内部状态进行关键修改和判断时,能够获得一个全局一致且准确的视图。

尽管GC技术不断进步,STW暂停变得越来越短,但我们不得不承认,某些“停下世界”的时刻,在可预见的未来,仍将是GC机制中不可或缺的一部分。理解这些底层机制,能帮助我们更好地设计和优化Java应用程序,以实现卓越的性能和响应能力。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注