JVM垃圾回收:从新生代到ZGC,从理论到调优

这是一篇写给所有Java开发者的GC通关文牒。无论你是刚入行的新手,还是被线上GC折磨过的老兵,这篇文章都可能会让你有新的收获。

写在前面:GC到底在解决什么问题?

如果你是C/C++程序员,你得自己管内存------就像租了一间办公室,东西用完了,必须亲手把桌椅搬走,不然办公室就堆满了(内存泄漏)。累不累?而且很容易出错,万一把正在用的桌子搬走了(野指针),程序直接崩溃。

Java说: "你别管了,我雇了一个全职保洁阿姨------GC(垃圾回收),她自动帮你清理。"

但问题来了:这个保洁阿姨干活的时候,会把你锁在门外(Stop-The-World,简称STW)。她收拾1分钟,你就得在门外等1分钟。如果你的网站有几百万用户,这1分钟就是严重事故。

所以,我们研究GC机制,本质上就一个目的:让保洁阿姨干活更快,把你锁在门外的时间越短越好。

第一部分:从零搭建GC认知框架

1.1 面试必考基础:对象到底死没死?

阿姨打扫卫生,第一件事就是判断:这玩意儿到底是不是垃圾?

错误方法:引用计数法 (Python早期用过,Java没用)。就像给每个物品贴个便利贴,被一个人拿着就写"+1"。但有个Bug:如果两个物品互相指着对方,谁都没用它们,但便利贴上都写着"有1个人拿",阿姨就永远不会扔(循环引用问题)。

Java的正确方法:可达性分析 。这就像顺着"根"找。JVM规定了一些"树根"(GC Roots),比如:

  • 你正在写代码时,手里捏着的局部变量(线程栈里引用的对象)
  • 静态变量(static引用的东西)
  • JNI引用等

阿姨从这些树根出发,顺着绳子(引用链)往下摸。摸得到的 ,说明你还活着在用。摸不到的,统统是垃圾,直接扫走。这一招完美解决了循环引用的问题。

1.2 核心机密:为什么堆要分"新生代"和"老年代"?

假设办公室很大,有1000张桌子。如果每次阿姨都全屋清查,累死不说,还把你锁外面很久。

科学家观察了几十年发现两个铁律

  1. 弱分代假说 :98%的对象(比如方法里的临时变量i)都是"朝生暮死"的,用了一次就完了。
  2. 强分代假说:活过几次清扫的对象,以后也很难变成垃圾。

基于这个发现,JVM把办公室分成了两个区域:

  • 新生代(Young Gen) :新员工入职坐这里。这里垃圾极多,阿姨每天都来扫好几次。
  • 老年代(Old Gen) :养老院。熬过了新生代多次清扫的"老油条"搬到这里。这里垃圾较少,阿姨很久才来扫一次。

分代的好处:如果不管死活,每次都扫全屋(Full GC),那是最慢的。现在阿姨大部分时间只扫"新生代"这个小房间(Minor GC),速度快极了!

第二部分:新生代------高频高效的"短期对象回收站"

2.1 区域划分:Eden + 两块Survivor

新生代进一步细分为三个区域:

  • Eden区 :占新生代的 80% ,新对象默认分配在这里
  • Survivor 0(From) :占 10%
  • Survivor 1(To) :占 10%

默认比例为 Eden : S0 : S1 = 8 : 1 : 1 (可通过 -XX:SurvivorRatio 调整)。

为什么需要两块 Survivor?这要从新生代采用的算法说起。

2.2 核心算法:复制算法(Copying)

场景模拟

  1. 新对象都在 Eden 办公。有一天Eden爆满了。
  2. 阿姨来了,她把 Eden 和 S0(假设S0有东西)里还活着的极少数人 ,一股脑搬到 S1 去。这些人年龄 +1 岁。
  3. 搬完后,阿姨直接把 Eden 和 S0 两个房间格式化,全部清空,干干净净!
  4. 下次清扫,再活着的搬到 S0,如此反复。

