JVM垃圾回收面试题
如何判断对象可回收?引用计数法 vs 可达性分析
核心结论先抛出来 💡
JVM 判断对象是否可回收,主要有两种经典算法:
- 引用计数法(Reference Counting)
- 可达性分析算法(Reachability Analysis)
Java 采用的是可达性分析算法,这是面试必背的基础点!✅
引用计数法 🔢
1. 核心原理
给每个对象添加一个引用计数器:
- 每当有一个地方引用它,计数器 + 1
- 每当引用失效,计数器 - 1
- 当计数器 = 0 时,说明该对象没有被任何地方引用,可以被回收
2. 优缺点
- ✅ 优点:实现简单,判定效率极高,不需要遍历整个堆
- ❌ 致命缺点:无法解决循环引用问题 ⚠️
3. 循环引用问题演示
public class ReferenceCountingDemo {
public Object instance = null;
public static void main(String[] args) {
ReferenceCountingDemo a = new ReferenceCountingDemo();
ReferenceCountingDemo b = new ReferenceCountingDemo();
// 互相引用
a.instance = b;
b.instance = a;
// 置空外部引用
a = null;
b = null;
// 此时a和b的引用计数器都是1,永远不会被回收!
System.gc();
}
}两个对象互相“暗恋”,外部没有其他引用,可它们彼此卡的计数器都是 1。计数器永远到不了 0,保洁阿姨永远没法进去打扫,最后内存泄漏。Java 因此果断抛弃了纯引用计数法。
✅ 不过现在 Python 除了引用计数,也配合了“标记清除”来解决循环引用。不同语言各显神通。
4. 实际应用
- Python 早期版本
- Objective-C
- 微软 COM 技术
可达性分析算法 🌳
1. 核心原理
以一系列称为 "GC Roots" 的对象作为起点,从这些根节点开始向下搜索:
- 搜索走过的路径称为引用链(Reference Chain)
- 当一个对象到 GC Roots没有任何引用链相连时,说明该对象不可达,可以被回收,即使它们内部循环引用,也会一锅端。
2. 什么是 GC Roots?(面试必问!⭐⭐⭐)
以下对象可以作为 GC Roots:
| 类型 | 说明 |
|---|---|
| 虚拟机栈(局部变量表)引用的对象 | 方法中定义的局部变量、参数 |
| 本地方法栈(JNI)引用的对象 | 本地方法调用时使用的对象 |
| 方法区中类静态属性引用的对象 | static 修饰的变量 |
| 方法区中常量引用的对象 | final static 修饰的常量 |
被同步锁(synchronized)持有的对象 | 被同步锁(synchronized)持有的对象 |
看个图解就明白了:
说明:垃圾 A 和 B 即使组成了死循环,只要从 GC Roots 找不到它俩,这一片都会被标记为可回收。循环引用不再是问题 🎉。
3. 生动比喻
把整个对象引用关系想象成一棵大树:
- GC Roots 就是树根
- 所有对象都是树枝和树叶
- 没有连到树根的树叶就是枯叶,会被风吹走(垃圾回收)
4. 优缺点
- ✅ 优点:完美解决了循环引用问题
- ❌ 缺点:实现复杂,需要遍历整个引用链,会导致Stop The World(STW)
5. 面试加分项:两次标记过程 🚩
可达性分析不是一次标记就直接回收,而是有两次标记:
- 第一次标记:不可达的对象被第一次标记
- 第二次标记:
- 如果对象重写了
finalize()方法,并且还没有被调用过,会被放到F-Queue队列 - 由
Finalizer线程执行finalize()方法 - 如果在
finalize()中重新与引用链建立关联,对象会被拯救 - 否则,第二次标记后就会被真正回收
⚠️ 注意:finalize()方法不推荐使用!因为执行时间不确定,甚至可能不执行,而且只能拯救一次。Java 9 已标为弃用。
两种算法核心对比 📊
| 对比维度 | 引用计数法🔢 | 可达性分析算法🌳 |
|---|---|---|
| 基本原理 | 计数器归零即回收 | 从 GC Roots 向下搜索,不可达即回收 |
| 实现难度 | 简单 | 复杂 |
| 判定效率 | 高 | 低(需要遍历引用链) |
| 循环引用问题 | ❌ 无法解决,典型内存泄漏 | ✅ 完美解决,循环的岛链一起丢弃 |
| STW 时间 | 几乎没有 | 较长 |
| Java 是否采用 | ❌ 否 | ✅ 是 |
| 代表语言 | Python 早期、Objective-C | Java、C#、现代 Python |
垃圾回收算法:标记-清除、标记-复制、标记-整理、分代收集
🎯 面试官您好,关于垃圾回收算法,我从4 个基础核心算法和JVM 实际落地的分代收集策略来给您讲解,直击考点不啰嗦👇
🔍 标记 - 清除算法(Mark-Sweep)
核心思想:先标记存活对象,再直接清除垃圾对象
执行步骤:
- 标记阶段:从 GC Roots 出发,遍历所有可达对象并打上标记(STW)
- 清除阶段:遍历堆内存,回收所有未被标记的垃圾对象
✅ 优点:实现最简单,不需要移动对象,对老年代友好
❌ 缺点:产生大量内存碎片,导致大对象无法分配;STW 时间随存活对象数量线性增长
💡 生活类比:大扫除只扫地上的垃圾,不整理桌上的物品,最后房间到处是空隙,放不下大件行李箱
📦 标记 - 复制算法(Copying)
核心思想:用空间换时间,解决内存碎片问题
执行步骤:
- 将可用内存划分为大小相等的两块,每次只使用其中一块
- 标记当前块中的存活对象(STW)
- 将所有存活对象按顺序复制到另一块空闲内存
- 一次性清空当前使用的整块内存
✅ 优点:无任何内存碎片;内存分配只需指针移动,效率极高
❌ 缺点:内存利用率只有 50%;存活对象越多,复制开销越大
💡 生活类比:搬家时只带走有用的东西,旧房子直接全部清空,新房子整整齐齐没有杂物
🧹 标记 - 整理算法(Mark-Compact)
核心思想:结合前两者优点,既不浪费内存又解决碎片问题
执行步骤:
- 标记所有存活对象(STW)
- 整理阶段:将所有存活对象向内存一端移动,按顺序紧密排列
- 一次性清除边界外的所有垃圾对象
✅ 优点:无内存碎片;内存利用率 100%
❌ 缺点:需要移动大量对象,STW 时间比标记 - 清除更长
💡 生活类比:整理衣柜,把所有衣服都叠好放到衣柜左边,右边全部清空,空间利用最大化
📊 四大核心算法对比表
| 算法 | 核心步骤 | 内存利用率 | 内存碎片 | STW 时间 | 适用场景 |
|---|---|---|---|---|---|
| 标记 - 清除 | 标记→清除 | 100% | 大量 | 中等 | 老年代(存活对象多) |
| 标记 - 复制 | 标记→复制→清空 | 50%(原始)/90%(JVM 优化后) | 无 | 短(存活对象少) | 新生代(朝生夕灭) |
| 标记 - 整理 | 标记→整理→清除 | 100% | 无 | 最长 | 老年代(存活对象多) |
| 分代收集 | 按代划分 + 组合使用上述算法 | 接近 100% | 极少 | 整体最优 | 现代 JVM 主流实现 |
🏢 分代收集算法(Generational Collection)
这是目前所有商用 JVM(HotSpot、OpenJ9)的实际采用方案,核心依据是弱分代假说:
- 绝大多数对象都是朝生夕灭的
- 熬过越多次垃圾回收的对象,越难被回收
所以 JVM 把堆劈成了两代人:
| 区域 | 对象特征 | 选用算法 | 典型回收 |
|---|---|---|---|
| 🐣 新生代 (Young Gen) | 死的快,存活率低 | 标记-复制 | Minor GC |
| 🧓 老年代 (Old Gen) | 活得久,存活率高 | 标记-清除 / 标记-整理 | Major / Full GC |
新生代又拆成 Eden + Survivor(From + To),复制只在 Survivor 之间倒腾,避免了浪费一半内存 🙌。
老年代则用 CMS(清除为主)、G1、ZGC 等,都在标记-整理思想上做并行/并发优化。
JVM 堆内存划分与算法选择
┌───────────────────────────────────────────────────┐
│ 堆内存 (Heap) │
├───────────────────┬───────────────────────────────┤
│ 新生代 (1/3) │ 老年代 (2/3) │
│ ┌───────┬───┬───┐ │ │
│ │ Eden │ S0│ S1│ │ 标记-清除 + 标记-整理混合 │
│ │ 80% │10%│10%│ │ (CMS用标记-清除,G1用混合) │
│ └───────┴───┴───┘ │ │
│ 标记-复制算法 │ │
└───────────────────┴───────────────────────────────┘关键细节:
- 新生代采用8:1:1的比例划分 Eden 和两个 Survivor 区,解决了原始标记 - 复制算法 50% 内存浪费的问题
- 每次 Minor GC 只回收 Eden 和一个 Survivor 区,将存活对象复制到另一个空的 Survivor 区
- 熬过 15 次(默认)Minor GC 的对象会晋升到老年代
🎯 一句话总结
新生代用复制,图的是快;老年代用整理,图的是省空间。分代收集是 JVM 自动帮你把这两种算法搭配起来,找到吞吐与延迟的平衡点。
✨ 面试加分项(延伸知识点)
- 所有 GC 算法都有 STW:只是时间长短不同,标记阶段必须暂停用户线程,否则会出现漏标 / 错标问题
- 现代 GC 的改进:
- G1:Region 化分代,同时兼顾吞吐量和低延迟
- ZGC/Shenandoah:并发整理算法,将 STW 时间控制在 10ms 以内
- CMS 的特殊之处:采用标记 - 清除算法,因为并发阶段不能移动对象,最后用 Serial Old 做兜底整理
常见垃圾收集器:Serial、Parallel、CMS、G1、ZGC、Shenandoah 对比与选型
🧹 面试官您好!关于 Java 垃圾收集器,我会从核心特点、性能指标、适用场景三个维度来对比分析,最后给出明确的选型建议。
📈 垃圾收集器发展时间线
JDK 1.3 → Serial GC (单线程)
JDK 1.4 → Parallel GC (多线程吞吐量优先)
JDK 1.5 → CMS GC (并发低延迟)
JDK 7u4 → G1 GC (区域化分代式)
JDK 11 → ZGC (低延迟里程碑)
JDK 12 → Shenandoah GC (超低延迟)一张图理清演进
停顿时间 ↑
高 ● Serial / Parallel ← 远古战神,简单粗暴
中 ● CMS ← 低延迟先锋,可惜浮动垃圾
低 ● G1 ← 垃圾优先,可控停顿
极低 ● ZGC / Shenandoah ← 亚毫秒级,停顿与堆大小无关
年代: JDK5 → JDK7 → JDK9(默认) → JDK11/12+- 左边:关注吞吐量,暂停可能几秒甚至几十秒。
- 右边:关注响应时间,暂停降到毫秒级。
🎯 各收集器核心特点速览
1. Serial GC 🧵
- 工作方式:单线程收集,"Stop-The-World"(STW)
- 算法:新生代复制算法,老年代标记 - 整理
- 优点:简单高效,内存占用小,没有线程交互开销
- 缺点:STW 时间长,多核 CPU 利用率低
- 适用场景:客户端应用、单核服务器、堆内存较小 (<100MB)
2. Parallel GC 🚀
- 工作方式:多线程收集,依然 STW
- 算法:同 Serial GC,只是多线程执行
- 优点:吞吐量高,充分利用多核 CPU
- 缺点:STW 时间仍然较长,对延迟敏感场景不友好
- 适用场景:后台批处理、大数据计算、科学计算
- 备注:JDK 8 默认垃圾收集器
3. CMS GC ⚡
- 工作方式:并发标记清除,低延迟优先
- 核心阶段:初始标记 (STW) → 并发标记 → 重新标记 (STW) → 并发清除
- 优点:并发执行,STW 时间大幅缩短
- 缺点:
- CPU 资源占用高
- 产生内存碎片
- 并发失败会退化为 Serial Old GC
- 适用场景:互联网 Web 应用、电商系统等对响应时间要求高的场景
- 备注:JDK 9 开始被标记为废弃
4. G1 GC 🎯
- 工作方式:区域化分代式,兼顾吞吐量和延迟
- 核心思想:将堆划分为多个大小相等的 Region,优先收集垃圾最多的 Region
- 核心阶段:初始标记 → 并发标记 → 最终标记 → 筛选回收
- 优点:
- 可预测的停顿时间模型
- 没有内存碎片
- 兼顾吞吐量和延迟
- 缺点:
- 内存占用比 CMS 高
- 小堆下表现可能不如 CMS
- 适用场景:堆内存较大 (>4GB),需要平衡吞吐量和延迟的应用
- 备注:JDK 9 及以上默认垃圾收集器
5. ZGC 🚄
- 工作方式:基于 Region,不分代,染色指针技术
- 核心优势:最大停顿时间不超过 10ms,与堆大小无关
- 优点:
- 极低延迟,几乎无感知
- 支持 TB 级堆内存
- 没有内存碎片
- 缺点:
- 吞吐量略低于 G1
- 对 CPU 要求较高
- 适用场景:金融交易、实时系统、微服务网关等对延迟要求极高的场景
- 备注:JDK 15 正式转正
6. Shenandoah GC 🚀
- 工作方式:基于 Region,不分代, Brooks 指针技术
- 核心优势:并发整理,停顿时间与堆大小无关
- 优点:
- 比 ZGC 更早支持并发整理
- 停顿时间极短
- 支持大堆
- 缺点:
- 吞吐量比 ZGC 略低
- 部分 JDK 版本支持不够完善
- 适用场景:与 ZGC 类似,对延迟要求极高的场景
- 备注:OpenJDK 专属,Oracle JDK 不包含
📊 核心指标对比表
| 收集器 | 线程数 | 算法 (Young/Old) | 主要 STW 阶段 | 最大停顿时间 | 吞吐量 | 内存占用 | 适用堆大小 | 并发程度 | JDK 推荐版本 |
|---|---|---|---|---|---|---|---|---|---|
| Serial | 单线程 | 复制 / 标记-整理 | 全程 STW | 长 | 低 | 极低 | <100MB | 无 | 所有版本 |
| Parallel | 多线程 | 复制 / 标记-整理 | 全程 STW | 中 | 最高 | 低 | <4GB | 无 | 所有版本 |
| CMS | 多线程 | 复制 / 并发标记-清除 | 初始标记、重新标记 | 短 | 中 | 中 | 2-8GB | 高 | JDK8 及以前 |
| G1 | 多线程 | 复制 / 并发标记-整理+复制 | 初始标记、最终标记 | 可预测 (10-200ms) | 中高 | 中高 | 4-64GB | 中高 | JDK9+ 默认 |
| ZGC | 多线程 | 标记-整理 (并发) | 标记开始、标记结束 | 极短 (<10ms) | 中 | 高 | 8GB-TB 级 | 极高 | JDK15+ 生产就绪 |
| Shenandoah | 多线程 | 并发标记-整理 | 初始标记、最终标记 | 极短 (<10ms) | 中低 | 高 | 8GB-TB 级 | 极高 | JDK12+ (非Oracle) |
📌 内存开销解读:ZGC 的染色指针需要额外位,它利用 x64 地址空间的高位,不占用堆内存但依赖多重映射,物理内存占用实际略增。Shenandoah 则需要额外转发指针。
🌳 选型决策树
开始
│
├─ 堆内存 < 100MB? → 是 → Serial GC 🧵
│
├─ 追求最高吞吐量? → 是 → Parallel GC 🚀
│
├─ 堆内存 < 4GB? → 是 → Parallel GC 🚀
│
├─ 堆内存 4-8GB? → 是 → G1 GC 🎯
│
├─ 堆内存 > 8GB? → 是
│ │
│ ├─ 对延迟要求一般? → 是 → G1 GC 🎯
│ │
│ └─ 对延迟要求极高? → 是
│ │
│ ├─ 使用Oracle JDK? → 是 → ZGC 🚄
│ │
│ └─ 使用OpenJDK? → 是 → Shenandoah GC 🚀
│
└─ 特殊情况:遗留系统 → 可能需要CMS GC ⚡现实中大部分场景:JDK11/17 起,堆 2~8GB 用 G1 基本不出错;一旦发现 GC 日志里停顿居高不下,立刻换 ZGC。
💡 面试高频考点总结
- CMS 的缺点:内存碎片、CPU 占用高、并发失败问题
- G1 的核心思想:Region 划分、Mixed GC、停顿时间预测
- ZGC 的关键技术:染色指针、读屏障、不分代
- ZGC vs Shenandoah:ZGC 用染色指针,Shenandoah 用 Brooks 指针
- JDK 版本默认 GC:
JDK 8:Parallel GC
JDK 9-10:G1 GC
JDK 11+:G1 GC (默认),ZGC/Shenandoah (可选)
“G1 的 Mixed GC 和 ZGC 的并发整理有何不同?”
- G1 只有部分阶段并发,转移对象还需要短暂的 STW;ZGC 转移期间通过读屏障转发,几乎不停顿。
“CMS 为什么有浮动垃圾?”
- 因为并发标记期间,用户线程产生的新垃圾无法被本次 GC 标记清除,只能等下一次。
“什么时候必须用 Serial?”
- 除了单核/微小堆,JVM 发生 CMS/G1 的并发模式失败时,会退化为 Serial Old 进行 Full GC,这也是为啥我们要避免 Full GC。
✅ 总结与建议
面试官,以上就是我对常见垃圾收集器的理解。现在主流的选型思路是:
- 大多数应用直接使用 JDK 默认的 G1 GC 即可
- 对延迟要求极高的应用,升级到 JDK 17 + 使用 ZGC
- 只有在特殊场景下才需要考虑其他收集器
G1 GC 原理:Region 分区、RSet、SATB、Mixed GC
面试官您好,我来详细讲解一下 G1 GC 的核心原理,我会从Region 分区、RSet、SATB、Mixed GC这四个最核心的点展开,尽量讲得清晰易懂。
G1 GC 整体定位 📍
G1(Garbage-First)是 JDK7 推出、JDK9 成为默认的服务端垃圾回收器,它的设计目标是:
- 兼顾高吞吐量和低延迟
- 支持超大堆内存(几十 GB 到上百 GB)
- 可预测的停顿时间(用户可以设置最大停顿时间)
它最大的创新是打破了传统分代收集器的连续内存划分,采用了 Region 分区的思想。
Region 分区机制 🗺️
核心思想
G1 将整个 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 的大小在 JVM 启动时自动确定,范围是1MB~32MB,且必须是 2 的幂次方。
堆内存布局(逻辑示意):
┌─────────────────────────────────────────┐
│ E │ S │ O │ H │ Free │ E │ O │ S │.. │
└─────────────────────────────────────────┘
每个小格子都是一个 Region,被动态赋予角色分区类型
┌───────────────────────────────────────────────────────────┐
│ Java Heap (N个Region) │
├────────┬────────┬────────┬────────┬────────┬──────────────┤
│ Eden │ Eden │ Survivor│ Survivor│ Old │ Humongous │
│ Region │ Region │ Region │ Region │ Region │ Region │
└────────┴────────┴────────┴────────┴────────┴──────────────┘- Eden Region:新生代 Eden 区,存放新创建的对象
- Survivor Region:新生代 Survivor 区,存放经过一次 GC 存活的对象
- Old Region:老年代,存放长期存活的对象
- Humongous Region:大对象区,专门存放 超过 Region 大小 50% 的对象
关键优势 ✨
- 内存碎片化问题大幅改善:G1 以 Region 为单位回收,回收后可以整理内存
- 停顿时间可控:G1 可以根据用户设置的最大停顿时间,优先回收垃圾最多的 Region
- 灵活的分代管理:不需要连续的内存空间,新生代和老年代都是动态的 Region 集合
用个图直观感受下:
RSet(Remembered Set) 📝
解决的问题
跨代引用问题:如果一个老年代对象引用了新生代对象,在 Minor GC 时需要扫描整个老年代,这会导致停顿时间过长。
RSet 的工作原理
问题:我们要回收一个老年代 Region,得知道别的地方谁引用了里面的对象,否则可能把活对象错杀。逐 Region 扫全堆?那单次停顿会非常长。
G1 的解法:每个 Region 都维护一个 RSet,记录 “别的 Region 引用了我的哪些卡(Card)”。
- Card Table:堆被划分成 512 字节的卡片,每次引用赋值时会通过 写屏障 把对应的卡片标脏。
- RSet 就是把这些脏卡片按来源 Region 归拢,相当于一个索引:别的 Region → 我的哪些卡被引用了。
💡 通俗比喻:
你管理一个快递站 🏭,每个 Region 是一个货架。RSet 就像贴在货架上的小本本,写明了“A货架第3层、B货架第7层有包裹指向本货架的物品”。回收时只翻那几个地方,不用跑遍整个仓库。
每个 Region 都维护一个RSet,它记录了其他 Region 中引用当前 Region 对象的指针。
┌─────────────┐ ┌─────────────┐
│ Region A │ │ Region B │
│ (老年代) │ │ (新生代) │
│ objA ───────┼───────►│ objB │
└─────────────┘ └─────────────┘
│ ▲
│ │
▼ │
┌─────────────┐ ┌─────────────┐
│ Region A │ │ Region B │
│ RSet │ │ RSet │
│ 空 │ │ 包含Region A│
│ │ │ 的引用指针 │
└─────────────┘ └─────────────┘关键特点
- 空间换时间:RSet 占用大约 5%~10% 的堆内存
- 写屏障实现:当对象引用发生变化时,通过写屏障更新 RSet
- 只记录非本 Region 的引用:避免重复记录
SATB(Snapshot At The Beginning) 📸
- 初始标记:STW,标记 GC Roots 直接引用的对象
- 并发标记:与用户线程并发执行,遍历对象图
- 重新标记:STW,处理 SATB 记录的引用变化
- 清理阶段:统计各个 Region 的存活对象数量,计算回收价值
解决的问题
并发标记阶段的对象引用变化问题:如果在并发标记时,一个存活对象的引用被修改,可能会被误标记为垃圾。
SATB 的核心思想
在并发标记开始时,对整个堆内存拍一张快照,所有在快照中存活的对象,以及在标记过程中新创建的对象,都被认为是存活的。
实现机制
核心机制:pre-write barrier
在并发标记期间,当你要修改一个引用字段 obj.field = new_value 时,G1 会在 赋值前 把旧值 old_value 记录下来,扔到一个队列里。这样即使旧引用被断开了,标记线程还能把它当成“活的”继续追踪。
为什么这么设计?
SATB 保证的是:并发标记结束时,堆里的“逻辑快照”时刻的活对象一定被标记到。虽然可能多标一些“浮动垃圾”(实际已经死掉,但被快照保留),但绝不会漏标活对象,安全可靠。这些浮动垃圾留到下一次 GC 回收即可,无伤大雅。
🧠 一句话总结:宁可多标,绝不漏标。用写屏障快照换正确性。
关键优势 ✨
- 减少重新标记阶段的停顿时间:不需要重新扫描整个堆
- 保证并发标记的正确性:不会漏标存活对象
Mixed GC 🔄
G1 的 GC 模式分几种:
- Young GC:只清 Eden 和 Survivor,和 ParNew 类似,STW。
- Mixed GC:G1 的招牌,在执行完并发标记后,不仅要回收所有年轻代 Region,还会额外选一部分“最划算”的老年代 Region 一起回收。
- Full GC:万一撑不住了才退化成单线程 Serial GC,应尽量避免。
什么是 Mixed GC
Mixed GC 是 G1 特有的 GC 类型,它既回收新生代的所有 Region,也回收一部分垃圾最多的老年代 Region。
触发时机
当老年代的占用率达到 IHOP(Initiating Heap Occupancy Percent) 阈值时,触发并发标记周期,之后会执行多次 Mixed GC。
执行流程
1. 初始标记 (STW) → 2. 并发标记 → 3. 重新标记 (STW) → 4. 清理 (STW)
↓
5. 多次Mixed GC📊 一个典型 G1 堆回示意图:
这种设计最终呼应了开头说的停顿时间目标:每次只回收能承受的那么一点点老年代,绝不一口吃成胖子,服务稳定又丝滑 🧈。
关键特点
- 分多次执行:避免一次回收太多老年代 Region 导致停顿时间过长
- 优先回收垃圾最多的 Region:这也是 G1 名字 "Garbage-First" 的由来
- 可控制停顿时间:根据用户设置的最大停顿时间,决定每次回收多少个 Region
面试加分点总结 🌟
- G1 的核心创新是Region 分区和优先回收垃圾最多的 Region
- RSet 解决了跨代引用扫描的问题,通过写屏障实现
- SATB 解决了并发标记的正确性问题,通过快照思想实现
- Mixed GC 是 G1 特有的,兼顾了新生代和老年代的回收
- G1 的最大优势是可预测的停顿时间,适合大堆内存的应用
ZGC 原理:染色指针、并发整理、亚毫秒级停顿
面试官您好,我来回答一下 ZGC 的核心原理。ZGC 是 JDK11 推出的低延迟垃圾回收器,目标是实现亚毫秒级停顿,且停顿时间与堆大小无关。它的核心技术就是您提到的染色指针、并发整理和读屏障,三者相辅相成,共同实现了革命性的低延迟特性。
染色指针(Colored Pointers)🎨
这是 ZGC 最具创新性的设计,也是一切并发操作的基础。
1. 核心原理
传统 GC 把对象的元数据(标记信息、转发信息)存在对象头里,而 ZGC 直接把这些信息存在64 位指针的高 4 位中。因为现代 CPU 的虚拟地址空间只用了低 48 位,高 16 位是空闲的,ZGC 就 "偷" 了其中 4 位来做 "颜色标记"。
|←── 16 位未使用 ──|← 4 位颜色 →|←──── 44 位物理地址 ────→|颜色位(在 Linux 上只用低 2 位)标明了对象此时所处的“视图”:
- Remapped(00)— 表示对象已搬迁完毕,指向最新位置
- Marked0 / Marked1(01/10)— 双标记位交替使用,防止并发标记残留
看到这里你会问:颜色占了位,怎么解引用? 这就引出 ZGC 的黑科技——多视图映射(Multi-mapping)。操作系统把同一块物理内存,映射到多个不同的虚拟地址区间(remapped 视图、marked0 视图、marked1 视图),只需指针的高位不同,就能直接算出去哪个视图读数据。内存像这样:
物理内存 虚拟地址空间 (视图)
┌─────────────┐ remapped 区: 0x0000... → 实际地址 + 00
│ 对象数据 │ marked0 区: 0x0010... → 实际地址 + 01
│ 对象数据 │ marked1 区: 0x0020... → 实际地址 + 10
└─────────────┘所以 读一个彩色指针时不需要去颜色位再映射,直接通过地址高位命中对应视图,效率极高。
2. 四个染色位的含义
| 位 | 名称 | 作用 |
|---|---|---|
| 第 0 位 | Marked0 | 标记阶段 0 的存活标记 |
| 第 1 位 | Marked1 | 标记阶段 1 的存活标记 |
| 第 2 位 | Remapped | 对象已完成重映射的标记 |
| 第 3 位 | Finalizable | 对象需要执行 finalize 方法 |
3. 巨大优势 ✨
- 不需要修改对象头:所有元数据操作都在指针上完成,避免了并发修改对象头的竞争
- 内存开销小:每个对象节省了 8 字节的 mark word 空间
- 支持并发操作:通过指针颜色就能判断对象状态,不需要 STW
并发整理(Concurrent Compaction)🔄
这是 ZGC 解决内存碎片问题的核心,也是它区别于 G1 的最大优势。G1 只能在混合 GC 时 STW 整理,而 ZGC 用 “转发表 + 读屏障自愈” 可以完全并发地进行内存整理。
1. 传统 GC 的痛点
传统的标记 - 整理算法必须 STW,因为在整理过程中对象地址会变,如果此时应用线程还在运行,就会访问到错误的地址。
2. ZGC 的解决方案:读屏障 + 染色指针
ZGC 在每次读取对象引用时,都会插入一个读屏障,检查指针的颜色:
- 如果指针是
Remapped状态:直接访问对象 - 如果指针是
Forwarded状态:自动跳转到新地址,并更新指针为Remapped状态
3. 并发整理的三个阶段
- 并发标记:GC 线程和应用线程并发运行,标记所有存活对象
- 并发重定位:GC 线程把存活对象复制到新的内存区域,生成转发表
- 并发重映射:应用线程通过读屏障自动更新所有指向旧地址的指针
流程像这样(🧹在扫地):
[标记存活] ──→ [选择要清扫的Region] ──→ [并发复制+自愈转发] ──→ [释放旧Region]
↑ 读屏障自动修指针 ↑
│ (自愈) │
└───── 全部与应用线程并发,极短STW只在头尾 ────────┘亚毫秒级停顿 ⚡
ZGC 的停顿时间之所以能做到亚毫秒级,且与堆大小无关,是因为它几乎把所有 GC 操作都并发化了。
1. 唯一的 STW 阶段
ZGC 只有两个非常短暂的 STW 阶段:
- 初始标记:只扫描 GC Roots,时间与 GC Roots 数量成正比(通常只有几毫秒)
- 最终标记:处理并发标记阶段的增量更新,时间极短(通常 < 1ms)
为什么能做到亚毫秒?
- 没有对象头修改的锁竞争(状态在指针里)
- 没有最终冻结整个堆的全局整理停顿(并发搬家)
- 读屏障自愈避免了并发重定位中的二次追踪停顿
搭配上彩色指针的多视图,连判断对象是在 marking 还是 relocating 都是 无分支代码,停顿自然瘦成闪电。实测中 16 TB 堆、万亿引用,最大停顿仍能压到 1 毫秒以内。🌩️
2. 关键数据对比 📊
| 垃圾回收器 | 典型停顿时间 | 停顿时间与堆大小关系 |
|---|---|---|
| Parallel GC | 数百毫秒~数秒 | 正相关 |
| G1 | 几十毫秒~几百毫秒 | 弱相关 |
| ZGC | <10ms | 完全无关 |
3. 为什么停顿时间与堆大小无关?
因为 ZGC 的 STW 阶段只处理 GC Roots,而 GC Roots 的数量是固定的,不会随着堆大小的增加而增加。即使堆从 8GB 扩大到 1TB,停顿时间也基本保持不变。
总结 📝
| 技术 | 作用 | 效果 |
|---|---|---|
| 🎨 染色指针 | 状态存在指针里,多视图映射零开销解引用 | 替代对象头,支持并发状态流转 |
| 🧹 并发整理 | 复制对象时应用线程可自由读写,读屏障自愈 | 移动对象不用停业务 |
| ⏱️ 极短 STW | 只在根扫描、少量收敛、根重定向停顿 | 亚毫秒级,与堆大小无关 |
ZGC 通过染色指针巧妙地利用了 64 位指针的空闲位存储元数据,再配合读屏障实现了完全并发的内存整理,最终达成了亚毫秒级停顿的目标。它特别适合对延迟敏感的应用,比如金融交易、实时通信、游戏服务器等。
GC 调优思路与常用参数
面试官您好,关于 GC 调优,我会从调优目标、核心思路、常用参数、实战步骤四个维度来回答,这也是我在实际项目中总结的一套可落地的方法论。
🎯 调优前必问自己三个问题
- 目标是什么? 高吞吐还是低延迟?这俩往往不能兼得。
- 现象是什么? 是频繁 Young GC,还是 Mixed/Full GC 停不下来?
- 根因是什么? 是对象分配太快,还是不该晋升的对象跑到老年代了?
没有这三点,调参就是瞎调。
先明确:GC 调优的终极目标 🎯
GC 调优不是为了调而调,而是为了解决业务痛点。核心目标优先级:
- 可用性优先:避免 OOM、Full GC 频繁导致服务宕机
- 延迟优先:响应时间敏感的业务(如电商秒杀、支付)
- 吞吐量优先:后台批处理、大数据计算等业务
- 内存占用:容器化部署、资源受限环境
📈 核心监控指标(记住这几个数)
| 指标 | 正常参考 | 报警阈值 |
|---|---|---|
| Minor GC 耗时 | < 50ms | > 100ms 需关注 |
| Full GC 频率 | 几小时一次甚至无 | 1 小时内多次,严重 |
| Full GC 平均耗时 | < 1s | > 2s 影响可用性 |
| Young GC 后存活对象 | 通常很低 | 过高说明可能过早晋升 |
| 老年代占用增长率 | 平缓 | 陡增说明泄漏或晋升异常 |
把 -Xlog:gc*(JDK9+)或 -XX:+PrintGCDetails 的日志倒进 GCeasy 之类工具,这些指标一目了然。
🧠 GC 调优通用思路(逻辑流)
GC 调优核心思路(黄金 6 步) 🛠️
1. 问题定位:先找到根因,再动手调优
- 现象:接口超时、CPU 飙升、服务卡顿、OOM 崩溃
- 工具:
jstat -gcutil pid 1000(实时监控)、jmap -dump(堆转储)、Arthas、GCEasy、MAT - 关键指标:
- Full GC 频率和耗时(>1 次 / 小时且耗时 > 1s 就需要关注)
- Young GC 频率和耗时
- 老年代使用率(>80% 且持续上涨)
- 元空间使用率
2. 确定目标:量化你的调优指标
- 错误示例:"让 GC 更快一点"
- 正确示例:"将 Full GC 频率从 1 次 / 10 分钟降低到 1 次 / 天,单次 Full GC 耗时控制在 500ms 以内"
3. 选择合适的垃圾收集器(最关键一步)
| 收集器 | 适用场景 | 核心优势 | 最大短板 | JDK 版本 |
|---|---|---|---|---|
| G1 | 通用场景,4-64G 堆内存 | 可预测停顿,兼顾吞吐和延迟 | 小堆(<4G)表现不如 Parallel | JDK9 + 默认 |
| ZGC | 大内存(8G+)、低延迟要求 | 停顿时间 < 10ms,与堆大小无关 | 吞吐量略低于 G1 | JDK15 + 生产可用 |
| Shenandoah | 低延迟,与 ZGC 类似 | 停顿时间 < 10ms,开源 | 部分 JDK 发行版不支持 | JDK12+ |
| Parallel | 高吞吐量,后台任务 | 吞吐量最高 | 停顿时间不可控 | JDK8 默认 |
| CMS | 低延迟(已废弃) | 并发标记,停顿短 | 内存碎片,CPU 占用高 | JDK9 废弃 |
💡 面试必问:JDK8 用 Parallel,JDK11 + 用 G1,JDK17 + 直接上 ZGC,这是行业最佳实践。
4. 内存分配:先调大小,再调比例
- 堆内存大小:
-Xms和-Xmx必须设置为相同值,避免堆内存动态调整带来的性能波动 - 新生代大小:G1 不建议手动设置
-Xmn,让 G1 自动调整;Parallel 建议新生代占堆的 1/3 到 1/2 - 元空间大小:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m,避免元空间扩容触发 Full GC
5. 参数调优:针对性优化,不要盲目复制
- 先调收集器和内存大小,再调其他参数
- 每次只改一个参数,便于对比效果
- 保留调优记录,方便回滚
6. 验证迭代:压测 + 线上监控
- 用 JMeter、Gatling 进行压测,模拟真实流量
- 上线后持续监控 GC 指标,观察是否达到目标
- 没有最好的参数,只有最适合业务的参数
GC 调优常用参数速查表 📋
通用参数
| 参数 | 作用 | 推荐值 |
|---|---|---|
-Xms4g -Xmx4g | 初始堆和最大堆大小 | 物理内存的 1/2-2/3 |
-XX:+UseG1GC | 使用 G1 收集器 | JDK9 + 默认 |
-XX:+UseZGC | 使用 ZGC 收集器 | JDK17 + 推荐 |
-XX:+PrintGCDetails | 打印 GC 详细日志 | 生产环境建议开启 |
-Xloggc:/var/log/gc.log | GC 日志输出路径 | 建议按天滚动 |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时自动生成堆转储 | 必须开启 |
-XX:HeapDumpPath=/var/log/heapdump.hprof | 堆转储文件路径 | 确保磁盘空间充足 |
G1 收集器专属参数
| 参数 | 作用 | 推荐值 |
|---|---|---|
-XX:MaxGCPauseMillis=200 | 最大停顿时间目标 | 100-500ms,根据业务调整 |
-XX:G1HeapRegionSize=16m | Region 大小 | 1-32m,堆越大 Region 越大 |
-XX:InitiatingHeapOccupancyPercent=45 | 触发并发标记的堆占用率 | 35-45% |
-XX:G1NewSizePercent=5 | 新生代最小占比 | 5% |
-XX:G1MaxNewSizePercent=60 | 新生代最大占比 | 60% |
ZGC 收集器专属参数
| 参数 | 作用 | 推荐值 |
|---|---|---|
-XX:+UnlockExperimentalVMOptions | 解锁实验性选项 | JDK11-14 需要 |
-XX:ZCollectionInterval=300 | 强制 GC 间隔(秒) | 300-600s |
-XX:ZAllocationSpikeTolerance=2 | 分配尖峰容忍度 | 2 |
日志与诊断
# JDK9+
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=100m
# JDK8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
# OOM 自动 Dump
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dump/面试加分项:常见误区与实战经验 ⚠️
误区 1:新生代越大越好
新生代太大,Young GC 耗时会变长;太小,对象会提前进入老年代,触发 Full GC。
误区 2:参数越多越好
大部分参数保持默认即可,JVM 团队已经做了大量优化。
误区 3:调优一次就一劳永逸
业务在变,流量在变,GC 调优是一个持续迭代的过程。
实战经验:
线上出现 OOM,第一时间保留堆转储文件,这是排查问题的关键
容器化部署时,要注意 JVM 感知容器内存的问题,JDK8u191 + 已经支持
大对象直接进入老年代,会导致老年代快速填满,尽量避免创建大对象
吞吐量选 Parallel,批量处理不墨迹。
低延迟上 G1,设好暂停目标别激进。
超低延迟试 ZGC,堆小于 8G 其实意义不大。
别一上来调 JVM,先看代码有没有问题,比如死循环造对象、缓存没上限。
一个参数一个参数改,改完压测看效果,不然你都不知道哪个起的作用。
总结 📝
GC 调优的核心是 "先定位问题,再解决问题"。90% 的 GC 问题都可以通过 "选择合适的收集器 + 合理分配内存" 解决,剩下的 10% 才需要深入调优参数。
记住:代码优化 > GC 调优。好的代码是最好的 GC 调优,比如避免内存泄漏、减少对象创建、使用对象池等。
定目标 → 抓日志 → 看分布 → 调参数 → 再验证,循环往复。工具推荐 jstat 快速看,GCViewer/GCeasy 深度分析,MAT/JProfiler 查泄漏。
JDK 17 默认 GraalVM JIT 编译器优化
面试官您好,关于这个问题我先澄清一个高频面试误区:JDK 17 本身并没有将 GraalVM JIT 设为默认编译器,但 Oracle JDK 17 首次将 Graal Compiler 作为实验性生产级 JIT 内置,可通过 JVM 参数启用;而 GraalVM CE 17 是基于 OpenJDK 17 构建的完整发行版,默认使用 Graal JIT。普通 OpenJDK 17 里需要通过 -XX:+UseJVMCICompiler 手动启用。下面我从核心优化、性能对比和适用场景三个方面展开说明。
Graal JIT 凭什么被称为次世代编译器?
先看一张架构对比图,把 Graal 的定位讲清楚:
┌─────────────────────────────────────────────────────────┐
│ HotSpot JVM 分层编译架构 │
├─────────────────────────────────────────────────────────┤
│ Level 0 │ 纯解释执行 (Interpreter) │
│ Level 1-3│ C1 编译器 (Client Compiler) — 快速编译,轻优化 │
│ Level 4 │ 顶级编译器 (Top-Tier JIT) │
│ │ ┌──────────────┬──────────────────┐ │
│ │ │ 传统 C2 🔵 │ Graal JIT 🟢 │ │
│ │ │ C++ 编写 │ Java 编写 │ │
│ │ │ 20年历史 │ 后发优势 │ │
│ │ │ 图IR(HIR) │ 图IR(HIR) 👈 更灵活│ │
│ │ └──────────────┴──────────────────┘ │
└─────────────────────────────────────────────────────────┘Graal JIT 对比 C2 的 5 大核心优化 🔥
Graal JIT 完全用 Java 编写(C2 用 C++),采用更先进的 Sea-of-Nodes 中间表示(IR),在全局优化能力上全面超越传统 C2 编译器,核心优势如下:
| 优化项 | C2 编译器 | Graal JIT 编译器 | 实际性能收益 |
|---|---|---|---|
| 逃逸分析 | 仅支持全逃逸分析 | 支持部分逃逸分析(PEA)(王牌优化) | 临时对象分配减少 30%-70%,GC 压力大幅降低 🚀 |
| 内联优化 | 基础内联,对复杂方法限制多 | 智能内联,支持 Lambda/Stream 深度内联 | 方法调用开销减少 20%-40% |
| 去虚拟化 | 基础去虚拟化 | 激进全程序去虚拟化 | 虚方法调用开销减少 50%+ |
| 循环向量化 | 基础 SIMD 支持 | 高级自动向量化,支持 AVX-512 等新指令集 | 数值计算速度提升 10%-30% |
| 全局优化 | 传统 IR,优化范围受限 | Sea-of-Nodes IR,支持跨函数全局优化 | 整体代码质量提升 5%-15% |
部分逃逸分析(Partial Escape Analysis)⭐️ 重点!
这是 Graal 的招牌优化,让我用一段代码对比讲清楚:
// 场景:Stream + Lambda 高频代码
list.stream()
.filter(x -> x > 10)
.map(x -> new Result(x * 2)) // ← 每个元素都 new 对象
.collect(Collectors.toList());C2 的困境:发现 Result 对象可能逃逸(被收集到 List 中),不敢优化,每个元素老老实实堆分配。
Graal 的做法:
x=5 ❌ filter过滤 → 不进入map → 不分配Result对象 🎉
x=15 ✅ → map → new Result(30) → 逃逸到List → 被迫堆分配
x=20 ✅ → map → new Result(40) → 逃逸到List → 被迫堆分配
→ 只有 2/3 对象走了堆分配,比 C2 少了 1/3 的 GC 压力!📊 效果对比:
对象分配次数 GC 停顿频率
┌─────────┐ ┌─────────┐
│ C2: 30万│ │ C2: 15次│
│ Graal:20万│ ✅ 少33% │ Graal:10次│ ✅ 低33%
└─────────┘ └─────────┘Graal 官方文档明确指出:使用 Streams 或 Lambdas 的现代 Java 代码会从这个优化中获益最大
方法内联升级
Graal 的内联比 C2 更“狠”:
- 深度内联:多层的函数式调用(
.filter().map().collect())能被“压扁”成一个编译单元 - 多态内联:对虚方法调用,Graal 能根据运行时 Profile 内联多个实现分支
- 反射调用内联:大量使用反射的场景(如 JSON 序列化),Graal 能识别热点反射调用并内联,消除反射开销
🎤 面试加分话术:“Graal 对高度抽象代码的优化能力,让其特别适合函数式编程和微服务框架(Spring Boot、Quarkus),这部分能覆盖大部分现代 Java 应用的性能瓶颈。”
libgraal:JIT 编译器也提前编译?🤯
这可能是 Graal 最被低估的设计。看这张架构图:
普通 JIT 编译器启动流程(jar 模式):
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ 启动JVM │ → │ 解释执行 │ → │ JIT 编译器自举 │ → 开始编译热点代码
│ │ │ 用户代码 │ │ (慢!) │
└──────────┘ └───────────┘ └──────────────┘
↑ 可能耗时数分钟!
Graal libgraal 模式(默认):
┌──────────┐ ┌───────────────────┐ ┌──────────────┐
│ 启动JVM │ → │ libgraal.so 已就绪 │ → │ 立即开始编译 │
│ │ │ (AOT 编译为本地库) │ │ 热点代码 │
└──────────┘ └───────────────────┘ └──────────────┘
↑ 驻留在 HotSpot 堆外内存!- libgraal:Graal 编译器本身被 AOT 编译成本地共享库(
.so/.dylib),直接加载进 JVM,零预热启动 - 使用独立内存空间,不占用 Java 堆,GC 压力更小
这在云原生和 Serverless 场景中优势巨大:函数实例启动即进入高性能状态。
Graal vs C2:如何选择?🤔
性能对比热力图:(颜色越深 = Graal 优势越明显)
┌─────────────────────┬──────────┬──────────┐
│ 场景 │ C2 │ Graal │
├─────────────────────┼──────────┼──────────┤
│ 传统 OOP 业务代码 │ ████████ │ ████████ │ ≈ 持平
│ 函数式编程/Stream │ ██████ │ ████████ │ ✅ Graal 更强
│ 反射密集型(JSON) │ ██████ │ ████████ │ ✅ Graal 更强
│ 动态语言(Scala等) │ ██████ │ ████████ │ ✅ Graal 更强
│ 计算密集型 │ ████████ │ ████████ │ ≈ 持平
│ 启动速度 │ ████████ │ ████████ │ ≈ 持平(libgraal)
└─────────────────────┴──────────┴──────────┘
💡 关键洞察:Graal 不是全面碾压 C2,而是在特定场景有 10-20% 优势
基准测试表明整体性能略优但差距不大[reference:9]。选择建议:
| 如果你做... | 推荐 |
|---|---|
| Spring Boot 微服务 + Stream/Lambda | 🟢 GraalVM |
| 传统 Java EE / 大量同步业务逻辑 | 🔵 OpenJDK + C2 足够 |
| 需要 Native Image 部署 | 🟢 GraalVM 必选 |
| Kotlin/Scala 等 JVM 语言 | 🟢 Graal 对函数式代码优化更好 |
真实场景性能对比 📊
以下是基于 OpenJDK 17 的基准测试结果(C2 性能 = 100,数值越高越好):
关键结论:
- Lambda/Stream 密集型代码收益最大(提升 35%),这也是现代 Java 应用最常见的场景
- 传统企业级应用性能相当或略优(提升 8%),无性能倒退
- 配合 GraalVM AOT 编译,微服务启动速度可提升 50%-90%
适用场景与局限性 ⚠️
✅ 最佳适用场景
- 数值计算、科学计算、机器学习推理
- 大数据处理(Spark、Flink 等)
- 微服务、Serverless 函数计算
- 现代 Java 代码(大量使用 Lambda、Stream、Optional)
- 需要跨语言互操作的场景(配合 GraalVM 多语言支持)
❌ 不推荐场景
- 启动时间要求极高且无法使用 AOT 编译的场景
- 内存小于 512MB 的嵌入式设备
- 依赖 C2 特定未文档化优化的遗留代码
- 编译时间敏感的短生命周期程序
💡 面试加分知识点
- 启用参数:JDK 17 中启用 Graal JIT 需要添加:
-XX:+UnlockExperimentalVMOptions -XX:+UseGraalJIT - 分层编译:Graal JIT 与 C1 配合形成 “解释执行→C1 编译→Graal 编译” 三层体系,兼顾启动速度和峰值性能
- 编译开销:Graal JIT 编译时间比 C2 长 20%-50%,内存占用高 10%-20%,因此对长生命周期应用更友好
- 未来趋势:JDK 21 开始 Graal JIT 已成为部分平台的默认编译器,未来将逐步全面替代 C2
如果面试官追问“一句话总结 Graal JIT 的核心价值”,答:
🔥 Graal 是一个用 Java 编写的、基于图 IR 的激进 JIT 编译器,核心杀手锏是部分逃逸分析——能细粒度消除 Stream/Lambda 场景下的无效对象分配,配合 libgraal 零预热启动,特别适合云原生微服务架构。
再加上这三个记忆锚点:
| 缩写记忆 | 含义 | 一句话 |
|---|---|---|
| PEA | Partial Escape Analysis | “只在逃逸路径上分配对象” |
| libgraal | AOT 编译的编译器本地库 | “编译器本身零预热” |
| Graal IR | 图中间表示 | “语言无关,优化更灵活” |
面试常见踩坑提醒 ⚠️
- ❌ 错误:“JDK 17 默认就是 Graal JIT” → ✅ 正确:需要 GraalVM 发行版才默认启用,普通 OpenJDK 需要手动开
- ❌ 错误:“Graal JIT 全面碾压 C2” → ✅ 正确:场景依赖,传统业务代码两者差距不大
- ❌ 错误:“Graal 就是用来做 AOT Native Image 的” → ✅ 正确:JIT 和 AOT 都是 Graal 的核心模式,别搞混
- ❌ 错误:“启用 Graal JIT 后启动变慢” → ✅ 正确:
libgraal模式下编译器本身是预编译的本地库,启动几乎无额外开销
