JVM 垃圾回收详解:回收算法、常见收集器与调优参数
GC(Garbage Collection,垃圾回收)是 JVM 面试的“半壁江山”:怎么判断对象该回收、用哪些算法、常见收集器有什么区别、生产上怎么调优、OOM 怎么排查。本文按“判定 → 算法 → 分代 → 收集器 → 调优命令”的顺序,一次性打通 JVM 垃圾回收的知识线。
一、怎么判断对象是“垃圾”
1. 引用计数法(已被主流 JVM 抛弃):给对象加计数器,被引用 +1、失效 -1,为 0 即可回收。缺点是无法解决循环引用(A 引用 B、B 引用 A,且都不被外部引用,计数永远不为 0)。
2. 可达性分析(主流方案):从一组 GC Roots 出发向下遍历,形成引用链;没有被任何根链到的对象即为可回收对象。GC Roots 主要包括:
- 虚拟机栈(栈帧中的本地变量表)引用的对象;
- 方法区中静态属性、常量引用的对象;
- 本地方法栈中 JNI(Native)引用的对象;
- 被 synchronized 持有的对象、活跃线程等(视实现而定)。
二、四种引用类型(配合判断机制)
| 类型 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用(Strong) | 只要可达就绝不回收 | new 出来的普通对象 |
| 软引用(SoftReference) | 内存不足(OOM 前)才回收 | 内存敏感的大缓存,如图片缓存 |
| 弱引用(WeakReference) | 下一次 GC 即回收(无论内存够不够) | WeakHashMap、ThreadLocal 的 Entry |
| 虚引用(PhantomReference) | 回收后通知队列,拿不到对象 | 对象回收跟踪、堆外内存释放 |
三、垃圾回收三大基础算法
1. 标记-清除(Mark-Sweep):先标记不可达对象,再统一清除。优点是简单;缺点是产生大量不连续内存碎片,后续大对象分配可能因找不到连续空间而提前触发 Full GC。
2. 复制算法(Copying):把内存等分成两块,只使用一块;GC 时把存活对象复制到另一块并整体清空当前块。无碎片、效率高;缺点是可用内存减半。所以新生代采用 Eden : Survivor0 : Survivor1 = 8 : 1 : 1 的非对称分配——只用 Eden + 一个 Survivor,另一块 Survivor 作为复制目的地,实际只浪费 10%。
3. 标记-整理(Mark-Compact):标记后把所有存活对象向一端移动并清掉边界外的内存。无碎片、不浪费空间,适合老年代这种“存活率高”的区域,代价是移动对象的开销。
分代收集理论:根据对象存活特征,新生代(朝生夕灭,用复制算法)与老年代(存活率高,用标记-清除或标记-整理)配合,各取所长。
四、常见垃圾收集器
| 收集器 | 作用代 | 算法 | 特点 |
|---|---|---|---|
| Serial / Serial Old | 新生代 / 老年代 | 复制 / 标记-整理 | 单线程,暂停所有线程(STW),客户端模式、小内存适用 |
| ParNew | 新生代 | 复制 | Serial 的多线程版,JDK 8 中常与 CMS 搭档 |
| Parallel Scavenge / Parallel Old | 新生代 / 老年代 | 复制 / 标记-整理 | 吞吐量优先,JDK 8 默认收集器组合 |
| CMS | 老年代 | 标记-清除 | 并发收集、停顿低,JDK 9 起废弃、JDK 14 移除 |
| G1 | 整堆(分 Region) | 复制 + 标记-整理 | JDK 9 起默认,可预测停顿、兼顾吞吐 |
| ZGC | 整堆 | 染色指针 + 读屏障 | JDK 15 转正,超大堆、亚毫秒级停顿 |
CMS 的四个阶段:初始标记(STW,很快)→ 并发标记 → 重新标记(STW)→ 并发清除。它追求低停顿,但用标记-清除会产生碎片,且并发阶段占用 CPU 与业务争抢,“并发失败”(Concurrent Mode Failure)时退化回 Serial Old 全停顿。
G1 的核心思想:把堆划分为一个个大小相等的 Region(逻辑上仍分新生代/老年代),优先回收“垃圾最多的 Region”(Garbage First),用 Remembered Set 记录跨 Region 引用,通过 -XX:MaxGCPauseMillis(默认 200ms)控制停顿目标。适合大堆、需要可控停顿的服务器。
五、常用调优参数速查
| 参数 | 作用 |
|---|---|
| -Xms / -Xmx | 初始 / 最大堆内存,生产上建议设为相同值,避免运行期扩容抖动 |
| -Xmn | 新生代大小(G1 下用 -XX:NewRatio 控制比例) |
| -XX:MaxMetaspaceSize | 元空间上限,防止加载类过多撑爆 |
| -XX:+UseG1GC | JDK 9+ 默认开启;JDK 8 需显式指定 |
| -XX:MaxGCPauseMillis=200 | G1 停顿时间目标(是软目标不是硬保证) |
| -XX:HeapDumpOnOutOfMemoryError | OOM 时自动导出堆快照(配合 -XX:HeapDumpPath 指定路径) |
| -XX:+PrintGCDetails / -Xlog:gc* | 打印 GC 日志(JDK 8 / JDK 9+ 的两种写法) |
六、GC 日志怎么读(一次 OOM 排查示例)
# 典型启动参数(生产参考)
java -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/logs/heap.hprof \
-Xlog:gc*:file=/app/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=50m \
-jar app.jar
# GC 日志片段(JDK 8 CMS 风格)
[GC (Allocation Failure) [PSYoungGen: 51200K->8192K(61440K)] 51200K->9876K(199680K), 0.0123s]
# 含义:新生代从 51.2MB 降到 8MB,堆总占用从 51.2MB 到 9.8MB,耗时 12.3ms
OOM 的排查套路:
- 看异常信息:
Java heap space(堆不够)、Metaspace(类太多)、Unable to create new native thread(线程耗尽)、GC overhead limit exceeded(GC 频繁但不释放); - 用启动参数导出 hprof,用 jmap -dump 或 MAT/VisualVM 分析,找大对象和引用链(哪些对象占满堆、被谁持有);
- 常用命令:
jps找进程、jstat -gcutil <pid> 1000看各代使用与 GC 频率、jstack看线程栈(排查死锁/阻塞)、jmap -histo看对象直方图; - 区分是内存泄漏(GC 后占用持续不降,定位代码持有)还是内存不足(业务峰值确实需要更多堆,调大 -Xmx 并压测验证)。
七、常见面试追问速答
- 什么时候触发 Full GC? 老年代空间不足、元空间不足、CMS 并发失败、System.gc()(默认也是 Full GC)等。
- Minor GC / Major GC / Full GC 区别? Minor GC 只回收新生代(频繁、快);Major GC 通常指老年代;Full GC 回收整堆+元空间(最慢,线上要避免频繁触发)。
- 为什么 JDK 8 用 Parallel 而 JDK 9+ 用 G1? 追求吞吐用 Parallel;需要可控停顿、堆较大(4G+)用 G1。
- 对象什么时候进入老年代? 躲过 15 次(-XX:MaxTenuringThreshold)Minor GC;大对象直接进老年代(-XX:PretenureSizeThreshold);Survivor 装不下时晋升。
- 如何减少 Full GC? 合理设置堆与新生代比例、避免大对象、及时释放静态集合引用、检查慢 SQL 缓存占用的堆外资源等。
GC 调优本质是用“可接受的停顿”换“可用的吞吐”。别一上来就堆参数,先通过 GC 日志和堆分析找到真正的问题(泄漏 or 不足),再对症下药——这比背参数表重要得多。