java 技术随笔

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:+UseG1GCJDK 9+ 默认开启;JDK 8 需显式指定
-XX:MaxGCPauseMillis=200G1 停顿时间目标(是软目标不是硬保证)
-XX:HeapDumpOnOutOfMemoryErrorOOM 时自动导出堆快照(配合 -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 的排查套路:

  1. 看异常信息:Java heap space(堆不够)、Metaspace(类太多)、Unable to create new native thread(线程耗尽)、GC overhead limit exceeded(GC 频繁但不释放);
  2. 用启动参数导出 hprof,用 jmap -dump 或 MAT/VisualVM 分析,找大对象和引用链(哪些对象占满堆、被谁持有);
  3. 常用命令:jps 找进程、jstat -gcutil <pid> 1000 看各代使用与 GC 频率、jstack 看线程栈(排查死锁/阻塞)、jmap -histo 看对象直方图;
  4. 区分是内存泄漏(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 不足),再对症下药——这比背参数表重要得多。

标签
JVM垃圾回收GC调优