JVM 的垃圾回收是怎样的?常见的垃圾收集器有哪些?

深入高频原理约 9 分钟读完

一句话回答

JVM 用可达性分析判断对象是否存活:从 GC Roots 出发沿着引用访问不到的对象就是垃圾。HotSpot 的经典收集器按分代管理堆:新生代的对象大多很快死亡,用标记-复制;老年代的对象存活率高,用标记-清除或标记-整理。常见收集器中,JDK 8 默认 Parallel,JDK 9 起默认 G1;CMS 已在 JDK 14 移除;ZGC、Shenandoah 追求极短的停顿,适合大堆、低延迟的场景。

详细解析

哪些对象是垃圾

Java 不用引用计数(它处理不了循环引用),而是做可达性分析。常见的 GC Roots:

  • 各线程栈帧中的局部变量和参数所引用的对象
  • 类的静态变量、类的常量池中引用的对象
  • native 方法通过 JNI 引用的对象
  • 被 synchronized 持有的锁对象,以及 JVM 内部的引用(如基本类型对应的 Class 对象、系统类加载器)

思路和 JS 的垃圾回收 一样:判断的是"从根出发是否可达",而不是"有没有被引用"。

分代和对象晋升

文本
堆
├── 新生代
│   ├── Eden:新对象在这里分配
│   ├── Survivor 0 ┐ 两块一样大,每次 Minor GC 后交换角色(From / To)
│   └── Survivor 1 ┘
└── 老年代:存活较久的对象、大对象
  1. 新对象在 Eden 分配,Eden 满了触发 Minor GC(也叫 Young GC)
  2. 把 Eden 和 From Survivor 中存活的对象复制到 To Survivor,年龄加 1,然后清空 Eden 和 From,两个 Survivor 交换角色
  3. 年龄达到阈值(-XX:MaxTenuringThreshold,最大 15,因为年龄存在对象头里只占 4 位)的对象晋升到老年代
  4. To Survivor 放不下的对象会直接进入老年代;Survivor 占用过高时,JVM 还会动态降低晋升的年龄阈值;大对象可能直接在老年代分配(G1 中是专门的 Humongous 区域)

经典配置下 Eden 和两个 Survivor 按 8:1:1 划分(-XX:SurvivorRatio=8),复制算法只浪费 10% 的新生代空间;Parallel 收集器默认开启自适应调节,实际比例会动态变化。

三种基础算法

算法 做法 优点 缺点 典型使用
标记-清除 标记存活对象,回收其余对象 不移动对象,实现简单 产生内存碎片 CMS
标记-复制 把存活对象复制到另一块空间,原空间整体清空 没有碎片,存活对象少时很快 要预留空间;存活对象多时复制成本高 新生代
标记-整理 标记后把存活对象移到一端,清理边界以外的空间 没有碎片,不浪费空间 移动对象、更新引用的成本高 老年代(Serial Old、Parallel Old)

常见收集器

收集器 区域 特点 版本
Serial / Serial Old 新生代 / 老年代 单线程,回收时暂停所有用户线程,额外开销最小,适合小堆、单核环境 非服务器级机器上的默认收集器
Parallel Scavenge / Parallel Old 新生代 / 老年代 多线程并行回收,吞吐量优先 JDK 8 默认
CMS 老年代 标记-清除,大部分标记和清除工作与用户线程并发,停顿短;有碎片和浮动垃圾,占用 CPU 较多 JDK 9 废弃,JDK 14 移除
G1 整个堆 把堆分成大小相等的 Region,优先回收垃圾最多的 Region,可以设置停顿目标;整体看是标记-整理,Region 之间是复制 JDK 9 起默认
ZGC 整个堆 标记、移动对象几乎都与用户线程并发,停顿极短,而且不随堆变大而明显增加 JDK 15 起可用于生产,JDK 21 加入分代模式
Shenandoah 整个堆 目标和 ZGC 相似,同样能并发整理 JDK 15 起可用于生产,Oracle JDK 不包含

表中的"默认"指服务器级机器。如果 JVM 判断当前机器不是"服务器级"的(CPU 少于 2 个或内存不到约 2GB,比如容器只分配了 1 个 CPU),不管 JDK 8 还是 JDK 9 及以后,默认都会改用 Serial。