为什么这个算法快? 因为新生代98%都是垃圾,所以需要搬运的"活人"特别少。虽然浪费了 10% 的空间(S0或S1总有一个空着),但换来了极快的清扫速度。用空间换时间,值!

2.3 对象年龄与晋升机制

每个对象在 Survivor 区中都有一个年龄计数器,每熬过一次 Minor GC,年龄就 +1。

当对象的年龄达到默认阈值 15 时(可通过 -XX:MaxTenuringThreshold 调整),就会被晋升到老年代。

除此之外,还有动态年龄判定 机制:如果 Survivor 区中某个年龄的所有对象大小总和超过了 Survivor 区的一半,那么年龄大于等于该值的对象会直接晋升到老年代。这是为了防止 Survivor 区空间被长期存活的对象占满,导致复制算法失效。

第三部分:老年代------长期对象的"归宿"

3.1 区域特点

老年代存储的是生命周期长的对象,包括:

  • 熬过多次 Minor GC 后仍然存活的对象
  • 大对象(超过 -XX:PretenureSizeThreshold 设置值的对象,直接分配到老年代)

老年代空间更大(默认是新生代的 2 倍),GC 频率低,但单次回收耗时更长,对应用的影响也更大。

3.2 核心算法:标记-清除 与 标记-整理

老年代不适合用复制算法------因为存活对象太多,复制成本太高。因此,老年代通常采用以下算法:

标记-清除(Mark-Sweep)

  1. 标记阶段:从 GC Roots 出发,标记所有存活对象。
  2. 清除阶段:回收所有未被标记的对象。

优点:实现简单,不需要额外空间。缺点:产生内存碎片,可能导致大对象无法分配。

标记-整理(Mark-Compact)

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

优点:没有内存碎片,适合大对象分配。缺点:移动对象需要更新所有引用,成本较高。

3.3 Major GC / Full GC 的触发条件

老年代的垃圾回收通常被称为 Major GCFull GC

Full GC 的触发条件主要包括:

  • 老年代空间不足
  • 元空间(Metaspace)空间不足
  • Minor GC 后晋升的对象平均大小超过老年代剩余空间
  • 显式调用 System.gc()(不推荐)

Full GC 会触发 Stop-The-World(STW) ,暂停所有应用线程。它的特点是低频、耗时 ,可能引发数秒甚至更长的应用停顿。在生产环境中,频繁的 Full GC 往往是性能问题的根源

第四部分:并发标记的底层原理------三色标记与读写屏障

这一部分是GC知识体系中最硬核的"深水区"。理解了它,你就超越了90%的Java开发者。

4.1 并发标记的基础:三色标记法

在并发回收(应用线程和GC线程一起跑)时,JVM使用 三色标记法 来描述对象的状态。GC Roots 出发,遍历对象图,给每个对象打上三种颜色标签:

  • 白色(White) :尚未被GC访问到。标记结束后,如果还是白色,说明不可达,会被回收
  • 灰色(Gray) :自己已经被GC访问到了(确定存活),但它引用的子对象还没被完全扫描。这是GC当前的工作队列。
  • 黑色(Black) :自己已被访问,且它引用的所有子对象也都被扫描完了。确定存活。

理想顺序:GC从Roots出发,把所有灰色对象拉进队列,扫完一个变黑,再把它的子对象涂灰。直到队列为空,全黑收工。

4.2 致命陷阱:漏标(Lost Object)

当GC线程和应用线程并发执行时,引用关系随时在变,会出现一种致命异常------

漏标 :标记开始时是存活(白色链在灰色下)的对象,标记过程中,应用线程断开了灰色对象指向它的引用 ,同时又让一个已扫描完的黑色对象指向了它 。此时,灰色不再理它,黑色已经扫描完不再看它。后果 :这个活着的对象被当成白色垃圾回收掉!程序必然崩溃!

漏标必须同时满足两个条件(缺一不可)

  1. 灰色对象 → 白色对象的引用被断开
  2. 黑色对象 → 白色对象的引用被新增

