垃圾回收与GC算法

核心概念

垃圾回收(Garbage Collection, GC) 是 JVM 自动管理内存的机制,通过「判断对象是否存活 → 选择回收算法 → 使用垃圾收集器」三个步骤,自动释放不再使用的对象占用的内存,避免内存泄漏和溢出。

🎯 核心公式

GC 性能 = f(吞吐量, 停顿时间, 内存占用)

最优解:根据应用场景选择合适的收集器组合


一、为什么需要垃圾回收?

1.1 问题背景(没有 GC 会怎样)

  • 内存泄漏:不再使用的对象无法释放,最终导致 OutOfMemoryError
  • 野指针问题:手动释放后仍被引用,访问已释放内存导致崩溃(C/C++ 常见)
  • 双重释放:同一块内存被释放多次,破坏内存管理结构
  • 开发效率低:程序员需要手动管理内存的分配和释放

1.2 设计目标

目标 说明 权衡
吞吐量 运行用户代码时间 / 总运行时间 与停顿时间冲突
低延迟 减少 GC 停顿时间(STW) 可能降低吞吐量
内存占用 堆内存和 GC 元数据占用 空间换时间

不可能三角:无法同时优化三个目标,必须根据场景选择侧重点。


二、对象存活判定算法

2.1 引用计数法(Reference Counting)❌

原理

为每个对象维护一个引用计数器:

  • 被引用时 +1
  • 引用失效时 -1
  • 计数为 0 时回收

优缺点

优点 缺点
✅ 实现简单 无法解决循环引用(致命缺陷)
✅ 实时性好,垃圾立即回收 ❌ 每次引用变化都要修改计数器(性能开销)
✅ 没有 STW ❌ 计数器本身占用额外内存

循环引用问题

public class CircularReference {

    Object instance = null;

    public static void main(String[] args) {
        CircularReference objA = new CircularReference();
        CircularReference objB = new CircularReference();

        objA.instance = objB;  // A 引用 B
        objB.instance = objA;  // B 引用 A

        objA = null;  // 外部引用断开
        objB = null;  // 但 A 和 B 互相引用,计数器永远不为 0

        // 引用计数法无法回收,导致内存泄漏!
    }
}

结论:主流 JVM(HotSpot)不使用引用计数法,而使用可达性分析。


2.2 可达性分析算法(Reachability Analysis)✅

原理

GC Roots 出发,通过引用链向下搜索:

  • 可达对象:存在从 GC Roots 到该对象的引用链 → 存活
  • 不可达对象:不存在任何引用链 → 可回收
GC Roots

    ↓

[对象A][对象B][对象C]   ← 可达(存活)

[对象X][对象Y]             ← 不可达(垃圾)

GC Roots 包括哪些?

GC Roots 类型 说明 示例
虚拟机栈中引用的对象 方法局部变量、参数 void method() { User user = new User(); }
方法区中类静态属性引用 类的 static 字段 public static User user;
方法区中常量引用 final static 常量 public static final String NAME = "abc";
本地方法栈中 JNI 引用 Native 方法引用的对象 native void nativeMethod();
活跃线程 正在运行的线程对象 Thread.currentThread()
被 Synchronized 锁持有的对象 锁对象 synchronized(obj)

完整回收流程

1. 标记阶段(Marking Phase)

   ├─ 从 GC Roots 开始遍历对象图

   ├─ 标记所有可达对象

   └─ 未被标记的对象 → 不可达对象

2. 第一次标记 + 筛选

   ├─ 对象是否重写了 finalize() 且未被调用过?

   │   ├─ 是 → 加入 F-Queue,等待 Finalizer 线程执行

   │   └─ 否 → 直接回收

3. 第二次标记(finalize 执行后)

   └─ 如果对象在 finalize() 中重新建立引用链 → 逃脱回收

       否则 → 真正回收

finalize() 自救机制(不推荐使用)

public class FinalizeEscape {

    public static FinalizeEscape SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("我还活着");

    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize() 被执行");
        // 自救:重新建立引用链
        FinalizeEscape.SAVE_HOOK = this;

    }

    public static void main(String[] args) throws InterruptedException {

        SAVE_HOOK = new FinalizeEscape();

        // 第一次自救成功
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);  // 等待 Finalizer 线程执行
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();  // 输出:我还活着
        }

        // 第二次自救失败(finalize 只会被调用一次)
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("我死了");  // 输出:我死了
        }
    }
}

⚠️ 警告

  • finalize() 运行代价高、不确定性大、无法保证调用顺序
  • Java 9 后已标记为 @Deprecated
  • 推荐使用 try-with-resourcesCleaner 机制

2.3 四种引用类型

引用类型 回收时机 典型应用场景 创建方式
强引用(Strong) 永不回收(即使 OOM) 普通对象引用 Object obj = new Object();
软引用(Soft) 内存不足时回收 内存敏感的缓存(图片缓存) SoftReference ref = new SoftReference<>(obj);
弱引用(Weak) 下次 GC 时回收 ThreadLocal、WeakHashMap WeakReference ref = new WeakReference<>(obj);
虚引用(Phantom) 随时可能回收,无法通过引用获取对象 对象回收跟踪、堆外内存管理 PhantomReference ref = new PhantomReference<>(obj, queue);

实战案例:软引用实现缓存

public class ImageCache {

    private Map<String, SoftReference<Image>> cache = new HashMap<>();

    public Image getImage(String path) {

        SoftReference<Image> ref = cache.get(path);

        // 检查软引用是否被回收
        if (ref != null) {
            Image img = ref.get();
            if (img != null) {
                return img;  // 缓存命中
            }
        }

        // 缓存失效,重新加载
        Image img = loadImageFromDisk(path);
        cache.put(path, new SoftReference<>(img));
        return img;
    }
}

三、垃圾回收算法

3.1 算法对比总览

算法 核心思想 优点 缺点 适用区域
标记-清除 Mark-Sweep 标记垃圾对象,然后清除 简单,不移动对象 内存碎片严重 老年代(CMS)
标记-复制 Mark-Copy 将存活对象复制到另一块区域 无碎片,分配快 浪费空间(需要额外空间) 新生代
标记-整理 Mark-Compact 标记后移动对象到一端 无碎片,空间利用率高 移动对象成本高 老年代(Serial Old、Parallel Old)
分代收集 Generational 根据对象生命周期分代管理 综合利用各算法优势 实现复杂 现代 JVM 标配

3.2 标记-清除算法(Mark-Sweep)

