JVM垃圾回收机制与常用调优参数详解

垃圾回收(Garbage Collection,简称 GC)是 JVM 最重要的特性之一,也是 Java 面试的必考点。理解 GC 原理不仅能帮你写出更高效的代码,还能在生产环境出现 OOM、CPU 飙高等问题时快速定位根因。

一、JVM 内存区域划分

1.1 运行时数据区

1
2
3
4
5
6
7
8
JVM 运行时数据区
├── 线程共享
│ ├── 堆(Heap)—— GC 主要工作区域
│ └── 方法区(Method Area)—— 元空间
└── 线程私有
├── 虚拟机栈(VM Stack)
├── 本地方法栈(Native Stack)
└── 程序计数器(PC Register)

1.2 堆内存分代模型

堆是 GC 的主战场,经典分代模型分为:

1
2
3
4
5
6
堆内存(Heap)
├── 年轻代(Young Generation)
│ ├── Eden 区(8/10)
│ ├── Survivor0(1/10)
│ └── Survivor1(1/10)
└── 老年代(Old Generation)
  • 年轻代:存放新创建的对象,垃圾回收频率高、速度快
  • 老年代:存放存活时间长的对象,回收频率低、耗时久
  • 默认比例:年轻代 : 老年代 = 1 : 2(可通过 -XX:NewRatio 调整)
  • Eden : S0 : S1 = 8 : 1 : 1

二、如何判断对象可以回收

2.1 引用计数法

  • 原理:每个对象维护一个引用计数器,被引用+1,引用失效-1
  • 缺点:无法解决循环引用问题(A 引用 B,B 引用 A,都为 0 但无法回收)
  • JVM 没有采用这种方式

2.2 可达性分析算法(JVM 采用)

从一组 GC Roots 根对象出发,向下搜索引用链:

  • 能到达的对象 → 存活
  • 不可达的对象 → 可回收

GC Roots 包括哪些?

  1. 虚拟机栈中引用的对象(局部变量)
  2. 方法区中静态变量引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI 引用的对象
  5. 被同步锁持有的对象

2.3 四种引用类型

引用类型 回收时机 用途
强引用(默认) 从不回收,宁可 OOM 普通对象引用
软引用 SoftReference 内存不足时回收 缓存、图片内存缓存
弱引用 WeakReference 下次 GC 一定回收 ThreadLocal、WeakHashMap
虚引用 PhantomReference 任何时候都可能回收 管理堆外内存

三、经典垃圾回收算法

3.1 标记-清除算法(Mark-Sweep)

两个阶段:标记所有存活对象 → 清除未标记对象

  • 优点:实现简单,不需要移动对象
  • 缺点:产生大量内存碎片,大对象可能找不到连续空间

3.2 复制算法(Copying)

原理:将内存分为两块,每次只用一块。回收时把存活对象复制到另一块,然后清空当前块。

  • 年轻代使用的算法(Eden → Survivor)
  • 优点:没有内存碎片,运行高效
  • 缺点:可用内存只有一半,空间利用率低

3.3 标记-整理算法(Mark-Compact)

原理:标记存活对象 → 将所有存活对象向一端移动 → 清理边界外内存

  • 老年代使用的算法
  • 优点:没有内存碎片
  • 缺点:需要移动对象,STW 时间长

3.4 分代收集算法

不是新算法,而是根据不同年代特点组合使用:

  • 年轻代:对象存活率低 → 复制算法(高效)
  • 老年代:对象存活率高、空间大 → 标记-清除 或 标记-整理

四、垃圾收集器

4.1 经典收集器概览

收集器 年代 算法 特点 JDK版本
Serial 年轻代 复制 单线程,STW 长 早期
ParNew 年轻代 复制 Serial 多线程版本 1.3+
Parallel Scavenge 年轻代 复制 吞吐量优先 1.4+
Serial Old 老年代 标记-整理 单线程 早期
Parallel Old 老年代 标记-整理 多线程吞吐量优先 1.6+
CMS 老年代 标记-清除 低延迟,并发收集 1.5 ~ 14
G1 全代 复制+标记整理 区域化,可预测停顿 1.7+,JDK9默认
ZGC 全代 着色指针 超低延迟(<1ms) JDK11+,JDK15正式
Shenandoah 全代 Brooks指针 超低延迟 JDK12+

4.2 CMS 收集器(Concurrent Mark Sweep)

以获取最短回收停顿时间为目标,适合互联网应用。

四个阶段

  1. 初始标记(STW):只标记 GC Roots 直接关联对象,很快
  2. 并发标记:与用户线程并发,遍历整个引用链
  3. 重新标记(STW):修正并发标记期间变动的对象
  4. 并发清除:与用户线程并发,清除垃圾对象

缺点

  • 标记-清除产生内存碎片
  • 并发阶段占用 CPU 资源
  • 无法处理”浮动垃圾”

4.3 G1 收集器(Garbage First)

JDK 9 及之后的默认收集器。

