深入理解 HotSpot 垃圾收集算法的细节实现

本节将详细讲解以下关键技术点:

  1. 根节点枚举与 OopMap

  2. 安全点与安全区域,以及抢先式中断与主动式中断的区别

  3. 记忆集与卡表

  4. 写屏障及其 AOP 切面实现

  5. 并发可达性分析 —— 增量更新与 SATB


一、根节点枚举与 OopMap

1.1 背景与挑战

可达性分析算法从 GC Roots(根节点)开始遍历整个对象图,以确定哪些对象是可达的,从而判断哪些对象需要回收。GC Roots 主要来源于全局引用(例如类的静态字段、运行时常量池中的对象)以及执行上下文(线程栈中的局部变量)。然而,在大型应用中,方法区中加载的类、常量等数量巨大(数百到数千兆),若逐一检查这些引用,必然会消耗大量时间,严重影响 GC 性能。

1.2 HotSpot 的优化方案:OopMap

为了高效枚举 GC Roots,HotSpot 在类加载完成后就会计算出每个类中各字段的偏移量及数据类型,并在即时编译(JIT)过程中将这些信息记录下来,形成一个称为 OopMap 的数据结构。

  • OopMap 记录了编译后的机器码中,哪些寄存器和栈中具体偏移位置存放着对象引用(Ordinary Object Pointer, OOP)。

  • 例如,在编译后的 String::hashCode() 方法中,某条 call 指令附近附带的 OopMap 记录可能如下:

    0x026eb7a9: call 0x026e83e0 ; OopMap{ebx=Oop [16]=Oop off=142}
    

    这表明从该 call 指令开始到指令流结束前 142 个字节内,EBX 寄存器和栈中偏移 16 的位置保存着对象引用。

  • 利用 OopMap,垃圾收集器在扫描根节点时可以直接定位到引用位置,而无需遍历整个内存区域,从而大幅提高根节点枚举效率。


二、安全点、安全区域与线程暂停策略

为了确保 GC 时获得一致性内存快照,必须暂停所有用户线程,使得引用关系保持静止。HotSpot 通过安全点、安全区域以及线程暂停策略来实现这一目标。

HotSpot 在 OopMap 的协助下,可以快速准确地完成 GC Roots 枚举,但一个现实的问题是:可能导致引用关系变化或 OopMap 内容变化的指令非常多,如果为每条指令都生成对应的 OopMap,将会导致额外存储空间需求巨大,增加垃圾收集的空间成本。为了解决这个问题,HotSpot 只在“特定的位置”记录 OopMap 信息,这些位置被称为安全点(Safepoint)。程序并非在代码执行的任意位置都能暂停进行垃圾收集,而是必须到达安全点后才能暂停。因此,安全点的选取需要权衡,既不能太少导致垃圾收集等待时间过长,也不能太频繁增加运行时的内存负荷。通常,以“是否具有让程序长时间执行的特征”为标准选定安全点,例如方法调用、循环跳转、异常跳转等,因为这些指令具有指令序列复用的特征,使得程序可能长时间执行。此外,还需要考虑在垃圾收集发生时,如何让所有线程(不包括执行 JNI 调用的线程)都尽快到达最近的安全点并停顿下来。常见的方案包括抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

2.1 安全点(Safepoint)

安全点是预先定义的检查点,确保线程在这些位置上处于稳定状态,便于 GC 扫描引用。

  • 插入位置:通常出现在方法调用边界、循环体内、异常处理逻辑以及所有涉及对象分配的地方。

  • 作用:当线程执行到安全点时,会检查全局中断标志(见下文),如果需要 GC 则主动挂起自己。

2.2 线程暂停策略:抢先式中断 vs 主动式中断

虽然两种方式最终都要求线程停在安全点,但它们的触发方式存在本质区别:

抢先式中断(Preemptive Suspension)
  • 原理:当 GC 发起时,系统立即主动中断所有用户线程,无需线程代码主动配合。如果发现线程当前不在安全点,系统会恢复其执行直到它进入安全点,然后强制挂起。

  • 优点:理论上能迅速中断所有线程。

  • 缺点:可能在非安全点强制中断,导致线程状态不一致;实现复杂且风险较高。

  • 现状:由于风险和实现难度,目前几乎没有 JVM 实现采用抢先式中断。