工作流程

执行前:
[对象A][对象B][对象C][对象D][对象E]
   ✓      ×      ✓      ×      ✓
 (存活)  (垃圾)  (存活)  (垃圾)  (存活)

执行后:
[对象A][空闲][对象C][空闲][对象E]
        ↑碎片        ↑碎片

两个阶段

  1. 标记阶段:从 GC Roots 出发,标记所有可达对象
  2. 清除阶段:遍历堆,回收所有未被标记的对象

问题

  • 内存碎片:频繁回收后,空闲内存不连续,大对象无法分配
  • 效率问题:标记和清除两个阶段效率都不高
  • 空间局部性差:存活对象分散,缓存命中率低

改进版:CMS 收集器使用此算法,通过并发标记减少停顿。


3.3 标记-复制算法(Mark-Copy)

工作流程(Appel 式回收)

新生代分为:Eden(80%) + Survivor0(10%) + Survivor1(10%)

Minor GC 前:
Eden               S0        S1
[对象ABCDE]        [空]      [空]
  ↓ 只有 A、C 存活

Minor GC 后:
Eden               S0        S1
[空]               [空]      [对象AC]  ← 复制到 S1

优缺点

优点 缺点
无内存碎片 浪费空间(需要预留复制区域)
分配简单:只需移动指针(bump-the-pointer) ❌ 存活对象多时效率低
✅ 适合"朝生夕死"的对象 ❌ 需要额外空间作为担保(Survivor 不够时进入老年代)

为什么新生代使用 8:1:1 的比例?

IBM 研究表明:新生代中 98% 的对象是"朝生夕死"的,所以:

  • Eden 占 80%:大部分对象分配在 Eden
  • Survivor 占 10% × 2:只有少量对象存活,10% 足够
  • 空间利用率 = 90%(Eden + 1 个 Survivor)

极端情况处理

if (Survivor 空间不足) {
    // 担保机制:直接进入老年代
    进入老年代 (Old Generation);
}

3.4 标记-整理算法(Mark-Compact)

工作流程

执行前:
[对象A][对象B][对象C][对象D][对象E]
   ✓      ×     ✓     ×     ✓

执行后(压缩):
[对象A][对象C][对象E][---空闲空间---]
 ← 所有存活对象移到一端

两个阶段

  1. 标记阶段:同标记-清除
  2. 整理阶段:将所有存活对象向内存一端移动,清理边界外的内存

优缺点

优点 缺点
✅ 无内存碎片 移动对象成本高(需要更新所有引用)
✅ 空间利用率高 STW 时间长(移动期间必须暂停用户线程)
✅ 适合老年代(对象存活率高) ❌ 效率低于标记-清除

优化:滑动式整理(Lisp2 算法)

  1. 标记阶段:标记所有存活对象

  2. 计算阶段:计算每个对象的新地址

  3. 更新引用:更新所有指向移动对象的引用

  4. 移动对象:将对象移动到新地址


3.5 分代收集算法(Generational Collection)⭐

核心思想

根据对象生命周期特点,将堆分为不同区域,使用不同的回收算法:

Java 堆内存结构(JDK 8)

┌─────────────────────────────────────────────────────┐

│  新生代 (Young Generation) 1/3                      │

│  ┌────────┬──────┬──────┐                          │

│  │ EdenS0S1   │  ← 标记-复制算法          │

│  │ 811    │                          │

│  └────────┴──────┴──────┘                          │

├─────────────────────────────────────────────────────┤

│  老年代 (Old Generation) 2/3                        │

│  ┌──────────────────────────────────────┐          │

│  │  标记-清除 或 标记-整理算法            │          │

│  └──────────────────────────────────────┘          │

└─────────────────────────────────────────────────────┘

元空间 (Metaspace) - 本地内存

分代假说(Generational Hypothesis)

假说 内容 应对策略
弱分代假说 绝大多数对象都是朝生夕死 新生代频繁 GC,使用复制算法
强分代假说 熬过多次 GC 的对象越难消亡 老年代较少 GC,使用标记-整理
跨代引用假说 跨代引用相对于同代引用来说占极少数 使用记忆集(Remembered Set)避免全堆扫描

对象晋升规则

// 规则1:年龄达到阈值(默认 15 次 Minor GC)
-XX:MaxTenuringThreshold=15

// 规则2:大对象直接进入老年代
-XX:PretenureSizeThreshold=1M  // 超过 1M 直接进老年代

// 规则3:动态年龄判定
// 如果 Survivor 中相同年龄对象大小总和 > Survivor 空间的一半
// 则年龄 >= 该年龄的对象直接进入老年代

// 规则4:空间分配担保
// Minor GC 前检查:老年代最大可用连续空间 > 新生代所有对象总大小
// 不满足则触发 Full GC

Minor GC vs Full GC

GC 类型 触发时机 回收区域 停顿时间 频率
Minor GC Eden 区满 新生代(Eden + Survivor) 短(几十毫秒) 频繁
Major GC 老年代满 老年代 较少
Full GC ① System.gc() ② 老年代空间不足 ③ 元空间不足 ④ Minor GC 晋升担保失败 整个堆 + 元空间 很长(秒级) 很少

四、垃圾收集器详解

4.1 收集器演进历史

时间线:

1999 ─────→ 2004 ─────→ 2012 ─────→ 2017 ─────→ 2018

Serial      CMS        G1         ZGC         Shenandoah

Parallel              (JDK 9 默认)  (JDK 11)    (JDK 12)

4.2 七大经典收集器总览

新生代收集器          老年代收集器

┌──────────┐        ┌──────────┐

│  Serial  │───────→│Serial Old│

└──────────┘        └──────────┘

┌──────────┐        ┌──────────┐

│ ParNew   │───────→│   CMS    │

└──────────┘        └──────────┘

┌──────────┐        ┌──────────┐

│ Parallel │───────→│Parallel  │

│ Scavenge │        │   Old    │

└──────────┘        └──────────┘

        跨代收集器

      ┌──────────┐

      │    G1    │  ← JDK 9+ 默认

      └──────────┘

      ┌──────────┐

      │   ZGC    │  ← 低延迟(< 10ms)

      └──────────┘

      ┌──────────┐

      │Shenandoah│  ← 低延迟

      └──────────┘

4.3 Serial / Serial Old 收集器

特点

  • 单线程:只用一个线程进行 GC
  • Stop-The-World:GC 时必须暂停所有用户线程
  • 新生代:标记-复制算法
  • 老年代:标记-整理算法