4.3 两大解决流派:CMS 的"增量更新" vs G1 的"原始快照"

为了解决上面的"漏标"问题,两位大神祭出了不同的法宝。

CMS 的策略:增量更新(Incremental Update)

  • 思路: "既然问题出在'黑色对象新增指向白色对象'上,那我就专门盯着这个动作。"
  • 当黑色对象新增指向白色对象时,CMS通过写屏障 拦截这个动作,把黑色的对象重新标记为灰色,扔回GC的工作队列重新扫描。
  • 缺陷:重新扫描黑色对象时效率有损耗,标记阶段容易反复。

G1 的策略:原始快照(SATB,Snapshot-At-The-Beginning)

  • 思路: "既然问题出在'灰色对象断开指向白色对象',那我就把这个'断开瞬间的旧引用'拍个快照记下来。"
  • 当灰色对象即将断开指向白色对象的引用时,G1通过写屏障 拦截,直接把白色对象当做存活对象(推入栈中)
  • 优点:G1 维护的是"标记开始时的那一瞬间"的对象存活视图。虽然可能会产生更多的浮动垃圾 (原本该死但快照标记为存活,留到下次扫),但绝对杜绝了漏标 ,标记过程更稳定。这就是为什么G1比CMS更稳健、不容易触发Full GC的底层原因!

4.4 底层的"眼睛":读写屏障(Barrier)

上面反复提到的写屏障 ,并不是什么魔法,它实际上是一段在赋值操作(比如 obj.field = value)前后插入的JVM底层C++代码 。仅仅是一行 obj.field = x 的 Java 代码,在 JVM 底层偷偷干了这么多"记笔记"的脏活累活,这就是为什么G1/ZGC会比Parallel GC消耗更多CPU的原因

4.5 提速利器:卡表(Card Table)与脏卡(Dirty Card)

在讲RSet时我们提到"卡表",它是写屏障的"好兄弟"。假如没有卡表,GC每次要查跨代引用,就得扫描整个老年代(几十GB),谁也受不了。

卡表的设计 :JVM把整个堆内存划分成一个个 512字节(Card Page) 的小格子,每个格子对应一个字节的标记位(Card Table)。

  • 当写屏障发现有引用赋值发生时,它不直接更新RSet(更新RSet代价太大)。
  • 它只是把这块内存对应的"卡表字节"标记为 "脏(Dirty)"
  • 当GC需要知道"谁引用了新生代"时,只需要扫描整张卡表里标记为脏的卡页,再精确解析这些卡页里的对象引用即可。

代价权衡 :扫描一张几百兆的卡表(脏页可能只占1%)远比扫描全堆(几十GB)快得多。用极小的内存(卡表)和CPU(写屏障标记脏)换取GC时数秒的STW时间,这波买卖血赚。

第五部分:垃圾回收器的演进------从Serial到ZGC

分代模型是策略 ,而垃圾回收器是具体执行者。随着 Java 的发展,垃圾回收器也在不断演进。

5.1 早期:Serial 与 Parallel

Serial GC-XX:+UseSerialGC):单线程回收,GC 时会 STW。适合单核或小内存场景(<100MB)。

Parallel GC-XX:+UseParallelGC):多线程并行回收,吞吐量优先。适合多核 CPU 的后台计算场景。新生代用复制算法,老年代用标记-整理。

5.2 追求低延迟:CMS(已废弃)

CMS(Concurrent Mark-Sweep)-XX:+UseConcMarkSweepGC):以低延迟 为目标,GC 线程与用户线程并发执行,减少 STW 时间。

但 CMS 也有明显缺陷:

  • 使用标记-清除算法,会产生内存碎片
  • 调优复杂,参数繁多
  • 在 JDK 9 中被标记为废弃,JDK 14 中被移除

5.3 平衡大师:G1(JDK 9+ 默认)

G1(Garbage-First)-XX:+UseG1GC)从 JDK 7 引入,JDK 9 起成为默认垃圾回收器

