JVM基本知识(二):垃圾回收机制
JVM基本知识(二):垃圾回收机制
导语:第二篇讲 GC。不用急着背参数,先把主线捋顺——怎么判断对象已死 → 用什么算法回收 → 用什么收集器实现。这三步串起来,收集器的演进就不再是一堆名词,而是一条有因果的技术路线。
一、怎么判断对象「已经死了」
GC 的第一件事是找出「谁还活着」。主流思路有两条:
- 引用计数:给对象记一个被引用次数,归零就回收。逻辑简单,但遇到循环引用就失效——两个对象互相引用、外部却已经没人用,计数永远不为 0;
- 可达性分析(Java 采用):从一组叫 GC Roots 的起点出发,顺着引用链往下搜,搜不到的对象就是垃圾。循环引用在这里不是问题,因为「互相引用」的两点如果都不可达,一样会被回收。
GC Roots 具体包括哪些(记这几条就够用):
- 虚拟机栈(栈帧局部变量表)中引用的对象;
- 方法区中静态变量引用的对象;
- 方法区中常量引用的对象(含字符串常量池);
- 本地方法栈中 JNI 引用的对象;
- 被
synchronized同步锁持有的对象; - JVM 内部引用(基本类型 Class 对象、常驻异常对象、系统类加载器等)。
四种引用:给对象分「抢救优先级」
Java 提供了四种强度递减的引用,本质是给缓存、映射表这类场景留出「内存紧张时先牺牲我」的空间:
| 引用类型 | 类 | 回收时机 | 典型用途 |
|---|---|---|---|
| 强引用 | 直接赋值 | 只要可达永不回收 | 普通对象,OOM 的主因 |
| 软引用 | SoftReference | 内存不足、即将 OOM 前 | 内存敏感型缓存 |
| 弱引用 | WeakReference | 下次 GC 无条件回收 | ThreadLocalMap 的 key、WeakHashMap |
| 虚引用 | PhantomReference | 随时可回收,无法取到对象 | 跟踪回收时机,如堆外内存清理 |
注意:对象被标记为不可达后,如果重写了
finalize()且从未执行过,还有最后一次「自救」机会(在finalize()里重新挂回引用链),但仅能生效一次。finalize()早在 Java 9 就被标记为 deprecated,资源释放应该用try-with-resources或Cleaner。
二、有了垃圾之后,怎么回收
三种基础算法,各自的取舍非常清晰:
| 算法 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 标记-清除 | 标记存活对象,直接清掉其余 | 实现简单、不移动对象 | 产生内存碎片;效率不稳定 |
| 复制 | 内存分两块,存活对象复制到另一块,原块整体清空 | 无碎片、效率高 | 浪费空间(需一块空的) |
| 标记-整理 | 标记后把存活对象向一端移动紧凑排列 | 无碎片 | 要移动对象、更新引用,开销大 |
为什么新生代用「复制」、老年代用「标记-清除/标记-整理」:
- 新生代对象存活率极低(绝大部分直接死掉),复制算法只需要搬运极少数存活对象,成本低且天然无碎片;
- 老年代存活率高,复制算法要搬的东西太多、还需要一整块空内存做担保,不划算,因此改用标记-清除(CMS)或标记-整理(Serial Old、Parallel Old)。
这也正是分代收集的核心逻辑:按对象的存活特征,把内存切成不同区域,各自使用最合适的算法。
三、一次 GC 到底发生了什么
GC 不是只有「全回收」一种,根据回收范围分成几档,代价从低到高:
几个容易混的名字:
- Minor GC / Young GC:只回收新生代,最频繁、停顿最短;
- Mixed GC:G1 特有,回收新生代 + 部分老年代 Region;
- Full GC:整堆 + 方法区一起回收,停顿最长,是线上最需要避免的;
- Major GC / Old GC:严格说只回收老年代,目前只有 CMS 有独立的老年代回收行为,日常常被和 Full GC 混着说。
所以「频繁 Full GC」是典型的故障信号。常见原因:老年代空间规划过小、对象晋升过快(Survivor 太小)、内存泄漏导致老年代长期高位、元空间动态生成类不释放。
四、收集器演进:一条有因果的技术路线
不同收集器其实是在回答同一个问题的不同阶段:「如何在回收干净的前提下,把 STW 停顿压得更短、把适用堆做得更大」。
| 收集器 | 分代/堆 | 算法 | 核心目标 | 现状 |
|---|---|---|---|---|
| Serial / Serial Old | 物理分代 | 复制 / 标记-整理 | 单线程,简单 | 仍在(单核、小容器) |
| ParNew | 物理分代(新生代) | 复制 | 并行缩小停顿 | 随 CMS 一起退役 |
| Parallel Scavenge / Old | 物理分代 | 复制 / 标记-整理 | 吞吐量优先 | JDK 8 及之前 Server 默认 |
| CMS | 物理分代(老年代) | 标记-清除 | 最短停顿(并发标记+清除) | JDK 9 废弃、JDK 14 移除 |
| G1 | Region 化、逻辑分代 | 整体标记-整理 + Region 间复制 | 可预测停顿 | JDK 9 起默认 |
| ZGC / Shenandoah | 着色指针 / Brooks Pointer | 并发标记 + 并发转移 | 亚 10ms 且与堆大小无关 | ZGC:JDK 15 生产可用;JDK 21 分代 |
看懂演进的三条线索:
- 从串行到并行再到并发:Serial(单线程 STW)→ ParNew / Parallel(多线程但仍 STW)→ CMS 起把「标记与清除」做成并发,只看停顿;
- 从「追求吞吐」到「追求可预测停顿」:Parallel 关注单位时间做多少事,CMS/G1 关注单次停顿有多长——这两种目标天然矛盾,只能取舍;
- 从「一整块堆」到「切碎管理」:G1 把堆切成 Region,才得以「挑几块回收」;ZGC 进一步用着色指针 + 读屏障把转移也做成并发,实现「停顿与堆大小无关」。
G1 的 Region 模型
G1 最容易被问「和 CMS 有什么区别」,答案就藏在它的堆布局里:
与 CMS 的三条核心差异:
- 碎片:CMS 用标记-清除,堆会碎;G1 靠 Region 间复制,基本无碎片,因此不必靠 Full GC 做压缩;
- 跨区引用:CMS 用卡表;G1 为每个 Region 维护 RSet,代价是内存占用更高(极端可达堆的 20%);
- 并发标记纠错:CMS 用增量更新(记录新增引用),G1 用 SATB(记录删除前的旧引用,按快照标记)。
五、一页速记
- 判活:可达性分析(GC Roots 出发),不用引用计数;
- 回收:复制(新生代,无碎片但费空间)、标记-清除(有碎片)、标记-整理(无碎片但慢);
- 分代:Minor GC → Mixed GC → Full GC,代价递增,Full GC 是最该避免的;
- 收集器选型:批处理要吞吐选 Parallel,通用大堆选 G1,超低延迟选 ZGC;
- 版本红线:JDK 8 默认 Parallel,JDK 9+ 默认 G1,CMS 已在 JDK 14 移除。
下一篇:《JVM基本知识(三)》讲类加载机制与调优排障——类加载生命周期、双亲委派及打破方式、解释器与 JIT 的配合,以及常用参数与线上问题排查路线。