工作流程

用户线程

────────┐  STW  ┌─────────

        │       │

        ▼       ▼

        [Serial GC 线程执行]

时间:  0ms    50ms   100ms

JVM 参数

# 新生代使用 Serial
-XX:+UseSerialGC

# 查看使用的收集器
java -XX:+PrintCommandLineFlags -version

适用场景

  • 单核 CPU 环境
  • 客户端模式(桌面应用)
  • 几十 MB 的小堆
  • 对停顿时间不敏感

优点:简单高效,单线程时没有线程交互开销,适合小应用。


4.4 ParNew 收集器

特点

  • Serial 的多线程版本
  • 新生代收集器,必须配合 CMS 使用
  • 标记-复制算法

工作流程

用户线程
────────┐  STW  ┌─────────

        │       │

        ▼       ▼
    [GC线程1][GC线程2][GC线程3][GC线程4]
    └────────并行执行────────┘

JVM 参数

# 启用 ParNew(JDK 8 默认配合 CMS)
-XX:+UseParNewGC

# 设置并行 GC 线程数(默认 = CPU 核心数)
-XX:ParallelGCThreads=4

适用场景

  • 多核 CPU 服务器
  • 配合 CMS 使用(CMS 只能与 ParNew 或 Serial 配合)
  • 新生代对象多、GC 频繁

4.5 Parallel Scavenge / Parallel Old 收集器⭐

特点(吞吐量优先)

  • 新生代:标记-复制(Parallel Scavenge)
  • 老年代:标记-整理(Parallel Old)
  • 多线程并行
  • 自适应调节策略(GC Ergonomics)

核心目标:最大化吞吐量

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC 时间)

例如:

  • 程序运行 100 分钟,GC 耗时 1 分钟

  • 吞吐量 = 99 / 100 = 99%

JVM 参数(关键配置)

# 启用 Parallel 收集器(JDK 8 Server 模式默认)
-XX:+UseParallelGC          # 新生代
-XX:+UseParallelOldGC       # 老年代(自动启用)

# 吞吐量控制(核心参数)
-XX:GCTimeRatio=99          # 吞吐量目标 = 99%(GC 时间占比 1%)
-XX:MaxGCPauseMillis=200    # 最大停顿时间目标(毫秒)

# 自适应调节(推荐开启)
-XX:+UseAdaptiveSizePolicy  # 自动调整 Eden、Survivor、晋升阈值

# 并行线程数
-XX:ParallelGCThreads=8     # 默认 = CPU 核心数

自适应调节策略示例

// JVM 会自动调整以下参数:

-Xmn                        # 新生代大小

-XX:SurvivorRatio           # Eden 和 Survivor 比例

-XX:MaxTenuringThreshold    # 对象晋升年龄阈值

// 目标:在满足停顿时间的前提下,最大化吞吐量

适用场景

  • 后台计算任务(批处理、数据分析)
  • 科学计算、大数据处理
  • 对停顿时间不敏感,追求吞吐量
  • 多核服务器

4.6 CMS 收集器(Concurrent Mark Sweep)⭐

特点(低延迟优先)

  • 老年代收集器
  • 标记-清除算法
  • 并发标记(大部分时间与用户线程并发执行)
  • 目标:最短 GC 停顿时间

四个阶段(核心)

1. 初始标记 (Initial Mark) - STW ⚡

   └─ 标记 GC Roots 直接关联的对象(速度快)

2. 并发标记 (Concurrent Mark) - 并发执行 🚀

   └─ 从 GC Roots 遍历整个对象图(耗时最长)

3. 重新标记 (Remark) - STW ⚡

   └─ 修正并发标记期间用户程序运行导致的标记变动

4. 并发清除 (Concurrent Sweep) - 并发执行 🚀

   └─ 清除标记为垃圾的对象

时间线图

用户线程  ████████████████████████████████████████

GC 线程        ██        ██████████        ██████

          ↑ STW    ↑ 并发     ↑ STW   ↑ 并发

      初始标记  并发标记  重新标记  并发清除

JVM 参数

# 启用 CMS(JDK 9 后弃用,JDK 14 移除)
-XX:+UseConcMarkSweepGC

# 触发阈值(老年代使用 68% 时触发 GC)
-XX:CMSInitiatingOccupancyFraction=68
-XX:+UseCMSInitiatingOccupancyOnly  # 只使用设定阈值

# 并发线程数(默认 = (CPU核心数 + 3) / 4)
-XX:ConcGCThreads=2

# 碎片整理
-XX:+UseCMSCompactAtFullCollection  # Full GC 时整理碎片
-XX:CMSFullGCsBeforeCompaction=5    # 5 次 Full GC 后整理一次

三大缺点

缺点 说明 解决方案
CPU 资源敏感 并发阶段占用 CPU 导致应用变慢 增量式 CMS(已弃用)
浮动垃圾 并发清除时产生的新垃圾无法清理 预留足够空间,降低触发阈值
内存碎片 标记-清除算法导致碎片 定期 Full GC 整理

浮动垃圾问题

// 并发标记阶段:

对象 A 被标记为存活

  ↓

// 用户线程继续运行:

对象 A 的引用被清除(A 变成垃圾)

  ↓

// 但本次 GC 不会清理 A(浮动垃圾)

// 需要等到下次 GC 才能回收

Concurrent Mode Failure

# 现象:
Java HotSpot(TM) 64-Bit Server VM warning:
Concurrent mode failure

# 原因:
- 并发清除时,老年代空间不足以容纳新晋升的对象
- 触发 Full GC(使用 Serial Old 收集器)

# 解决:
-XX:CMSInitiatingOccupancyFraction=70  # 提前触发 GC
-Xmx4G  # 增大堆内存

适用场景

  • 互联网应用(要求低延迟)
  • B/S 架构服务端
  • 注重用户体验的应用
  • 老年代空间充足

4.7 G1 收集器(Garbage First)⭐⭐⭐

