欢迎光临
我们一直在努力

【Java GC】Java JVM 垃圾回收(GC)完全指南:从内存结构到并发回收的底层原理

作者介绍

大家好,我是 CodeStats。

一个在底层技术上“考古”了四年的硬核爱好者,也是 WWAIC(全周项目AI编程) 范式的提出者和实践者。我曾手写过一个完整的 Java Web 框架(从 IoC 容器到嵌入式 Tomcat,代码全开源),也喜欢用通俗的语言拆解 CPU、JVM、操作系统的运行本质。

我的技术信条:所有高深的技术,最后都能用大白话讲清楚。如果讲不清楚,说明还没真正理解。

📖 本文你将获取到什么?

  • 不是"八股文式"的概念罗列,而是从问题出发,层层递进的原理推导

  • 为什么会有垃圾回收?它到底在解决什么问题?

  • 并发回收这个"不可能三角"——如何在不暂停业务的情况下,安全地回收内存?

  • 从 Serial 到 ZGC,每一代回收器为了解决什么问题而诞生,又付出了什么代价

  • 读完本文,你将能用自己的话,向别人讲清楚 G1 和 ZGC 到底是怎么做到"并发"的


📑 目录

  • Java 内存结构是如何划分的?各区域存储什么内容?为什么这样划分?

  • 垃圾回收算法的本质是什么?标记-清除、复制、标记-整理各自的优劣和适用边界在哪里?

  • 引用计数为什么被主流 JVM 抛弃?可达性分析的 GC Roots 到底包含哪些?为什么必须是这些?

  • Serial 和 Parallel:STW 暂停回收的极致——它们的工作原理是什么?为什么堆大了就撑不住?

  • CMS 的并发标记:三色标记 + 增量更新是如何工作的?为什么最终还是要 STW?老年代为什么只能用标记-清除?

  • G1 的并发回收:Region 化布局 + SATB 写屏障 + TAMS 指针,如何实现可预测停顿?完整流程原理拆解

  • ZGC 的超低延迟:着色指针 + 读屏障 + 转发表,如何把 STW 压到 1ms 以内?完整流程原理拆解

  • 各回收器优缺点与适用场景总结

  • JVM GC 参数配置与调优思路


  • 1. Java 内存结构是如何划分的?各区域存储什么内容?为什么这样划分?

    🎯 核心观点

    JVM 内存划分的本质,是根据数据的"生命周期"和"共享范围"做分类管理。分类越精细,GC 就越精准。堆是 GC 的主战场,栈是 GC Roots 的核心来源。

    📦 线程共享区域

    堆(Heap)

    存储所有类实例和数组。它是 GC 的"主战场"。之所以把堆设计为 GC 的核心区域,是因为对象的生命周期差异极大——有的对象存活几毫秒,有的存活几小时。GC 的核心任务,就是把"死"的对象占用的内存腾出来。

    方法区(Method Area / Metaspace)

    存储类信息、常量、静态变量、JIT 编译后的代码。JDK 8 之前叫"永久代",之后叫"元空间"(使用本地内存)。静态变量指向的对象,是 GC Roots 的重要来源——只要类还在,静态变量引用的对象就不会被回收。

    🧵 线程私有区域

    程序计数器:记录当前线程执行到哪一条字节码指令。这是 JVM 规范中唯一没有规定 OOM 的区域。

    Java 虚拟机栈:每个方法执行时创建栈帧,存储局部变量表、操作数栈等。局部变量表中的对象引用,是 GC Roots 中最活跃的一类——只要栈帧还在,方法内的局部对象就不会被回收。

    本地方法栈:为 Native 方法服务,与 Java 栈类似。

    💡 为什么这样划分?

    划分原则原因
    线程共享 vs 私有 共享区域需要 GC 管理(堆、方法区);私有区域随线程销毁而释放(栈、PC)
    堆内分代(或分区) 不同生命周期的对象用不同算法处理,提高效率
    元空间独立 避免 PermGen 大小难调、OOM 频发的问题

    2. 垃圾回收算法的本质是什么?标记-清除、复制、标记-整理各自的优劣和适用边界在哪里?

    🎯 核心观点

    所有 GC 算法都是在"内存利用率"、"内存碎片"、"移动开销"三个维度之间做取舍。没有完美的算法,只有适合的场景。

    🗑️ 标记-清除(Mark-Sweep)

    • 流程:标记所有垃圾 → 直接清除

    • 优点:实现简单,不需要移动对象

    • 缺点:内存碎片化严重,导致大对象分配失败,提前触发 Full GC

    • 适用:对象存活率低的场景(如 CMS 老年代)

    📋 复制(Copying)

    • 流程:内存对半分 → 只用一个半区 → 满了就把活对象复制到另一个半区 → 原半区全部清空

    • 优点:无碎片,实现简单

    • 缺点:内存利用率只有 50%(HotSpot 优化为 Eden:Survivor = 8:1,利用率提高到 90%)

    • 适用:对象存活率低的场景(新生代)

    🧹 标记-整理(Mark-Compact)

    • 流程:标记所有存活对象 → 将它们向一端移动 → 清理边界以外的内存

    • 优点:无碎片,内存利用率高

    • 缺点:移动和更新引用开销大,必须 STW

    • 适用:对象存活率高的场景(老年代)

    📊 算法对比

    算法碎片内存利用率移动开销适用场景
    标记-清除 有 高 无 存活率低
    复制 无 低(50%~90%) 有(复制) 存活率极低(新生代)
    标记-整理 无 高 大(移动+改引用) 存活率高(老年代)

    💡 分代收集的本质

    新生代用复制(因为大部分对象死得快),老年代用标记-整理或标记-清除(因为存活率高,移动不划算)。这就是分代收集的底层逻辑——用不同的算法处理不同生命周期的对象。


    3. 引用计数为什么被主流 JVM 抛弃?可达性分析的 GC Roots 到底包含哪些?为什么必须是这些?

    🎯 核心观点

    引用计数解决不了循环引用,这是它的"原罪"。可达性分析从 GC Roots 出发,只有从根上不可达的对象才是真正的垃圾。GC Roots 必须是"一定不会回收"的对象——它们是判断对象是否存活的"锚点"。

    🚫 引用计数的致命缺陷

    java

    class Node { Node next; }
    Node a = new Node();
    Node b = new Node();
    a.next = b;
    b.next = a; // 循环引用!
    a = null;
    b = null; // 现在 a 和 b 都无法被外部访问,但它们的引用计数器都是 1(互相指向)
    // 引用计数算法永远不会回收它们——内存泄漏!

    ✅ 可达性分析的 GC Roots

    GC Roots 是直接从 JVM 外部可达的引用,包括:

    GC Roots 类型示例为什么是根?
    栈帧中的局部变量 方法内的 Object obj = … 当前正在执行的方法一定需要它
    静态变量 static Object obj 类还在,静态变量就在
    JNI 引用 Native 方法中的 jobject JNI 层持有的对象,Java 层不能动
    常量池引用 final 常量 常量不可变,永远需要
    同步锁持有的对象 synchronized(obj) 中的 obj 锁释放前,对象必须存活
    活动线程 Thread 对象本身 线程在运行,线程本身不能回收

    4. Serial 和 Parallel:STW 暂停回收的极致——它们的工作原理是什么?为什么堆大了就撑不住?

    🎯 核心观点

    Serial 是单线程 STW,Parallel 是多线程 STW。它们的共同点是"回收时必须暂停所有业务线程"。堆越大,存活对象越多,复制/整理的时间越长,STW 就越长。这就是它们无法用于大堆的根本原因。

    💻 Serial 收集器

    • 新生代:复制算法(Eden → Survivor),单线程

    • 老年代:标记-整理算法,单线程

    • STW 时间:与堆大小成正比,堆越大暂停越长

    • 适用:单核 CPU、小堆(< 几百 MB)

    🔄 Parallel 收集器(JDK 8 默认)

    • 新生代(Parallel Scavenge):复制算法,多线程并行

    • 老年代(Parallel Old):标记-整理算法,多线程并行

    • 核心目标:吞吐量最大化(-XX:GCTimeRatio 控制)

    • STW 时间:比 Serial 短,但仍随堆增大而显著增加

    • 适用:多核 CPU、吞吐量优先、能接受秒级停顿的后台任务

    ⚠️ 为什么堆大了就不行?

    STW 暂停的时间 ≈ (存活对象总量 / GC 线程吞吐量)。堆越大,存活对象越多,复制/整理的时间就越长。当堆超过 4GB,STW 可能达到数秒甚至数十秒——这对在线服务是灾难性的。


    5. CMS 的并发标记:三色标记 + 增量更新是如何工作的?为什么最终还是要 STW?老年代为什么只能用标记-清除?

    🎯 核心观点

    CMS 的核心突破是把"标记"阶段拆成了"STW 初始标记 + 并发标记 + STW 最终标记",让最耗时的并发标记与业务线程同时运行。三色标记 + 增量更新解决了并发标记期间的"漏标"问题,但最终标记仍需 STW 来"收尾"。而为了并发清除,CMS 被迫选择了产生碎片的标记-清除算法。

    🎨 三色标记(Tri-color Marking)

    颜色含义
    白色 尚未被标记(本轮 GC 的"待定垃圾")
    灰色 已被标记,但引用的对象尚未全部扫描完("待办队列")
    黑色 已被标记,且引用的对象全部扫描完("已完成")

    ⚠️ 并发标记的"漏标"问题

    并发标记时,业务线程在跑。如果发生以下情况:

  • 一个黑色对象(已扫描完)新增了一个指向白色对象(未标记)的引用

  • 所有指向该白色对象的灰色对象删除了引用

  • 那这个白色对象就永远无法被标记——它被漏标了。

    🛡️ CMS 的解法:增量更新(Incremental Update)

    CMS 用写屏障(Write Barrier)拦截"黑色→白色"的新增引用,把黑色对象记录下来。在最终标记(Remark – STW) 阶段,重新扫描这些黑色对象,把漏标的白色对象救回来。

    📋 CMS 完整流程

    阶段是否 STW做什么
    初始标记 ✅ STW(短) 标记 GC Roots 直接可达的对象
    并发标记 ❌ 并发 遍历对象图,三色标记;写屏障记录"黑→白"
    并发预清理 ❌ 并发 提前处理部分写屏障记录,减少 Remark 压力
    最终标记 ✅ STW(较长) 处理所有写屏障记录,完成标记
    并发清除 ❌ 并发 标记-清除,回收垃圾内存
    并发重置 ❌ 并发 重置内部状态

    🤔 为什么老年代只能用标记-清除?

    CMS 要"并发清除",就不能移动对象(移动必须暂停业务线程改引用)。所以它只能选择不移动对象的标记-清除。代价是内存碎片。为了解决碎片,CMS 提供了 Full GC 时的压缩选项(-XX:+UseCMSCompactAtFullCollection),但那是 STW 的。


    6. G1 的并发回收:Region 化布局 + SATB 写屏障 + TAMS 指针,如何实现可预测停顿?完整流程原理拆解

    🎯 核心观点

    G1 的核心创新是把堆拆成多个 Region,每次只回收"垃圾最多"的 Region,把全堆回收的总 STW 时间打散成多次可控的小停顿。SATB 写屏障 + TAMS 指针解决了并发标记的安全性问题。可预测停顿的本质是"每次只干一部分活,绝不一次干完"。

    🗺️ Region 化布局

    G1 不再将堆物理划分为新生代/老年代,而是划分成多个大小相等的 Region(区域)。每个 Region 逻辑上可以扮演 Eden、Survivor 或老年代的角色。

    TAMS(Top at Mark Start)指针:每个 Region 的 nextTAMS 指针在初始标记时被设置为当前 top(已分配内存的顶)。并发标记只处理 TAMS 以下的"旧对象",TAMS 以上的新对象默认存活——这样就避免了并发标记和业务分配之间的锁竞争。

    🛡️ SATB(Snapshot-At-The-Beginning)写屏障

    G1 用写前屏障(Pre-Write Barrier)记录"被删除的旧引用"。当一个灰色对象删除指向白色对象的引用时,这个白色对象被记录到 SATB 队列中。最终标记时处理 SATB 队列,确保所有在"快照"中存活的对象都被标记。

    SATB 破坏了"漏标"的必要条件之一:灰色对象删除白色引用。 因为删除时已经把白色对象"存档"了。

    📋 G1 完整流程

    阶段是否 STW核心动作为什么必须(或可以)并发?
    初始标记 ✅ STW 标记 GC Roots,设置 TAMS 必须冻结快照,否则 TAMS 边界会变
    并发标记 ❌ 并发 遍历对象图,三色标记;SATB 记录 最耗时的操作,必须并发
    最终标记 ✅ STW(可控) 处理 SATB 队列 队列有限,停顿可控
    清理 ✅ STW 交换位图,统计存活率,排序 Region 决策阶段,必须一致
    混合收集 ✅ STW(多次,每次可控) 复制选中的 Region 中的存活对象 复制时必须 STW(G1 没有读屏障)

    🎯 "可预测停顿"的底层逻辑

    G1 通过"每次只回收一部分 Region"来实现停顿可控。它根据用户设置的 MaxGCPauseMillis,动态计算本次 Mixed GC 回收多少个 Region。这种"切碎"策略,把全堆回收的 STW 从"一次性数秒"变成了"多次数十毫秒"。代价是总回收时间变长,吞吐量略降。


    7. ZGC 的超低延迟:着色指针 + 读屏障 + 转发表,如何把 STW 压到 1ms 以内?完整流程原理拆解

    🎯 核心观点

    ZGC 把 GC 状态存进指针(着色指针),用读屏障在业务线程读对象时即时修正指针(自愈),用转发表把"改全堆指针"的巨量工作拆碎。核心思路是"把一切耗时的操作都搬到后台并发执行,业务线程只做纳秒级的检查"——这是它对"并发回收"的终极回答。

    🎨 着色指针(Colored Pointers)

    ZGC 在 64 位指针的高位中分配了 4 个元数据位:

    text

    [ 高18位未使用 | Finalizable | Remapped | Marked1 | Marked0 | 42位对象地址 ]

    • Marked0 / Marked1:标记位,轮流使用(本轮用 M0,下轮用 M1),永远不用清空位图

    • Remapped:1 表示指向最新地址,0 表示指向旧地址(需要查转发表修正)

    • 标记/修正对象,只需要修改指针的几位,不需要访问对象头,速度极快且天然原子

    🛡️ 读屏障(Load Barrier)+ 自愈(Self-Healing)

    G1 用写屏障拦截"赋值",ZGC 用读屏障拦截"读取"。 因为"写"的前提是"读"——业务线程要先读到一个对象,才能把它赋值给另一个字段。

    读屏障的逻辑(伪代码):

    c

    if (指针.Remapped == 0) {
    // 指向旧地址,查转发表
    new_addr = ForwardTable[指针.地址];
    // 修正当前指针(自愈)
    指针.地址 = new_addr;
    指针.Remapped = 1;
    }
    return 指针;

    "自愈"的本质:业务线程每次读到一个旧地址,读屏障就帮它修正为新地址。没人读的旧指针,不急着修,留到下一轮 GC。

    📋 ZGC 完整流程

    阶段是否 STW核心动作为什么必须(或可以)并发?
    初始标记 ✅ STW(<0.1ms) 标记 GC Roots,设置指针颜色 Roots 必须冻结,但 Roots 数量有限
    并发标记 ❌ 并发 遍历对象图;读屏障即时标活 最耗时操作,必须并发
    最终标记 ✅ STW(<0.5ms) 处理弱引用等残留 残留工作极少
    并发预备重分配 ❌ 并发 计算垃圾最多的 ZPage 只是算账,不动内存
    初始转移 ✅ STW(<0.1ms) 准备根的转移引用 根引用必须瞬间一致
    并发重分配 ❌ 并发(最耗时) 复制存活对象;建立转发表;读屏障自愈;空间立即回收;整页 CAS 释放 复制和空间回收都不动业务内存,完全并发
    并发重映射 ❌ 并发(实际合并到下轮标记) 修正残留旧指针 不着急,可合并

    🗑️ 并发回收的安全保障(重点)

    ZGC 在"并发重分配"阶段,用两套机制保证安全:

  • 空间回收(立即生效):存活对象被复制走后,原 ZPage 里它们占用的空间立即被插入空闲链表。这些空间在纳秒级后就可被新对象使用。本轮 GC 已经回收了绝大多数垃圾空间。

  • 整页释放(CAS 原子裁决):只有当一个 ZPage 的存活对象计数为 0 时,GC 才尝试用 CAS 原子操作将其物理内存归还。如果在 CAS 的瞬间有业务线程复活了对象,CAS 失败,释放取消。"检查"和"释放"是一个 CPU 指令,没有时间窗口。

  • 转发表保护:只要转发表里有记录,对应的旧 ZPage 物理内存就绝不归还。业务线程拿着旧地址进来,读屏障能通过转发表找到新地址。

  • ZGC 的"并发"不是无锁魔法,而是把"全局 STW 大锁"拆成了"ZPage 级的 CAS 小锁",配合着色指针和读屏障,让业务线程在绝大多数情况下只是多执行几条位运算指令。


    8. 各回收器优缺点与适用场景总结

    回收器核心目标核心机制优点缺点适用场景
    Serial 单核吞吐 单线程 STW 简单高效 堆大则暂停长 小堆、单核、开发测试
    Parallel 吞吐量 多线程 STW 吞吐量高 堆大暂停增长 批处理、离线计算
    CMS 低延迟 并发标记 + 增量更新 停顿较短 碎片、Remark 不定、CPU 敏感 中等堆(<4GB),延迟敏感
    G1 可预测停顿 Region + SATB + 混合收集 停顿可控、无碎片 调优复杂,吞吐略降 大堆(>4GB),需可预测停顿
    ZGC 超低延迟 着色指针 + 读屏障 + 并发重分配 STW <1ms,支持 TB 堆 吞吐量下降 5-10% 超大堆(>16GB),极低延迟
    Shenandoah 超低延迟 并发移动 + 读屏障 与 ZGC 类似 吞吐量开销 低延迟场景

    9. JVM GC 参数配置与调优思路

    🎯 核心观点

    GC 调优的本质是"在吞吐量和延迟之间做取舍"。先明确目标,再监控数据,最后针对性调整。任何调优都必须基于 GC 日志分析,不能拍脑袋。

    ⚙️ 通用启动参数(JDK 17+,G1 示例)

    bash

    -Xms4G -Xmx4G # 堆大小固定
    -XX:+UseG1GC # 使用 G1
    -XX:MaxGCPauseMillis=200 # 目标停顿时间
    -XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记阈值

    # GC 日志(必须)
    -Xloggc:/var/log/app-gc.log
    -XX:+PrintGCDetails
    -XX:+PrintGCDateStamps
    -XX:+UseGCLogFileRotation
    -XX:NumberOfGCLogFiles=5
    -XX:GCLogFileSize=20M

    🎯 调优三步走

  • 明确目标:吞吐量优先?延迟优先?设定量化指标(如"GC 总停顿 < 50ms/天")

  • 观察基线:不加调优参数运行,用 jstat -gc <pid> 1s、GC 日志分析工具(GCViewer、GCEasy)观察

  • 迭代调整:

    • 堆太小 → 频繁 GC;堆太大 → Full GC 时间长

    • MaxGCPauseMillis 设太小 → 频繁 Mixed GC,吞吐量暴跌

    • 元空间(Metaspace)也要监控,防止无限增长

  • 🚨 常见陷阱

    • G1 的 MaxGCPauseMillis 设得过低:G1 会频繁做小批量回收,反而拖慢整体吞吐量。从 200ms 开始,实测调整。

    • 忽视 GC 日志:不分析日志就调参,无异于蒙眼开车。

    • 堆太大(>32GB)仍用 G1:建议评估 ZGC,G1 在超大堆上停顿可能突破 100ms。


    💎 总结:一个完整的思想链条

    我们把整个 GC 机制串起来看:

  • 为什么需要 GC:堆内存有限,程序不断创建对象,必须回收"死"对象的内存。

  • 怎么判断对象是死是活:从 GC Roots 出发做可达性分析——从根上不可达,就是垃圾。

  • 单线程 STW 回收:Serial / Parallel,简单但堆大了就扛不住。

  • 并发标记:CMS 用三色标记 + 增量更新,让标记阶段大部分并发。但最终还是逃不掉 STW 的 Remark 和碎片问题。

  • 可预测停顿:G1 把堆切分成 Region,每次只收一部分,用 SATB 保证并发标记安全。把"一次性大暂停"拆成"多次小暂停"。

  • 超低延迟:ZGC 把 GC 状态塞进指针,用读屏障让业务线程"顺手"修正引用,用转发表把修指针的巨量工作打碎。把"一切能并发的都并发",只留下 3 个 <1ms 的 STW 点。

  • 每一次 GC 技术的进化,本质上都是在回答同一个问题:怎么让"找垃圾"和"清垃圾"这两件事,对正在跑的业务线程造成最小的干扰?答案从"完全暂停"到"尽可能并发",再到"几乎完全并发",每一步都是对并发安全的更精细控制。


    如果本文让你对 JVM GC 有了真正"底层级"的理解,欢迎点赞、收藏、关注!

    有疑问或想深入探讨某个细节,评论区见。😊

    赞(0)
    未经允许不得转载:171主机测评 » 【Java GC】Java JVM 垃圾回收(GC)完全指南:从内存结构到并发回收的底层原理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址