Java GC 小知识点
深入理解 JVM 垃圾回收,从入门到调优
一、什么是 GC?
GC(Garbage Collection,垃圾回收) 是 Java 虚拟机(JVM)自动管理内存的一种机制。它负责自动检测并回收那些不再被程序使用的对象所占用的内存空间,从而避免内存泄漏和内存溢出。
Java 程序员不需要像 C/C++ 那样手动调用 free() 或 delete 来释放内存,这一切由 JVM 的 GC 模块代为完成。
GC 解决的三个核心问题
| 问题 | 说明 |
|---|---|
| 哪些内存需要回收? | 堆中的对象实例 |
| 什么时候回收? | 对象不再被引用时 |
| 如何回收? | 通过特定的垃圾收集算法 |
二、Java 堆内存结构
Java 堆(Heap)是 GC 管理的主要区域。根据对象存活周期的不同,堆被划分为以下几个区域:
┌─────────────────────────────────────────────────┐
│ Java Heap │
│ ┌───────────────┬──────────────┬──────────────┐ │
│ │ Eden │ Survivor │ Old Gen │ │
│ │ (Young) │ S0 | S1 │ (老年代) │ │
│ └───────────────┴──────────────┴──────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Metaspace(元空间) │ │
│ │ (JDK8+ 取代永久代 PermGen) │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
2.1 年轻代(Young Generation)
- Eden 区:大多数新创建的对象都分配在这里
- Survivor 区(S0 / S1):每次 Minor GC 后存活的对象会在两个 Survivor 区之间来回复制
- 默认比例:
Eden : S0 : S1 = 8 : 1 : 1
2.2 老年代(Old Generation)
- 存放经过多次 Minor GC 仍然存活的对象
- 某些大对象(如长数组、大字符串)直接在老年代分配
- 标记对象年龄阈值默认 15(可通过
-XX:MaxTenuringThreshold调整)
2.3 元空间(Metaspace)
- JDK 8 开始取代永久代(PermGen)
- 存储类的元数据(Class、Method 等)
- 区别:元空间使用本地内存(Native Memory),不再受 JVM 堆大小限制
- 相关参数:
-XX:MetaspaceSize、-XX:MaxMetaspaceSize
小知识:永久代在 JDK 8 中被彻底移除,原因是永久代大小难以预测,容易引发 OOM。
三、Minor GC vs Full GC
3.1 Minor GC(年轻代 GC)
| 特性 | 说明 |
|---|---|
| 触发时机 | Eden 区空间不足时 |
| 频率 | 高(非常频繁) |
| 速度 | 快(一般几十毫秒) |
| 影响范围 | 仅年轻代 |
| STW(Stop-The-World) | 存在,但时间很短 |
3.2 Full GC(老年代 GC)
| 特性 | 说明 |
|---|---|
| 触发时机 | 老年代空间不足、元空间不足、System.gc() 调用 |
| 频率 | 低(较少触发) |
| 速度 | 慢(可能几秒甚至几十秒) |
| 影响范围 | 整个堆(年轻代 + 老年代 + 元空间) |
| STW | 时间长,严重影响系统响应 |
触发 Full GC 的常见场景
- 老年代空间不足 —— 对象晋升过快或内存泄漏
- 元空间(Metaspace)达到上限
- 调用
System.gc()或Runtime.getRuntime().gc() - CMS 并发模式失败(Concurrent Mode Failure)
- GC 调优参数设置不当(如新生代过小)
核心原则:尽量减少 Full GC 的频率和停顿时间,因为 Full GC 的 STW 是应用性能的杀手。
四、常见 GC 算法
4.1 标记-清除(Mark-Sweep)
最基础的 GC 算法,分为两个阶段:
- 标记(Mark):从 GC Roots 出发,遍历所有可达对象并标记
- 清除(Sweep):遍历整个堆,回收未被标记的对象
优点:实现简单,无需对象移动
缺点:
- 产生大量内存碎片
- 分配大对象时可能触发 GC
标记前: [A][B][ ][C][ ][D]
标记后: [A][B][ ][C][ ][D] ← 只标记不移动
(灰色为存活对象,白色为垃圾)
清除后: [A][B][ ][C][ ][ ] ← 产生碎片
4.2 标记-整理(Mark-Compact)
在标记-清除的基础上增加了整理(Compact) 阶段:
- 标记:标记所有存活对象
- 整理:将存活对象向一端移动,消除碎片
优点:无内存碎片,内存分配效率高
缺点:移动对象需要 STW,耗时比标记-清除长
标记前: [A][B][ ][C][ ][D]
整理后: [A][B][C][D][ ][ ] ← 对象连续,无碎片
4.3 复制算法(Copying)
将内存分为两块(如 From 和 To),每次只使用一块:
- Eden 区存活对象复制到 Survivor 区
- 清空 Eden 区和已使用的 Survivor 区
- 交换两块 Survivor 区的角色
优点:效率高,无碎片,分配指针只需顺序移动
缺点:浪费部分内存(需要保留一块空区域)
From区: [A][B][C][D][ ] → [ ] ← 清空
↓
To区: [ ] → [A][B][C][D]
这是年轻代 GC 的默认算法。
4.4 分代收集算法(Generational Collection)
当前主流 JVM 都采用分代思想,不局限于某一种算法,而是针对不同代使用不同算法:
| 分代 | 特点 | 采用的算法 |
|---|---|---|
| 年轻代 | 对象存活率低,GC 频繁 | 复制算法(效率高) |
| 老年代 | 对象存活率高,GC 频率低 | 标记-清理 或 标记-整理 |
分代假设(Weak Generational Hypothesis):绝大多数对象都是"朝生夕死"的。
五、常见垃圾收集器
5.1 Serial 收集器
- 类型:单线程,串行
- 年轻代:复制算法
- 老年代:标记-整理
- 特点:简单高效,适合单核 CPU、客户端模式、内存较小的环境
- 参数:
-XX:+UseSerialGC
适用场景:几百 MB 内存的桌面应用、开发环境、教学演示。
5.2 Parallel 收集器(吞吐量优先)
- 类型:多线程,并行
- 年轻代:复制算法
- 老年代:标记-整理
- 特点:关注系统吞吐量,适合后台批处理任务
- 参数:
-XX:+UseParallelGC、-XX:ParallelGCThreads=N
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC 停顿时间)
5.3 CMS 收集器(低延迟优先)
- 全称:Concurrent Mark Sweep
- 算法:标记-清除
- 特点:并发收集、低停顿,但会产生碎片
- 参数:
-XX:+UseConcMarkSweepGC
工作流程:
① 初始标记(STW 极短) → ② 并发标记 → ③ 重新标记(STW 极短) → ④ 并发清除
CMS 在 JDK 9 被标记为废弃,JDK 14 正式移除。
5.4 G1 收集器(区域化分代)
- 全称:Garbage First
- JDK 7u4 引入,JDK 9+ 成为默认收集器
- 特点:将堆划分为多个 Region(1MB~32MB),优先回收垃圾最多的 Region
┌────┬────┬────┬────┬────┐
│ E │ S │ O │ H │ E │ E = Eden
├────┼────┼────┼────┼────┤ S = Survivor
│ O │ O │ S │ E │ E │ O = Old
├────┼────┼────┼────┼────┤ H = Humongous(大对象)
│ E │ E │ O │ O │ H │
└────┴────┴────┴────┴────┘
- 参数:
-XX:+UseG1GC、-XX:MaxGCPauseMillis=200
可预测的停顿时间模型 —— 你可以设置期望的最大 GC 停顿时间。
5.5 ZGC 收集器(超低延迟)
- JDK 11 实验性引入,JDK 15 正式转正
- 特点:并发、基于 Region、染色指针、读屏障
- 目标:STW 不超过 10ms,与堆大小无关(几 GB 到几 TB 都一样)
- 参数:
-XX:+UseZGC
| 对比维度 | G1 | ZGC |
|---|---|---|
| 算法 | 复制 + 标记-整理 | 并发引用处理 |
| STW 停顿 | ~200ms | <10ms |
| 最大堆 | ~64GB | 16TB |
| JDK 版本 | JDK 9+ 默认 | JDK 15+ 正式 |
5.6 收集器总结
| 收集器 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Serial | 单核、小内存 | 简单高效 | 停顿时间长 |
| Parallel | 高吞吐量场景 | 吞吐量高 | 停顿时间较长 |
| CMS | 低延迟场景 | 并发低停顿 | 碎片、CPU 敏感 |
| G1 | 多核大内存(默认) | 停顿可控 | 内存占用略高 |
| ZGC | 超大堆、极低延迟 | <10ms 停顿 | 吞吐量略低 |
六、GC 调优基础
6.1 核心目标
- 降低 GC 频率 —— 减少对象创建,提高对象复用
- 降低 GC 停顿时间 —— 选择低延迟收集器,调整停顿时间参数
- 避免 Full GC —— 合理设置各代大小
6.2 常用 JVM 参数
# 堆大小设置
-Xms4g # 初始堆大小
-Xmx4g # 最大堆大小
-Xmn2g # 年轻代大小
-XX:SurvivorRatio=8 # Eden:Survivor 比例
# 元空间设置
-XX:MetaspaceSize=256m # 元空间初始大小
-XX:MaxMetaspaceSize=256m # 元空间最大大小
# GC 收集器选择
-XX:+UseG1GC # 使用 G1 收集器
-XX:+UseZGC # 使用 ZGC 收集器
# GC 日志
-Xlog:gc* # JDK 9+ 统一 GC 日志
-XX:+PrintGCDetails # JDK 8 及之前
6.3 GC 调优基本原则
- 先排查问题,再调整参数 —— 不要盲目调优
- 优先选择 G1 或 ZGC —— 现代 JVM 默认就是最优选择
- 避免设置过小的堆 —— 堆过小会导致频繁 Full GC
- 关注 GC 日志 —— 它是调优的"眼睛"
- 一般无需手动调优 —— 大多数场景默认参数已经优化得很好
七、GC 监控代码示例
7.1 通过 JMX 监控 GC
import javax.management.*;
import java.lang.management.*;
public class GCMonitor {
public static void main(String[] args) throws Exception {
// 获取所有 GC MXBean
for (GarbageCollectorMXBean gcBean : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.println("收集器名称: " + gcBean.getName());
System.out.println("GC 次数: " + gcBean.getCollectionCount());
System.out.println("GC 耗时 (ms): " + gcBean.getCollectionTime());
System.out.println("内存池: " + Arrays.toString(gcBean.getMemoryPoolNames()));
System.out.println("----------------------------");
}
// 手动触发 GC(仅用于测试,生产环境不要用!)
System.gc();
Thread.sleep(1000);
System.out.println("\n=== 触发 GC 后 ===");
for (GarbageCollectorMXBean gcBean : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.println(gcBean.getName() + " 总次数: " + gcBean.getCollectionCount()
+ ", 总耗时: " + gcBean.getCollectionTime() + "ms");
}
}
}
7.2 监听 GC 通知(NotificationListener)
import javax.management.*;
import javax.management.openmbean.CompositeData;
import java.lang.management.*;
public class GCNotificationListener {
public static void main(String[] args) throws Exception {
for (GarbageCollectorMXBean gcBean : ManagementFactory.getGarbageCollectorMXBeans()) {
NotificationEmitter emitter = (NotificationEmitter) gcBean;
NotificationListener listener = (notification, handback) -> {
if (notification.getType().equals(
"com.sun.management.gc.notification")) {
CompositeData cd = (CompositeData) notification.getUserData();
String gcName = (String) cd.get("gcName");
String gcAction = (String) cd.get("gcAction");
long gcId = (Long) cd.get("gcId");
long duration = (Long) cd.get("gcDuration");
System.out.printf("[GC 通知] %s | 动作: %s | ID: %d | 耗时: %dms%n",
gcName, gcAction, gcId, duration);
}
};
emitter.addNotificationListener(listener, null, null);
}
// 模拟生产对象,触发 GC
for (int i = 0; i < 100; i++) {
byte[] bytes = new byte[1024 * 1024];
Thread.sleep(200);
}
Thread.sleep(5000); // 等待 GC 通知
}
}
7.3 使用 jstat 命令行监控
# 查看 GC 概况(每 2 秒打印一次,共 10 次)
jstat -gc <PID> 2000 10
# 输出字段说明
# S0C S1C S0U S1U EC EU OC OU MC MU YGC YGCT FGC FGCT
# 1024.0 1024.0 0.0 512.0 8192.0 4096.0 16384.0 8192.0 5120.0 2560.0 5 0.123 2 0.456
# └─ 各代容量 (C=Capacity) 和各代使用量 (U=Used)
# YGC/YGCT = 年轻代 GC 次数/耗时 | FGC/FGCT = Full GC 次数/耗时
# 查看 GC 详细信息(同 -XX:+PrintGCDetails)
jstat -gcutil <PID> 2000 5
# 输出: S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT
# 0.00 50.00 45.00 60.00 80.00 70.00 5 0.123 2 0.456 0 0.000
7.4 启用 GC 日志(生产环境必备)
JDK 8 及之前:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/to/gc-%t.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=10M
JDK 9+(统一日志格式):
-Xlog:gc*:file=/path/to/gc-%t.log:time,uptime:filecount=10,filesize=10M
推荐使用在线 GC 日志分析工具:
八、常见问题速查
Q1:如何判断一个对象可以回收?
可达性分析算法:从 GC Roots(栈帧中的本地变量、静态变量、JNI 引用等)出发,搜索路径上不可达的对象即为可回收对象。
Q2:System.gc() 一定会触发 Full GC 吗?
不一定。System.gc() 只是建议 JVM 执行 GC,JVM 可以忽略这个请求。可通过 -XX:+DisableExplicitGC 禁用显式 GC。
Q3:什么是 Stop-The-World(STW)?
GC 执行时,所有用户线程会被暂停,等待 GC 完成。STW 时间越短,对应用影响越小。
Q4:对象何时从年轻代晋升到老年代?
- 对象年龄达到
MaxTenuringThreshold(默认 15) - Survivor 区中相同年龄的对象总大小超过 Survivor 区一半
- 大对象直接在老年代分配
九、总结
| 知识点 | 一句话总结 |
|---|---|
| GC | JVM 自动管理内存的机制 |
| 堆结构 | 年轻代(Eden + Survivor)→ 老年代 + 元空间 |
| Minor vs Full | Minor GC 频繁快速,Full GC 低频慢速 |
| 核心算法 | 复制(年轻代)、标记-整理(老年代) |
| 常用收集器 | G1(默认)、ZGC(超低延迟) |
| 调优原则 | 先观察日志,再调整参数,默认参数已够好 |
最后的话:GC 调优不是银弹。绝大多数情况下,选择 G1 或 ZGC 保持默认参数就足够了。真正的优化应该从减少对象创建、避免内存泄漏开始。
本文涵盖 Java GC 的核心知识点,适合面试复习和日常开发参考。如有疏漏,欢迎指正。