核心创新(革命性变化)

  • 不再区分新生代/老年代(逻辑上仍存在)

  • 堆划分为多个Region(每个 Region 大小相等,1-32MB)

  • 优先回收价值最大的 Region(Garbage First 的由来)

  • 可预测的停顿时间模型

  • Region 结构

  • Java 堆(G1 视角)
    
    ┌────┬────┬────┬────┬────┬────┬────┬────┐
    
    │ E  │ E  │ S  │ O  │ O  │ H  │ E  │ O  │
    
    └────┴────┴────┴────┴────┴────┴────┴────┘
    
      Eden  Survivor  Old  Humongous(大对象)
    

    每个 Region:

    • 大小相等(1MB、2MB、4MB...32MB)

    • 动态角色:可以是 Eden、Survivor、Old、Humongous

    • 大对象(≥ Region 50%)直接分配到 Humongous Region

  • 四个阶段

  • 1. 初始标记 (Initial Marking) - STW
    
       └─ 标记 GC Roots 直接关联对象
    
       └─ 借用 Minor GC 的 STW,几乎无额外停顿
    
    2. 并发标记 (Concurrent Marking) - 并发
    
       └─ 从 GC Roots 遍历对象图
    
       └─ 耗时最长,但与用户线程并发
    
    3. 最终标记 (Final Marking) - STW
    
       └─ 处理并发阶段遗留的标记变动
    
       └─ 使用 SATB(Snapshot-At-The-Beginning)算法
    
    4. 筛选回收 (Live Data Counting and Evacuation) - STW
    
       └─ 根据停顿时间目标,选择价值最大的 Region
    
       └─ 将存活对象复制到空 Region,清理旧 Region
    
  • 关键技术

    1. 记忆集(Remembered Set, RSet)

    问题:如何避免全堆扫描来处理跨 Region 引用?

    Region A           Region B
    
    ┌─────────┐       ┌─────────┐
    
    │ 对象1   │──────→│ 对象2   │  ← 跨 Region 引用
    
    └─────────┘       └─────────┘
    

    Region B 的 RSet 记录:

    “Region A 中的对象1 引用了我”

    回收 Region B 时:

    只需扫描 Region B 的 RSet,无需扫描整个堆

  • 实现

    每个 Region 维护一个 RSet

    使用写屏障(Write Barrier)记录跨 Region 引用

    RSet 占用堆内存的 5% 左右

  • 2. SATB 算法(解决并发标记时的对象消失问题)
// 问题场景(三色标记):

黑色对象(已标记完成)

  ↓ 添加引用

白色对象(未标记)← 应该存活但会被误回收

  ↑ 删除引用

灰色对象(标记中)

// SATB 解决方案:

记录"初始快照",认为快照时刻的对象都是活的

→ 宁可错杀(浮动垃圾),不可漏标
  • 3. 停顿预测模型
// G1 维护统计信息:
- 每个 Region 的回收耗时
- 每个 Region 的垃圾占比

// 计算公式:
回收价值 = 垃圾字节数 / 回收耗时

// 选择策略:
在目标停顿时间内,优先选择价值最大的 Region
  • JVM 参数(核心配置)

# 启用 G1(JDK 9+ 默认)
-XX:+UseG1GC

# ⭐ 最重要参数:设置停顿时间目标(毫秒)
-XX:MaxGCPauseMillis=200      # 默认 200ms

# Region 大小(1MB-32MB,必须是 2 的幂)
-XX:G1HeapRegionSize=4M       # 默认根据堆大小自动计算

# 触发并发标记的堆占用阈值
-XX:InitiatingHeapOccupancyPercent=45  # 堆使用 45% 时触发

# 新生代占比(动态调整)
-XX:G1NewSizePercent=5        # 最小 5%
-XX:G1MaxNewSizePercent=60    # 最大 60%

# 混合 GC 参数
-XX:G1MixedGCCountTarget=8    # 混合 GC 最多执行 8 次
-XX:G1HeapWastePercent=10     # 可容忍的堆浪费百分比
  • G1 的三种 GC 模式

  • 模式 触发时机 回收范围 停顿时间
    Young GC Eden Region 满 所有 Eden + Survivor 短(< 200ms)
    Mixed GC 并发标记完成后 所有 Eden + Survivor + 部分 Old Region 中等
    Full GC ① Mixed GC 无法跟上分配速度 ② Humongous 对象分配失败 ③ 晋升失败 整个堆 长(秒级,⚠️ 需要避免)
  • 典型日志分析

    # Young GC 日志
    [GC pause (G1 Evacuation Pause) (young), 0.0234567 secs]
    
       [Parallel Time: 20.1 ms, GC Workers: 4]
    
       [Eden: 512M(512M)->0B(480M) Survivors: 32M->64M Heap: 1.2G->768M]
    
    # Mixed GC 日志
    [GC pause (G1 Evacuation Pause) (mixed), 0.0456789 secs]
    
       [Eden: 480M->0B Old: 2G->1.8G]  ← 回收了部分老年代
    
    # Full GC 日志(需要优化)
    [Full GC (Allocation Failure) 4G->2.5G, 2.3456 secs]
    
  • 何时会触发 Full GC?(需要避免)

    # 场景1:分配巨型对象失败
    [Full GC (G1 Humongous Allocation)]
    
    # 场景2:晋升失败(老年代碎片化)
    [Full GC (G1 Evacuation Pause)]
    
    # 场景3:并发标记失败
    [Full GC (Concurrent Mode Failure)]
    
    # 优化方案:
    -Xmx8G -Xms8G                    # 增大堆内存
    -XX:G1HeapRegionSize=16M         # 增大 Region 减少巨型对象
    -XX:ConcGCThreads=4              # 增加并发标记线程
    
  • 适用场景

  • 6GB+ 大堆内存

  • 需要可预测的低延迟(200ms 以内)

  • 堆中存活对象占比 50% 以下

  • 对象分配速率变化大

  • 不希望出现长时间 GC 停顿

  • 与 CMS 的对比

  • 对比项 CMS G1
    算法 标记-清除(碎片) 标记-复制(整理,无碎片)
    内存布局 连续的新生代/老年代 Region 化(可预测停顿)
    停顿可预测性 ❌ 不可预测 ✅ 可通过 MaxGCPauseMillis 控制
    Full GC 频繁(碎片导致) 很少(除非参数不当)
    适用堆大小 < 4GB > 6GB
    JDK 默认 JDK 8(配合 ParNew) JDK 9+

4.8 ZGC 收集器(Z Garbage Collector)🚀

革命性特点(JDK 11 实验,JDK 15 正式)

  • 超低延迟:停顿时间不超过 10ms
  • 支持 TB 级堆(最大 16TB)
  • 并发执行所有阶段(包括移动对象)
  • 单代收集器(不分代)