Minor GC 和 Full GC

  • Minor GC:Eden 空间不足时触发,只回收新生代。大多数对象在这一步就被回收了,停顿通常较短
  • Full GC:回收整个堆和方法区,停顿长,要尽量避免。常见的触发原因:老年代放不下晋升的对象或大对象;元空间不足,需要卸载类;代码调用了 System.gc();CMS 并发回收跟不上分配速度(Concurrent Mode Failure),或 G1 来不及回收、没有空闲 Region 可用时退化成 Full GC

排查思路

  1. 打开 GC 日志:JDK 8 用 -XX:+PrintGCDetails -Xloggc:gc.log,JDK 9 起改用统一日志 -Xlog:gc*:file=gc.log
  2. 用 jstat -gcutil <pid> 1000 每秒观察各区的使用率、GC 次数和耗时,确认是 Minor GC 太频繁,还是 Full GC 太频繁
  3. 每次 Full GC 后老年代都降不下来,而且持续上涨,多半是内存泄漏:导出堆转储(jmap -dump:live,format=b,file=heap.hprof <pid>,live 会先触发一次 Full GC,线上要慎用),用 MAT 等工具找到占用最大的对象和它的引用链
  4. 老年代能回收下来,但很快又涨满,多半是对象过早晋升:新生代或 Survivor 太小,或者有大量存活时间不长不短的对象,可以调整新生代大小或者优化代码

面试官可能追问

只回收新生代时,老年代的对象引用了新生代的对象怎么办?

不能为了这些跨代引用去扫描整个老年代。HotSpot 用卡表记录:把老年代按固定大小划分成许多卡页,某个卡页里的对象引用了新生代对象时,通过写屏障把这张卡标记为"脏"。Minor GC 时只需把脏卡里的对象也当作 GC Roots 扫描。G1 中每个 Region 还有一个记忆集(Remembered Set),记录哪些地方引用了这个 Region 里的对象。

G1 为什么能控制停顿时间?和 CMS 有什么区别?

G1 把堆分成许多大小相等的 Region,每个 Region 可以充当 Eden、Survivor 或老年代。它会估算每个 Region 的回收价值(能回收多少空间、要花多少时间),在停顿目标(-XX:MaxGCPauseMillis,默认 200 毫秒)内优先回收收益最高的 Region,这也是 Garbage First 名字的由来。和 CMS 相比,G1 通过把存活对象复制到空闲 Region 来回收,不会产生大量碎片;CMS 用标记-清除,碎片多到分配不下时只能靠 Full GC 整理。

ZGC 的停顿为什么这么短?

大多数收集器移动对象时必须暂停用户线程,因为要更新所有指向它的引用。ZGC 用染色指针(在指针里记录状态位)和读屏障实现了并发移动:用户线程读取一个引用时,如果发现对象已经被移走,读屏障会顺手把引用修正为新地址。这样标记、移动、修正引用几乎都和用户线程并发进行,只剩几次很短的停顿。代价是读屏障会占用一部分吞吐量。

强引用、软引用、弱引用、虚引用有什么区别?
  • 强引用:普通的 Object o = new Object(),只要还可达就不会被回收
  • 软引用(SoftReference):只被软引用指向的对象,JVM 会根据内存压力决定是否回收,并保证在抛出 OOM 之前回收,适合做缓存
  • 弱引用(WeakReference):只被弱引用指向的对象,GC 发现后就会回收,不管内存是否充足,例如 WeakHashMap 的 key、ThreadLocalMap 的 key(见 ThreadLocal)
  • 虚引用(PhantomReference):不能通过它拿到对象,只能配合引用队列在对象被回收后收到通知,用来做资源清理

易错点

  • Serial、Parallel、G1 等收集器的 Minor GC 也会暂停所有用户线程(Stop The World),只是新生代存活对象少,停顿通常很短;CMS、G1、ZGC 这些并发收集器也都有短暂的全停顿阶段,并发不等于完全没有停顿
  • System.gc() 只是建议,JVM 可以不执行。用 -XX:+DisableExplicitGC 禁用它要小心:NIO 分配直接内存不够时会调用 System.gc() 来回收已经没有引用的 DirectByteBuffer,禁用后可能抛出 OutOfMemoryError: Direct buffer memory
  • "Major GC"的说法并不统一,有人指老年代 GC,有人指 Full GC,回答时最好说清楚
  • JDK 9 起默认收集器是 G1,不要再说默认是 Parallel;CMS 在 JDK 14 已被移除,JDK 17、21 都用不了

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。