第3章:垃圾收集器与内存分配策略
一、导读
3.1 本章学习目标
本章讲解垃圾收集的原理和实现:如何判断对象已死、垃圾收集算法、以及各种垃圾收集器的工作原理。
本章的核心学习目标包括:
- 掌握垃圾收集的判断算法:引用计数法、可达性分析
- 理解垃圾收集算法:标记-清除、标记-复制、标记-整理、分代收集
- 掌握主流垃圾收集器:Serial、Parallel、CMS、G1
- 理解对象分配策略:TLAB、优先在 Eden 区分配
3.2 为什么这一章如此重要
垃圾收集是 JVM 的核心功能。理解垃圾收集的原理,对于:
- 优化 GC 性能:选择合适的收集器
- 减少 GC 停顿:调优 GC 参数
- 避免内存泄漏:理解对象的生命周期
- 提升应用性能:减少 GC 对业务的影响
二、核心概念详解
2.1 如何判断对象已死
2.1.1 引用计数法
每个对象维护一个引用计数器,引用加 1,释放减 1。计数器为 0 时,对象已死。
优点:简单高效
缺点:无法解决循环引用
// 循环引用示例
public class ReferenceCounting {
public Object partner;
public static void main(String[] args) {
ReferenceCounting a = new ReferenceCounting();
ReferenceCounting b = new ReferenceCounting();
a.partner = b;
b.partner = a;
a = null;
b = null;
// a 和 b 的引用计数都不为 0,但已经无法访问
}
}2.1.2 可达性分析
从 GC Roots 开始搜索,搜索到的对象是存活的,未搜索到的对象是已死的。
GC Roots 包括:
- 虚拟机栈中引用的对象(局部变量)
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI 引用的对象
// 可达性分析示例
public class ReachabilityAnalysis {
public static void main(String[] args) {
Object obj = new Object(); // obj 是 GC Root
// obj 可达,不会被回收
obj = null; // obj 不可达,会被回收
}
}2.2 垃圾收集算法
2.2.1 标记-清除算法(Mark-Sweep)
过程:
标记所有存活对象
清除所有未标记对象
优点:简单
缺点:内存碎片
标记前:
┌─────┬─────┬─────┬─────┬─────┐
│ A │ B │ C │ D │ E │
│存活 │死亡 │存活 │死亡 │存活 │
└─────┴─────┴─────┴─────┴─────┘
清除后:
┌─────┬ ┬─────┬ ┬─────┐
│ A │ │ C │ │ E │
└─────┴─────┴─────┴─────┴─────┘
↑ 内存碎片2.2.2 标记-复制算法(Mark-Copy)
过程:
将内存分为两半
标记存活对象
将存活对象复制到另一半
清除原来的内存
优点:无内存碎片
缺点:浪费一半内存
复制前:
┌─────────────┬─────────────┐
│ From │ To │
│ A C E │ │
└─────────────┴─────────────┘
复制后:
┌─────────────┬─────────────┐
│ From │ To │
│ │ A C E │
└─────────────┴─────────────┘2.2.3 标记-整理算法(Mark-Compact)
过程:
标记所有存活对象
将存活对象向一端移动
清除边界外的内存
优点:无内存碎片
缺点:移动对象开销大
整理前:
┌─────┬ ┬─────┬ ┬─────┐
│ A │ │ C │ │ E │
└─────┴─────┴─────┴─────┴─────┘
整理后:
┌─────┬─────┬─────┬ │ │
│ A │ C │ E │ │ │
└─────┴─────┴─────┴─────┴─────┘2.2.4 分代收集算法
根据对象的存活周期,将堆分为新生代和老年代。
- 新生代:使用标记-复制算法(存活率高)
- 老年代:使用标记-清除或标记-整理算法(存活率低)
2.3 主流垃圾收集器
2.3.1 Serial 收集器
特点:单线程收集器
适用场景:客户端模式
┌─────────────────────────────────┐
│ 应用线程暂停 │
├─────────────────────────────────┤
│ Serial GC 线程(单线程) │
└─────────────────────────────────┘运行参数:
-XX:+UseSerialGC # 新生代使用 Serial,老年代使用 Serial Old2.3.2 Parallel Scavenge 收集器
特点:多线程收集器,吞吐量优先
适用场景:后台计算
┌─────────────────────────────────┐
│ 应用线程暂停 │
├─────────────────────────────────┤
│ GC 线程1 │ GC 线程2 │ ... │
└─────────────────────────────────┘运行参数:
-XX:+UseParallelGC # 新生代使用 Parallel Scavenge
-XX:+UseParallelOldGC # 老年代使用 Parallel Old
-XX:MaxGCPauseMillis=200 # 最大 GC 停顿时间
-XX:GCTimeRatio=19 # GC 时间占比2.3.3 CMS 收集器(Concurrent Mark Sweep)
特点:低延迟收集器,并发标记和清除
适用场景:Web 应用
过程:
初始标记(STW)
并发标记
重新标记(STW)
并发清除
┌─────────────────────────────────┐
│ 应用线程 │ 应用线程 │ 应用线程 │
├─────────────────────────────────┤
│ 初始标记 │ 并发标记 │ 重新标记 │ 并发清除
│ (STW) │ │ (STW) │
└─────────────────────────────────┘运行参数:
-XX:+UseConcMarkSweepGC # 使用 CMS 收集器
-XX:CMSInitiatingOccupancyFraction=75 # 老年代使用率达到 75% 时触发 CMS
-XX:+UseCMSInitiatingOccupancyOnly # 只使用设定的阈值缺点:
- CPU 敏感
- 无法处理浮动垃圾
- 内存碎片
2.3.4 G1 收集器(Garbage-First)
特点:面向全堆,可预测的停顿时间
适用场景:大堆、低延迟
核心概念:
- Region:将堆划分为多个大小相等的 Region
- Eden、Survivor、Old、Humongous:不同类型的 Region
┌─────┬─────┬─────┬─────┬─────┐
│ Eden│ S0 │ Old │ Old │ H │
├─────┼─────┼─────┼─────┼─────┤
│ Old │ Old │ H │ Eden│ S1 │
└─────┴─────┴─────┴─────┴─────┘过程:
初始标记(STW)
并发标记
最终标记(STW)
筛选回收(STW)
运行参数:
-XX:+UseG1GC # 使用 G1 收集器
-XX:MaxGCPauseMillis=200 # 最大 GC 停顿时间
-XX:G1HeapRegionSize=4m # Region 大小
-XX:InitiatingHeapOccupancyPercent=45 # 堆使用率达到 45% 时触发 G12.4 对象分配策略
2.4.1 TLAB(Thread Local Allocation Buffer)
每个线程在 Eden 区分配一个私有的内存缓冲区,减少锁竞争。
┌─────────────────────────────────┐
│ Eden 区 │
├──────────┬──────────┬───────────┤
│ TLAB 1 │ TLAB 2 │ TLAB 3 │
│ (线程1) │ (线程2) │ (线程3) │
└──────────┴──────────┴───────────┘2.4.2 优先在 Eden 区分配
对象优先在 Eden 区分配,Minor GC 后存活的对象进入 Survivor 区,经过多次 Minor GC 后进入老年代。
Eden → Survivor → Old2.4.3 大对象直接进入老年代
大对象(如长数组、长字符串)直接进入老年代,避免在 Eden 和 Survivor 之间频繁复制。
-XX:PretenureSizeThreshold=1m # 大于 1MB 的对象直接进入老年代2.4.4 长期存活的对象进入老年代
对象在 Survivor 区每经过一次 Minor GC,年龄加 1。当年龄达到阈值(默认 15)时,进入老年代。
-XX:MaxTenuringThreshold=15 # 最大年龄阈值三、重要知识点
3.1 GC 日志分析
# 开启 GC 日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:gc.log
# 使用 GCViewer 分析 GC 日志
# 下载 GCViewer,打开 gc.log 文件3.2 垃圾收集器选择
| 收集器 | 新生代 | 老年代 | 特点 | 适用场景 |
|---|---|---|---|---|
| Serial | Serial | Serial Old | 单线程 | 客户端模式 |
| Parallel | Parallel Scavenge | Parallel Old | 吞吐量优先 | 后台计算 |
| CMS | ParNew | CMS | 低延迟 | Web 应用 |
| G1 | G1 | G1 | 可预测停顿 | 大堆、低延迟 |
3.3 GC 调优原则
减少 Minor GC 频率:增大新生代
减少 Full GC 频率:增大老年代、调整 CMS/G1 触发阈值
减少 GC 停顿时间:使用 G1、调整停顿时间目标
提高吞吐量:使用 Parallel 收集器
四、常见误区与难点
4.1 误区一:"GC 越少越好"
GC 是必要的,过多的 GC 会影响性能,但完全没有 GC 会导致内存溢出。
4.2 误区二:"G1 总是比 CMS 好"
G1 和 CMS 各有优缺点,需要根据实际场景选择。G1 适合大堆,CMS 适合小堆。
4.3 难点:理解 G1 的 Region 机制
G1 将堆划分为多个 Region,每个 Region 可以是 Eden、Survivor、Old 或 Humongous。理解 Region 的分配和回收机制是掌握 G1 的关键。
4.4 难点:理解并发标记和重新标记
CMS 和 G1 都使用并发标记,但需要重新标记来修正并发标记期间的变动。理解 STW(Stop-The-World)的时机和影响对于调优至关重要。
五、实践应用
5.1 查看 GC 信息
# 查看 GC 情况
jstat -gcutil <pid> 1000
# 查看 GC 原因
jstat -gccause <pid>
# 查看 GC 日志
tail -f gc.log5.2 调优 GC 参数
# G1 收集器调优
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=4m
-XX:InitiatingHeapOccupancyPercent=45
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=4
# CMS 收集器调优
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75
-XX:+UseCMSInitiatingOccupancyOnly
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=45.3 分析 GC 日志
# 使用 GCViewer 分析
# 下载 GCViewer:https://github.com/chewiebug/GCViewer
# 使用 GCEasy 在线分析
# https://gceasy.io/六、本章小结
第3章讲解了垃圾收集器与内存分配策略:
引用计数法:无法解决循环引用
可达性分析:从 GC Roots 搜索存活对象
垃圾收集算法:标记-清除、标记-复制、标记-整理、分代收集
Serial 收集器:单线程,客户端模式
Parallel 收集器:多线程,吞吐量优先
CMS 收集器:低延迟,并发标记和清除
G1 收集器:面向全堆,可预测的停顿时间
对象分配策略:TLAB、优先在 Eden 区分配、大对象直接进入老年代
后续章节将讲解虚拟机性能监控与调优工具。