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

作者介绍

大家好,我是 CodeStats

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

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

📖 本文你将获取到什么?

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

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

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

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

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


📑 目录

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

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

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

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

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

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

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

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

  9. 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 的"待定垃圾")
灰色 已被标记,但引用的对象尚未全部扫描完("待办队列")
黑色 已被标记,且引用的对象全部扫描完("已完成")

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

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

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

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

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

🛡️ 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 在"并发重分配"阶段,用两套机制保证安全:

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

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

  3. 转发表保护:只要转发表里有记录,对应的旧 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

🎯 调优三步走

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

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

  3. 迭代调整

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

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

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

🚨 常见陷阱

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

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

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


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

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

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

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

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

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

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

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

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


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

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

相关推荐
风中芦苇啊15 小时前
Java EasyExcel 导入通用工具类:自定义注解映射字段 + 反射机制
java·开发语言
家有娇妻张兔兔15 小时前
Java 对接 PLC 主流型号最合适的方案:Apache PLC4X 实战指南
java·开发语言·plc·modbus·数据缓存·西门子s7·apache plc4x
摇滚侠16 小时前
Codebuddy 官网 Codebuddy IntelliJ IDEA 插件 阅读笔记 2
java·笔记·intellij-idea
gugucoding17 小时前
24. 【Java】枚举:更安全的常量
java·开发语言
Kyrie_kk17 小时前
Java--BigInteger 超大整数计算
java·算法
开发者联盟league17 小时前
Java 通过 JNA 调用 C++ DLL:关键流程总结
java·c++·jna
z落落17 小时前
T-SQL 事务(Transaction)
java·数据库·sql
库克克18 小时前
【C++】智能指针
java·开发语言·c++
lemon_sjdk18 小时前
Spring WebFlux 响应式编程深度解析:从架构选型到核心抽象
java·spring·架构
就改了18 小时前
Mybatis快速入门大全(详细版)
java·spring boot·mybatis