这是一篇写给所有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张桌子。如果每次阿姨都全屋清查,累死不说,还把你锁外面很久。
科学家观察了几十年发现两个铁律:
- 弱分代假说 :98%的对象(比如方法里的临时变量
i)都是"朝生暮死"的,用了一次就完了。 - 强分代假说:活过几次清扫的对象,以后也很难变成垃圾。
基于这个发现,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)
场景模拟:
- 新对象都在 Eden 办公。有一天Eden爆满了。
- 阿姨来了,她把 Eden 和 S0(假设S0有东西)里还活着的极少数人 ,一股脑搬到 S1 去。这些人年龄 +1 岁。
- 搬完后,阿姨直接把 Eden 和 S0 两个房间格式化,全部清空,干干净净!
- 下次清扫,再活着的搬到 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) :
- 标记阶段:从 GC Roots 出发,标记所有存活对象。
- 清除阶段:回收所有未被标记的对象。
优点:实现简单,不需要额外空间。缺点:产生内存碎片,可能导致大对象无法分配。
标记-整理(Mark-Compact) :
- 标记阶段:同标记-清除,标记所有存活对象。
- 整理阶段:将所有存活对象向内存一端移动,然后清理边界外的内存。
优点:没有内存碎片,适合大对象分配。缺点:移动对象需要更新所有引用,成本较高。
3.3 Major GC / Full GC 的触发条件
老年代的垃圾回收通常被称为 Major GC 或 Full 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线程和应用线程并发执行时,引用关系随时在变,会出现一种致命异常------
漏标 :标记开始时是存活(白色链在灰色下)的对象,标记过程中,应用线程断开了灰色对象指向它的引用 ,同时又让一个已扫描完的黑色对象指向了它 。此时,灰色不再理它,黑色已经扫描完不再看它。后果 :这个活着的对象被当成白色垃圾回收掉!程序必然崩溃!
漏标必须同时满足两个条件(缺一不可) :
- 灰色对象 → 白色对象的引用被断开。
- 黑色对象 → 白色对象的引用被新增。
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 垃圾回收的最后一块拼图补上。如果感觉有帮助就点个赞吧。