核心技术

  • 1. 着色指针(Colored Pointers)
    64 位指针布局(Linux x86-64):
    
    ┌─────────┬──────┬──────┬──────┬──────┬─────────────────────┐
    
    │ 未使用  │FinalizableRemappedMarked1Marked0│  对象地址 (42 位)   │
    
    │ 16 位   │  1 位    │  1 位  │  1 位  │  1 位  │ 支持 4TB 堆        │
    
    └─────────┴──────┴──────┴──────┴──────┴─────────────────────┘
    
  • 通过指针中的标志位,直接判断对象状态:

    • Marked0/Marked1:标记信息(双缓冲)

    • Remapped:是否已重定向到新地址

    • Finalizable:是否只能通过 finalize 访问

  • 2. 读屏障(Load Barrier)
    // 普通对象访问:
    Object obj = fieldA;
    
    // ZGC 插入读屏障:
    Object obj = barrier(fieldA);  // 自动处理对象移动
    
    // 读屏障逻辑:
    if (指针颜色不对) {
        修正指针 → 指向对象的新地址
    }
    
  • 3. 并发移动对象
    并发重定向(Concurrent Relocation):
    
    用户线程访问旧地址
    
        ↓ 读屏障拦截
    
    转发表查找新地址
    
    
    返回新地址的对象
    
        ↓ 后台线程并发
    
    逐步更新所有引用
    
  • GC 阶段(全部并发)

    1. 并发标记 (Concurrent Mark)
    
       └─ 标记存活对象
    
    2. 并发预备重分配 (Concurrent Prepare for Relocate)
    
       └─ 统计需要移动的 Region
    
    3. 并发重分配 (Concurrent Relocate)
    
       └─ 移动对象到新 Region
    
    4. 并发重映射 (Concurrent Remap)
    
       └─ 修正所有指向旧对象的引用
    
    ⚡ 仅在初始标记和初始重分配有短暂 STW(< 1ms)
    
  • JVM 参数

    # 启用 ZGC(JDK 15+)
    -XX:+UseZGC
    
    # 堆大小设置
    -Xmx16G -Xms16G  # 建议固定大小
    
    # 并发 GC 线程数(默认自动计算)
    -XX:ConcGCThreads=4
    
    # 不常用参数
    -XX:ZCollectionInterval=120  # 强制 GC 间隔(秒)
    -XX:ZAllocationSpikeTolerance=2  # 分配速率峰值容忍度
    
  • 适用场景

    超低延迟要求(金融交易、游戏服务器)

    大堆内存(几十 GB 到 TB 级)

    JDK 15+

    ⚠️ 吞吐量会降低 10-15%(代价)


4.9 Shenandoah GC

特点(与 ZGC 类似)

  • 超低延迟(< 10ms)
  • 并发整理(包括并发移动对象)
  • 不分代
  • OpenJDK 独有(Oracle JDK 不包含)

与 ZGC 的区别

  • 对比项 ZGC Shenandoah
    并发移动技术 读屏障 + 着色指针 读写屏障 + 转发指针
    支持堆大小 16TB 相对较小(几百 GB)
    厂商 Oracle 官方 Red Hat 主导
    JDK 版本 JDK 11+ JDK 12+

JVM 参数

# 启用 Shenandoah
-XX:+UseShenandoahGC

# 停顿时间目标(实验性)
-XX:ShenandoahGCHeuristics=adaptive  # 自适应

五、收集器选择策略

5.1 决策树

                    堆内存大小?

                    ↙        ↘

              < 100MB        > 100MB

                 ↓              ↓

             Serial GC      是否需要低延迟?

                            ↙            ↘

                         是              否

                         ↓              ↓

                   堆大小?        Parallel GC

                   ↙    ↘          (吞吐量优先)

              < 6GB    > 6GB

                 ↓        ↓

              CMS       G1 / ZGC / Shenandoah

         (已弃用)    (根据 JDK 版本和延迟要求)

5.2 场景推荐表

应用场景 推荐收集器 关键参数 JDK 版本
单核/客户端应用 Serial GC -XX:+UseSerialGC All
多核/后台批处理 Parallel GC -XX:+UseParallelGC -XX:GCTimeRatio=99 All
Web 应用(堆 < 6GB) CMS(已弃用)→ G1 -XX:+UseConcMarkSweepGC -XX:+UseG1GC -XX:MaxGCPauseMillis=200 JDK 8
Web 应用(堆 > 6GB) G1 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16M JDK 9+
大堆低延迟(电商秒杀) ZGC -XX:+UseZGC -Xmx32G -Xms32G JDK 15+
超低延迟(金融交易) ZGC / Shenandoah -XX:+UseZGC-XX:+UseShenandoahGC JDK 15+
Kubernetes 容器 G1 -XX:+UseG1GC -XX:MaxRAMPercentage=75.0 JDK 11+

5.3 不同 JDK 版本的默认收集器

JDK 版本 默认收集器 说明
JDK 7-8 Parallel GC Server 模式默认,吞吐量优先
JDK 9+ G1 GC 官方推荐,平衡吞吐量和延迟
JDK 11+ G1 GC ZGC 作为实验特性可用
JDK 15+ G1 GC ZGC 正式发布,生产可用
JDK 17 (LTS) G1 GC 长期支持版本,推荐使用

六、GC 调优实战

6.1 GC 日志分析

开启 GC 日志

# JDK 8 及之前
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/to/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M

# JDK 9+ 统一日志框架
-Xlog:gc*:file=/path/to/gc.log:time,level,tags:filecount=5,filesize=20M

典型 GC 日志解读

# G1 Young GC 日志
[2024-01-20T10:30:45.123+0800][0.234s][info][gc] 
GC(10) Pause Young (Normal) (G1 Evacuation Pause) 
512M->128M(2048M) 23.456ms

解读:
- GC(10):第 10 次 GC
- Pause Young:新生代 GC
- 512M->128M:堆内存从 512M 降到 128M
- (2048M):堆总大小 2GB
- 23.456ms:停顿时间 23.456 毫秒

# Full GC 日志(警告信号)
[2024-01-20T10:35:50.789+0800][300.567s][info][gc] 
GC(50) Pause Full (Allocation Failure) 
3800M->2100M(4096M) 2345.678ms

⚠️ 问题:
- Full GC 停顿 2.3 秒(影响用户体验)
- 原因:Allocation Failure(内存分配失败)

GC 日志可视化工具

工具 特点 网址
GCeasy 在线分析,生成报告(推荐) https://gceasy.io
GCViewer 开源桌面工具 https://github.com/chewiebug/GCViewer
GCPlot 实时监控 https://gcplot.com
JClarity Censum 商业工具,功能强大 已被 Microsoft 收购