G1 的核心设计思想是分区回收(Region-based)

  • 将整个堆划分为若干个大小相等的 Region
  • 每个 Region 可以扮演 Eden、Survivor 或 Old 的角色
  • 优先回收垃圾最多的 Region(Garbage-First 名字的由来)
  • 可预测的停顿时间(通常目标在 200ms 以内

G1 为什么能做到可控停顿? G1 维护了一个 停顿预测模型 。它会记录每个 Region 的回收耗时和历史数据。当触发 GC 时,G1 不是回收全堆,而是计算出哪些 Region 中的垃圾最多,并只回收这些 Region。通过控制每次回收的 Region 数量,G1 就能将 STW 时间控制在预设值之内。

跨代引用的难题------记忆集(RSet) :G1 为每个 Region 维护了一个 RSet(记忆集) ,记录了"谁引用了我"。虽然 RSet 占用了大约 5% - 10% 的堆内存,且维护写屏障带来了性能开销,但这是用内存和 CPU 换 STW 时间的典型案例

5.4 极致低延迟:ZGC

当堆内存达到 TB 级别 时,G1 的停顿时间仍然会随堆增大而上升。ZGC-XX:+UseZGC)应运而生:

  • 设计目标:停顿时间恒低于 10ms ,且与堆大小无关(TB 级堆也成立)
  • 核心技术:染色指针(Colored Pointers) + 读屏障(Load Barrier)
  • 代价:约 5-15% 的吞吐量损失
  • Java 11 引入,Java 15 生产可用

ZGC 为什么这么牛? 它把标记信息直接写在 内存地址的指针 上,而不是写在对象头里。配合虚拟内存映射,ZGC 甚至能边搬动对象(整理内存),边让程序读写,实现了真正的并发整理。

5.5 选型决策树

回收器 启用参数 适用场景 核心调优参数
Serial -XX:+UseSerialGC 小内存、单核
Parallel -XX:+UseParallelGC 高吞吐量:后台批处理、离线计算 -XX:ParallelGCThreads=N
G1 -XX:+UseG1GC (JDK 9+默认) 平衡之选:通用服务、追求可控的延迟 -XX:MaxGCPauseMillis=200
ZGC -XX:+UseZGC (JDK 11+) 极致低延迟:金融、实时推荐,堆内存极大 -XX:ConcGCThreads=N

第六部分:代码实战------把理论跑出"现象"

光说不练假把式。这一部分,我们用几段代码把前面讲的理论亲手验证一遍

6.1 验证TLAB(线程本地分配缓冲区)

java 复制代码
/**
 * 验证TLAB分配
 * 运行参数:-XX:+UseG1GC -XX:+PrintTLAB -XX:+PrintGCDetails -XX:TLABSize=512k
 */
public class TLABDemo {
    public static void main(String[] args) throws InterruptedException {
        for (int t = 0; t < 4; t++) {
            new Thread(() -> {
                for (int i = 0; i < 100_000; i++) {
                    byte[] data = new byte[16];
                }
            }, "TLAB-Worker-" + t).start();
        }
        Thread.sleep(5000);
    }
}

看什么? 日志中的 TLAB: refill waste 比例。如果超过20%,说明对象大小不齐,考虑调整 -XX:TLABSize

6.2 验证G1的大对象(Humongous Object)分配

java 复制代码
/**
 * 验证G1的大对象分配
 * 运行参数:-XX:+UseG1GC -XX:+PrintGCDetails -XX:G1HeapRegionSize=2m
 */
public class HumongousDemo {
    public static void main(String[] args) throws InterruptedException {
        // 1.5MB,超过Region 2m的50%,走Humongous路径
        byte[] bigData = new byte[1024 * 1024 * 3 / 2];
        System.out.println("大对象已分配,请查看GC日志中的 Humongous Allocation");
        Thread.sleep(30000);
    }
}

看什么? GC日志中出现 [GC pause (G1 Humongous Allocation)]。频繁出现说明业务频繁创建大对象,应考虑对象池化复用。

6.3 演示 System.gc() 的危害