核心特点

  • Region 分区:把堆分成多个大小相等的独立区域
  • 可预测停顿:可以设置最大 GC 停顿时间(默认 200ms)
  • 优先回收价值高的区域:根据回收收益排序,优先回收垃圾多的 Region
  • Humongous 区域:大对象直接进入老年代专用区域

GC 模式

  • Young GC:年轻代 Region 回收
  • Mixed GC:年轻代 + 部分老年代 Region 一起回收
  • Full GC:极端情况触发(应尽量避免)

4.4 ZGC 收集器

JDK 15 正式发布的下一代低延迟收集器。

  • 目标:TB 级堆内存,停顿时间 < 1ms,且不随堆增大而增加
  • 核心技术:着色指针(Colored Pointers)、读屏障
  • 全并发:几乎所有阶段都和用户线程并发执行
  • 适用场景:大内存、低延迟要求的服务(如微服务网关、实时计算)

五、GC 日志解读

5.1 开启 GC 日志

1
2
3
4
5
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log

# JDK 9+(统一日志系统)
-Xlog:gc*:file=gc.log:time,uptime:filecount=10,filesize=100M

5.2 Young GC 日志示例

1
2
3
4
[GC (Allocation Failure) 
[PSYoungGen: 65536K->8192K(76288K)]
65536K->12288K(251392K),
0.0083420 secs]

解读:

  • PSYoungGen:年轻代使用 Parallel Scavenge 收集器
  • 65536K->8192K:回收前年轻代 64M → 回收后 8M
  • (76288K):年轻代总大小约 74.5M
  • 65536K->12288K:回收前整个堆 64M → 回收后 12M
  • 0.0083420 secs:本次 GC 耗时 8ms

六、常用 JVM 调优参数

6.1 堆内存设置

参数 说明 示例
-Xms 初始堆大小 -Xms2g
-Xmx 最大堆大小 -Xmx4g
-Xmn 年轻代大小 -Xmn1g
-XX:NewRatio 年轻代与老年代比例 -XX:NewRatio=2(1:2)
-XX:SurvivorRatio Eden 与 Survivor 比例 -XX:SurvivorRatio=8(8:1:1)
-XX:MaxTenuringThreshold 晋升老年代年龄阈值 -XX:MaxTenuringThreshold=15

生产环境建议 -Xms 和 -Xmx 设置为相同值,避免堆内存动态扩容带来的性能抖动。

6.2 收集器选择

1
2
3
4
5
6
7
8
9
10
11
12
# JDK 8 推荐:Parallel Scavenge + Parallel Old(默认)
-XX:+UseParallelGC -XX:+UseParallelOldGC

# JDK 8 低延迟:ParNew + CMS
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC

# JDK 9+ 默认:G1
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿时间

# JDK 11+ 超低延迟:ZGC
-XX:+UseZGC

6.3 元空间设置

1
2
-XX:MetaspaceSize=256m       # 元空间初始大小
-XX:MaxMetaspaceSize=512m # 元空间最大大小

JDK 8 用元空间(Metaspace)替代了永久代(PermGen),使用本地内存,默认无上限。

6.4 其他重要参数

1
2
3
4
5
-XX:+HeapDumpOnOutOfMemoryError    # OOM 时自动 dump 堆快照
-XX:HeapDumpPath=/logs/heapdump.hprof # dump 文件路径
-XX:+PrintCommandLineFlags # 打印 JVM 启动参数
-XX:+DisableExplicitGC # 禁用 System.gc()
-XX:+UseTLAB # 启用线程本地分配缓冲区(默认开启)

七、调优思路与常见问题

7.1 调优基本思路

  1. 先监控再调优:不要盲目调参,先通过 GC 日志、JConsole、VisualVM 等工具定位问题
  2. 目标明确:是追求吞吐量还是低延迟?两者不可兼得
  3. 从小到大:先调整堆大小,再选收集器,最后微调细节参数
  4. 压测验证:每次调整都要压测对比效果

7.2 常见 GC 问题排查

频繁 Young GC

  • 原因:年轻代太小,对象频繁 Eden 区满
  • 解决:适当增大 -Xmn 年轻代空间

Full GC 频繁

  • 可能原因:
    1. 老年代空间不足 → 增大堆内存
    2. 大对象过多 → 检查代码,避免一次性创建大对象
    3. 内存泄漏 → 用 MAT 分析堆 dump

CPU 100%

  1. top 找到高 CPU 进程
  2. top -Hp pid 找到高 CPU 线程
  3. jstack pid 查看线程栈
  4. 结合 GC 日志判断是否 GC 线程占用过高

7.3 内存泄漏常见场景

  • 静态集合类(static List/Map)只加不删
  • 各种连接(数据库、网络)未关闭
  • 监听器注册后未注销
  • ThreadLocal 使用完未 remove
  • 内部类持有外部类引用

调优没有银弹。大部分场景下,JDK 默认参数已经足够优秀。只有当业务确实遇到性能瓶颈时,才有针对性地进行调优。理解原理比记住参数更重要。