JVM GC 详解:面试官问的比我踩过的坑还多
说实话,JVM GC 这块我被问麻了。每次面试都能碰到相关问题,而且越来越刁钻。今天把 GC 彻底搞明白,写给自己,也写给正在备战秋招的你。
GC 到底是什么?
GC 的全称是 Garbage Collection,垃圾回收。听起来高大上,本质就是 JVM 自动清理"死了"的对象,释放内存。
为什么要自动?因为手动释放太容易出 bug。忘记 free 内存泄漏,free 两次直接崩溃。C++ 程序员天天和这个较劲,Java 直接帮你解决了。
GC 的两个核心指标:
| 指标 | 含义 | 你要关心吗 |
|---|---|---|
| 吞吐量 | CPU 实际执行业务代码的时间占比 | 高并发场景必须关注 |
| 延迟(STW) | GC 时业务线程暂停的时间 | 低延迟场景必须关注(金融、游戏) |
这两个指标是天平的两端,没有完美的方案,只有取舍。
什么样的对象算"垃圾"?
GC 之前得先知道哪些对象该回收。判断方法有两种。
第一种:引用计数法。
每个对象有个引用计数器,有地方引用就+1,引用失效就-1。计数器为0就是垃圾。
听起来没问题,但有个致命缺陷:循环引用。
对象 A 引用对象 B,对象 B 引用对象 A,但他俩都没被其他对象引用。计数器都是1,但已经是垃圾了。这种对象永远不会被回收。
JVM 没用这个方案。
第二种:可达性分析。
这个是 JVM 真正用的。
首先定义一批"gc root"对象,作为 GC 的起点。然后从这些根对象出发,顺着引用链往下走。能被走到的对象就是活的,走不到的就是垃圾。
gc root 包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即 native 方法)引用的对象
记住这个图:
gc roots
│
├── 栈帧中的局部变量
│ └── 指向堆中对象的引用
│
├── 方法区静态属性
│ └── 指向堆中对象的引用
│
└── 本地方法栈
└── native 方法中的引用
一个对象是不是垃圾,就看他能不能被 gc roots 到达。
分代回收:JVM 的内存管理哲学
JVM 不是统一对待所有对象的,而是根据对象的"年龄"分而治之。这就是分代回收理论。
堆内存的划分:
┌─────────────────────────────────────┐
│ 堆 │
├─────────────────┬───────────────────┤
│ Young Gen │ Old Gen │
│ (年轻代) │ (老年代) │
├─────┬─────┬─────┼───────────────────┤
│Eden │S0 │S1 │ │
│ │Surv │Surv │ │
└─────┴─────┴─────┴───────────────────┘
年轻代:新创建的对象在这里。分 Eden 区 + 两个 Survivor 区(S0、S1)。
老年代:经历了多次 GC 还存活的对象会挪到这里。大对象直接进老年代。
分代的理由是什么?
统计学告诉我们:大多数对象朝生夕死。
你 new 出来一个对象,可能下一秒就不用了。只有少数对象会活很久。如果每次 GC 都扫描全部对象,效率太低。
分代之后,年轻代 GC 频繁但快(对象多死得也快),老年代 GC 少但慢(对象少但活得久)。各取所需,效率最大化。
我踩过的坑:对象直接进老年代。
有一次线上 OOM,检查 dump 发现老年代占了 95%。排查半天,发现代码里有个逻辑:一次性从数据库加载了 50 万条记录,new 了一个巨大的 List 对象。
这个 List 直接触发了大对象直接进老年代的规则。结果年轻代根本没压力,老年代 GC 来不及回收,直接崩了。
教训:处理大集合数据要分批,别一口气全加载。
三大 GC 算法
1. 标记-清除(Mark-Sweep)
最基础的算法,分两步:
- 标记:遍历所有对象,把活着的对象标记
- 清除:遍历所有对象,把没标记的干掉
问题:
- 效率不高。两次遍历,对象多的时候很慢
- 产生内存碎片。存活对象之间有空洞,下一个大对象可能放不下
2. 复制(Copying)
把内存分成两块,每次只用一块。GC 时把活着的对象复制到另一块,然后整体清空。
优点: 没有内存碎片,效率高。
缺点: 浪费一半内存。
JVM 用的是改良版:把年轻代分成一个 Eden + 两个 Survivor。默认比例是 8:1:1。
每次 GC 把 Eden + From Survivor 活着的对象复制到 To Survivor,然后清空其他区域。这样只浪费 10% 的空间。
Survivor 区为什么是两个?
为了解决内存碎片和分配效率的问题。From 和 To 角色每次 GC 互换。
3. 标记-整理(Mark-Compact)
老年代一般用这个。
- 标记:和之前一样,把活着的对象标记
- 整理:让存活对象向一端移动,然后清理边界外的内存
优点: 没有内存碎片。
缺点: 整理需要移动对象,耗时。
三种算法对比:
| 算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 标记-清除 | 不需要移动对象 | 产生内存碎片 | 老年代 |
| 复制 | 无碎片,效率高 | 浪费50%空间 | 年轻代 |
| 标记-整理 | 无碎片 | 移动对象耗时 | 老年代 |
常用垃圾回收器
Serial:最老最简单
单线程工作,GC 时必须 Stop The World(STW)。
年轻代用复制,老年代用标记-整理。
适合:单核机器,或者对延迟不敏感的批处理任务。
Parallel(吞吐量优先)
多个线程并行 GC,目标是最大化吞吐量(业务代码执行时间 / 总时间)。
JDK 8 默认的年轻代收集器就是这个。
特点:吞吐量高,但 STW 时间长。
CMS(Concurrent Mark Sweep)
这是第一个真正并发的收集器。GC 线程和业务线程可以一起跑。
工作流程:
- 初始标记:STW,标记 gc roots 直接引用的对象(很快)
- 并发标记:业务线程跑,同时 GC 线程顺着引用链标记
- 重新标记:STW,修正并发标记期间产生的变动
- 并发清除:和业务线程一起跑,清除垃圾
优点:延迟低,大部分时间并发。
缺点:
- CPU 敏感,多核才有效
- 产生内存碎片
- 浮动垃圾(并发标记期间新产生的垃圾只能等下次 GC)
JDK 14 已经废弃了。
G1(Garbage First)
现代 JVM 的主力收集器。JDK 9+ 默认。
核心思想:把堆分成多个大小相等的 Region,每个 Region 可以是 Eden、Survivor 或 Old。
GC 时根据"回收价值"优先回收垃圾最多的 Region。这就是"Garbage First"的含义。
工作流程:
- 初始标记:STW,标记 gc roots 直接引用的对象
- 并发标记:业务线程跑,GC 线程标记存活对象
- 最终标记:STW,处理并发标记期间的变动
- 筛选回收:STW,根据回收价值和成本选择Region回收
优点:
- 可以设置期望的停顿时间(
-XX:MaxGCPauseMillis=200) - 整体看是并发收集,延迟可控
- 不产生内存碎片
适合:大内存、低延迟场景。4GB+ 堆内存推荐用 G1。
ZGC
革命性的收集器,JDK 11 引入了。
核心特性:STW 时间不会超过 10ms,且停顿时间不随堆大小增加而增加。
原理是用了读屏障和着色指针,实现了并发标记、并发压缩,整个过程几乎不需要 STW。
缺点:吞吐量比 G1 低一些,延迟极低但不是零。
适合:超大内存(TB级别)、要求极低延迟的场景。
各收集器对比:
| 收集器 | 线程 | STW | 适用场景 |
|---|---|---|---|
| Serial | 单线程 | 长 | 单核、批处理 |
| Parallel | 多线程 | 长 | 吞吐量优先 |
| CMS | 并发 | 短 | 低延迟(已废弃) |
| G1 | 并发 | 可控 | 大内存、低延迟 |
| ZGC | 并发 | <10ms | 超大内存、极低延迟 |
面试高频问题
Q1:为什么新生代用复制算法,老年代用标记-整理?
新生代对象存活率低,复制算法浪费的空间少(只用10%的 Survivor 区)。老年代对象存活率高,复制代价太大,用标记-整理更划算。
Q2:对象在 GC 后一定被回收吗?
不一定。可达性分析中,如果对象重写了 finalize() 方法且没被调用过,对象会被放入一个 F-Queue。GC 线程会触发一次 finalize() 调用,对象可以在这时自救(把自己的引用赋值给某个类变量)。但 finalize() 只执行一次,第二次 GC 就没了。
Q3:Minor GC 和 Full GC 有什么区别?
Minor GC 只清理年轻代,频率高但快。Full GC 清理整个堆,耗时很长。Full GC 触发条件包括:老年代空间不足、永久代/元数据区空间不足、System.gc() 调用等。
Q4:G1 和 ZGC 怎么选?
看数据量。几十 GB 选 G1,TB 级别选 ZGC。G1 调优复杂,ZGC 对吞吐量有一定牺牲。
我踩过的坑
第一个坑:CMS 导致频繁 Full GC。
线上 CMS 收集器,老年代使用率达到 70% 就触发 GC。但并发标记期间业务还在跑,产生新的垃圾叫"浮动垃圾"。如果浮动垃圾增长太快,CMS 还没清理完老年代就满了,只能触发 Full GC。
解决:调低 -XX:CMSInitiatingOccupancyFraction,提前触发回收。我设的是 65%。
第二个坑:G1 的 Mixed GC 没配好。
G1 的 Mixed GC 会回收年轻代+老年代的 Region。有一次年轻代设太大(50%),Mixed GC 回收老年代时没有足够时间,只能继续触发 Full GC。
解决:合理设置 -XX:G1NewSizePercent 和 -XX:G1MaxNewSizePercent,不要让年轻代占太多。
记忆口诀
GC 判断靠可达,引用计数有循环
分代理论是核心,Eden 复制效率高
老年代用标记整理,CMS 并发延迟低
G1 分区回收快,ZGC 十毫秒极低
总结
JVM GC 这块,核心就三件事:
第一:判断垃圾。 可达性分析 + gc roots。
第二:分代回收。 年轻代频繁快,老年代慢但少。
第三:选对收集器。 吞吐量优先用 Parallel,低延迟用 G1 或 ZGC。
面试问到 GC,能把这个链条讲清楚,能结合自己踩过的坑,基本就稳了。
更多推荐


所有评论(0)