java 复制代码
/**
 * 演示 System.gc() 的破坏性
 * 运行参数:-XX:+UseG1GC -XX:+PrintGCDetails
 * 对比加上 -XX:+DisableExplicitGC 的差异
 */
public class ExplicitGCDemo {
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            byte[] data = new byte[1024 * 10];
        }
        System.out.println("准备调用 System.gc()...");
        long start = System.currentTimeMillis();
        System.gc();  // ← 这行代码可能导致秒级停顿!
        long end = System.currentTimeMillis();
        System.out.println("System.gc() 耗时: " + (end - start) + "ms");
    }
}

看什么? 不加 -XX:+DisableExplicitGC 时,日志出现 [Full GC (System.gc())],暂停时间可能是数百毫秒~数秒。加上该参数后,System.gc() 被静默忽略。

生产铁律 :启动参数永远加上 -XX:+DisableExplicitGC

6.4 模拟元空间(Metaspace)泄漏

java 复制代码
/**
 * 模拟元空间内存泄漏
 * 运行参数:-XX:MaxMetaspaceSize=64m -XX:+PrintGCDetails
 * 注意:运行到 OOM: Metaspace 会终止!
 */
public class MetaspaceLeakDemo {
    public static void main(String[] args) throws Exception {
        for (int i = 0; ; i++) {
            ClassLoader loader = new java.net.URLClassLoader(
                new java.net.URL[]{new java.io.File(".").toURI().toURL()}
            );
            Class<?> clazz = loader.loadClass("com.example.DynamicClass" + i);
            System.out.println("第 " + i + " 次加载");
            if (i % 1000 == 0) Thread.sleep(100);
        }
    }
}

看什么? 日志反复出现 [GC (Metadata GC Threshold)],最终 java.lang.OutOfMemoryError: Metaspace

实战启示 :元空间频繁GC,不要盲调 -XX:MetaspaceSize。检查代码中是否有自定义类加载器没被回收、动态代理类在热部署时未清理、ThreadLocal中存储了类加载器。

第七部分:调优实战------从"猜"到"测"

7.1 调优前的三问

在动手调参前,先回答三个问题:

问题 思考方向
目标是什么? 低延迟?高吞吐?内存高效?三选一
瓶颈在哪? CPU飙高?RT超时?频繁OOM?
数据在哪? GC日志、监控大盘、堆转储文件准备好了吗?

7.2 标准调优流程

第一步:建立基线。改动任何参数之前,先记录当前的QPS、P99延迟、GC频率和暂停时间。

第二步:定位瓶颈。开启GC日志是调优的"黑匣子":

  • JDK 8及以前:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log
  • JDK 9+:-Xlog:gc*:file=/path/to/gc.log:time,uptime,tags,level

第三步:一次只改一个参数 ,然后观察效果。第四步:验证效果,对比基线数据。

7.3 三大实战场景

场景一:频繁 Full GC + CPU 打满

  • 现象:系统响应变慢,CPU飙升,GC日志显示老年代频繁被填满。
  • 紧急止血:若服务不可用,临时通过流量限流/降级恢复。
  • 根因定位 :这通常是内存泄漏大对象频繁创建
  • 解决方案jmap -dump:live,format=b,file=heap.hprof <pid> 导出堆转储,用 Eclipse MAT 分析,找到占用内存最大的对象及其GC Root引用链。

场景二:GC 停顿时间过长

  • 现象:GC日志显示单次GC暂停超过500ms,影响业务SLA。
  • 检查GC类型:如果是Full GC,参考场景一;如果是YGC时间长,可能新生代过大。
  • 检查Safepoint :开启 -XX:+PrintSafepointStatistics,看 wait 字段是否过大(说明有长循环导致线程进安全点慢)。
  • 解决方案 :G1下调低 -XX:MaxGCPauseMillis(但别<100ms);堆>32G且延迟<10ms,考虑升级到ZGC。