主动式中断(Voluntary Suspension)
  • 原理:GC 发起时,虚拟机仅设置一个全局中断标志,各线程在执行过程中会在安全点主动轮询该标志,一旦检测到标志为真,就在安全点处自行挂起。

  • 优点:依赖线程主动检查,保证在安全、状态一致的地方暂停;实现简单,风险低。

  • 缺点:线程只能在预设安全点处检测到中断标志,可能有短暂延迟,但总体上这种延迟可控且通常非常短。

总结:虽然抢先式中断和主动式中断都要求线程最终在安全点处停下,但抢先式中断由系统直接强制中断线程,而主动式中断则依赖线程主动检测全局中断标志并挂起。由于抢先式中断可能在不安全状态下中断线程,现代 JVM(如 HotSpot)均采用主动式中断。

2.3 内存保护陷阱与轮询机制

为了让主动式中断快速响应,HotSpot 在安全点和对象分配处嵌入了轮询代码:

  • 实现方式:通常只需要一条简单的汇编指令检查全局中断标志,例如:

    0x01b6d62d: test %eax, 0x160100 ; {poll}
    
  • 当 GC 需要暂停线程时,虚拟机会将地址 0x160100 对应的内存页设置为不可读;线程执行到该指令时触发自陷异常,由异常处理器捕获后将线程挂起。

  • 效果:这种机制以极低开销实现轮询,确保线程在关键位置能够迅速响应 GC 请求。

2.4 安全区域(Safe Region)

安全点要求线程处于运行状态主动检测中断标志,但如果线程处于 Sleep 或 Blocked 状态,则无法主动达到安全点。为此,HotSpot 引入了安全区域

  • 定义:安全区域是一段保证内存引用关系不发生变化的代码区域,可以看作是被扩展的安全点。

  • 机制:线程进入安全区域后,会标记自己为“已进入安全区域”,此时即使 GC 发起,也不需要该线程响应;当线程离开安全区域前,则必须检查 GC 是否已完成根节点枚举等关键阶段,如果未完成,则需等待。

  • 作用:确保即使线程暂时不活跃,GC 仍然能够获得一致性快照,避免因线程无法达到安全点而引起数据不一致。


三、记忆集与卡表

3.1 跨代引用的挑战

分代收集理论将堆划分为新生代和老年代。在进行新生代垃圾收集(Minor GC)时,通常只扫描新生代内存。但新生代对象可能被老年代对象引用(跨代引用),如果在每次 Minor GC 时都要扫描整个老年代,将大大增加开销。

3.2 记忆集(Remembered Set)与卡表(Card Table)

记忆集用于记录非收集区域(例如老年代)中指向收集区域(新生代)的引用。如果简单记录所有这样的引用,不仅空间开销巨大,而且维护成本高。

为此,HotSpot 引入了卡表作为记忆集的具体实现:

  • 卡表结构:实现为一个字节数组,每个元素对应堆内固定大小的内存区域,称为卡页。例如,HotSpot 默认卡页大小为 512 字节(地址右移 9 位,即除以 512)。

  • 标记机制:当卡页内某对象字段发生赋值操作,产生跨代引用时,卡表中对应的字节被标记为“脏”(Dirty,通常设置为非零值)。GC 时,仅扫描这些“脏”卡页,显著降低扫描范围。

例如,如果内存从地址 0 开始,卡表的第 0 个字节对应 0x0000~0x01FF,第 1 个字节对应 0x0200~0x03FF,以此类推。只要卡页内存在跨代引用,则对应卡表元素被置脏。


四、写屏障及其 AOP 切面实现

4.1 写屏障的必要性

在每次对象引用赋值时,必须确保卡表中跨代引用记录及时更新。如果不这样做,GC 时可能漏掉这些引用,导致错误地回收本应存活的对象。对于解释执行的字节码,这一过程较容易实现;但对于即时编译后的机器码,则需要一种机制将更新操作嵌入到每次赋值中,这就是写屏障。

4.2 写屏障的工作原理

