垃圾回收与GC算法
核心概念
垃圾回收(Garbage Collection, GC) 是 JVM 自动管理内存的机制,通过「判断对象是否存活 → 选择回收算法 → 使用垃圾收集器」三个步骤,自动释放不再使用的对象占用的内存,避免内存泄漏和溢出。
🎯 核心公式:
GC 性能 = f(吞吐量, 停顿时间, 内存占用)
最优解:根据应用场景选择合适的收集器组合
一、为什么需要垃圾回收?
1.1 问题背景(没有 GC 会怎样)
- 内存泄漏:不再使用的对象无法释放,最终导致
OutOfMemoryError - 野指针问题:手动释放后仍被引用,访问已释放内存导致崩溃(C/C++ 常见)
- 双重释放:同一块内存被释放多次,破坏内存管理结构
- 开发效率低:程序员需要手动管理内存的分配和释放
1.2 设计目标
| 目标 | 说明 | 权衡 |
|---|---|---|
| 吞吐量 | 运行用户代码时间 / 总运行时间 | 与停顿时间冲突 |
| 低延迟 | 减少 GC 停顿时间(STW) | 可能降低吞吐量 |
| 内存占用 | 堆内存和 GC 元数据占用 | 空间换时间 |
不可能三角:无法同时优化三个目标,必须根据场景选择侧重点。
二、对象存活判定算法
2.1 引用计数法(Reference Counting)❌
原理
为每个对象维护一个引用计数器:
- 被引用时
+1 - 引用失效时
-1 - 计数为
0时回收
优缺点
| 优点 | 缺点 |
|---|---|
| ✅ 实现简单 | ❌ 无法解决循环引用(致命缺陷) |
| ✅ 实时性好,垃圾立即回收 | ❌ 每次引用变化都要修改计数器(性能开销) |
| ✅ 没有 STW | ❌ 计数器本身占用额外内存 |
循环引用问题
public class CircularReference {
Object instance = null;
public static void main(String[] args) {
CircularReference objA = new CircularReference();
CircularReference objB = new CircularReference();
objA.instance = objB; // A 引用 B
objB.instance = objA; // B 引用 A
objA = null; // 外部引用断开
objB = null; // 但 A 和 B 互相引用,计数器永远不为 0
// 引用计数法无法回收,导致内存泄漏!
}
}
结论:主流 JVM(HotSpot)不使用引用计数法,而使用可达性分析。
2.2 可达性分析算法(Reachability Analysis)✅
原理
从 GC Roots 出发,通过引用链向下搜索:
- 可达对象:存在从 GC Roots 到该对象的引用链 → 存活
- 不可达对象:不存在任何引用链 → 可回收
GC Roots
↓
[对象A] → [对象B] → [对象C] ← 可达(存活)
[对象X] → [对象Y] ← 不可达(垃圾)
GC Roots 包括哪些?
| GC Roots 类型 | 说明 | 示例 |
|---|---|---|
| 虚拟机栈中引用的对象 | 方法局部变量、参数 | void method() { User user = new User(); } |
| 方法区中类静态属性引用 | 类的 static 字段 |
public static User user; |
| 方法区中常量引用 | final static 常量 |
public static final String NAME = "abc"; |
| 本地方法栈中 JNI 引用 | Native 方法引用的对象 | native void nativeMethod(); |
| 活跃线程 | 正在运行的线程对象 | Thread.currentThread() |
| 被 Synchronized 锁持有的对象 | 锁对象 | synchronized(obj) |
完整回收流程
1. 标记阶段(Marking Phase)
├─ 从 GC Roots 开始遍历对象图
├─ 标记所有可达对象
└─ 未被标记的对象 → 不可达对象
2. 第一次标记 + 筛选
├─ 对象是否重写了 finalize() 且未被调用过?
│ ├─ 是 → 加入 F-Queue,等待 Finalizer 线程执行
│ └─ 否 → 直接回收
3. 第二次标记(finalize 执行后)
└─ 如果对象在 finalize() 中重新建立引用链 → 逃脱回收
否则 → 真正回收
finalize() 自救机制(不推荐使用)
public class FinalizeEscape {
public static FinalizeEscape SAVE_HOOK = null;
public void isAlive() {
System.out.println("我还活着");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize() 被执行");
// 自救:重新建立引用链
FinalizeEscape.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscape();
// 第一次自救成功
SAVE_HOOK = null;
System.gc();
Thread.sleep(500); // 等待 Finalizer 线程执行
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive(); // 输出:我还活着
}
// 第二次自救失败(finalize 只会被调用一次)
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("我死了"); // 输出:我死了
}
}
}
⚠️ 警告:
finalize()运行代价高、不确定性大、无法保证调用顺序- Java 9 后已标记为
@Deprecated - 推荐使用
try-with-resources或Cleaner机制
2.3 四种引用类型
| 引用类型 | 回收时机 | 典型应用场景 | 创建方式 |
|---|---|---|---|
| 强引用(Strong) | 永不回收(即使 OOM) | 普通对象引用 | Object obj = new Object(); |
| 软引用(Soft) | 内存不足时回收 | 内存敏感的缓存(图片缓存) | SoftReference ref = new SoftReference<>(obj); |
| 弱引用(Weak) | 下次 GC 时回收 | ThreadLocal、WeakHashMap | WeakReference ref = new WeakReference<>(obj); |
| 虚引用(Phantom) | 随时可能回收,无法通过引用获取对象 | 对象回收跟踪、堆外内存管理 | PhantomReference ref = new PhantomReference<>(obj, queue); |
实战案例:软引用实现缓存
public class ImageCache {
private Map<String, SoftReference<Image>> cache = new HashMap<>();
public Image getImage(String path) {
SoftReference<Image> ref = cache.get(path);
// 检查软引用是否被回收
if (ref != null) {
Image img = ref.get();
if (img != null) {
return img; // 缓存命中
}
}
// 缓存失效,重新加载
Image img = loadImageFromDisk(path);
cache.put(path, new SoftReference<>(img));
return img;
}
}
三、垃圾回收算法
3.1 算法对比总览
| 算法 | 核心思想 | 优点 | 缺点 | 适用区域 |
|---|---|---|---|---|
| 标记-清除 Mark-Sweep | 标记垃圾对象,然后清除 | 简单,不移动对象 | 内存碎片严重 | 老年代(CMS) |
| 标记-复制 Mark-Copy | 将存活对象复制到另一块区域 | 无碎片,分配快 | 浪费空间(需要额外空间) | 新生代 |
| 标记-整理 Mark-Compact | 标记后移动对象到一端 | 无碎片,空间利用率高 | 移动对象成本高 | 老年代(Serial Old、Parallel Old) |
| 分代收集 Generational | 根据对象生命周期分代管理 | 综合利用各算法优势 | 实现复杂 | 现代 JVM 标配 |
3.2 标记-清除算法(Mark-Sweep)
工作流程
执行前:
[对象A][对象B][对象C][对象D][对象E]
✓ × ✓ × ✓
(存活) (垃圾) (存活) (垃圾) (存活)
执行后:
[对象A][空闲][对象C][空闲][对象E]
↑碎片 ↑碎片
两个阶段
- 标记阶段:从 GC Roots 出发,标记所有可达对象
- 清除阶段:遍历堆,回收所有未被标记的对象
问题
- ❌ 内存碎片:频繁回收后,空闲内存不连续,大对象无法分配
- ❌ 效率问题:标记和清除两个阶段效率都不高
- ❌ 空间局部性差:存活对象分散,缓存命中率低
改进版:CMS 收集器使用此算法,通过并发标记减少停顿。
3.3 标记-复制算法(Mark-Copy)
工作流程(Appel 式回收)
新生代分为:Eden(80%) + Survivor0(10%) + Survivor1(10%)
Minor GC 前:
Eden S0 S1
[对象ABCDE] [空] [空]
↓ 只有 A、C 存活
Minor GC 后:
Eden S0 S1
[空] [空] [对象AC] ← 复制到 S1
优缺点
| 优点 | 缺点 |
|---|---|
| ✅ 无内存碎片 | ❌ 浪费空间(需要预留复制区域) |
| ✅ 分配简单:只需移动指针(bump-the-pointer) | ❌ 存活对象多时效率低 |
| ✅ 适合"朝生夕死"的对象 | ❌ 需要额外空间作为担保(Survivor 不够时进入老年代) |
为什么新生代使用 8:1:1 的比例?
IBM 研究表明:新生代中 98% 的对象是"朝生夕死"的,所以:
- Eden 占 80%:大部分对象分配在 Eden
- Survivor 占 10% × 2:只有少量对象存活,10% 足够
- 空间利用率 = 90%(Eden + 1 个 Survivor)
极端情况处理:
if (Survivor 空间不足) {
// 担保机制:直接进入老年代
进入老年代 (Old Generation);
}
3.4 标记-整理算法(Mark-Compact)
工作流程
执行前:
[对象A][对象B][对象C][对象D][对象E]
✓ × ✓ × ✓
执行后(压缩):
[对象A][对象C][对象E][---空闲空间---]
← 所有存活对象移到一端
两个阶段
- 标记阶段:同标记-清除
- 整理阶段:将所有存活对象向内存一端移动,清理边界外的内存
优缺点
| 优点 | 缺点 |
|---|---|
| ✅ 无内存碎片 | ❌ 移动对象成本高(需要更新所有引用) |
| ✅ 空间利用率高 | ❌ STW 时间长(移动期间必须暂停用户线程) |
| ✅ 适合老年代(对象存活率高) | ❌ 效率低于标记-清除 |
优化:滑动式整理(Lisp2 算法)
-
标记阶段:标记所有存活对象
-
计算阶段:计算每个对象的新地址
-
更新引用:更新所有指向移动对象的引用
-
移动对象:将对象移动到新地址
3.5 分代收集算法(Generational Collection)⭐
核心思想
根据对象生命周期特点,将堆分为不同区域,使用不同的回收算法:
Java 堆内存结构(JDK 8)
┌─────────────────────────────────────────────────────┐
│ 新生代 (Young Generation) 1/3 │
│ ┌────────┬──────┬──────┐ │
│ │ Eden │ S0 │ S1 │ ← 标记-复制算法 │
│ │ 8 │ 1 │ 1 │ │
│ └────────┴──────┴──────┘ │
├─────────────────────────────────────────────────────┤
│ 老年代 (Old Generation) 2/3 │
│ ┌──────────────────────────────────────┐ │
│ │ 标记-清除 或 标记-整理算法 │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
元空间 (Metaspace) - 本地内存
分代假说(Generational Hypothesis)
| 假说 | 内容 | 应对策略 |
|---|---|---|
| 弱分代假说 | 绝大多数对象都是朝生夕死 | 新生代频繁 GC,使用复制算法 |
| 强分代假说 | 熬过多次 GC 的对象越难消亡 | 老年代较少 GC,使用标记-整理 |
| 跨代引用假说 | 跨代引用相对于同代引用来说占极少数 | 使用记忆集(Remembered Set)避免全堆扫描 |
对象晋升规则
// 规则1:年龄达到阈值(默认 15 次 Minor GC)
-XX:MaxTenuringThreshold=15
// 规则2:大对象直接进入老年代
-XX:PretenureSizeThreshold=1M // 超过 1M 直接进老年代
// 规则3:动态年龄判定
// 如果 Survivor 中相同年龄对象大小总和 > Survivor 空间的一半
// 则年龄 >= 该年龄的对象直接进入老年代
// 规则4:空间分配担保
// Minor GC 前检查:老年代最大可用连续空间 > 新生代所有对象总大小
// 不满足则触发 Full GC
Minor GC vs Full GC
| GC 类型 | 触发时机 | 回收区域 | 停顿时间 | 频率 |
|---|---|---|---|---|
| Minor GC | Eden 区满 | 新生代(Eden + Survivor) | 短(几十毫秒) | 频繁 |
| Major GC | 老年代满 | 老年代 | 长 | 较少 |
| Full GC | ① System.gc() ② 老年代空间不足 ③ 元空间不足 ④ Minor GC 晋升担保失败 | 整个堆 + 元空间 | 很长(秒级) | 很少 |
四、垃圾收集器详解
4.1 收集器演进历史
时间线:
1999 ─────→ 2004 ─────→ 2012 ─────→ 2017 ─────→ 2018
Serial CMS G1 ZGC Shenandoah
Parallel (JDK 9 默认) (JDK 11) (JDK 12)
4.2 七大经典收集器总览
新生代收集器 老年代收集器
┌──────────┐ ┌──────────┐
│ Serial │───────→│Serial Old│
└──────────┘ └──────────┘
┌──────────┐ ┌──────────┐
│ ParNew │───────→│ CMS │
└──────────┘ └──────────┘
┌──────────┐ ┌──────────┐
│ Parallel │───────→│Parallel │
│ Scavenge │ │ Old │
└──────────┘ └──────────┘
跨代收集器
┌──────────┐
│ G1 │ ← JDK 9+ 默认
└──────────┘
┌──────────┐
│ ZGC │ ← 低延迟(< 10ms)
└──────────┘
┌──────────┐
│Shenandoah│ ← 低延迟
└──────────┘
4.3 Serial / Serial Old 收集器
特点
- 单线程:只用一个线程进行 GC
- Stop-The-World:GC 时必须暂停所有用户线程
- 新生代:标记-复制算法
- 老年代:标记-整理算法
工作流程
用户线程
────────┐ STW ┌─────────
│ │
▼ ▼
[Serial GC 线程执行]
时间: 0ms 50ms 100ms
JVM 参数
# 新生代使用 Serial
-XX:+UseSerialGC
# 查看使用的收集器
java -XX:+PrintCommandLineFlags -version
适用场景
- ✅ 单核 CPU 环境
- ✅ 客户端模式(桌面应用)
- ✅ 几十 MB 的小堆
- ✅ 对停顿时间不敏感
优点:简单高效,单线程时没有线程交互开销,适合小应用。
4.4 ParNew 收集器
特点
- Serial 的多线程版本
- 新生代收集器,必须配合 CMS 使用
- 标记-复制算法
工作流程
用户线程
────────┐ STW ┌─────────
│ │
▼ ▼
[GC线程1][GC线程2][GC线程3][GC线程4]
└────────并行执行────────┘
JVM 参数
# 启用 ParNew(JDK 8 默认配合 CMS)
-XX:+UseParNewGC
# 设置并行 GC 线程数(默认 = CPU 核心数)
-XX:ParallelGCThreads=4
适用场景
- ✅ 多核 CPU 服务器
- ✅ 配合 CMS 使用(CMS 只能与 ParNew 或 Serial 配合)
- ✅ 新生代对象多、GC 频繁
4.5 Parallel Scavenge / Parallel Old 收集器⭐
特点(吞吐量优先)
- 新生代:标记-复制(Parallel Scavenge)
- 老年代:标记-整理(Parallel Old)
- 多线程并行
- 自适应调节策略(GC Ergonomics)
核心目标:最大化吞吐量
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC 时间)
例如:
-
程序运行 100 分钟,GC 耗时 1 分钟
-
吞吐量 = 99 / 100 = 99%
JVM 参数(关键配置)
# 启用 Parallel 收集器(JDK 8 Server 模式默认)
-XX:+UseParallelGC # 新生代
-XX:+UseParallelOldGC # 老年代(自动启用)
# 吞吐量控制(核心参数)
-XX:GCTimeRatio=99 # 吞吐量目标 = 99%(GC 时间占比 1%)
-XX:MaxGCPauseMillis=200 # 最大停顿时间目标(毫秒)
# 自适应调节(推荐开启)
-XX:+UseAdaptiveSizePolicy # 自动调整 Eden、Survivor、晋升阈值
# 并行线程数
-XX:ParallelGCThreads=8 # 默认 = CPU 核心数
自适应调节策略示例
// JVM 会自动调整以下参数:
-Xmn # 新生代大小
-XX:SurvivorRatio # Eden 和 Survivor 比例
-XX:MaxTenuringThreshold # 对象晋升年龄阈值
// 目标:在满足停顿时间的前提下,最大化吞吐量
适用场景
- ✅ 后台计算任务(批处理、数据分析)
- ✅ 科学计算、大数据处理
- ✅ 对停顿时间不敏感,追求吞吐量
- ✅ 多核服务器
4.6 CMS 收集器(Concurrent Mark Sweep)⭐
特点(低延迟优先)
- 老年代收集器
- 标记-清除算法
- 并发标记(大部分时间与用户线程并发执行)
- 目标:最短 GC 停顿时间
四个阶段(核心)
1. 初始标记 (Initial Mark) - STW ⚡
└─ 标记 GC Roots 直接关联的对象(速度快)
2. 并发标记 (Concurrent Mark) - 并发执行 🚀
└─ 从 GC Roots 遍历整个对象图(耗时最长)
3. 重新标记 (Remark) - STW ⚡
└─ 修正并发标记期间用户程序运行导致的标记变动
4. 并发清除 (Concurrent Sweep) - 并发执行 🚀
└─ 清除标记为垃圾的对象
时间线图
用户线程 ████████████████████████████████████████
GC 线程 ██ ██████████ ██████
↑ STW ↑ 并发 ↑ STW ↑ 并发
初始标记 并发标记 重新标记 并发清除
JVM 参数
# 启用 CMS(JDK 9 后弃用,JDK 14 移除)
-XX:+UseConcMarkSweepGC
# 触发阈值(老年代使用 68% 时触发 GC)
-XX:CMSInitiatingOccupancyFraction=68
-XX:+UseCMSInitiatingOccupancyOnly # 只使用设定阈值
# 并发线程数(默认 = (CPU核心数 + 3) / 4)
-XX:ConcGCThreads=2
# 碎片整理
-XX:+UseCMSCompactAtFullCollection # Full GC 时整理碎片
-XX:CMSFullGCsBeforeCompaction=5 # 5 次 Full GC 后整理一次
三大缺点
| 缺点 | 说明 | 解决方案 |
|---|---|---|
| CPU 资源敏感 | 并发阶段占用 CPU 导致应用变慢 | 增量式 CMS(已弃用) |
| 浮动垃圾 | 并发清除时产生的新垃圾无法清理 | 预留足够空间,降低触发阈值 |
| 内存碎片 | 标记-清除算法导致碎片 | 定期 Full GC 整理 |
浮动垃圾问题
// 并发标记阶段:
对象 A 被标记为存活
↓
// 用户线程继续运行:
对象 A 的引用被清除(A 变成垃圾)
↓
// 但本次 GC 不会清理 A(浮动垃圾)
// 需要等到下次 GC 才能回收
Concurrent Mode Failure
# 现象:
Java HotSpot(TM) 64-Bit Server VM warning:
Concurrent mode failure
# 原因:
- 并发清除时,老年代空间不足以容纳新晋升的对象
- 触发 Full GC(使用 Serial Old 收集器)
# 解决:
-XX:CMSInitiatingOccupancyFraction=70 # 提前触发 GC
-Xmx4G # 增大堆内存
适用场景
- ✅ 互联网应用(要求低延迟)
- ✅ B/S 架构服务端
- ✅ 注重用户体验的应用
- ✅ 老年代空间充足
4.7 G1 收集器(Garbage First)⭐⭐⭐
核心创新(革命性变化)
-
不再区分新生代/老年代(逻辑上仍存在)
-
堆划分为多个Region(每个 Region 大小相等,1-32MB)
-
优先回收价值最大的 Region(Garbage First 的由来)
-
可预测的停顿时间模型
-
Region 结构
-
Java 堆(G1 视角) ┌────┬────┬────┬────┬────┬────┬────┬────┐ │ E │ E │ S │ O │ O │ H │ E │ O │ └────┴────┴────┴────┴────┴────┴────┴────┘ Eden Survivor Old Humongous(大对象)每个 Region:
-
大小相等(1MB、2MB、4MB...32MB)
-
动态角色:可以是 Eden、Survivor、Old、Humongous
-
大对象(≥ Region 50%)直接分配到 Humongous Region
-
-
四个阶段
-
1. 初始标记 (Initial Marking) - STW └─ 标记 GC Roots 直接关联对象 └─ 借用 Minor GC 的 STW,几乎无额外停顿 2. 并发标记 (Concurrent Marking) - 并发 └─ 从 GC Roots 遍历对象图 └─ 耗时最长,但与用户线程并发 3. 最终标记 (Final Marking) - STW └─ 处理并发阶段遗留的标记变动 └─ 使用 SATB(Snapshot-At-The-Beginning)算法 4. 筛选回收 (Live Data Counting and Evacuation) - STW └─ 根据停顿时间目标,选择价值最大的 Region └─ 将存活对象复制到空 Region,清理旧 Region -
关键技术
1. 记忆集(Remembered Set, RSet)
问题:如何避免全堆扫描来处理跨 Region 引用?
Region A Region B ┌─────────┐ ┌─────────┐ │ 对象1 │──────→│ 对象2 │ ← 跨 Region 引用 └─────────┘ └─────────┘Region B 的 RSet 记录:
“Region A 中的对象1 引用了我”
回收 Region B 时:
只需扫描 Region B 的 RSet,无需扫描整个堆
-
实现:
每个 Region 维护一个 RSet
使用写屏障(Write Barrier)记录跨 Region 引用
RSet 占用堆内存的 5% 左右
-
2. SATB 算法(解决并发标记时的对象消失问题)
// 问题场景(三色标记):
黑色对象(已标记完成)
↓ 添加引用
白色对象(未标记)← 应该存活但会被误回收
↑ 删除引用
灰色对象(标记中)
// SATB 解决方案:
记录"初始快照",认为快照时刻的对象都是活的
→ 宁可错杀(浮动垃圾),不可漏标
-
3. 停顿预测模型
// G1 维护统计信息:
- 每个 Region 的回收耗时
- 每个 Region 的垃圾占比
// 计算公式:
回收价值 = 垃圾字节数 / 回收耗时
// 选择策略:
在目标停顿时间内,优先选择价值最大的 Region
-
JVM 参数(核心配置)
# 启用 G1(JDK 9+ 默认)
-XX:+UseG1GC
# ⭐ 最重要参数:设置停顿时间目标(毫秒)
-XX:MaxGCPauseMillis=200 # 默认 200ms
# Region 大小(1MB-32MB,必须是 2 的幂)
-XX:G1HeapRegionSize=4M # 默认根据堆大小自动计算
# 触发并发标记的堆占用阈值
-XX:InitiatingHeapOccupancyPercent=45 # 堆使用 45% 时触发
# 新生代占比(动态调整)
-XX:G1NewSizePercent=5 # 最小 5%
-XX:G1MaxNewSizePercent=60 # 最大 60%
# 混合 GC 参数
-XX:G1MixedGCCountTarget=8 # 混合 GC 最多执行 8 次
-XX:G1HeapWastePercent=10 # 可容忍的堆浪费百分比
-
G1 的三种 GC 模式
-
模式 触发时机 回收范围 停顿时间 Young GC Eden Region 满 所有 Eden + Survivor 短(< 200ms) Mixed GC 并发标记完成后 所有 Eden + Survivor + 部分 Old Region 中等 Full GC ① Mixed GC 无法跟上分配速度 ② Humongous 对象分配失败 ③ 晋升失败 整个堆 长(秒级,⚠️ 需要避免) -
典型日志分析
# Young GC 日志 [GC pause (G1 Evacuation Pause) (young), 0.0234567 secs] [Parallel Time: 20.1 ms, GC Workers: 4] [Eden: 512M(512M)->0B(480M) Survivors: 32M->64M Heap: 1.2G->768M] # Mixed GC 日志 [GC pause (G1 Evacuation Pause) (mixed), 0.0456789 secs] [Eden: 480M->0B Old: 2G->1.8G] ← 回收了部分老年代 # Full GC 日志(需要优化) [Full GC (Allocation Failure) 4G->2.5G, 2.3456 secs] -
何时会触发 Full GC?(需要避免)
# 场景1:分配巨型对象失败 [Full GC (G1 Humongous Allocation)] # 场景2:晋升失败(老年代碎片化) [Full GC (G1 Evacuation Pause)] # 场景3:并发标记失败 [Full GC (Concurrent Mode Failure)] # 优化方案: -Xmx8G -Xms8G # 增大堆内存 -XX:G1HeapRegionSize=16M # 增大 Region 减少巨型对象 -XX:ConcGCThreads=4 # 增加并发标记线程 -
适用场景
-
✅ 6GB+ 大堆内存
-
✅ 需要可预测的低延迟(200ms 以内)
-
✅ 堆中存活对象占比 50% 以下
-
✅ 对象分配速率变化大
-
✅ 不希望出现长时间 GC 停顿
-
与 CMS 的对比
-
对比项 CMS G1 算法 标记-清除(碎片) 标记-复制(整理,无碎片) 内存布局 连续的新生代/老年代 Region 化(可预测停顿) 停顿可预测性 ❌ 不可预测 ✅ 可通过 MaxGCPauseMillis控制Full GC 频繁(碎片导致) 很少(除非参数不当) 适用堆大小 < 4GB > 6GB JDK 默认 JDK 8(配合 ParNew) JDK 9+
4.8 ZGC 收集器(Z Garbage Collector)🚀
革命性特点(JDK 11 实验,JDK 15 正式)
- 超低延迟:停顿时间不超过 10ms
- 支持 TB 级堆(最大 16TB)
- 并发执行所有阶段(包括移动对象)
- 单代收集器(不分代)
核心技术
-
1. 着色指针(Colored Pointers)
64 位指针布局(Linux x86-64): ┌─────────┬──────┬──────┬──────┬──────┬─────────────────────┐ │ 未使用 │Finalizable│Remapped│Marked1│Marked0│ 对象地址 (42 位) │ │ 16 位 │ 1 位 │ 1 位 │ 1 位 │ 1 位 │ 支持 4TB 堆 │ └─────────┴──────┴──────┴──────┴──────┴─────────────────────┘ -
通过指针中的标志位,直接判断对象状态:
-
Marked0/Marked1:标记信息(双缓冲)
-
Remapped:是否已重定向到新地址
-
Finalizable:是否只能通过 finalize 访问
-
-
2. 读屏障(Load Barrier)
// 普通对象访问: Object obj = fieldA; // ZGC 插入读屏障: Object obj = barrier(fieldA); // 自动处理对象移动 // 读屏障逻辑: if (指针颜色不对) { 修正指针 → 指向对象的新地址 }
-
3. 并发移动对象
并发重定向(Concurrent Relocation): 用户线程访问旧地址 ↓ 读屏障拦截 转发表查找新地址 ↓ 返回新地址的对象 ↓ 后台线程并发 逐步更新所有引用 -
GC 阶段(全部并发)
1. 并发标记 (Concurrent Mark) └─ 标记存活对象 2. 并发预备重分配 (Concurrent Prepare for Relocate) └─ 统计需要移动的 Region 3. 并发重分配 (Concurrent Relocate) └─ 移动对象到新 Region 4. 并发重映射 (Concurrent Remap) └─ 修正所有指向旧对象的引用 ⚡ 仅在初始标记和初始重分配有短暂 STW(< 1ms) -
JVM 参数
# 启用 ZGC(JDK 15+) -XX:+UseZGC # 堆大小设置 -Xmx16G -Xms16G # 建议固定大小 # 并发 GC 线程数(默认自动计算) -XX:ConcGCThreads=4 # 不常用参数 -XX:ZCollectionInterval=120 # 强制 GC 间隔(秒) -XX:ZAllocationSpikeTolerance=2 # 分配速率峰值容忍度 -
适用场景
✅ 超低延迟要求(金融交易、游戏服务器)
✅ 大堆内存(几十 GB 到 TB 级)
✅ JDK 15+
⚠️ 吞吐量会降低 10-15%(代价)
4.9 Shenandoah GC
特点(与 ZGC 类似)
- 超低延迟(< 10ms)
- 并发整理(包括并发移动对象)
- 不分代
- OpenJDK 独有(Oracle JDK 不包含)
与 ZGC 的区别
-
对比项 ZGC Shenandoah 并发移动技术 读屏障 + 着色指针 读写屏障 + 转发指针 支持堆大小 16TB 相对较小(几百 GB) 厂商 Oracle 官方 Red Hat 主导 JDK 版本 JDK 11+ JDK 12+
JVM 参数
# 启用 Shenandoah
-XX:+UseShenandoahGC
# 停顿时间目标(实验性)
-XX:ShenandoahGCHeuristics=adaptive # 自适应
五、收集器选择策略
5.1 决策树
堆内存大小?
↙ ↘
< 100MB > 100MB
↓ ↓
Serial GC 是否需要低延迟?
↙ ↘
是 否
↓ ↓
堆大小? Parallel GC
↙ ↘ (吞吐量优先)
< 6GB > 6GB
↓ ↓
CMS G1 / ZGC / Shenandoah
(已弃用) (根据 JDK 版本和延迟要求)
5.2 场景推荐表
| 应用场景 | 推荐收集器 | 关键参数 | JDK 版本 |
|---|---|---|---|
| 单核/客户端应用 | Serial GC | -XX:+UseSerialGC |
All |
| 多核/后台批处理 | Parallel GC | -XX:+UseParallelGC -XX:GCTimeRatio=99 |
All |
| Web 应用(堆 < 6GB) | CMS(已弃用)→ G1 | -XX:+UseConcMarkSweepGC -XX:+UseG1GC -XX:MaxGCPauseMillis=200 |
JDK 8 |
| Web 应用(堆 > 6GB) | G1 | -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16M |
JDK 9+ |
| 大堆低延迟(电商秒杀) | ZGC | -XX:+UseZGC -Xmx32G -Xms32G |
JDK 15+ |
| 超低延迟(金融交易) | ZGC / Shenandoah | -XX:+UseZGC 或 -XX:+UseShenandoahGC |
JDK 15+ |
| Kubernetes 容器 | G1 | -XX:+UseG1GC -XX:MaxRAMPercentage=75.0 |
JDK 11+ |
5.3 不同 JDK 版本的默认收集器
| JDK 版本 | 默认收集器 | 说明 |
|---|---|---|
| JDK 7-8 | Parallel GC | Server 模式默认,吞吐量优先 |
| JDK 9+ | G1 GC | 官方推荐,平衡吞吐量和延迟 |
| JDK 11+ | G1 GC | ZGC 作为实验特性可用 |
| JDK 15+ | G1 GC | ZGC 正式发布,生产可用 |
| JDK 17 (LTS) | G1 GC | 长期支持版本,推荐使用 |
六、GC 调优实战
6.1 GC 日志分析
开启 GC 日志
# JDK 8 及之前
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/to/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
# JDK 9+ 统一日志框架
-Xlog:gc*:file=/path/to/gc.log:time,level,tags:filecount=5,filesize=20M
典型 GC 日志解读
# G1 Young GC 日志
[2024-01-20T10:30:45.123+0800][0.234s][info][gc]
GC(10) Pause Young (Normal) (G1 Evacuation Pause)
512M->128M(2048M) 23.456ms
解读:
- GC(10):第 10 次 GC
- Pause Young:新生代 GC
- 512M->128M:堆内存从 512M 降到 128M
- (2048M):堆总大小 2GB
- 23.456ms:停顿时间 23.456 毫秒
# Full GC 日志(警告信号)
[2024-01-20T10:35:50.789+0800][300.567s][info][gc]
GC(50) Pause Full (Allocation Failure)
3800M->2100M(4096M) 2345.678ms
⚠️ 问题:
- Full GC 停顿 2.3 秒(影响用户体验)
- 原因:Allocation Failure(内存分配失败)
GC 日志可视化工具
| 工具 | 特点 | 网址 |
|---|---|---|
| GCeasy | 在线分析,生成报告(推荐) | https://gceasy.io |
| GCViewer | 开源桌面工具 | https://github.com/chewiebug/GCViewer |
| GCPlot | 实时监控 | https://gcplot.com |
| JClarity Censum | 商业工具,功能强大 | 已被 Microsoft 收购 |
6.2 常见 GC 问题诊断
问题 1:频繁 Minor GC
现象:
[GC (Allocation Failure) ... ] 0.010s
[GC (Allocation Failure) ... ] 0.012s
[GC (Allocation Failure) ... ] 0.011s
# 几秒内多次 Minor GC
原因:
- 新生代空间太小
- 对象分配速率过快
解决方案:
# 增大新生代
-Xmn1G # 或 -XX:NewRatio=2(老年代:新生代 = 2:1)
# 调整 Eden 和 Survivor 比例
-XX:SurvivorRatio=8 # Eden:Survivor = 8:1:1
# 使用 G1 的动态新生代
-XX:+UseG1GC
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=60
问题 2:频繁 Full GC
现象:
[Full GC (Allocation Failure) ... ] 2.345s
[Full GC (Allocation Failure) ... ] 2.456s
# Full GC 频繁且耗时长
原因分析:
| 原因 | 检查方法 | 解决方案 |
|---|---|---|
| 老年代空间不足 | 查看老年代使用率 | 增大堆内存 -Xmx |
| 元空间不足 | java.lang.OutOfMemoryError: Metaspace |
-XX:MetaspaceSize=256M |
| 大对象直接进入老年代 | 查看对象大小分布 | -XX:PretenureSizeThreshold=1M |
| 内存泄漏 | 使用 MAT 分析堆转储 | 修复代码逻辑 |
| System.gc() 显式调用 | 搜索代码 | -XX:+DisableExplicitGC |
诊断命令:
# 查看堆内存使用情况
jstat -gcutil <pid> 1000 10
# 输出示例:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 95.23 78.45 89.67 94.12 93.45 100 1.234 10 12.345 13.579
# 关键指标:
- O (Old):89.67% ← 老年代使用率过高
- FGC:10 次 Full GC
- FGCT:12.345 秒(Full GC 总耗时)
# 生成堆转储文件
jmap -dump:format=b,file=heap.hprof <pid>
# 使用 MAT (Eclipse Memory Analyzer) 分析
# 找出内存占用最大的对象
问题 3:GC 停顿时间过长
现象:
[GC pause (young) 512M->128M, 0.5678 secs]
# 停顿超过 500ms
原因与解决:
# 原因1:堆内存过大(单次扫描时间长)
# 解决:使用低延迟收集器
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 设置停顿目标
# 原因2:存活对象过多(复制耗时)
# 解决:优化对象生命周期,减少长生命周期对象
# 原因3:并发线程数不足
# 解决:增加 GC 线程
-XX:ParallelGCThreads=8 # 并行 GC 线程
-XX:ConcGCThreads=4 # 并发标记线程
# 原因4:NUMA 架构未优化
# 解决:启用 NUMA 支持
-XX:+UseNUMA
问题 4:内存泄漏(Memory Leak)
现象:
# 老年代持续增长,Full GC 后也无法降低
Old Generation: 80% -> 85% -> 90% -> 95% -> OOM
排查步骤:
# 1. 监控堆内存趋势
jstat -gccause <pid> 1000
# 2. 对比两次堆转储
jmap -dump:live,format=b,file=heap1.hprof <pid>
# 等待一段时间后
jmap -dump:live,format=b,file=heap2.hprof <pid>
# 3. 使用 MAT 对比分析
# Histogram 视图 -> 按大小排序
# Dominator Tree -> 找出支配对象
# 4. 常见泄漏场景
常见内存泄漏 Case:
// Case 1: 集合类未清理
public class LeakExample1 {
private static List list = new ArrayList<>();
public void addObject() {
list.add(new Object()); // 持续增加,永不清理
}
}
// Case 2: 监听器未移除
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 处理逻辑
}
});
// 解决:button.removeActionListener(listener);
// Case 3: ThreadLocal 未清理
public class LeakExample3 {
private static ThreadLocal<List> threadLocal = new ThreadLocal<>();
public void process() {
threadLocal.set(new ArrayList<>());
// 处理逻辑
// ⚠️ 忘记调用 threadLocal.remove();
}
}
// Case 4: 数据库连接/IO 流未关闭
Connection conn = DriverManager.getConnection(url);
// 使用连接
// ⚠️ 忘记 conn.close();
// 正确做法:使用 try-with-resources
try (Connection conn = DriverManager.getConnection(url)) {
// 使用连接
} // 自动关闭
6.3 GC 调优的黄金法则
法则 1:不要过早优化
1. 先用默认配置运行
2. 监控 GC 指标(吞吐量、停顿时间)
3. 确认存在性能问题
4. 再进行针对性调优
法则 2:明确优化目标
| 目标 | 关键指标 | 调优方向 |
|---|---|---|
| 高吞吐量 | GC 时间占比 < 5% | Parallel GC,增大堆内存 |
| 低延迟 | P99 停顿 < 100ms | G1 / ZGC,减少单次 GC 停顿 |
| 低内存占用 | 堆内存使用率 | 减小堆大小,使用 Serial GC |
法则 3:合理设置堆内存
# ⚠️ 错误示例
-Xms512m -Xmx4g # 初始和最大差距过大,导致频繁扩容
# ✅ 推荐做法
-Xms4g -Xmx4g # 固定堆大小,避免扩容开销
# 容器环境
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0 # 使用容器内存的 75%
法则 4:避免过度调优
# ❌ 过度优化(参数过多,互相冲突)
-Xms4g -Xmx4g
-Xmn2g
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=15
-XX:+UseAdaptiveSizePolicy=false
...(还有 20 个参数)
# ✅ 简洁配置(让 JVM 自适应)
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
6.4 调优工具箱
命令行工具
| 工具 | 功能 | 示例 |
|---|---|---|
| jstat | 实时监控 GC 统计 | jstat -gcutil <pid> 1000 |
| jmap | 生成堆转储 | jmap -dump:format=b,file=heap.hprof <pid> |
| jstack | 线程堆栈 | jstack <pid> > thread.txt |
| jinfo | 查看/修改 JVM 参数 | jinfo -flag MaxHeapSize <pid> |
| jcmd | 多功能诊断 | jcmd <pid> GC.heap_info |
可视化工具
| 工具 | 特点 | 推荐场景 |
|---|---|---|
| JVisualVM | JDK 自带,实时监控 | 开发测试 |
| JProfiler | 商业工具,功能全面 | 性能分析 |
| Arthas | 阿里开源,无需重启 | 线上诊断(⭐ 推荐) |
| Async-profiler | 低开销火焰图 | CPU 分析 |
Arthas 实战示例
# 下载启动
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
# 监控 GC
dashboard # 实时仪表盘
# 查看堆内存
memory
# 生成火焰图
profiler start
profiler stop --format html
# 查看对象分布
heapdump /tmp/heap.hprof
七、常见面试题
Q1:为什么新生代使用复制算法,老年代使用标记-清除/标记-整理?
答案:
新生代特点:
- 对象朝生夕死(98% 对象在第一次 GC 时死亡)
- 每次 GC 后存活对象很少(约 10%)
复制算法优势:
假设新生代 100 个对象:
- 标记-清除:需要扫描 100 个 + 清除 90 个 = 190 次操作
- 复制算法:只需复制 10 个存活对象 = 10 次操作
效率对比:复制算法快 19 倍!
老年代特点:
- 对象生命周期长(存活率 60-80%)
- 空间大(占堆内存 2/3)
不用复制算法的原因:
假设老年代 100 个对象,70 个存活:
- 复制 70 个对象 + 需要额外 50% 空间(浪费)
- 标记-清除:只需标记 100 个 + 清除 30 个
结论:复制算法在老年代既慢又浪费空间
结论:复制算法在老年代既慢又浪费空间
为什么用标记-整理而非标记-清除:
- 标记-清除会产生内存碎片
- 老年代对象大,碎片会导致提前 Full GC
- 标记-整理虽慢,但消除碎片,长期性能更好
Q2:什么是 Stop-The-World (STW)?如何减少 STW 时间?
STW 定义: GC 时暂停所有用户线程(应用程序停顿)
为什么需要 STW:
// 场景:不 STW 会导致的问题
A -> B -> C // 标记阶段
// 用户线程修改引用:
A -> D
B 断开 C
// 结果:C 被误回收(对象消失问题)
减少 STW 的策略:
| 层面 | 方法 | 示例 |
|---|---|---|
| 算法层 | 使用并发算法 | CMS、G1、ZGC 的并发标记 |
| 收集器层 | 选择低延迟收集器 | G1(< 200ms)、ZGC(< 10ms) |
| 参数层 | 减少单次回收量 | -XX:MaxGCPauseMillis=100 |
| 代码层 | 优化对象生命周期 | 对象池、减少临时对象 |
| 架构层 | 垂直拆分服务 | 单个 JVM 堆不要超过 32GB |
实战优化:
# 优化前(Parallel GC)
-XX:+UseParallelGC
Young GC: 200ms, Full GC: 3s
# 优化后(G1)
-XX:+UseG1GC -XX:MaxGCPauseMillis=50
Young GC: 50ms, Mixed GC: 100ms, Full GC: 很少发生
Q3:对象一定分配在堆上吗?什么是栈上分配和标量替换?
答案:
不一定!JVM 有三种优化手段。
1. 栈上分配(Stack Allocation)
前提条件:
- 对象作用域不会逃逸出方法
- 对象较小
public void method() {
User user = new User(); // 对象未逃逸(方法内创建并使用)
user.setName("Alice");
System.out.println(user.getName());
// 方法结束,user 不再使用
}
// JVM 优化:直接在栈上分配 user 对象
// 好处:方法结束自动回收,无需 GC
2. 标量替换(Scalar Replacement)
概念:
- 标量:不可再分的数据(int, long, reference)
- 聚合量:可分解的对象(User 对象)
public void method() {
User user = new User();
user.name = "Alice";
user.age = 18;
}
// JVM 优化为:
public void method() {
String name = "Alice"; // 标量
int age = 18; // 标量
// 不创建 User 对象,直接使用局部变量
}
3. TLAB(Thread Local Allocation Buffer)
问题:多线程在 Eden 区分配对象需要加锁。
解决:
每个线程在 Eden 区预先分配一小块缓冲区(TLAB):
Eden 区
┌────────────────────────────────────┐
│ [线程1 TLAB] [线程2 TLAB] [线程3...]│
└────────────────────────────────────┘
线程在自己的 TLAB 内分配对象,无需加锁
JVM 参数:
# 启用逃逸分析(JDK 8+ 默认开启)
-XX:+DoEscapeAnalysis
# 启用标量替换
-XX:+EliminateAllocations
# TLAB 配置
-XX:+UseTLAB # 默认开启
-XX:TLABSize=256k
Q4:如何判断一个对象是否可以被回收?
两种算法对比:
1. 引用计数法(Reference Counting)
A -> B // B 的引用计数 = 1
C -> B // B 的引用计数 = 2
A 断开 // B 的引用计数 = 1
C 断开 // B 的引用计数 = 0 → 可回收
缺陷:无法解决循环引用
A.ref = B;
B.ref = A;
// A 和 B 的引用计数都是 1,但实际上都是垃圾
2. 可达性分析(Reachability Analysis)⭐
GC Roots 包括:
- 虚拟机栈中的引用(局部变量表)
- 方法区中静态属性引用
- 方法区中常量引用
- 本地方法栈中 JNI 引用
- JVM 内部引用(Class 对象、异常对象、类加载器)
- 被同步锁持有的对象
GC Roots
├─ 栈变量 A ──> 对象1 ──> 对象2
├─ 静态变量 B ──> 对象3
└─ 常量 C ──> 对象4
对象5 <──> 对象6 ← 无法从 GC Roots 到达,可回收
实战示例:
public class GCRootsDemo {
private static Object staticObj = new Object(); // GC Root(静态变量)
public void method() {
Object localObj = new Object(); // GC Root(栈变量)
// localObj 和 staticObj 引用的对象不会被回收
}
}
Q5:四种引用类型有什么区别?
| 引用类型 | 回收时机 | 使用场景 | 示例 |
|---|---|---|---|
| 强引用 | 永远不回收 | 普通对象引用 | Object obj = new Object() |
| 软引用 | 内存不足时回收 | 缓存(图片缓存) | SoftReference<byte[]> cache |
| 弱引用 | 下次 GC 时回收 | 缓存(ThreadLocalMap) | WeakReference ref |
| 虚引用 | 任何时候(用于跟踪回收) | 堆外内存回收 | PhantomReference ref |
代码示例:
// 1. 强引用(默认)
Object obj = new Object();
// 只要 obj 可达,永不回收
// 2. 软引用(内存敏感缓存)
SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = softRef.get(); // 可能返回 null(已被回收)
// 3. 弱引用(临时缓存)
WeakReference<User> weakRef = new WeakReference<>(new User());
User user = weakRef.get(); // 可能返回 null
// 4. 虚引用(追踪对象回收)
ReferenceQueue queue = new ReferenceQueue<>();
PhantomReference phantomRef = new PhantomReference<>(new Object(), queue);
// phantomRef.get() 永远返回 null
// 对象被回收时,phantomRef 会被加入 queue
ThreadLocal 内存泄漏分析:
ThreadLocal<User> threadLocal = new ThreadLocal<>();
// ThreadLocalMap 结构:
Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 强引用
}
// 问题:
threadLocal = null; // ThreadLocal 被回收(弱引用)
// 但 value 仍被 Entry 强引用 → 内存泄漏
// 解决:
threadLocal.remove(); // 手动清理
Q6:什么时候会触发 Full GC?如何避免?
触发条件(8 种场景):
| 触发场景 | 说明 | 避免方法 |
|---|---|---|
| 1. 老年代空间不足 | 对象晋升时老年代放不下 | 增大堆内存 -Xmx |
| 2. 元空间不足 | 类加载过多 | -XX:MetaspaceSize=256M |
| 3. System.gc() | 代码显式调用 | -XX:+DisableExplicitGC |
| 4. CMS Concurrent Mode Failure | 并发清除时空间不足 | 提前触发 GC(降低阈值) |
| 5. 晋升担保失败 | Minor GC 前检查失败 | 增大老年代 |
| 6. 大对象分配失败 | 老年代碎片化 | 使用 G1(无碎片) |
| 7. JDK 7 永久代满 | 永久代不足 | 升级 JDK 8+ |
| 8. GC Ergonomics | 自适应策略触发 | 调整停顿时间目标 |
诊断和优化流程:
# 1. 监控 Full GC 频率
jstat -gccause <pid> 1000
# 2. 查看触发原因
[Full GC (Allocation Failure) ...] # 分配失败
[Full GC (Ergonomics) ...] # 自适应策略
[Full GC (System.gc()) ...] # 显式调用
# 3. 针对性优化
# 场景1:频繁 Allocation Failure
-Xmx8G -Xms8G # 增大堆
# 场景2:元空间问题
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=512M
# 场景3:禁用显式 GC
-XX:+DisableExplicitGC
# 场景4:切换到 G1
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
Q7:G1 为什么能实现可预测的停顿时间?
核心机制:
1. Region 化设计
传统分代:必须回收整个新生代/老年代
G1:选择部分 Region 回收
2. 停顿预测模型
// G1 维护每个 Region 的统计信息:
class RegionStats {
long gcTime; // 历史 GC 耗时
long garbageBytes; // 垃圾对象字节数
double value = garbageBytes / gcTime; // 回收价值
}
// 选择算法(贪心):
在停顿时间预算内,选择价值最大的 Region 组合
3. 增量回收
Young GC:只回收所有 Eden + Survivor Region
Mixed GC:回收所有新生代 + 部分老年代 Region(价值高的)
不需要一次回收整个堆 → 停顿时间可控
配置示例:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100 # 目标停顿 100ms
# G1 内部决策:
如果回收 10 个 Region 需要 80ms:继续
如果回收 11 个 Region 需要 105ms:停止,留到下次
Q8:ZGC 如何实现 10ms 内的超低延迟?
三大核心技术:
1. 着色指针(Colored Pointers)
传统 GC:标记信息存储在对象头(需要访问对象)
ZGC:标记信息存储在指针中(无需访问对象)
64 位指针布局:
[unused 16bit][Marked1 1bit][Marked0 1bit][Remapped 1bit][地址 42bit]
判断对象状态:直接检查指针的标志位(极快)
2. 读屏障(Load Barrier)
// 用户代码:
Object obj = field.someObject;
// ZGC 插入读屏障:
Object obj = loadBarrier(field.someObject);
// 屏障逻辑(硬件层面优化):
if (指针颜色不匹配当前阶段) {
slowPath(); // 处理重定位、标记等
}
// 快速路径几乎无开销(1-2 个 CPU 周期)
3. 并发移动对象
传统 GC:移动对象时必须 STW(防止引用失效)
ZGC:通过转发表 + 读屏障实现并发移动
并发重定位过程:
┌─────────────────────────────────────┐
│ 1. 标记阶段(并发) │
│ 标记存活对象 │
├─────────────────────────────────────┤
│ 2. 重定位准备(短暂 STW < 1ms) │
│ 选择需要移动的 Region │
├─────────────────────────────────────┤
│ 3. 并发重定位 │
│ 后台线程:移动对象到新地址 │
│ 转发表:记录 旧地址 -> 新地址 │
├─────────────────────────────────────┤
│ 4. 用户线程访问对象 │
│ 读屏障检测到旧指针 │
│ 查询转发表 -> 返回新地址 │
│ ⚡ 用户线程协助修正引用(自愈) │
└─────────────────────────────────────┘
关键点:转发表(Forwarding Table)
转发表存储在每个 Region 内:
┌──────────┬──────────┐
│ 旧地址 │ 新地址 │
├──────────┼──────────┤
│ 0x1000 │ 0x5000 │
│ 0x1100 │ 0x5100 │
└──────────┴──────────┘
用户线程访问旧地址时:
读屏障拦截
查转发表获取新地址
自愈(修正引用为新地址)
下次访问直接命中新地址(无开销)
为什么只有 < 10ms 停顿:
ZGC 的 STW 只发生在:
初始标记:扫描 GC Roots(< 1ms)
重定位准备:统计 Region(< 1ms)
重映射准备(可选,< 1ms)
其他所有阶段(标记、移动、重映射)都是并发的
Q9:JVM 内存模型(JMM)与 GC 的关系?
澄清概念:
- JVM 内存结构:堆、栈、方法区等(本文讨论的)
- Java 内存模型(JMM):线程间通信规范(happens-before)
JMM 对 GC 的影响:
1. 写屏障(Write Barrier)
// G1/ZGC 使用写屏障记录跨 Region 引用
obj.field = anotherObj;
// 编译后插入写屏障代码:
obj.field = anotherObj;
if (obj 和 anotherObj 在不同 Region) {
记录到 Remembered Set;
}
2. 读屏障(Load Barrier)
// ZGC 使用读屏障处理对象重定位
Object obj = field.someObject;
// 插入读屏障:
Object obj = loadBarrier(field.someObject);
// 检查指针颜色,必要时重定位
3. 内存屏障(Memory Barrier)
// GC 的 STW 过程需要内存屏障确保可见性
// 1. GC 线程发起 STW 请求
// 2. 插入内存屏障,确保所有线程看到最新状态
// 3. 各线程到达安全点后停止
💬 评论