6.2 常见 GC 问题诊断

问题 1:频繁 Minor GC

现象

[GC (Allocation Failure) ... ] 0.010s

[GC (Allocation Failure) ... ] 0.012s

[GC (Allocation Failure) ... ] 0.011s

# 几秒内多次 Minor GC

原因

  • 新生代空间太小
  • 对象分配速率过快

解决方案

# 增大新生代
-Xmn1G              # 或 -XX:NewRatio=2(老年代:新生代 = 2:1)

# 调整 EdenSurvivor 比例
-XX:SurvivorRatio=8  # Eden:Survivor = 8:1:1

# 使用 G1 的动态新生代
-XX:+UseG1GC
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60

问题 2:频繁 Full GC

现象

[Full GC (Allocation Failure) ... ] 2.345s

[Full GC (Allocation Failure) ... ] 2.456s

# Full GC 频繁且耗时长

原因分析

原因 检查方法 解决方案
老年代空间不足 查看老年代使用率 增大堆内存 -Xmx
元空间不足 java.lang.OutOfMemoryError: Metaspace -XX:MetaspaceSize=256M
大对象直接进入老年代 查看对象大小分布 -XX:PretenureSizeThreshold=1M
内存泄漏 使用 MAT 分析堆转储 修复代码逻辑
System.gc() 显式调用 搜索代码 -XX:+DisableExplicitGC

诊断命令

# 查看堆内存使用情况
jstat -gcutil <pid> 1000 10

# 输出示例:
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00  95.23  78.45  89.67  94.12  93.45   100    1.234    10   12.345  13.579

# 关键指标:
- O (Old):89.67% ← 老年代使用率过高
- FGC:10 次 Full GC
- FGCT:12.345 秒(Full GC 总耗时)

# 生成堆转储文件
jmap -dump:format=b,file=heap.hprof <pid>

# 使用 MAT (Eclipse Memory Analyzer) 分析
# 找出内存占用最大的对象

问题 3:GC 停顿时间过长

现象

[GC pause (young) 512M->128M, 0.5678 secs]
# 停顿超过 500ms

原因与解决

# 原因1:堆内存过大(单次扫描时间长)
# 解决:使用低延迟收集器
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200  # 设置停顿目标

# 原因2:存活对象过多(复制耗时)
# 解决:优化对象生命周期,减少长生命周期对象

# 原因3:并发线程数不足
# 解决:增加 GC 线程
-XX:ParallelGCThreads=8      # 并行 GC 线程
-XX:ConcGCThreads=4          # 并发标记线程

# 原因4:NUMA 架构未优化
# 解决:启用 NUMA 支持
-XX:+UseNUMA

问题 4:内存泄漏(Memory Leak)

现象

# 老年代持续增长,Full GC 后也无法降低
Old Generation: 80% -> 85% -> 90% -> 95% -> OOM

排查步骤

# 1. 监控堆内存趋势
jstat -gccause <pid> 1000

# 2. 对比两次堆转储
jmap -dump:live,format=b,file=heap1.hprof <pid>
# 等待一段时间后
jmap -dump:live,format=b,file=heap2.hprof <pid>

# 3. 使用 MAT 对比分析
# Histogram 视图 -> 按大小排序
# Dominator Tree -> 找出支配对象

# 4. 常见泄漏场景

常见内存泄漏 Case

// Case 1: 集合类未清理
public class LeakExample1 {
    private static List list = new ArrayList<>();
    
    public void addObject() {
        list.add(new Object());  // 持续增加,永不清理
    }
}

// Case 2: 监听器未移除
button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        // 处理逻辑
    }
});
// 解决:button.removeActionListener(listener);

// Case 3: ThreadLocal 未清理
public class LeakExample3 {
    private static ThreadLocal<List> threadLocal = new ThreadLocal<>();
    
    public void process() {
        threadLocal.set(new ArrayList<>());
        // 处理逻辑
        // ⚠️ 忘记调用 threadLocal.remove();
    }
}

// Case 4: 数据库连接/IO 流未关闭
Connection conn = DriverManager.getConnection(url);
// 使用连接
// ⚠️ 忘记 conn.close();

// 正确做法:使用 try-with-resources
try (Connection conn = DriverManager.getConnection(url)) {
    // 使用连接
} // 自动关闭

6.3 GC 调优的黄金法则

法则 1:不要过早优化

1. 先用默认配置运行
2. 监控 GC 指标(吞吐量、停顿时间)
3. 确认存在性能问题
4. 再进行针对性调优

法则 2:明确优化目标

目标 关键指标 调优方向
高吞吐量 GC 时间占比 < 5% Parallel GC,增大堆内存
低延迟 P99 停顿 < 100ms G1 / ZGC,减少单次 GC 停顿
低内存占用 堆内存使用率 减小堆大小,使用 Serial GC

法则 3:合理设置堆内存

# ⚠️ 错误示例
-Xms512m -Xmx4g  # 初始和最大差距过大,导致频繁扩容

# ✅ 推荐做法
-Xms4g -Xmx4g    # 固定堆大小,避免扩容开销

# 容器环境
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0  # 使用容器内存的 75%

法则 4:避免过度调优

# ❌ 过度优化(参数过多,互相冲突)
-Xms4g -Xmx4g 
-Xmn2g 
-XX:SurvivorRatio=8 
-XX:MaxTenuringThreshold=15 
-XX:+UseAdaptiveSizePolicy=false 
...(还有 20 个参数)

# ✅ 简洁配置(让 JVM 自适应)
-Xms4g -Xmx4g 
-XX:+UseG1GC 
-XX:MaxGCPauseMillis=200

6.4 调优工具箱

命令行工具

工具 功能 示例
jstat 实时监控 GC 统计 jstat -gcutil <pid> 1000
jmap 生成堆转储 jmap -dump:format=b,file=heap.hprof <pid>
jstack 线程堆栈 jstack <pid> > thread.txt
jinfo 查看/修改 JVM 参数 jinfo -flag MaxHeapSize <pid>
jcmd 多功能诊断 jcmd <pid> GC.heap_info

可视化工具

工具 特点 推荐场景
JVisualVM JDK 自带,实时监控 开发测试
JProfiler 商业工具,功能全面 性能分析
Arthas 阿里开源,无需重启 线上诊断(⭐ 推荐)
Async-profiler 低开销火焰图 CPU 分析

Arthas 实战示例

# 下载启动
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