场景三:元空间 (Metaspace) 内存泄漏

  • 现象 :GC日志频繁出现 Metadata GC Threshold,最终 OOM: Metaspace。
  • 原因:通常是类加载器(尤其是自定义ClassLoader)泄漏,或动态代理(CGLIB)生成了大量类且未被回收。
  • 解决方案 :检查代码中频繁创建类加载器的逻辑;设置元空间上限 -XX:MaxMetaspaceSize=256m;使用 jcmd <pid> GC.class_stats 查看每个类加载器加载的类数量。

7.4 核心参数速查表

参数 作用 建议
-Xms / -Xmx 初始/最大堆大小 设为相同值,通常为物理内存的70%-80%
-XX:NewRatio 新生代:老年代比例 默认2(1:2),一般不动
-XX:SurvivorRatio Eden:Survivor比例 默认8,一般不动
-XX:MaxTenuringThreshold 晋升老年代年龄阈值 默认15,一般不动
-XX:+UseG1GC 启用G1 JDK 9+默认,推荐
-XX:MaxGCPauseMillis G1目标停顿时间 建议100-200ms,勿设<50ms
-XX:+UseZGC 启用ZGC JDK 15+生产可用,堆>32G且要求<10ms延迟
-XX:+DisableExplicitGC 禁用System.gc() 生产必加!
-XX:+PrintGCDetails 打印GC详情 调优时开启
-XX:MetaspaceSize / -XX:MaxMetaspaceSize 元空间大小 设置上限防止泄漏拖垮系统

7.5 调优的核心心法

调优遵循一个黄金法则:先看日志,再查代码,最后才改参数

如果你依然频繁遭遇GC问题,请跳出调参的视角,回到代码本身:

  • 是不是在循环里拼命创建大对象?
  • 是不是用了不恰当的缓存导致内存泄漏?
  • 是不是可以复用对象来减少分配压力?
  • 是不是线程池误用了 ThreadLocal 导致内存泄漏?

将底层原理烂熟于心,以数据为指导进行调优,专注于写出内存友好的代码------这才是JVM调优的终极之道。

写在最后:GC 演进史就是一部"抗STW史"

让我们最后回顾一下整个 GC 的发展脉络:

  • 新生代:用复制算法,快准狠,因为98%对象活不过第一轮。
  • 老年代:用标记整理,稳但慢,因为存活对象太多搬不动。
  • CMS:第一次尝试并发回收,但碎片太多、调优复杂,最终烂尾。
  • G1:切豆腐块(Region),预测停顿时长,用RSet和SATB彻底解决了并发标记的稳定性和可控性,成为当今主流。
  • ZGC:染色指针 + 并发整理,TB级堆也能做到毫秒级停顿,代表未来方向。

GC 的本质,是在吞吐量、延迟和内存开销三者之间做零和博弈。 每一步演进,都是工程师们用极致的工程智慧,把"锁门时间"从秒级一步步压缩到毫秒级甚至微秒级。

希望这篇文章,能帮你把 JVM 垃圾回收的最后一块拼图补上。如果感觉有帮助就点个赞吧。

相关推荐
长不胖的路人甲1 小时前
二叉排序树(BST)Java 完整实现 + 删除思路详解
java·开发语言·算法
Conan在掘金1 小时前
鸿蒙报错速查:arkts-no-func-expressions 禁用 function 表达式,用了就炸,根因 + 真解法
后端
geovindu1 小时前
python: Breadth First Search Algorithm and Depth First Search Algorithm
开发语言·后端·python·算法·搜索算法
大模型码小白1 小时前
Java 部署:Jenkins Pipeline 构建 Java 项目(自动化)
java·人工智能·python·机器学习
长不胖的路人甲1 小时前
可达性分析法(根搜索算法)完整详解
java·jvm·算法
笨笨饿1 小时前
#102_Codex无在VSCold无法打开
java·c语言·数据库·笔记
程序员清风1 小时前
AI不是万能的,大家要专注实践!
java·后端·面试
青山木1 小时前
Hot 100 --- 全排列
java·数据结构·算法·leetcode·深度优先
猫猫不是喵喵.2 小时前
认证授权【Spring Security】
java·认证授权