JVM 的垃圾回收是怎样的?常见的垃圾收集器有哪些?
一句话回答
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 ┘
└── 老年代:存活较久的对象、大对象
- 新对象在 Eden 分配,Eden 满了触发 Minor GC(也叫 Young GC)
- 把 Eden 和 From Survivor 中存活的对象复制到 To Survivor,年龄加 1,然后清空 Eden 和 From,两个 Survivor 交换角色
- 年龄达到阈值(
-XX:MaxTenuringThreshold,最大 15,因为年龄存在对象头里只占 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
排查思路
- 打开 GC 日志:JDK 8 用
-XX:+PrintGCDetails -Xloggc:gc.log,JDK 9 起改用统一日志-Xlog:gc*:file=gc.log - 用
jstat -gcutil <pid> 1000每秒观察各区的使用率、GC 次数和耗时,确认是 Minor GC 太频繁,还是 Full GC 太频繁 - 每次 Full GC 后老年代都降不下来,而且持续上涨,多半是内存泄漏:导出堆转储(
jmap -dump:live,format=b,file=heap.hprof <pid>,live会先触发一次 Full GC,线上要慎用),用 MAT 等工具找到占用最大的对象和它的引用链 - 老年代能回收下来,但很快又涨满,多半是对象过早晋升:新生代或 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 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。