# 监控 GC
dashboard  # 实时仪表盘

# 查看堆内存
memory

# 生成火焰图
profiler start
profiler stop --format html

# 查看对象分布
heapdump /tmp/heap.hprof

七、常见面试题

Q1:为什么新生代使用复制算法,老年代使用标记-清除/标记-整理?

答案

新生代特点

  • 对象朝生夕死(98% 对象在第一次 GC 时死亡)
  • 每次 GC 后存活对象很少(约 10%)

复制算法优势

假设新生代 100 个对象:
- 标记-清除:需要扫描 100 个 + 清除 90 个 = 190 次操作
- 复制算法:只需复制 10 个存活对象 = 10 次操作

效率对比:复制算法快 19 倍!

老年代特点

  • 对象生命周期长(存活率 60-80%)
  • 空间大(占堆内存 2/3)

不用复制算法的原因

假设老年代 100 个对象,70 个存活:
- 复制 70 个对象 + 需要额外 50% 空间(浪费)
- 标记-清除:只需标记 100 个 + 清除 30 个

结论:复制算法在老年代既慢又浪费空间

结论:复制算法在老年代既慢又浪费空间

为什么用标记-整理而非标记-清除

  • 标记-清除会产生内存碎片
  • 老年代对象大,碎片会导致提前 Full GC
  • 标记-整理虽慢,但消除碎片,长期性能更好

Q2:什么是 Stop-The-World (STW)?如何减少 STW 时间?

STW 定义: GC 时暂停所有用户线程(应用程序停顿)

为什么需要 STW

// 场景:不 STW 会导致的问题
A -> B -> C  // 标记阶段

// 用户线程修改引用:
A -> D
B 断开 C

// 结果:C 被误回收(对象消失问题)

减少 STW 的策略

层面 方法 示例
算法层 使用并发算法 CMS、G1、ZGC 的并发标记
收集器层 选择低延迟收集器 G1(< 200ms)、ZGC(< 10ms)
参数层 减少单次回收量 -XX:MaxGCPauseMillis=100
代码层 优化对象生命周期 对象池、减少临时对象
架构层 垂直拆分服务 单个 JVM 堆不要超过 32GB

实战优化

# 优化前(Parallel GC)
-XX:+UseParallelGC
Young GC: 200ms, Full GC: 3s

# 优化后(G1)
-XX:+UseG1GC -XX:MaxGCPauseMillis=50
Young GC: 50ms, Mixed GC: 100ms, Full GC: 很少发生

Q3:对象一定分配在堆上吗?什么是栈上分配和标量替换?

答案

不一定!JVM 有三种优化手段。

1. 栈上分配(Stack Allocation)

前提条件

  • 对象作用域不会逃逸出方法
  • 对象较小
public void method() {
    User user = new User();  // 对象未逃逸(方法内创建并使用)
    user.setName("Alice");
    System.out.println(user.getName());
    // 方法结束,user 不再使用
}

// JVM 优化:直接在栈上分配 user 对象
// 好处:方法结束自动回收,无需 GC

2. 标量替换(Scalar Replacement)

概念

  • 标量:不可再分的数据(int, long, reference)
  • 聚合量:可分解的对象(User 对象)
public void method() {
    User user = new User();
    user.name = "Alice";
    user.age = 18;
}

// JVM 优化为:
public void method() {
    String name = "Alice";  // 标量
    int age = 18;           // 标量
    // 不创建 User 对象,直接使用局部变量
}

3. TLAB(Thread Local Allocation Buffer)

问题:多线程在 Eden 区分配对象需要加锁。

解决

每个线程在 Eden 区预先分配一小块缓冲区(TLAB):

Eden 区
┌────────────────────────────────────┐
│ [线程1 TLAB] [线程2 TLAB] [线程3...]│
└────────────────────────────────────┘

线程在自己的 TLAB 内分配对象,无需加锁

JVM 参数

# 启用逃逸分析(JDK 8+ 默认开启)
-XX:+DoEscapeAnalysis

# 启用标量替换
-XX:+EliminateAllocations

# TLAB 配置
-XX:+UseTLAB  # 默认开启
-XX:TLABSize=256k

Q4:如何判断一个对象是否可以被回收?

两种算法对比

1. 引用计数法(Reference Counting)

A -> B  // B 的引用计数 = 1
C -> B  // B 的引用计数 = 2
A 断开  // B 的引用计数 = 1
C 断开  // B 的引用计数 = 0 → 可回收

缺陷:无法解决循环引用

A.ref = B;
B.ref = A;
// A 和 B 的引用计数都是 1,但实际上都是垃圾

2. 可达性分析(Reachability Analysis)⭐

GC Roots 包括

  • 虚拟机栈中的引用(局部变量表)
  • 方法区中静态属性引用
  • 方法区中常量引用
  • 本地方法栈中 JNI 引用
  • JVM 内部引用(Class 对象、异常对象、类加载器)
  • 被同步锁持有的对象
GC Roots
  ├─ 栈变量 A ──> 对象1 ──> 对象2
  ├─ 静态变量 B ──> 对象3
  └─ 常量 C ──> 对象4
  
  对象5 <──> 对象6  ← 无法从 GC Roots 到达,可回收

实战示例

public class GCRootsDemo {
    private static Object staticObj = new Object();  // GC Root(静态变量)
    
    public void method() {
        Object localObj = new Object();  // GC Root(栈变量)
        
        // localObj 和 staticObj 引用的对象不会被回收
    }
}

Q5:四种引用类型有什么区别?

引用类型 回收时机 使用场景 示例
强引用 永远不回收 普通对象引用 Object obj = new Object()
软引用 内存不足时回收 缓存(图片缓存) SoftReference<byte[]> cache
弱引用 下次 GC 时回收 缓存(ThreadLocalMap) WeakReference ref
虚引用 任何时候(用于跟踪回收) 堆外内存回收 PhantomReference ref

代码示例

// 1. 强引用(默认)
Object obj = new Object();
// 只要 obj 可达,永不回收

// 2. 软引用(内存敏感缓存)
SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = softRef.get();  // 可能返回 null(已被回收)

// 3. 弱引用(临时缓存)
WeakReference<User> weakRef = new WeakReference<>(new User());
User user = weakRef.get();  // 可能返回 null

// 4. 虚引用(追踪对象回收)
ReferenceQueue queue = new ReferenceQueue<>();
PhantomReference phantomRef = new PhantomReference<>(new Object(), queue);
// phantomRef.get() 永远返回 null
// 对象被回收时,phantomRef 会被加入 queue

