JVM 并发标记原理

18 阅读5分钟

JVM 并发标记

JVM 并发标记(Concurrent Marking)是现代垃圾回收器(如 CMS、G1、ZGC、Shenandoah)实现低停顿回收的核心技术。其原理如下:


1. 目标

  • 减少 Stop-The-World 停顿时间,让应用线程和 GC 线程可以同时运行,大部分标记工作在应用运行时完成。

2. 基本流程

  1. 初始标记(Initial Mark)

    • 短暂 Stop-The-World,标记 GC Roots 直接可达对象。
  2. 并发标记(Concurrent Mark)

    • GC 线程和应用线程并发,从初始标记的对象出发,遍历整个对象图,标记所有可达对象。
    • 这一步是并发标记的主体,极大减少了应用停顿。
  3. 重新标记(Remark)

    • 再次 Stop-The-World,修正并发标记期间对象引用的变化,确保标记准确无误。

3. 三色标记法

  • 并发标记通常采用三色标记算法(白、灰、黑):

    • 白色:未访问过的对象(初始状态)。
    • 灰色:已访问但引用还未全部处理的对象。
    • 黑色:已访问且引用全部处理完的对象。

4. 写屏障机制

  • 为了保证并发标记期间对象引用变化不会导致“漏标”,JVM 引入了写屏障(如 SATB 或增量更新):

    • SATB(Snapshot-At-The-Beginning) :记录原引用,保证“标记开始时可达的对象”都被标记。
    • 增量更新:记录新引用,保证新产生的可达对象不会漏标。

5. 浮动垃圾

  • 在一次并发垃圾回收过程中,在标记开始时仍然是“活的”对象,但在 GC 执行期间变成了“不可达”的对象。由于标记阶段已经错过了它们,它们没有被当前这一轮 GC 回收,而“浮动”到下一次 GC 被清理。

6. 优点

  • 极大降低 GC 停顿时间,提升应用响应性。
  • 支持大堆和高并发场景。

总结:
JVM 并发标记通过三色标记法和写屏障机制,实现了应用线程和 GC 线程的并发工作,保证了标记的准确性和高效性,是现代低停顿垃圾回收器的核心。

JVM 并发标记逐步分析

1. 初始状态

  • 所有对象都是白色(未被访问)。
  • 应用线程正常运行。

2. 初始标记(Initial Mark,STW)

  • 短暂停顿(Stop-The-World)
  • 从 GC Roots(如栈、静态变量等)出发,将直接可达的对象标记为灰色(已访问但引用未处理),放入待处理队列。
  • 其余对象仍为白色。

3. 并发标记(Concurrent Marking,应用线程和 GC 线程并发)

  • GC 线程和应用线程同时运行

  • GC 线程不断从灰色队列取对象,将其引用的白色对象变为灰色,自己变为黑色(已完全处理)。

  • 三色标记法在此阶段起作用:

    • 白色:未访问,可能是垃圾。
    • 灰色:待处理。
    • 黑色:已处理完毕。
  • 写屏障机制(如 SATB)启动:

    • 当应用线程修改对象引用(如 A 的字段从 B 改为 C),写屏障会把原引用 B 记录到一个辅助结构中。
    • 这样即使 B 还没被 GC 线程标记,也能保证它不会被漏标。

4. 重新标记(Remark,STW)

  • 再次短暂停顿
  • GC 线程处理写屏障记录的所有对象,确保并发标记期间所有“本应存活”的对象都被正确标记为黑色。
  • 修正并发期间对象引用的变化,消除漏标。

5. 并发清理/回收

  • 标记完成后,GC 线程并发清理所有白色对象(不可达对象),回收内存。
  • 黑色对象(存活对象)不会被回收。

6. 浮动垃圾

  • 在一次并发垃圾回收过程中,在标记开始时仍然是“活的”对象,但在 GC 执行期间变成了“不可达”的对象。由于标记阶段已经错过了它们,它们没有被当前这一轮 GC 回收,而“浮动”到下一次 GC 被清理。
  • 举例说明,在并发标记开始时,A 引用的是 B,并发标记阶段,应用线程把 A 引用 B 改为了 A 引用 C。因为写屏障只记录并发标记开始时的状态,所以 B 还是“活的”对象,会在下次 GC 时被清理。

7. 总结流程图

  1. 所有对象白色
  2. 初始标记(STW):GC Roots → 灰色
  3. 并发标记:灰色对象处理,引用变灰,自己变黑
  4. 写屏障:记录原引用,防止漏标
  5. 重新标记(STW):处理写屏障记录,补充标记
  6. 并发清理:回收白色对象
  7. 下次 GC 处理浮动垃圾

一句话总结:
并发标记通过三色标记法和写屏障机制,保证了在应用线程和 GC 线程并发运行时,所有“本应存活”的对象都被正确标记,极大降低了 GC 停顿时间。

引用更改问题

并发标记开始时,A 引用 B,并发标记过程中应用线程把它改成了 A 引用 C,那么 C 会不会被回收?

1. SATB 写屏障的核心点

  • 当 A.ref = C 时,写屏障只记录旧引用 B,确保“标记开始时可达的对象”不会被漏标。
  • 新引用 C 不会被写屏障记录,所以如果 C 在标记开始时不可达,理论上有漏标风险。

2. 为什么 C 不会被漏标?

  • 如果 C 在赋值前已经是可达对象,它会在正常的标记流程中被标记为存活。

  • 如果 C 是新分配的对象(如 C = new Object()),

    • JVM 的 SATB 实现会在对象分配时立即将新对象标记为“活的” ,即使它还没被正式引用。
    • 这通常通过分配时的 pre-mark 或 thread-local buffer 实现,确保新对象不会被 GC 漏标。

3. 总结

  • SATB 写屏障只记录旧引用,不记录新引用。
  • 新分配的对象会被立即标记为存活,避免被 GC 漏标。
  • 这样,无论是旧对象还是新对象,都不会因为 SATB 的机制而被漏标。

一句话总结:
SATB 写屏障配合新对象分配时的即时标记,确保了所有“本应存活”的对象都不会被漏标,无论是旧引用还是新分配的对象。