写屏障可以看作是在虚拟机层面对“引用类型字段赋值”这一操作的**面向切面编程(AOP)**切面:

  • 环形通知(Around Advice):即在赋值操作前后都执行额外的动作。写前屏障(Pre-Write Barrier)可用于记录或保护旧引用(部分 GC 算法需要),而写后屏障(Post-Write Barrier)则在完成赋值后立即更新卡表状态,将相关卡页标记为脏。

  • 示例代码

    void oop_field_store(oop* field, oop new_value) {
        // 执行引用字段赋值操作
        *field = new_value;
        // 写后屏障:更新卡表状态,确保跨代引用信息正确记录
        post_write_barrier(field, new_value);
    }
    

这种设计确保了每次引用修改时,虚拟机都能在机器码层面执行额外操作,实时更新记忆集信息。

4.3 伪共享问题与条件卡标

由于卡表通常以字节数组实现,一个缓存行(例如 64 字节)中可能包含多个卡表元素。如果多个线程同时更新同一缓存行,会引发**伪共享(False Sharing)**问题,导致缓存一致性问题,从而影响性能。为此,HotSpot 引入了条件卡标机制:

  • 条件卡标:只有当卡表中对应的元素未被标记为脏时,才进行更新操作。典型实现逻辑如下:

    if (CARD_TABLE[this_address >> 9] != 0)
        CARD_TABLE[this_address >> 9] = 0;
    

在 JDK 7 之后,HotSpot 通过参数 -XX:+UseCondCardMark 控制是否启用这种条件判断,从而在额外判断开销与避免伪共享之间取得平衡。


五、并发可达性分析

5.1 并发标记的挑战

传统的可达性分析要求在一个一致性快照上遍历整个对象图,这通常需要暂停所有用户线程,保证引用关系不变。然而,现代垃圾收集器希望在尽可能短的停顿时间内完成标记工作,因此允许 GC 与用户线程并发执行。此时,用户线程对对象引用的修改可能干扰标记过程,导致以下风险:

  • 错误标记为存活:本应被回收的对象因并发新增引用而被错误地保留。

  • 错误标记为垃圾(对象消失):本应存活的对象因并发删除引用而误被标记为不可达,回收后导致程序错误。

5.2 三色标记(Tri-color Marking)算法

为解决并发修改问题,HotSpot 采用了三色标记算法,将对象分为三类:

  • 白色:未被访问的对象。GC 结束时仍为白色的对象被视为不可达,应被回收。

  • 灰色:已访问但其引用尚未全部扫描的对象。灰色对象尚待扫描。

  • 黑色:已访问且所有引用均已扫描的对象。原则上,黑色对象不会指向白色对象。

GC 过程中,从 GC Roots 开始,所有对象初始为白色,然后标记为灰色,再逐步扫描转换为黑色。这个过程可以形象地看作是一波灰色“波峰”从 GC Roots 推进,最终留下的白色对象即为垃圾。

5.3 并发修改的解决方案:增量更新与 SATB

为防止并发修改导致“对象消失”,HotSpot 提出了两种主要策略:

增量更新(Incremental Update)
  • 工作原理:当一个已被标记为黑色的对象在并发执行期间插入了一个指向白色对象的新引用时,写屏障会检测到这一变化,并立即记录该引用,同时将该对象的颜色从黑色转为灰色。这样,在并发标记结束后,GC 会重新扫描这些灰色对象,确保新引用也被正确处理。

  • 优点:实时捕捉到新引用插入,避免因遗漏更新而导致存活对象被误回收。

  • 缺点:如果系统中频繁出现这种情况,则可能导致频繁的状态转换和重新扫描,从而增加写屏障的负担和额外开销。

原始快照(Snapshot At The Beginning, SATB)
  • 工作原理:在并发标记开始时,虚拟机建立整个对象图的快照。此后,无论用户线程如何修改引用,都按照最初的快照来判断对象是否可达。对于删除引用的操作,同样会被记录下来,并在标记阶段结束后重新扫描这些记录,确保不会遗漏本应存活的对象。

  • 优点:能够提供更强的一致性保障,适用于高并发引用修改场景,因为标记始终依据固定的快照进行。

  • 缺点:写屏障需要记录删除操作,这会增加额外的记录和扫描成本,开销较大,可能导致一定延迟。

在实际应用中,CMS 收集器主要采用增量更新方案,而 G1、Shenandoah 等收集器更多使用 SATB。两种方案各有优缺点,设计者需要根据系统特性权衡选择。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值