前面两篇我们学习了 JVM 运行时数据区、堆的分代模型。我们知道堆是存放对象的主战场,也是 GC 工作的核心区域。 C/C++ 需要手动malloc申请内存、free释放内存,一旦忘记释放就会造成内存泄漏。Java 最大的优势就是自带自动垃圾回收机制,由 JVM 帮我们识别无效对象、回收内存。
但是 GC 并不是一个黑盒,面试非常爱问:JVM 凭什么知道对象已经没用?有哪些 GC 算法?各个收集器有什么差别?并发标记的时候业务线程还在跑,怎么防止误回收存活对象?
这一篇我们从底层原理一步步拆解 GC,是 JVM 调优和面试最重要的一环。
回顾前置知识点: GC 主要回收Java 堆;方法区(元空间)GC 条件苛刻,只有 FullGC 才会尝试卸载类;虚拟机栈、本地方法栈、程序计数器属于线程私有,线程结束直接释放,不需要 GC。
一、GC 基础概念
1.1 什么是 GC
GC 全称Garbage Collection,垃圾回收。 简单一句话:JVM 自动识别堆中不再被使用的对象,释放它占用的堆内存。
1.2 STW(Stop-The-World,世界暂停)【重中之重】
几乎所有 GC 都会触发 STW。 含义:GC 工作线程开始执行回收时,除 GC 自身线程以外,所有业务用户线程全部暂停,不执行任何业务代码;GC 完成之后,业务线程才恢复运行。
STW 会带来业务停顿,接口响应变长,这也是 GC 调优的核心目标:尽量降低 STW 的停顿时长,减少对业务的影响。
评价 GC 收集器两大核心指标:
- 吞吐量吞吐量 = 业务代码执行时间 /(业务执行时间 + GC 耗时) 吞吐量越高,代表 CPU 更多时间在执行业务,而不是 GC。Parallel 系列收集器就是优先追求高吞吐量。
- 停顿时间单次 GC 造成业务线程暂停的时长。CMS、G1、ZGC 这类收集器优先追求低停顿,适合互联网对响应时间敏感的业务。
注意:吞吐量和低停顿天然存在制衡,很难同时做到极致。想要更低停顿,往往要牺牲一部分吞吐量。
二、如何判断对象可以被回收?
GC 第一件事:区分哪些对象还在使用(存活),哪些对象已经没有引用(垃圾)。有两种经典算法:引用计数法、可达性分析算法。HotSpot 采用可达性分析算法。
2.1 引用计数法
原理:给每一个对象维护一个引用计数器
- 有引用指向这个对象,计数器 + 1
- 引用断开、赋值为 null,计数器 - 1
- 计数器 = 0,判定为垃圾,可以回收
优点:实现简单,回收即时;Python、OC 等语言使用。
致命缺陷:无法解决循环引用问题
循环引用演示代码:
class Node { Node next; } public class RefCycleDemo { public static void main(String[] args) { Node n1 = new Node(); Node n2 = new Node(); n1.next = n2; n2.next = n1; // 切断外部引用 n1 = null; n2 = null; // n1和n2互相引用,计数器都不为0,引用计数算法识别不出这是垃圾 } }这就是 HotSpot 不选用引用计数法的根本原因。
2.2 可达性分析算法(HotSpot 使用)
核心思路:定义一组起始根节点,叫做GC-Root。从 GC-Root 出发沿着引用链遍历:
- 能遍历到达的对象:存活对象,不能回收
- 遍历不到、引用链断裂的对象:垃圾对象,可以回收
循环引用的对象,只要没有 GC-Root 可达,就会被判定为垃圾,正常回收。
必背:哪些可以作为 GC-Root?
- 虚拟机栈里面,栈帧局部变量表引用的对象(方法内局部变量)
- 方法区中 static 静态变量引用的对象
- 正在运行的活跃 Java 线程
- JNI 本地方法栈中 native 引用的对象
举个例子:
public void test(){ User u = new User(); }u在虚拟机栈局部变量表,是 GC-Root;test 执行结束,栈帧出栈,这个引用消失,User 对象失去 GC-Root 链路,满足回收条件。
三、三大基础 GC 算法
算法是理论思想,收集器是算法在 JVM 中的工程实现。
3.1 标记 - 清除 Mark-Sweep
两个阶段:标记、清除
- 标记:从 GC-Root 遍历,标记所有存活对象
- 清除:遍历堆,释放所有未标记的垃圾对象
优点:逻辑简单
缺点:
- 需要两次扫描堆,效率一般
- 产生大量内存碎片;堆空闲内存总量足够,但没有连续空间存放大对象,依然会 OOM
应用:CMS 老年代底层就是标记清除算法
3.2 复制算法 Copying
原理:将内存分成两块,只使用其中一块;GC 时,把这块内存里存活对象复制到另一块空闲区域,然后直接清空原来这块内存。
优点:复制完成后内存连续规整,没有内存碎片,分配对象速度快
缺点:
- 理论上浪费一半内存
- 如果对象存活率很高,复制大量对象开销巨大
HotSpot 新生代巧妙使用 Eden + 两块 Survivor(8:1:1),规避 50% 内存浪费;新生代对象朝生夕死、存活率低,非常适合复制算法。
3.3 标记 - 整理 Mark-Compact
- 标记:标记全部存活对象
- 整理:把存活对象全部向内存一侧移动、压缩,边界以外的空间直接回收
优点:没有内存碎片,不需要预留双倍内存
缺点:移动对象,并且修改所有指向这个对象的引用地址;对象存活率高的时候开销很大
应用:Serial Old、Parallel Old 老年代收集器
3.4 分代收集算法
分代并不是一种全新算法,是上面三种算法的组合策略,现代 JVM 的核心思想。 依据对象存活时长,把堆分成新生代、老年代,不同区域选用最合适算法:
- 新生代:对象短命、存活率低 →复制算法
- 老年代:对象长寿、存活率高 →标记清除 / 标记整理
四、Java 的四种引用(面试高频)
Java 把引用划分成 4 个强度等级,用来灵活控制对象什么时候被 GC 回收。
4.1 强引用 StrongReference
我们日常写代码默认都是强引用。只要强引用链存在,GC 永远不会回收对象。
User user = new User();只有user = null切断强引用,对象才具备回收资格。
4.2 软引用 SoftReference
特点:内存快要 OOM 之前,才会回收软引用指向的对象;内存充足就保留。 适合做内存敏感型缓存,内存够就留着加速访问,内存紧张自动释放。
SoftReference<User> softRef = new SoftReference<>(new User()); User user = softRef.get(); // 内存充足返回对象,OOM前回收后返回null典型场景:本地缓存框架。
4.3 弱引用 WeakReference
特点:只要发生 GC,不管内存够不够,都会回收仅被弱引用关联的对象。
WeakReference<User> weakRef = new WeakReference<>(new User()); User user = weakRef.get();典型场景:WeakHashMap,比如 ThreadLocal 里面的 key 就是弱引用。
4.4 虚引用 PhantomReference(幻影引用)
特点:调用 get () 永远返回 null,拿不到对象本身。 唯一作用:对象被 GC 回收的时候,收到通知,用来做资源后置清理。 必须配合ReferenceQueue引用队列使用。
ReferenceQueue<User> queue = new ReferenceQueue<>(); PhantomReference<User> phantomRef = new PhantomReference<>(new User(), queue);典型场景:NIO 堆外直接内存的回收监控。
汇总表格:
表格
| 引用类型 | 回收时机 | 典型场景 |
|---|---|---|
| 强引用 | 不会主动回收 | 普通业务对象 |
| 软引用 | OOM 之前回收 | 内存敏感缓存 |
| 弱引用 | 只要 GC 就回收 | WeakHashMap、ThreadLocal key |
| 虚引用 | 对象回收时入队列通知 | 堆外内存资源监控 |
五、主流垃圾收集器
版本基线: JDK8 默认:Parallel Scavenge + Parallel Old JDK9:G1 成为默认收集器 JDK14:正式移除 CMS JDK15:ZGC 转正
5.1 Serial + Serial Old(串行收集器)
- Serial:新生代,复制算法,单线程 GC
- Serial Old:老年代,标记整理,单线程 GC 全程 STW,只有一个 GC 线程工作;适合资源有限的客户端程序。 启动参数:
-XX:+UseSerialGC
5.2 ParNew
新生代收集器,Serial 的多线程版本,复制算法。唯一能够和 CMS 老年代搭配的新生代收集器。 参数:-XX:+UseParNewGC、-XX:ParallelGCThreads指定 GC 线程数。
5.3 Parallel Scavenge + Parallel Old(并行收集器,JDK8 默认)
目标:优先高吞吐量
- Parallel Scavenge:新生代,复制算法,多线程回收
- Parallel Old:老年代,标记整理,多线程回收 适合后台计算型任务、批处理服务,优先榨干 CPU。 参数:
-XX:+UseParallelGC
区分并行和并发: 并行:多个 GC 线程一起工作,业务线程全部暂停(STW) 并发:GC 线程和业务用户线程同时运行
5.4 CMS Concurrent Mark Sweep 并发标记清除
定位:老年代收集器,追求低停顿;底层标记清除算法,JDK14 已经删除。一般搭配 ParNew 新生代。 CMS 四个阶段:
- 初始标记(STW):只标记 GC-Root 直接相连的对象,速度很快
- 并发标记:GC 线程和业务线程并发运行,遍历完整引用链
- 重新标记(STW):修正并发阶段业务线程修改引用带来的标记错误
- 并发清除:GC 线程和业务线程并发,清除垃圾
优点:大部分阶段并发,停顿短
缺点:
- 标记清除,产生内存碎片
- 并发阶段占用 CPU,吞吐量下降
- 需要预留一部分老年代空间;预留不足会触发并发模式失败,退化到 Serial Old,长时间 FullGC
CMS 解决并发标记漏标方案:增量更新写屏障
5.5 G1 Garbage-First
JDK9 之后默认收集器,用来替代 CMS。 核心设计:把整个 Java 堆切分成很多大小相等的 Region(1~32M,2 的幂)。Region 角色分为 Eden、Survivor、Old、Humongous 巨型对象区。
Humongous 区:对象大小超过 Region 一半,直接放这里,不走 Eden 分配。
G1 四大特点:
- 支持并行、并发,多核充分利用
- 保留分代思想,但新生代、老年代不再是连续整块,是一批 Region 集合
- 空间整合:整体标记整理、局部复制,几乎没有内存碎片
- 可预测停顿:用户设置预期最大停顿时间
-XX:MaxGCPauseMillis=200,后台统计每个 Region 回收收益,优先回收收益最高的 Region,尽量满足停顿目标
G1 回收四阶段:
- 初始标记(STW):标记 GC-Root 直达对象
- 并发标记:和业务线程并发做可达性分析
- 最终标记(STW):处理 Remembered Set 日志
- 筛选回收(STW):按收益排序,选定一批 Region,复制存活对象回收
G1 解决漏标方案:SATB 原始快照写屏障,会产生浮动垃圾,放到下一轮 GC 清理
G1 常用参数:
-XX:+UseG1GC -XX:G1HeapRegionSize=4M -XX:MaxGCPauseMillis=2005.6 ZGC
JDK11 实验,JDK15 转正。目标是亚毫秒级 STW 停顿,支持 TB 级超大堆,牺牲少量吞吐量换取极低延迟。 核心技术:染色指针,把标记信息直接存放在对象指针里,不用频繁扫描对象头。适合超大堆、延迟极其敏感的业务。
六、三色标记(并发标记核心)
CMS、G1 在并发标记阶段,业务线程还在跑,不能全程 STW,依靠三色标记完成可达性分析。 三种颜色代表对象扫描状态:
- 白色:还没有扫描;初始全部白色,本轮结束白色就是垃圾
- 灰色:已经访问该对象,但是它引用的子对象还没扫描完
- 黑色:对象本身 + 它所有引用的子对象全部扫描完毕
并发标记带来两个经典问题:
- 浮动垃圾某个对象标记完成变黑之后,业务代码切断它所有引用。本轮 GC 已经扫描完毕,无法识别,只能等到下一轮 GC 回收。浮动垃圾影响不大。
- 漏标(致命)同时满足两个条件: ① 黑色对象新增引用指向白色对象 ② 灰色对象删除对这个白色对象的引用 结果:白色对象仍然存活,但是被当成垃圾回收,引发空指针异常。
两种解决方案:
- CMS:增量更新:黑色对象新增白色引用时,记录这个引用,重新标记阶段再扫描;破坏条件①
- G1:SATB 原始快照:引用删除的时候,把原来被引用对象记录下来,当作存活对象;破坏条件②,代价是产生浮动垃圾
七、收集器选型总结
- 客户端、小应用:Serial
- JDK8 批处理、计算密集、追求高吞吐:Parallel
- JDK8 延迟敏感业务:ParNew + CMS(注意 JDK14 之后无 CMS)
- JDK9 + 绝大多数线上业务:G1(平衡吞吐和停顿,首选)
- TB 级大堆、极致低延迟:ZGC
没有万能收集器,一切以业务需求、JDK 版本、堆大小为基准。
八、本篇面试真题汇总
- 引用计数法的缺点?可达性分析的 GC-Root 有哪些?
- 标记清除、复制、标记整理算法各自优缺点,适用场景?
- 强、软、弱、虚引用区别和使用场景?
- CMS 的四个阶段,优缺点,并发模式失败是什么?
- G1 的 Region、Humongous 对象是什么?可预测停顿怎么实现?
- STW 是什么?吞吐量、停顿时间的含义?
- 三色标记黑白灰含义,浮动垃圾和漏标是什么?
- 增量更新和 SATB 分别是哪个收集器使用,解决什么问题?
- Parallel、CMS、G1、ZGC 怎么选型?
小贴士
理解 GC 算法和收集器,才能看懂 JVM 调优参数。下一篇进入实战,线上故障排查:CPU 飙高、死锁、OOM,JDK 自带工具 + Arthas 全套实战流程。