垃圾回收与GC调优
# 03.垃圾回收与GC调优
# 目录介绍
# 3.1 开篇疑问
疑惑:Java 号称不需要手动管理内存,那 GC 到底是怎么工作的?为什么有时候程序会出现长时间卡顿(Stop-The-World)?不同的垃圾收集器到底有什么区别,该怎么选?三色标记是什么?
答疑:垃圾回收是 JVM 最核心的子系统之一。从最早的 Serial 收集器到现代的 ZGC,GC 技术经历了近 30 年的演进。理解 GC 算法的原理和演变,不仅能帮你写出更高效的代码,还能在生产环境中快速定位和解决性能问题。
# 3.2 为什么需要垃圾回收
在 C/C++ 中,程序员需要手动管理内存(malloc/free、new/delete)。这带来两个典型问题:
- 内存泄漏:忘记释放内存,程序长时间运行后内存耗尽
- 野指针:释放后仍然访问,导致不可预期的行为
Java 通过自动垃圾回收解决了这两个问题,但代价是引入了 GC 停顿(Stop-The-World)。GC 技术的演进史,本质上就是一部不断缩短 STW 时间的历史。
| 时代 | 收集器 | STW 时间 | 特点 |
|---|---|---|---|
| JDK 1.3 | Serial | 秒级 | 单线程,全程 STW |
| JDK 1.4 | Parallel | 百毫秒级 | 多线程并行回收 |
| JDK 1.5 | CMS | 十毫秒级 | 并发标记,减少 STW |
| JDK 1.7 | G1 | 毫秒级可控 | 分区域,可设定目标停顿 |
| JDK 11+ | ZGC | 亚毫秒级 | 染色指针,几乎无停顿 |
# 3.3 判断对象存活的算法
# 3.3.1 引用计数法
每个对象维护一个引用计数器,有引用加 1,引用失效减 1,计数为 0 的对象即可回收。
优点:实现简单,判定效率高。Python、Swift 等语言使用引用计数(配合其他机制处理循环引用)。
致命缺陷:无法处理循环引用。
// 循环引用示例
class Node {
Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b; // b 的引用计数 = 2
b.next = a; // a 的引用计数 = 2
a = null; // a 指向对象的引用计数 = 1(b.next 还引用着)
b = null; // b 指向对象的引用计数 = 1(a.next 还引用着)
// 两个对象都无法被外部访问,但引用计数不为0,永远无法回收
2
3
4
5
6
7
8
9
10
11
12
实验证明 JVM 不用引用计数:
public class ReferenceCountingGC {
public Object instance = null;
private byte[] bigSize = new byte[2 * 1024 * 1024]; // 2MB
public static void main(String[] args) {
ReferenceCountingGC a = new ReferenceCountingGC();
ReferenceCountingGC b = new ReferenceCountingGC();
a.instance = b;
b.instance = a;
a = null;
b = null;
System.gc();
// GC 日志显示内存被回收了 → 证明 JVM 不用引用计数
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 3.3.2 可达性分析法
JVM 采用可达性分析算法。从 GC Roots 出发,沿引用链遍历,不可达的对象即为可回收对象。
GC Roots
├── 栈帧中的局部变量
├── 静态变量
├── 常量
└── JNI引用
↓ 沿引用链遍历
Object A → Object B → Object C (可达,存活)
Object D → Object E (从GC Roots不可达,可回收)
↑________↓ (即使互相引用也会被回收)
2
3
4
5
6
7
8
9
10
# 3.3.3 GC Roots完整枚举
能作为 GC Roots 的对象包括:
| GC Root 类型 | 说明 |
|---|---|
| 虚拟机栈中的局部变量 | 当前所有活跃线程的栈帧中引用的对象 |
| 方法区中的静态变量 | 类的 static 字段引用的对象 |
| 方法区中的常量 | 字符串常量池中的引用 |
| JNI 引用 | Native 方法持有的引用 |
| JVM 内部引用 | 基本类型的 Class 对象、系统类加载器等 |
| synchronized 持有的对象 | 被 monitor lock 持有的对象 |
| JMXBean、JVMTI 中注册的回调 | |
| 跨代引用(部分GC时) | 老年代中引用新生代对象 |
枚举根节点的 OopMap:
问:GC 时如何快速找到所有 GC Roots?逐个扫描栈和寄存器太慢了。
答:JVM 使用 OopMap(Ordinary Object Pointer Map)数据结构。
在类加载完成时,JVM 就把对象内什么偏移量上是什么类型的引用计算出来。
在 JIT 编译时,也会在特定的位置记录栈和寄存器中哪些位置是引用。
这些特定位置就是"安全点"(Safepoint)。
GC 不是在任意位置暂停线程,而是等线程到达安全点后才暂停。
2
3
4
5
6
7
8
安全点(Safepoint)与安全区域(Safe Region):
// 安全点的典型位置:
// 1. 方法调用处
// 2. 循环跳转处
// 3. 异常抛出处
// 这些位置可以确保 OopMap 是最新的
// 如何让线程跑到安全点?两种方式:
// 1. 抢先式中断(Preemptive Suspension):中断所有线程,没到安全点的恢复执行
// → 几乎没有实现使用这种方式
// 2. 主动式中断(Voluntary Suspension):设置标志位,线程轮询到标志时自行中断
// → 主流实现方式
// 安全区域:线程处于 sleep 或 blocked 状态时,无法走到安全点
// 安全区域 = 引用关系不会变化的代码区域
// 线程进入安全区域时标记自己,GC 直接忽略这些线程
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3.3.4 Java四种引用类型
JDK 1.2 引入了四种引用类型,让开发者可以更细粒度地控制对象的生命周期:
| 引用类型 | 回收时机 | 用途 | 类 |
|---|---|---|---|
| 强引用 | 永不回收(只要可达) | 普通赋值 Object o = new Object() | - |
| 软引用 | 内存不足时回收 | 缓存 | SoftReference<T> |
| 弱引用 | 下次 GC 时回收 | WeakHashMap、ThreadLocal | WeakReference<T> |
| 虚引用 | 随时回收 | 跟踪对象回收,堆外内存清理 | PhantomReference<T> |
// 软引用做缓存
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024 * 10]);
byte[] data = cache.get();
if (data != null) {
// 缓存命中,直接使用
} else {
// 内存不足被回收了,重新加载
data = loadFromDisk();
cache = new SoftReference<>(data);
}
// 配合引用队列使用
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
SoftReference<byte[]> ref = new SoftReference<>(new byte[1024], queue);
// 当 ref 被回收后,ref 会被加入 queue
// 可以通过 queue.poll() 获取被回收的引用,做清理工作
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
虚引用的典型应用——堆外内存管理:
// DirectByteBuffer 使用虚引用跟踪对象回收
// 当 DirectByteBuffer 对象被 GC 回收时
// 其关联的虚引用(Cleaner)被放入 ReferenceQueue
// Cleaner 线程从队列中取出虚引用,释放堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
// 内部创建了 Cleaner(虚引用的子类)
// buffer 被 GC 回收时,Cleaner.clean() 自动调用 Unsafe.freeMemory()
2
3
4
5
6
7
8
# 3.3.5 finalize方法与对象自救
对象被可达性分析判定为不可达后,并不会立即被回收,还有一次"自救"的机会:
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize 被执行");
// 自救:把自己重新赋给一个 GC Root 可达的引用
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Exception {
SAVE_HOOK = new FinalizeEscapeGC();
// 第一次自救:成功
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
System.out.println(SAVE_HOOK != null ? "存活" : "死亡"); // 存活
// 第二次自救:失败(finalize 只会被调用一次)
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
System.out.println(SAVE_HOOK != null ? "存活" : "死亡"); // 死亡
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
注意:finalize() 已被 JDK 9 标记为 @Deprecated,不推荐使用。原因:
- 执行时机不确定
- 严重影响 GC 性能(Finalizer 线程优先级低,可能导致对象长时间不被回收)
- 可能导致对象复活
- 推荐使用
try-with-resources或Cleaner(JDK 9+)替代
# 3.4 核心垃圾回收算法
# 3.4.1 标记-清除算法
Mark-Sweep 是最基础的 GC 算法,分两步:
- 标记:从 GC Roots 遍历,标记所有可达对象
- 清除:遍历堆,回收未被标记的对象
标记前: [A][B][ ][C][ ][D][E][ ][F]
标记: [A][B][ ][C][ ][ ][E][ ][ ] (D, F 不可达)
清除后: [A][B][空][C][空][空][E][空][空]
2
3
缺点:
- 效率不稳定:堆中对象越多,标记和清除耗时越长
- 内存碎片:清除后产生大量不连续的空闲内存块,可能导致大对象无法分配
# 3.4.2 复制算法
Copying 算法将内存分为两块,每次只使用一块。GC 时将存活对象复制到另一块,然后清空当前块。
复制前:
From: [A][B][空][C][空][D] To: [空][空][空][空][空][空]
复制后(A,B,C存活,D不可达):
From: [空][空][空][空][空][空] To: [A][B][C][空][空][空]
2
3
4
5
优点:没有内存碎片,分配效率高(指针碰撞) 缺点:可用内存缩小为一半
优化:新生代采用 Eden + 2×Survivor 的 8:1:1 布局,只浪费 10% 空间。因为新生代对象 98% 以上都活不过一次 GC,复制的对象很少。
新生代内存布局:
┌──────────────────┬────┬────┐
│ Eden (80%) │ S0 │ S1 │
│ │10% │10% │
└──────────────────┴────┴────┘
Minor GC 过程:
1. 新对象分配在 Eden
2. Eden 满 → 触发 Minor GC
3. Eden + S0 中存活对象 → 复制到 S1
4. 清空 Eden + S0
5. S0 和 S1 角色互换(From/To)
6. 重复...
2
3
4
5
6
7
8
9
10
11
12
13
# 3.4.3 标记-整理算法
Mark-Compact 在标记后不直接清除,而是将所有存活对象向内存一端移动,然后清理边界以外的内存。
标记后: [A][空][B][空][空][C][空][D]
整理后: [A][B][C][D][空][空][空][空]
2
优点:没有内存碎片 缺点:移动对象需要更新所有引用,且必须 STW
关键取舍:标记-清除不需要移动对象但有碎片;标记-整理没有碎片但需要移动。CMS 选择了前者(追求低延迟),Parallel Old 选择了后者(追求高吞吐)。
# 3.4.4 分代收集算法
分代收集不是一种新算法,而是将上述算法按对象特征组合使用:
堆内存
├── 新生代(对象存活率低 ~2%)
│ └── 复制算法(Eden → Survivor,只复制少量存活对象)
└── 老年代(对象存活率高 ~70%+)
└── 标记-清除 或 标记-整理
2
3
4
5
为什么这样设计?
弱分代假说:绝大多数对象朝生夕死。强分代假说:熬过多次 GC 的对象越难消亡。基于这两个假说,对不同"年龄"的对象采用不同策略,是最优解。
# 3.4.5 跨代引用与记忆集
问题:Minor GC 只回收新生代,但如果老年代有对象引用新生代怎么办?需要扫描整个老年代吗?
解决方案:记忆集(Remembered Set)+ 卡表(Card Table)
老年代被划分为多个"卡页"(Card Page,通常 512 字节)
每个卡页对应卡表中的一个字节
卡表: [0][0][1][0][1][0][0][0]
↑ ↑
这个卡页有 这个卡页有
跨代引用 跨代引用
Minor GC 时,只需要扫描"脏卡页",不需要遍历整个老年代
2
3
4
5
6
7
8
9
// 卡表的本质:一个字节数组
// CARD_TABLE[address >> 9] = 1 (脏卡)
// 512 字节 = 2^9,右移9位就是卡页索引
// 写屏障(Write Barrier)维护卡表
// 每次引用赋值时,JVM 自动更新卡表
// 伪代码:
void oop_field_store(Object* field, Object* new_value) {
*field = new_value;
// 写后屏障:标记卡表
CARD_TABLE[field >> 9] = DIRTY;
}
2
3
4
5
6
7
8
9
10
11
12
# 3.4.6 三色标记与并发标记
CMS 和 G1 在并发标记阶段使用三色标记算法:
白色:尚未访问的对象(GC 开始时所有对象都是白色)
灰色:已被访问,但其引用的对象还未全部访问
黑色:已被访问,且其引用的对象也都已访问
标记过程:
1. 初始:所有对象白色
2. 将 GC Roots 直接引用的对象标为灰色
3. 从灰色对象出发,遍历其引用的对象,标为灰色
4. 自身标为黑色
5. 重复 3-4 直到没有灰色对象
6. 剩余的白色对象即为垃圾
2
3
4
5
6
7
8
9
10
11
并发标记的问题——漏标:
并发标记期间,用户线程可能修改引用关系,导致两种问题:
1. 浮动垃圾(多标):本该回收的对象被标记为存活
→ 不影响正确性,下次GC回收即可
2. 漏标(对象消失):存活对象被当成垃圾回收
→ 严重问题!必须解决
漏标发生条件(同时满足):
a. 一个黑色对象新增了指向白色对象的引用
b. 所有灰色对象到该白色对象的引用都被删除
解决方案:
1. 增量更新(Incremental Update):记录新增的引用
→ CMS 使用
2. 原始快照(SATB, Snapshot At The Beginning):记录被删除的引用
→ G1 使用
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CMS 的增量更新:
黑色对象 A 新增引用 → 白色对象 C
增量更新:记录 A → C 这条新引用
重新标记阶段:从 A 出发重新扫描
2
3
G1 的 SATB:
灰色对象 B 删除引用 → 白色对象 C
SATB:在删除前记录 B → C 这条旧引用
最终标记阶段:以这条旧引用为根,扫描 C
相当于"快照"了标记开始时的引用关系
2
3
4
# 3.5 经典垃圾收集器
# 3.5.1 Serial与Serial Old
Serial (新生代,复制算法)
线程: ──────│STW: 单线程GC│──────
│← GC 停顿 →│
Serial Old (老年代,标记-整理)
同理,单线程
2
3
4
5
6
特点:简单高效,适合客户端模式和内存较小的场景。单核 CPU 下没有线程切换开销,效率反而最高。
使用场景:
- 客户端应用(桌面程序)
- 嵌入式设备
- 堆内存 < 100MB 的应用
-XX:+UseSerialGC # 新生代 Serial + 老年代 Serial Old
# 3.5.2 ParNew与Parallel
ParNew 是 Serial 的多线程版本,Parallel Scavenge 关注吞吐量。
Parallel (新生代)
线程1: ────│STW: GC线程1│──────
线程2: ────│STW: GC线程2│──────
线程3: ────│STW: GC线程3│──────
│← GC停顿 →│
2
3
4
5
ParNew vs Parallel Scavenge 的区别:
| 特性 | ParNew | Parallel Scavenge |
|---|---|---|
| 关注点 | 低延迟(配合CMS) | 高吞吐量 |
| 框架 | 基于 Serial 改造 | 独立实现 |
| 配合的老年代 | CMS | Parallel Old |
| 自适应调节 | 无 | -XX:+UseAdaptiveSizePolicy |
Parallel Scavenge 的独特设计:提供两个参数精确控制吞吐量:
-XX:MaxGCPauseMillis:最大 GC 停顿时间-XX:GCTimeRatio:GC 时间占总时间的比率
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC时间)
# Parallel 收集器(JDK 8 默认)
-XX:+UseParallelGC # 新生代 Parallel Scavenge
-XX:+UseParallelOldGC # 老年代 Parallel Old
-XX:ParallelGCThreads=4 # GC 线程数
-XX:MaxGCPauseMillis=100 # 最大停顿目标 100ms
-XX:GCTimeRatio=99 # 吞吐量目标 99%
2
3
4
5
6
# 3.5.3 CMS收集器
CMS(Concurrent Mark Sweep)是第一款真正意义上的并发收集器,以最短停顿时间为目标。
四个阶段:
阶段1: 初始标记 (STW) ← 极短,只标记GC Roots直接引用的对象
阶段2: 并发标记 ← 与用户线程并发执行,遍历引用链
阶段3: 重新标记 (STW) ← 修正并发标记期间变动的标记(增量更新)
阶段4: 并发清除 ← 与用户线程并发执行,清除死亡对象
时间线:
用户线程: ──│STW│────并发────│STW│────并发────→
GC线程: │标记│────标记────│重标│────清除────→
2
3
4
5
6
7
8
CMS 的三大缺陷:
- CPU 敏感:并发阶段占用 CPU 资源,默认 GC 线程数 = (CPU核心数 + 3) / 4
- 浮动垃圾:并发清除阶段用户线程还在产生新垃圾,只能等下次 GC 清理
- 内存碎片:基于标记-清除算法,会产生碎片。碎片过多时会触发 Serial Old 做 Full GC
# 3.5.4 CMS的并发模式失败与退化
# CMS 核心参数
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75 # 老年代使用 75% 时触发 CMS GC
-XX:+UseCMSCompactAtFullCollection # Full GC 时整理碎片
-XX:CMSFullGCsBeforeCompaction=5 # 多少次 Full GC 后做一次碎片整理
2
3
4
5
Concurrent Mode Failure:
并发清除阶段,用户线程还在分配对象
如果老年代空间不足以容纳新晋升的对象
→ Concurrent Mode Failure
→ CMS 退化为 Serial Old 进行 Full GC(灾难性的长停顿!)
解决方案:
1. 降低 CMSInitiatingOccupancyFraction,提前触发 CMS
2. 增大老年代空间
3. 优化对象分配,减少晋升压力
2
3
4
5
6
7
8
9
Promotion Failed:
Minor GC 时,存活对象需要晋升到老年代
但老年代因为碎片化,虽然总空间足够但没有连续空间
→ Promotion Failed
→ 触发 Full GC
解决方案:
1. 开启碎片整理:-XX:+UseCMSCompactAtFullCollection
2. 增大老年代空间
3. 考虑迁移到 G1
2
3
4
5
6
7
8
9
# 3.5.5 G1收集器
G1(Garbage-First)是 JDK 9 的默认收集器,开创了基于 Region 的内存布局:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ S │ O │ H │ E │ O │ E │ S │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O │ E │ │ O │ S │ E │ O │ │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ E │ O │ O │ E │ O │ │ E │ O │
└────┴────┴────┴────┴────┴────┴────┴────┘
E=Eden S=Survivor O=Old H=Humongous(大对象)
空白=Free Region
2
3
4
5
6
7
8
9
核心设计思想:
- 将堆划分为大小相等的 Region(1~32MB),每个 Region 可以扮演不同角色
- 跟踪每个 Region 的垃圾回收价值(回收空间 / 回收时间),优先回收价值最大的 Region
- 用户可以设定期望停顿时间
-XX:MaxGCPauseMillis=200,G1 会尽量在这个时间内完成回收
G1 的三种 GC 模式:
1. Young GC:只回收所有 Eden 和 Survivor Region
→ 存活对象复制到新的 Survivor / Old Region
→ 类似新生代 Minor GC
2. Mixed GC:回收所有 Young Region + 部分 Old Region
→ "部分" = 回收价值最高的 Old Region
→ 这就是 "Garbage-First" 名字的由来
3. Full GC:回收所有 Region(Serial Old 串行执行)
→ 通常意味着 G1 调优不佳
2
3
4
5
6
7
8
9
10
G1 的 Mixed GC 过程:
- 初始标记(STW):标记 GC Roots 直接关联的对象(借助 Young GC 完成)
- 并发标记:从 GC Roots 开始遍历整个堆(SATB 算法)
- 最终标记(STW):处理并发标记阶段 SATB 记录的变化
- 筛选回收(STW):对 Region 排序,选择回收价值最高的若干 Region 回收
Humongous 对象:
// 大小超过 Region 一半的对象被称为 Humongous 对象
// 直接分配在 Humongous Region 中(可能跨多个连续 Region)
// G1 会在 Young GC 和 Mixed GC 时同时回收 Humongous Region
// 如果 Region 大小为 2MB,则 > 1MB 的对象就是 Humongous
// 可以通过 -XX:G1HeapRegionSize 调整 Region 大小
2
3
4
5
6
# 3.5.6 G1的Remembered Set与Card Table
G1 的每个 Region 都有自己的 Remembered Set(RSet),记录其他 Region 到本 Region 的引用:
Region A 中有对象引用了 Region B 中的对象
→ Region B 的 RSet 中记录: "Region A 的某个位置引用了我"
Minor GC 回收 Region B 时:
→ 不需要扫描所有其他 Region
→ 只需查看 Region B 的 RSet,就知道哪些外部引用指向 B
2
3
4
5
6
RSet 的内存开销:RSet 通常占堆内存的 5%~20%。这是 G1 的一个缺点——空间换时间。
# G1 核心参数
-XX:+UseG1GC # 使用 G1(JDK 9+ 默认)
-XX:MaxGCPauseMillis=200 # 目标停顿时间 200ms
-XX:G1HeapRegionSize=4m # Region 大小(1~32MB,2的幂)
-XX:InitiatingHeapOccupancyPercent=45 # 堆使用 45% 时触发并发标记
-XX:G1MixedGCCountTarget=8 # Mixed GC 次数目标
2
3
4
5
6
# 3.5.7 ZGC深度剖析
ZGC(JDK 11+)代表了 GC 技术的最前沿,目标是将停顿时间控制在 10ms 以内,且不随堆大小增长。
ZGC 核心技术——染色指针(Colored Pointers):
64位指针布局 (ZGC):
┌──────────┬───┬───┬───┬───┬─────────────────────┐
│ 未使用 │ F │ R │ M1│ M0│ 对象地址 (42位) │
│ (16bit) │ │ │ │ │ 支持 4TB 堆 │
└──────────┴───┴───┴───┴───┴─────────────────────┘
F=Finalizable R=Remapped M0/M1=Marked0/Marked1
2
3
4
5
6
ZGC 将 GC 信息存储在指针中(而非对象头),配合读屏障(Load Barrier),实现了几乎完全并发的垃圾回收。
ZGC 的工作流程:
1. 初始标记 (STW):标记 GC Roots(极短,<1ms)
2. 并发标记:遍历对象图,通过染色指针标记存活对象
3. 再标记 (STW):处理少量变化(极短)
4. 并发转移准备:选择要回收的 Region
5. 初始转移 (STW):转移 GC Roots 直接引用的对象(极短)
6. 并发转移:将存活对象复制到新 Region(核心!并发执行)
并发转移的关键:读屏障(Load Barrier)
→ 用户线程读取对象引用时,如果发现染色指针中标记了"已转移"
→ 自动转发到新地址(类似 HTTP 301 重定向)
→ 这就是为什么不需要 STW 来更新所有引用
2
3
4
5
6
7
8
9
10
11
ZGC vs G1:
| 特性 | G1 | ZGC |
|---|---|---|
| 最大停顿 | ~200ms(可控但较大) | <10ms |
| 堆大小支持 | ~64GB | ~16TB |
| 内存占用 | RSet 占 5-20% | 染色指针无额外占用 |
| 碎片处理 | Region 间复制 | Region 间复制 |
| 并发程度 | 标记并发,回收 STW | 标记+转移都并发 |
| JDK 版本 | JDK 7+ | JDK 11+(JDK 15 正式) |
| 适用场景 | 通用 | 超低延迟要求 |
# ZGC 参数
-XX:+UseZGC # 使用 ZGC
-XX:ZAllocationSpikeTolerance=5 # 分配尖峰容忍度
-XX:ZCollectionInterval=120 # GC 间隔(秒)
-Xmx16g # 堆大小
2
3
4
5
# 3.5.8 Shenandoah收集器
Shenandoah(JDK 12+,Red Hat 贡献)的目标与 ZGC 类似,但实现方式不同:
| 特性 | ZGC | Shenandoah |
|---|---|---|
| 核心技术 | 染色指针 | Brooks Pointer(转发指针) |
| 内存屏障 | 读屏障 | 读+写屏障 |
| 堆大小 | 最大 16TB | 理论无限制 |
| 开发者 | Oracle | Red Hat |
| 可用 JDK | Oracle/OpenJDK | 仅 OpenJDK |
# 3.5.9 收集器组合搭配
新生代收集器 老年代收集器 说明
─────────────────────────────────────────────────
Serial → Serial Old 单线程,客户端模式
ParNew → CMS 低延迟(JDK 8 常用)
ParNew → Serial Old CMS 退化时的后备
Parallel Scavenge → Parallel Old 高吞吐(JDK 8 默认)
G1 → G1 分区域收集(JDK 9+ 默认)
ZGC → ZGC 超低延迟(JDK 15+)
Shenandoah → Shenandoah 超低延迟(OpenJDK)
2
3
4
5
6
7
8
9
收集器选择决策树:
需要最低延迟?
/ \
是 否
/ \
堆 > 4GB? 需要高吞吐?
/ \ / \
是 否 是 否
ZGC CMS/G1 Parallel Serial/G1
2
3
4
5
6
7
8
# 3.6 GC调优实战原理
# 3.6.1 GC日志分析
开启 GC 日志是调优的第一步:
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log
-XX:+PrintGCApplicationStoppedTime # 打印 STW 时间
-XX:+PrintHeapAtGC # GC 前后打印堆信息
# JDK 9+(统一日志框架)
-Xlog:gc*:file=/path/gc.log:time,uptime,level,tags
-Xlog:gc+heap=debug:file=/path/gc.log # 详细堆信息
2
3
4
5
6
7
8
关键日志解读:
[GC (Allocation Failure)
[PSYoungGen: 153600K->2048K(153600K)] // 新生代: 回收前->回收后(总容量)
153600K->13056K(502784K), // 堆: 回收前->回收后(总容量)
0.0123456 secs] // 耗时
// 153600K - 2048K = 151552K (新生代回收量)
// 153600K - 13056K = 140544K (堆回收量)
// 差值 151552K - 140544K = 11008K (晋升到老年代的对象)
2
3
4
5
6
7
8
G1 日志解读:
[GC pause (G1 Evacuation Pause) (young), 0.0134567 secs]
[Parallel Time: 12.3 ms, GC Workers: 4]
[GC Worker Start: Min: 1234.5, Avg: 1234.5, Max: 1234.6]
[Ext Root Scanning: Min: 0.1, Avg: 0.2, Max: 0.3]
[Update RS: Min: 1.2, Avg: 1.5, Max: 1.8]
[Scan RS: Min: 0.5, Avg: 0.7, Max: 0.9]
[Code Root Scanning: 0.0]
[Object Copy: Min: 8.1, Avg: 8.5, Max: 9.0]
[Termination: Min: 0.0, Avg: 0.1, Max: 0.2]
[Code Root Fixup: 0.1 ms]
[Clear CT: 0.2 ms]
[Other: 1.0 ms]
[Eden: 24.0M(24.0M)->0.0B(24.0M)
Survivors: 4096.0K->4096.0K
Heap: 112.0M(256.0M)->95.0M(256.0M)]
[Times: user=0.05 sys=0.00, real=0.01 secs]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 3.6.2 调优核心指标
| 指标 | 含义 | 目标 |
|---|---|---|
| 吞吐量 | 用户代码运行时间占比 | > 95% |
| 停顿时间 | 单次 GC 暂停时间 | < 200ms(Web 应用) |
| GC 频率 | 单位时间 GC 次数 | Minor GC: 秒级;Full GC: 小时级以上 |
| 内存占用 | 堆使用情况 | 不超过 70% |
疑惑:吞吐量和停顿时间能不能同时做到最优?
论证:不能。这是一对矛盾的目标。降低停顿时间意味着更频繁的小规模 GC,但总的 GC 时间可能增加(吞吐量下降)。追求高吞吐量意味着减少 GC 次数,但单次 GC 时间会更长(停顿增加)。必须根据业务场景做取舍:
- 批处理任务:追求吞吐量 → Parallel
- Web 应用:追求低延迟 → G1 / ZGC
- 实时系统:追求极低延迟 → ZGC
# 3.6.3 常见调优策略
1. 合理设置堆大小:
# 原则:初始堆 = 最大堆,避免堆大小动态调整带来的开销
-Xms4g -Xmx4g
# 新生代大小
-Xmn2g
# 或通过比例设置
-XX:NewRatio=2 # 老年代:新生代 = 2:1
2
3
4
5
6
2. 选择合适的收集器:
# JDK 8 默认: Parallel
# JDK 9+ 默认: G1
# 低延迟场景
-XX:+UseZGC # JDK 11+
# 吞吐量优先
-XX:+UseParallelGC # JDK 8
2
3
4
5
6
3. 新生代调优:
# 增大新生代,减少 Minor GC 频率
-Xmn2g
# 调整 Eden/Survivor 比例
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
# 设置对象晋升阈值
-XX:MaxTenuringThreshold=15 # 默认15,对象经过15次Minor GC后晋升老年代
2
3
4
5
6
4. 避免 Full GC:
- 大对象(长字符串、大数组)直接进老年代,合理设置
-XX:PretenureSizeThreshold - 避免在短时间内创建大量临时对象(如循环中拼接字符串用
StringBuilder) - 检查是否有内存泄漏(对象持续累积不释放)
# 3.6.4 对象晋升机制详解
对象从新生代晋升到老年代的条件:
1. 年龄达到阈值
→ 每经过一次 Minor GC 且存活,年龄 +1
→ 年龄 >= MaxTenuringThreshold(默认15)时晋升
→ 年龄存储在对象头的 Mark Word 中(4bit,最大15)
2. 动态年龄判定
→ Survivor 中,某个年龄的对象大小总和 > Survivor 的一半
→ 大于等于该年龄的所有对象直接晋升
→ 不需要等到 MaxTenuringThreshold
3. 大对象直接进老年代
→ -XX:PretenureSizeThreshold(仅 Serial + ParNew 有效)
→ G1 中大于 Region 一半的对象直接进 Humongous
4. Minor GC 后 Survivor 放不下
→ 通过空间分配担保进入老年代
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
空间分配担保:
// Minor GC 前的检查流程(JDK 6u24+):
// 1. 老年代最大可用连续空间 > 新生代所有对象总空间?
// → 是:安全,直接 Minor GC
// → 否:检查 HandlePromotionFailure(JDK 6u24+ 始终为 true)
// 2. 老年代最大可用连续空间 > 历次晋升平均大小?
// → 是:冒险进行 Minor GC
// → 否:改为 Full GC
2
3
4
5
6
7
# 3.6.5 GC调优实战案例
案例1:频繁 Full GC
现象:每隔几分钟发生一次 Full GC,停顿 2-3 秒
排查:
1. jstat -gcutil PID 1000 → 发现老年代快速增长
2. jmap -histo PID → 发现大量 byte[] 和 String
3. MAT 分析 dump 文件 → 发现缓存未设上限
原因:本地缓存(HashMap)持续增长,对象不断晋升到老年代
解决:
1. 改用 LRU 缓存(如 Caffeine),设置最大容量
2. 考虑使用弱引用缓存(WeakHashMap)
3. 增大堆内存作为临时方案
2
3
4
5
6
7
8
9
10
11
案例2:Minor GC 耗时长
现象:Minor GC 耗时 100ms+,正常应 < 50ms
排查:
1. -XX:+PrintGCDetails 发现大量对象存活
2. 存活对象 > Survivor 容量 → 直接晋升老年代
原因:Survivor 太小,大量对象被迫晋升
解决:
1. 增大 Survivor:-XX:SurvivorRatio=4(Eden:S0:S1=4:1:1)
2. 增大新生代总大小
3. 增大晋升阈值:-XX:MaxTenuringThreshold=15
2
3
4
5
6
7
8
9
10
案例3:CMS 退化为 Serial Old
现象:偶尔出现 10 秒级长停顿
日志:[GC (CMS Initial Mark) ... Concurrent Mode Failure]
原因:CMS 并发清除时老年代空间不足
解决:
1. 降低触发阈值:-XX:CMSInitiatingOccupancyFraction=60
2. 增大老年代空间
3. 迁移到 G1
2
3
4
5
6
7
8
# 3.7 JVM监控工具
| 工具 | 用途 | 命令示例 |
|---|---|---|
| jps | 查看 Java 进程 | jps -l |
| jstat | GC 统计 | jstat -gcutil PID 1000 |
| jmap | 堆内存分析 | jmap -histo PID |
| jstack | 线程栈分析 | jstack PID |
| jinfo | JVM 参数 | jinfo -flags PID |
| jcmd | 综合诊断 | jcmd PID GC.heap_info |
# 常用命令
jstat -gcutil PID 1000 10 # 每秒输出 GC 统计,共10次
# 输出说明:
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 82.5 45.3 56.2 95.1 92.3 125 1.23 3 0.35 1.58
# S0/S1: Survivor使用率 E: Eden使用率 O: Old使用率
# YGC: Young GC次数 YGCT: Young GC总耗时 FGC: Full GC次数
# 导出堆转储
jmap -dump:live,format=b,file=heap.hprof PID
# 或
jcmd PID GC.heap_dump /path/heap.hprof
2
3
4
5
6
7
8
9
10
11
12
13
# 3.8 总结与核心要点
GC 算法的演进逻辑:
单线程全停顿 (Serial)
→ 多线程全停顿 (Parallel) // 用多核加速GC
→ 并发标记+清除 (CMS) // 减少STW阶段
→ 分区域+可控停顿 (G1) // 精细化管理
→ 染色指针+全并发 (ZGC) // 极致低延迟
2
3
4
5
核心设计思想:
- 分治:分代/分区是对"不同对象不同策略"的分治思想
- 空间换时间:复制算法牺牲空间换取零碎片;卡表(Card Table)牺牲空间换取跨代引用的快速定位;G1 的 RSet 牺牲空间换取独立回收
- 并发化:从全程 STW 到部分并发再到几乎完全并发,这是降低停顿的核心方向
- 可控性:从不可预测到可设定目标停顿时间,让 GC 行为可控可观测
- 读/写屏障:CMS 用写屏障维护增量更新,G1 用写屏障维护 SATB 和 RSet,ZGC 用读屏障实现并发转移
核心要点速查:
| 问题 | 答案 |
|---|---|
| JVM 用什么判断对象存活 | 可达性分析(从 GC Roots 出发) |
| 新生代用什么算法 | 复制算法(Eden + 2×Survivor) |
| 老年代用什么算法 | 标记-清除(CMS)或标记-整理(Parallel Old) |
| CMS 的核心问题 | 内存碎片 + Concurrent Mode Failure |
| G1 的核心优势 | 可控停顿 + Region 化管理 |
| ZGC 的核心技术 | 染色指针 + 读屏障 |
| 三色标记的漏标问题 | CMS 用增量更新,G1 用 SATB |
| Full GC 怎么避免 | 合理堆大小 + 控制对象晋升 + 避免内存泄漏 |
| 对象晋升条件 | 年龄达标 / 动态年龄判定 / 大对象 / Survivor 放不下 |
理解 GC 原理,是 Java 性能调优和生产环境问题排查的核心能力。