ThreadLocal 内存泄漏分析:

ThreadLocal<User> threadLocal = new ThreadLocal<>();

// ThreadLocalMap 结构:
Entry extends WeakReference<ThreadLocal<?>> {
    Object value;  // 强引用
}

// 问题:
threadLocal = null;  // ThreadLocal 被回收(弱引用)
// 但 value 仍被 Entry 强引用 → 内存泄漏

// 解决:
threadLocal.remove();  // 手动清理

Q6:什么时候会触发 Full GC?如何避免?

触发条件(8 种场景)

触发场景 说明 避免方法
1. 老年代空间不足 对象晋升时老年代放不下 增大堆内存 -Xmx
2. 元空间不足 类加载过多 -XX:MetaspaceSize=256M
3. System.gc() 代码显式调用 -XX:+DisableExplicitGC
4. CMS Concurrent Mode Failure 并发清除时空间不足 提前触发 GC(降低阈值)
5. 晋升担保失败 Minor GC 前检查失败 增大老年代
6. 大对象分配失败 老年代碎片化 使用 G1(无碎片)
7. JDK 7 永久代满 永久代不足 升级 JDK 8+
8. GC Ergonomics 自适应策略触发 调整停顿时间目标

诊断和优化流程

# 1. 监控 Full GC 频率
jstat -gccause <pid> 1000

# 2. 查看触发原因
[Full GC (Allocation Failure) ...]  # 分配失败
[Full GC (Ergonomics) ...]          # 自适应策略
[Full GC (System.gc()) ...]         # 显式调用

# 3. 针对性优化
# 场景1:频繁 Allocation Failure
-Xmx8G -Xms8G  # 增大堆

# 场景2:元空间问题
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=512M

# 场景3:禁用显式 GC
-XX:+DisableExplicitGC

# 场景4:切换到 G1
-XX:+UseG1GC -XX:MaxGCPauseMillis=200

Q7:G1 为什么能实现可预测的停顿时间?

核心机制

1. Region 化设计

传统分代:必须回收整个新生代/老年代

G1:选择部分 Region 回收

2. 停顿预测模型

// G1 维护每个 Region 的统计信息:
class RegionStats {
    long gcTime;          // 历史 GC 耗时
    long garbageBytes;    // 垃圾对象字节数
    double value = garbageBytes / gcTime;  // 回收价值
}

// 选择算法(贪心):
在停顿时间预算内,选择价值最大的 Region 组合

3. 增量回收

Young GC:只回收所有 Eden + Survivor Region
Mixed GC:回收所有新生代 + 部分老年代 Region(价值高的)

不需要一次回收整个堆 → 停顿时间可控

配置示例

-XX:+UseG1GC
-XX:MaxGCPauseMillis=100  # 目标停顿 100ms

# G1 内部决策:
如果回收 10Region 需要 80ms:继续
如果回收 11Region 需要 105ms:停止,留到下次

Q8:ZGC 如何实现 10ms 内的超低延迟?

三大核心技术

1. 着色指针(Colored Pointers)

传统 GC:标记信息存储在对象头(需要访问对象)
ZGC:标记信息存储在指针中(无需访问对象)

64 位指针布局:
[unused 16bit][Marked1 1bit][Marked0 1bit][Remapped 1bit][地址 42bit]

判断对象状态:直接检查指针的标志位(极快)

2. 读屏障(Load Barrier)

// 用户代码:
Object obj = field.someObject;

// ZGC 插入读屏障:
Object obj = loadBarrier(field.someObject);

// 屏障逻辑(硬件层面优化):
if (指针颜色不匹配当前阶段) {
    slowPath(); // 处理重定位、标记等
}
// 快速路径几乎无开销(1-2 个 CPU 周期)

3. 并发移动对象

传统 GC:移动对象时必须 STW(防止引用失效)
ZGC:通过转发表 + 读屏障实现并发移动

并发重定位过程:
┌─────────────────────────────────────┐
│ 1. 标记阶段(并发)                    │
│    标记存活对象                        │
├─────────────────────────────────────┤
│ 2. 重定位准备(短暂 STW < 1ms)        │
│    选择需要移动的 Region               │
├─────────────────────────────────────┤
│ 3. 并发重定位                         │
│    后台线程:移动对象到新地址           │
│    转发表:记录 旧地址 -> 新地址        │
├─────────────────────────────────────┤
│ 4. 用户线程访问对象                    │
│    读屏障检测到旧指针                  │
│    查询转发表 -> 返回新地址             │
│    ⚡ 用户线程协助修正引用(自愈)      │
└─────────────────────────────────────┘

关键点:转发表(Forwarding Table)

转发表存储在每个 Region 内:
┌──────────┬──────────┐
│  旧地址   │  新地址   │
├──────────┼──────────┤
│ 0x10000x5000   │
│ 0x11000x5100   │
└──────────┴──────────┘

用户线程访问旧地址时:
读屏障拦截
查转发表获取新地址
自愈(修正引用为新地址)
下次访问直接命中新地址(无开销)

为什么只有 < 10ms 停顿:

ZGC 的 STW 只发生在:

初始标记:扫描 GC Roots(< 1ms)

重定位准备:统计 Region(< 1ms)

重映射准备(可选,< 1ms)

其他所有阶段(标记、移动、重映射)都是并发的

Q9:JVM 内存模型(JMM)与 GC 的关系?

澄清概念

  • JVM 内存结构:堆、栈、方法区等(本文讨论的)
  • Java 内存模型(JMM):线程间通信规范(happens-before)

JMM 对 GC 的影响

1. 写屏障(Write Barrier)

// G1/ZGC 使用写屏障记录跨 Region 引用
obj.field = anotherObj;

// 编译后插入写屏障代码:
obj.field = anotherObj;
if (obj 和 anotherObj 在不同 Region) {
    记录到 Remembered Set;
}

2. 读屏障(Load Barrier)

// ZGC 使用读屏障处理对象重定位
Object obj = field.someObject;

// 插入读屏障:
Object obj = loadBarrier(field.someObject);
// 检查指针颜色,必要时重定位

3. 内存屏障(Memory Barrier)

// GC 的 STW 过程需要内存屏障确保可见性

// 1. GC 线程发起 STW 请求

// 2. 插入内存屏障,确保所有线程看到最新状态

// 3. 各线程到达安全点后停止

⬅️ 类加载机制 🏠 00-Java ➡️ JMM与内存可见性