一、如何判断对象可以回收
1、引用计数算法
原理是:
-
每个对象维护一个引用计数(ref count),每当有引用指向该对象时计数 +1,引用消失时计数 -1。计数为 0 时对象可回收。
-
操作在引用赋值和取消引用处调整计数。
优点是:
实现简单,判定快速(无需全局遍历),理论上可在任意时刻即时回收对象(无需 Stop-the-World 扫描)。
缺点是:
-
无法处理引用环(A→B、B→A,且两者对外不可达,但各自计数都 ≠0),这类循环引用不会被回收。
-
维护计数的开销(每次赋值/销毁都要更新计数,频繁的原子操作可能很慢)。
-
实际 JVM(HotSpot)不采用纯引用计数作为主算法(主要用于某些特殊场景或语言实现)。
缺点代码演示:
java
A.refCount = 1 // root -> A
B.refCount = 0
A.field = B // B.refCount = 1
B.field = A // A.refCount stays 1 (no problem)
root = null // root -> A 断开,理论上 A 和 B 都不可达
// 但 A.refCount = 1, B.refCount = 1 => 不会被回收(错误)
总结:引用计数直观,但不能解决环引用,因此现代 JVM 使用更可靠的可达性分析(见下一节)。引用计数仍可用于某些资源管理或弱引用实现策略中。
2、可达性分析算法
(1)核心思想
从一组称为 GC Roots 的"根对象"出发,沿引用链做图遍历(DFS/BFS)。凡是可达的对象视为存活;不可达的视为垃圾。
(2)GC Roots组成
可达性分析的起点是 GC Roots,典型包括:
| GC Root 类型 | 示例 |
|---|---|
| 虚拟机栈中引用的对象 | 方法中的局部变量、参数 |
| 本地方法栈(JNI)引用的对象 | native 层引用的 Java 对象 |
| 方法区中的静态变量引用的对象 | static 修饰的对象 |
| 方法区中的常量引用对象 | final 字符串等 |
| 正在运行的线程对象 | Thread |
| 类加载器引用的对象 | 各种 ClassLoader |
| JVM 内部特殊结构 | 常驻异常、系统类等 |
需要注意的是,堆里的对象并不会自动成为 GC Root,除非它被上述 Root 之一引用。
(3)可达性分析的执行过程
用一个例子来具体说明一下,假如对象之间存在如下的引用:
java
GC Root → A → B → C
↓
D → E
那么,可达性分析的过程大致如下:
先找从Roots出发的引用链,我们发现能从GC Roots出发找到的对象有ABCDE五个对象,那么他们都会被标记为可达(Alive)。
然后找与GC Roots无引用链的对象,假如有一个对象x,且该对象没有任何从Root出发的引用链,那么x就是不可达对象,是可回收的。
(4)可达性分析的底层实现方式
垃圾回收算法(特别是 CMS、G1、ZGC、Shenandoah)在并发标记阶段使用"三色标记"来实现对象状态跟踪。
1)、三色标记法
首先先解释一下三种颜色的含义:
| 颜色 | 含义 | 能否回收 |
|---|---|---|
| 白色(White) | 默认状态:未被访问过 → 候选垃圾 | ✔ 可能回收 |
| 灰色(Gray) | 已被访问,但其引用的对象尚未扫描完 | ✘ 不可回收 |
| 黑色(Black) | 已访问且引用的对象全部扫描完 | ✘ 不可回收 |
标记流程如下:
初始全部对象都为白色,从GC Roots开始,将Roots指向的对象设为灰色,然后就是不断的处理灰色对象,需要将灰色对象的引用对象染成灰色,同时也需要将自己变为黑色,等到所有的灰色对象处理完成之后,剩余的白色对象就是垃圾。
当应用线程和GC同时运行时,即并发标记时会出现两个致命问题,问题如下:
问题1:漏标(Missing/Lost Marking)
漏标的含义就是本来应该存活的对象被认为是垃圾。
经典场景:
-
GC 已扫描完 A(A 变黑)
-
应用线程将一个新对象 X 赋给 A(A → X)
-
因为 A 是黑色,GC 不会再扫描它
-
GC 不知道 X 被访问了 → X 仍然是白色 → 被当成垃圾
问题2:误标(Floating Garbage)
GC 认为对象是存活的,但实际上它已经不可达,因为 Mutator 修改了引用关系。
为了避免上面两个问题的发生,JVM中采用写屏障(Write Barrier)
2)写屏障
写屏障不是屏幕栅栏,而是编译器在对象引用写操作前后插入的钩子代码。写屏障主要有两类:
-
插入屏障(Post-Write Barrier)
-
删除屏障(Pre-Write Barrier)
不同的垃圾回收器采用不同的策略(主要CMS和G1)
CMS中的写屏障策略:
①CMS的并发标记背景
CMS采用并发标记+并发清除:在标记阶段大部分与应用线程并发;应用线程在标记期间仍然在修改对象的引用关系;如果不做补偿,会出现漏标(本应该存活的对象被当成垃圾)
CMS选择的标记不变式是:强三色不变式(Strong Tri-color Invarint),黑色对象不能直接引用白色的对象
②CMS写屏障类型:Incremental Update(增量更新)
本质上就是关注"新写入的引用"
当程序执行如下操作时:
javaobj.field = newRef;CMS的写屏障逻辑关注的是newRef(新引用)
写屏障触发的条件(概念模型)
java如果 obj 是黑色 并且 newRef 是白色 → 将 newRef 重新标记为灰色含义是:当obj已经扫描完成(黑色),newRef在本轮标记中还没有发现(白色),如果不补救,newRef会被漏标,因此会强制把newRef放回待扫描队列。
③Incremental Update的核心特征
a、维持强三色不变式
黑对象永远不会直接指向白对象
所有白对象必须从灰对象可达
b、补偿的是"新增引用"
删除引用不关心
只关心 黑 → 白 的新边
c、副作用:浮动垃圾(Floating Garbage)
由于 CMS 是"边标记、边修改":
标记完成后,可能有对象已经"逻辑死亡"
但在标记期间曾被引用过
本次 GC 不回收,只能留到下次
④小结
项目 CMS 写屏障类型 Incremental Update 维护不变式 强三色不变式 关注点 新写入的引用(newRef) 风险 浮动垃圾 结果 极低 STW,但吞吐下降、碎片多
G1 中的写屏障策略(SATB)
①G1的并发标记背景
G1同样支持并发标记,但是设计的目标不同,其目的是是为了面向大堆+可预测停顿,Region化内存,必须保证标记稳、可控。
G1采用的标记思想是:SATB(Snapshot-At-The-Beginning),以"标记开始那一刻的对象图"为准
②G1的写屏障类型:STAB写屏障
本质上关注的是"被覆盖的旧引用"
当程序执行时:
javaobj.field = newRef;STAB的写屏障记录的是:
javaoldRef = obj.field(写入前)写屏障的触发条件(概念模型)
java如果 oldRef 不为 null 并且 oldRef 是白色 → 将 oldRef 标记为灰色含义就是:oldRef在"快照开始时"是可达的,即使现在的引用被断开了,也需要保证它不会被漏标。
③STAB的核心特征
a、维护若三色不变式
灰色对象不会丢失对白色对象的引用,不要求黑对象不能指向白对象。
b、补偿的是"删除引用"
CMS 关心"新增引用"
G1 关心"被删除的引用"
c、天然适合 Region + 并发标记
标记阶段不需要频繁回溯
标记结束后,Region 存活率统计稳定
便于后续 Mixed GC 选择回收 Region
d、副作用:更多浮动垃圾
标记的是"开始时的快照"
一些标记后立即死亡的对象也会存活一轮
④小结
项目 G1 写屏障类型 SATB 维护不变式 弱三色不变式 关注点 被覆盖/删除的旧引用(oldRef) 额外机制 TAMS(新对象默认黑) 优点 标记稳定、适合 Region 回收
CMS与G1对比:
| 对比维度 | CMS | G1 |
|---|---|---|
| 并发标记策略 | 增量更新 | SATB |
| 写屏障关注 | 新引用 | 旧引用 |
| 维护不变式 | 强三色不变式 | 弱三色不变式 |
| 漏标风险 | 需严格补偿 | 理论上更安全 |
| 浮动垃圾 | 有 | 更多 |
| 是否压缩 | 否(碎片问题) | 是(Region 内复制) |
| 适用场景 | 低停顿、老 JVM | 大堆、可预测停顿 |
小结:
CMS 使用 Incremental Update 写屏障,通过补偿"黑对象新增对白对象的引用"来维持强三色不变式;
G1 使用 SATB 写屏障,通过记录"被覆盖的旧引用"来维护标记开始时的对象快照,从而保证并发标记的稳定性。
3)、不同GC对应的屏障策略
| 垃圾回收器 | 写屏障策略 | 说明 |
|---|---|---|
| Serial / Parallel | 无并发标记 → 不需要写屏障 | STW 标记 |
| CMS | Incremental Update(增量更新) | 防止黑指向白 |
| G1 | SATB | 标记开始的快照 |
| Shenandoah | SATB + 读屏障 | 完全并发压缩 |
| ZGC | 读屏障 + 染色指针 | 完全并发、超低停顿 |
(5)为什么可达性分析不会出现引用计数算法的问题
引用计数算法最大的问题就是循环引用无法回收,但是实际上循环引用的两个对象已经不被程序使用。
而可达性分析算法不关心引用的数量,只关心是否能从GC Roots走到他们。由于从Root走不到A或B,所以它们都属于不可达对象,都能被正确回收。
(6)可达性分析中的两次标记(Finalization机制)
JVM会对不可达对象进行两次标记:
第一次:判断对象不可达
如果对象没有覆盖finalize()方法,则直接回收,如果覆盖了,则进入复活队列
第二次:执行finalize()后再次判断是否可达
若在finalize()中把自己重新关联到GC Roots,则该对象就被"复活"了,本次不回收,否则直接回收。(finalize()只能被自动调用一次,也就是说对象只能复活一次)。
(7)Stop-The-World 与可达性分析
可达性分析原本需要暂停所有用户线程(STW),原因是:
对象引用关系会随代码运行持续变化,所以必须冻结引用图才能保证分析正确。
现代 JVM(G1、ZGC)通过"三色标记"和写屏障实现并发标记,减少暂停时间,但仍不可避免有短暂停顿。
(8)特点总结
| 特点 | 说明 |
|---|---|
| 准确性高 | 没有引用计数的循环引用问题 |
| STW 或并发标记 | 根据不同 GC 具体实现 |
| 与现代 GC(CMS、G1、ZGC)匹配 | 这些 GC 算法都基于 Reachability |
| 依赖 GC Root 和三色标记法 | 不同代不同 Root |
可达性分析通过从 GC Roots 开始搜索引用链来判断对象是否可达,所有不可达对象会被视为垃圾。算法依赖三色标记法确保并发正确性,并通过写屏障避免"漏标"和"误标"问题。该算法不会出现引用计数循环引用的问题,是现代 JVM(G1、ZGC、Shenandoah 等)垃圾回收的基础。
3、引用类型
(1)、什么是引用类型
在JVM垃圾回收器中,判定对象的存活都与"引用"有关;引用类型(Reference Types)用于"对象与 GC Roots 之间的影响性强",从而决定描述对象是否在 GC 时。
(2)、JVM定义的四种引用类型
java
强引用 → 软引用 → 弱引用 → 虚引用
他们的规定在java.lang.ref包中(强引用是语言级概念)。
①强引用(Strong Reference)
定义:最普通的对象引用
java
Object obj = new Object();
对于GC而言,持久GC Roots通过强引用关联的可达的对象,就永远不会被GC回收,即使内存不足,虚拟机宁愿抛出OutOfMemoryError错误,也不会随意回收。
特点是:生命周期由程序逻辑控制,是默认、最安全的引用。
典型的来源(GC Roots路径)
-
栈帧局部变量
-
静态变量
-
活跃线程
-
类加载器
②软引用(Soft Reference)
定义:
java
SoftReference<Object> ref =
new SoftReference<>(new Object());
对于GC 而言,在内存充足时不会回收它,如果内存不足了,就会回收这些对象的内存,JVM中会保留没有回收的对象,该对象就可以继续被程序使用。(JDK9+中软引用恢复更积极,不在绝对可靠)
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
使用场景:
实现内存敏感的高速缓存、图片存储、大对象存储
③弱引用(Weak Reference)
定义:
java
WeakReference<Object> ref =
new WeakReference<>(new Object());
对于GC而言,能够发生GC,就能够被回收,不关心内存是否紧张。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
使用场景:
-
ThreadLocal的钥匙 -
WeakHashMap -
防止存储导致内存泄漏
弱引用的生命周期极短。
④虚引用(Phantom Reference)
定义:
java
PhantomReference<Object> ref =
new PhantomReference<>(obj, queue);
对于GC而言,不会影响对象存活,get()永远无法返回null,在对象回收前,引用会进入ReferenceQueue。虚引用主要用来跟踪对象被垃圾回收的活动。
使用场景:
-
资源回收通知
-
堆外内存(DirectMemory)清理
-
替代
finalize()
虚引用和弱引用的区别:虚引用必须和引用队列联合使用。当垃圾回收器准备回收某个对象时,如果发现该对象有虚引用,就会把虚引用加入到与之关联的引用队列中。垃圾回收器可以通过引用队列中的虚引用来判断虚引用关联的对象是否需要被垃圾回收。如果发现某个对象的虚引用已经加入了引用队列,可以在所引用的对象被回收之前采取必要的行动。
(3)、ReferenceQueue(引用队列)
①概念:
ReferenceQueue 是 JVM 提供的一种 对象回收状态通知机制 。当某个对象的 引用状态发生变化(即对象即将或已经被 GC 回收) 时,JVM 会把对应的 Reference 对象放入一个 ReferenceQueue 中,供应用程序感知和处理。需要注意的是:入队的是Reference对象而不是被回收的对象。
②为什么需要:
GC 是 自动的、不可预测的,而应用程序有时需要:
-
感知对象何时被回收
-
释放与对象关联的 非堆资源
-
清理缓存 / 映射关系
-
替代
finalize()的不确定性
ReferenceQueue 是 JVM 与应用层之间的"回收事件通知通道"。
③与四种引用的关系
| 引用类型 | 是否可配 ReferenceQueue | 入队时机 |
|---|---|---|
| 强引用 | ❌ 不支持 | --- |
| 软引用 | ✅ 支持 | 引用被清除后 |
| 弱引用 | ✅ 支持 | GC 回收时 |
| 虚引用 | ✅ 必须 | 对象即将被回收 |
④工作机制
以弱引用为例
java
ReferenceQueue<Object> queue = new ReferenceQueue<>();
WeakReference<Object> ref =
new WeakReference<>(new Object(), queue);
GC 执行时发生的事情:
-
可达性分析
-
发现目标对象只被弱引用关联
-
判定对象可回收
-
清除 ref 内部的 referent 指针
-
将 ref 放入 ReferenceQueue
-
对象内存随后被回收
入队 ≠ 内存已立即释放
入队 = "对象已经不可达"
⑤底层实现要点
Reference对象内部结构:
java
Reference
├─ referent // 指向目标对象
├─ queue // 关联的 ReferenceQueue
├─ next // 用于队列链接
GC与Refernece的协作:
-
GC 会扫描
Reference对象 -
处理顺序:
Soft → Weak → Final → Phantom
-
满足条件:
-
referent 被清空
-
Reference 入队
-
⑥ReferenceQueue 与 finalize() 的区别
| 对比项 | ReferenceQueue | finalize() |
|---|---|---|
| 是否可靠 | ✅ 是 | ❌ 不可靠 |
| 是否可控 | ✅ 可控 | ❌ 不可控 |
| 性能 | 较好 | 很差 |
| 是否推荐 | ✅ | ❌(已废弃) |
⑦小结
ReferenceQueue 是 JVM 提供的对象回收通知机制,当软引用、弱引用或虚引用所关联的对象被 GC 判定为不可达时,对应的 Reference 会被放入引用队列。应用程序可以通过轮询 ReferenceQueue 感知对象回收事件,从而安全、可控地释放相关资源,尤其常用于缓存清理和堆外内存回收。
(4)四种引用对比
| 引用类型 | 回收时机 | 是否影响对象存活 | 常见用途 |
|---|---|---|---|
| 强引用 | 永不回收 | 是 | 普通对象 |
| 软引用 | 内存不足 | 是 | 缓存 |
| 弱引用 | 发生 GC | 是 | ThreadLocal |
| 虚引用 | 不影响 | 否 | 资源释放 |
(5)小结
JVM 中的引用类型用于描述对象与 GC Roots 之间的可达性强弱,分为强引用、软引用、弱引用和虚引用。强引用对象永远不会被回收;软引用在内存不足时回收,常用于缓存;弱引用只要发生 GC 就会回收;虚引用不影响对象生命周期,主要用于在对象回收前获得通知并释放相关资源。
4、如何判断一个常量是废弃常量
(1)概念:
废弃常量指的是:运行时常量池中已经不再被任何地方引用的常量,在满足条件时可以被 JVM 回收。注意:我们这里说的是运行时常量池而不是源码中的final常量本身。
(2)常量所在的位置(以JDK8+为例)
| 内容 | 所在内存区域 |
|---|---|
| 类元数据 | 元空间(Metaspace) |
| 运行时常量池 | 元空间 |
| 字符串字面量(StringTable) | Java 堆 |
判断废弃常量的前提是常量池本身仍然存在在。
(3)判定一个常量是否为废弃常量的两个必要条件
条件一:常量池所属的 Class 未被任何地方引用
也就是:
-
该类 无法从任何 GC Roots 到达
-
类对象(
java.lang.Class)不可达 -
对应的 ClassLoader 不可达
类未卸载 → 常量池不可能回收
条件二:常量池中的该常量未被任何地方引用
包括但不限于:
-
没有被任何对象引用
-
没有被任何静态字段引用
-
没有被任何方法字节码引用
-
没有被其他类的常量池引用
两个条件 缺一不可
(4)字符串常量的特殊说明
①String.intern()的变化
在JDK+中,StringTable在堆中,是可以被GC回收的。
java
String s = new String("abc");
s.intern();
s = null;
如果没有其他的强引用,StringTable中的字符串也是可以被回收的。
②字符串"不可回收"的误解
String常量永远不回收这个说法是错误的,只要是不再被引用,就可以被回收。
(5)与类卸载(Class Unloading)的关系
废弃常量的回收前提是类卸载
类卸载的条件(三个条件必须同时满足):
-
该类的所有实例已被回收
-
加载该类的 ClassLoader 不可达
-
该类的
java.lang.Class对象不可达
类卸载成功 → 常量池可回收 → 废弃常量可回收
(6)小结
判断一个常量是否为废弃常量,必须同时满足两个条件:一是该常量所属的类已经无法从 GC Roots 到达,具备类卸载条件;二是该常量本身没有被任何对象、字段或其他常量池引用。在类卸载过程中,运行时常量池中满足条件的常量会被 JVM 回收,这类常量即为废弃常量。
二、垃圾收集算法
1、标记-清除算法
(1)概念
标记--清除算法 是最基础的垃圾回收算法之一,其核心思想是:先标记所有存活对象,再清除所有未被标记的对象。
算法一共分为两个阶段:标记(Mark)& 清除(Sweep)
(2)算法执行流程
①标记阶段(Mark)
-
从 GC Roots 出发
-
使用 可达性分析
-
遍历对象引用关系
-
给所有可达对象打上"存活标记"
示例:
java
GC Roots
↓
A(存活)
↓
B(存活)
C(未标记 → 垃圾)
标记阶段只负责识别存活的对象,不回收内存。
②清除阶段(Sweep)
-
遍历整个堆空间
-
回收所有 未被标记 的对象
-
存活对象 不移动位置
清除后内存示意:
java
[ A ][ 空 ][ B ][ 空 ][ 空 ]
(3)标记-清除算法的核心特征
| 特征 | 说明 |
|---|---|
| 是否移动对象 | ❌ 不移动 |
| 是否压缩内存 | ❌ 不压缩 |
| 是否产生碎片 | ✅ 会产生 |
| 实现复杂度 | 低 |
(4)优缺点
优点:
-
实现简单
-
不需要额外内存空间
-
对象地址不变,引用无需更新
缺点:
①内存碎片化严重
回收后产生大量不连续的空间,可能会导致大对象分配失败,空间虽然足够但是OOM。
②回收效率不稳定
清除阶段需要扫描整个堆,堆越大的话,耗时就越长。
(5)标记-清除算法与STW的关系
在Serial/Parallel GC中,标记和清除阶段全程STW。
在CMS GC 中,初始标记和重新标记会STW,标记和清除阶段则是并发执行。
算法本身不决定是否STW,由垃圾回收器的实现具体决定。
(6)标记-清除算法在JVM中的实际应用
①用于CMS的老年代中
在CMS中:基于标记-清除算法,追求低停顿,不进行内存压缩。这样的直接后果就是长期运行容易产生内存碎片。
②为什么新生代中不使用标记-清除算法
新生代的特点是对象的生命周期短且存活率低,更适合使用复制算法
(7)为什么"清除"不等于"置零"
在清除阶段:回收内存,更新空闲列表。
在内存清零:发生在对象分配时,由JVM保证安全性
(8)小结
标记--清除算法通过"标记存活对象 + 清除垃圾对象"完成内存回收,其实现简单且不移动对象,但会产生内存碎片,影响空间利用率。在 JVM 中主要用于老年代,如 CMS 回收器采用该算法,通过并发执行降低停顿时间,但也因此引入内存碎片问题。
2、复制算法
(1)概念
复制算法 的核心思想是:将内存按区域划分,每次只使用其中一部分,把存活对象复制到另一部分,然后一次性清理原区域。
(2)基本结构
最原始的模型:
java
From Space(使用中) ──复制存活对象──▶ To Space(空闲)
对于复制算法,每次GC 都发生在Form Space中,GC后,To Space称为新的Form Space,原先的Form Space被整块清除了。
(3)执行流程
首先标记/发现存活的对象,从GC Roots出发,使用可达性分析,找到存活的对象;
然后复制活的对象,按照顺序复制到To Space中,同时更新对象的引用地址;
最后清空Form Space,整块的内存一次性回收,无需再遍历垃圾对象。
(4)复制算法的核心特征
| 特性 | 说明 |
|---|---|
| 是否移动对象 | ✅ 是 |
| 是否产生碎片 | ❌ 不会 |
| 回收速度 | 快 |
| 空间利用率 | 低(需预留空间) |
(5)优缺点
优点:
-
无内存碎片
-
分配速度快(指针碰撞)
-
GC 实现简单
-
回收效率高(只复制存活对象)
缺点:
-
空间浪费
- 至少浪费 50%(理论模型)
-
对象复制成本
- 存活对象多时成本上升
-
不适合老年代
- 老年代存活率高,复制代价大
(6)复制算法再JVM中的实际应用
在新生代结构(HotSpot)中
java
Eden : S0 : S1 = 8 : 1 : 1(默认)
Eden是新对象的分配区,S0/S1是Survivor区(From/To)
没有50%的浪费,而是约10%的浪费。
在Minor GC的过程中:
-
对 Eden + From Survivor 进行 GC
-
存活对象复制到 To Survivor
-
Eden + From 一次性清空
-
To ↔ From 角色交换
-
对象年龄 +1
(7)对象晋升与复制失败
对象晋升:当对象达到年龄阈值(-XX:MaxTenuringThreshold)时,会直接进入老年代;
复制失败:当Survivor放不下时,存活对象会直接晋升到老年代中,老年代不足会发生Full GC。
(8)复制算法与 STW
新生代GC(Minor GC)必然会STW,但是时间很短,因为对象很少。
(9)小结
复制算法通过将存活对象复制到另一块内存区域来完成垃圾回收,能够彻底避免内存碎片问题,并具备高效的回收性能。在 JVM 中,该算法主要用于新生代,采用 Eden 与 Survivor 区的结构,大幅降低了空间浪费,是 Minor GC 的核心实现方式。
3、标记-整理算法
(1)概念
标记--整理算法的核心思想是:先标记所有存活对象,然后将存活对象向一端移动并按顺序排列,最后清理边界以外的内存。
(2)执行流程
①标记阶段(Mark)
-
从 GC Roots 出发
-
使用 可达性分析
-
标记所有存活对象
(与标记--清除算法完全相同)
②整理阶段(Compact)
-
计算每个存活对象的新地址
-
将对象向内存一端滑动
-
按顺序排列,消除空洞
-
更新所有引用
整理完成后的内存示意:
java
整理前: [ A ][ 空 ][ B ][ 空 ][ C ][ 空 ]
整理后: [ A ][ B ][ C ][ 连续空闲空间 ]
(3)核心特征
| 特性 | 说明 |
|---|---|
| 是否移动对象 | ✅ 是 |
| 是否产生碎片 | ❌ 不会 |
| 是否更新引用 | ✅ 必须 |
| 实现复杂度 | 高 |
(4)优缺点
优点:
-
彻底消除内存碎片
-
适合大对象分配
-
空间利用率高
缺点:
-
对象移动成本高
- 更新大量引用
-
暂停时间较长
- 整理阶段通常 STW
-
实现复杂
(5)标记-整理算法在JVM中的实际应用
①Serial Old/Parallel Old
在老年中使用标记-整理,当Full-GC时会执行,会STW。
②G1/ZGC/Shenandoah(改进版)
本质上仍然是"标记+移动",但是对于G1,是Region级整理,对于ZGC/Shenandoah是并发移动的。
算法的思路是相同的,只是实现的策略不相同。
(6)为什么老年代更适合标记-整理
老年代的特点是:对象的存活率很高,对象大且存活周期长。
如果使用标记-清除的话,会导致碎片迅速积累,大对象频繁分配失败。
所以对于老年代来说必须要整理。
(7)标记-整理与STW的关系
在标记阶段,是否 STW,取决于具体使用的是哪一种垃圾回收器。
在整理阶段,几乎一定STW。
原因是:对象的地址发生了变化,必须要保证引用的一致性。
(8)小结
标记--整理算法通过标记存活对象并将其向内存一端移动,从而消除内存碎片,提高空间利用率。相比标记--清除,它需要移动对象并更新引用,导致停顿时间更长,但更适合老年代和大对象分配场景,因此被 Serial Old、Parallel Old 以及 G1 等回收器采用。
4、分代收集算法
(1)概念
分代收集算法并不是一种新的 GC 算法 ,而是一种内存管理思想 / 策略:根据对象生命周期的不同特征,将堆划分为不同区域,并对不同区域采用最合适的垃圾回收算法。
(2)为什么要分代
如果整个堆统一使用一种算法:
-
要么为了"短命对象"浪费性能
-
要么为了"长命对象"产生碎片
分代的目的是用最小的代价回收最多的垃圾。
(3)JVM的分代结构
①新生代(Young Generation)
伊甸区加上两个幸存区:
java
Eden + Survivor(S0 / S1)
特点是:对象存活率低,回收频繁,回收的速度要求高。
②老年代(Old Generation)
特点是:对象的存活率高,对象大、生命周期长,回收的频率低。
③元空间(Metaspace)
包括类元数据、运行时常量池,不属于"分代收集"的对象代,但是会参与GC
(4)各代采用的GC算法
| 区域 | 对象特征 | 采用算法 |
|---|---|---|
| 新生代 | 朝生夕死 | 复制算法 |
| 老年代 | 长期存活 | 标记--清除 / 标记--整理 |
| 整个堆 | 混合 | 分代收集 |
(5)执行流程
①对象创建
java
new Object();
创建的对象会通过指针碰撞这种快速的方式在Eden优先分配一块空间。
②Minor GC(新生代GC)
当Eden区满时会触发。
过程:
-
标记 Eden + From Survivor 中的存活对象
-
复制到 To Survivor
-
对象年龄 +1
-
Eden + From 清空
-
Survivor 角色交换
STW,但时间短
③对象晋升
晋升到老年代的条件是:年龄达到阈值(MaxTenuringThreshold)、Survivor 空间不足、大对象直接分配(PretenureSizeThreshold)
④Major/Full GC(老年代)
当老年代空间不足时、Promotion Failure、显示Ful GC、元空间不足时会发生,主要使用标记-整理算法或标记-清除算法(CMS)
(6)跨代引用问题与解决方案
问题是:当老年代对象引用新生代对象时,如果每次Minor GC都扫描到老年代,成本会极高
解决方法:记忆集(Remembered Set)
记录老年代中指向新生代的引用,Minor GC时就只扫描这些区域。
具体可以通过写屏障和Card Table来实现。
(7)优缺点
优点:
-
减少 GC 扫描范围
-
提高回收效率
-
降低停顿时间
-
针对性使用最优算法
缺点:
-
实现复杂
-
跨代引用维护成本
-
在大堆、低延迟场景下:
- 被 ZGC / Shenandoah 弱化
(8)分代收集与现代GC的关系
| 回收器 | 是否分代 |
|---|---|
| Serial / Parallel | ✅ |
| CMS | ✅ |
| G1 | 逻辑分代 |
| ZGC | ❌(弱分代 / 无分代) |
| Shenandoah | ❌ |
趋势:弱化甚至取消分代
(9)小结
分代收集算法基于对象生命周期的差异,将堆划分为新生代和老年代,并分别采用复制算法和标记类算法进行回收,从而在保证吞吐量和停顿时间之间取得平衡。通过记忆集和写屏障机制,分代收集有效解决了跨代引用带来的扫描开销,是 HotSpot JVM 长期以来的核心垃圾回收策略。
三、垃圾收集器
1、概念
垃圾收集器(GC Collector) 是 JVM 中 具体执行垃圾回收算法的实现者。算法是"怎么回收",
收集器是"谁来回收、如何并行/并发回收"。
2、垃圾回收器的分类维度
(1)按回收区域
-
新生代收集器
-
老年代收集器
-
整堆 / 混合收集器
(2)按并发能力
| 类型 | 说明 |
|---|---|
| 串行 | 单 GC 线程 |
| 并行 | 多 GC 线程,STW |
| 并发 | GC 与用户线程同时执行 |
(3)按目标
-
吞吐量优先
-
低停顿优先
-
可预测停顿
3、经典垃圾收集器总览(HotSpot)
java
新生代:
Serial
ParNew
Parallel Scavenge
老年代:
Serial Old
Parallel Old
CMS
整堆 / 混合:
G1
ZGC
Shenandoah
查看JDK默认垃圾收集器可以使用下面的命令:
java
java -XX:+PrintCommandLineFlags -version
打开cmd窗口,输入命令:

从图片可以看出,我的电脑默认使用的垃圾处理器是Parallel Scavenge(新生代) + Parallel Old(老年代), 也称为 Parallel GC / 吞吐量优先垃圾收集器。
4、各垃圾收集器详解
(1)Serial/Serial Old收集器
特点是:单线程(它的单线程不仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他的工作线程,直到它收集结束),全程STW,实现非常简单。
算法:
新生代使用复制算法,老年代使用的是标记-整理算法
适用的场景:单核、Client模式、小内存。
(2)ParNew收集器
特点:是Serial的多线程版本 ,新生代并行,常与CMS搭配使用
算法:
新生代使用复制算法,老年代使用的是标记-整理算法
适用场景:是许多运行在Server模式下的虚拟机的首要选择。
并行和并发的概念:
-
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
-
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
(3)Parallel Scavenge / Parallel Old收集器
目的是为了实现吞吐量最大化。
特点是:
Parallel 收集器通过多线程并行执行 GC 任务,在全程 STW 的前提下最大化 CPU 利用率,并借助自适应调节策略,动态调整堆内存结构以满足用户设定的停顿时间或吞吐量目标。
算法:新生代使用复制算法,老年代使用的是标记-整理算法。
适用场景:批处理、后台计算
自动调优:
1、什么是:
Parallel GC 内置了一套 自适应调节策略,JVM 会根据运行情况自动调整:
新生代大小
Eden / Survivor 比例
对象晋升阈值
GC 触发频率
目标是 满足你给定的性能指标。
2、两个核心调优目标参数:
(1)最大停顿时间
java-XX:MaxGCPauseMillis希望单次 GC 停顿不超过多少毫秒(软目标)
(2)吞吐量目标
java-XX:GCTimeRatio计算公式:
java吞吐量 = 应用运行时间 / (应用运行时间 + GC 时间)例如:
java-XX:GCTimeRatio=99表示希望 GC 时间不超过 1%。
3、如何"自动调"的:
Parallel GC 会在运行过程中:
记录 GC 次数、耗时
判断是否违反目标(停顿过长 or 吞吐不足)
动态调整堆结构参数
在下一轮 GC 中验证效果
反复迭代
这是一种 运行时反馈调节(Feedback Loop)
(4)CMS收集器(Concurrent Mark Sweep)
目标:获取最短回收停顿时间
特点:
- 老年代并发回收:第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
- 是基于标记-清除算法实现的,实现更为复杂。
- 会产生内存碎片。
标记-清除算法实现的四个步骤:
- 初始标记(STW):会短暂STW,标记直接与root相连的对象(根对象)
- 并发标记:同时开启GC和用户线程,这里会用一个闭包结构去记录可达对象。由于用户线程会不断的去更新引用域,所以GC线程无法保证可达性分析的时效性,不可能包含所有可达的对象。这个算法里会跟踪记录这些更新引用的地方。
- 重新标记:这个过程就是为了标记那些更新引用的地方,这个阶段的停顿时间会比初始标记的时间长,但是远远比并发标记的时间短的多。
- 并发清除:开启用户线程,同时GC线程开始清除未标记的地方。
缺点:
- 会产生大量的碎片空间(标记-清除法的缺点)
- 无法处理浮动垃圾
- 对CPU资源敏感
在JDK14中已经被淘汰了。
(5)G1收集器(Garbage First)
1)目标:在大堆内存下,提供可预测的低停顿垃圾回收。
2)核心设计思想:
-
Region 化内存:不再是连续的新生代/老年代
-
按收益回收:优先回收垃圾最多的区域(Garbage First)
-
可预测停顿:通过控制每次回收的 Region 数量
3)内存结构
Region是什么:
-
堆被划分为 多个等大小 Region
-
默认大小:1MB ~ 32MB(2 的幂)
-
每个 Region 在运行期可以扮演不同角色
Region的角色:
| Region 类型 | 说明 |
|---|---|
| Eden | 新创建对象 |
| Survivor | 存活对象 |
| Old | 晋升对象 |
| Humongous | 超大对象 |
| Free | 空闲 |
逻辑上分代,物理上不分代。
4)回收类型
Young GC(年轻代GC):
-
STW
-
只回收 Eden + Survivor
-
使用复制算法(跨 Region)
Mixed GC(混合GC)(核心):
-
STW
-
回收:所有年轻代 Region、一部分老年代 Region
这是 G1 替代 CMS 的关键
Full GC(兜底):
-
Serial Old
-
STW
-
非常昂贵
-
G1 尽量避免
5)并发标记周期
G1使用并发标记+标记-整理(Region),下面是各周期详解:
初始标记(Initial Mark):
-
STW
-
标记 GC Roots 直接可达对象
-
借用一次 Young GC 完成
这是 G1 相比 CMS 的一个优化点
并发标记(Concurrent Mark):
-
GC 线程 + 用户线程并发
-
遍历整个堆
-
统计每个 Region 的存活对象比例
最终标记(Final Mark):
-
STW
-
使用 SATB 写屏障
-
修正并发期间的引用变动
筛选回收(Live Data Counting & Evacuation):
-
STW
-
按 Region 垃圾比例排序
-
回收收益最高的 Region
-
控制停顿时间
6)关键机制
a、可预测停顿
通过
java
-XX:MaxGCPauseMillis
JVM 会:
-
估算回收一个 Region 的时间
-
在目标停顿内选出最多 Region
-
分批回收
b、写屏障策略
| 收集器 | 写屏障 |
|---|---|
| CMS | Incremental Update |
| G1 | SATB(Snapshot At The Beginning) |
SATB的核心思想是:在并发标记开始时,逻辑上冻结对象图。记录"被删除的引用",防止漏标。
c、Remembered Set(RSet)
-
每个 Region 都有一个 RSet
-
记录"哪些其他 Region 引用了我"
避免全堆扫描
d、Humongous 对象
-
超过 Region 的 50%
-
直接分配到 Humongous Region
-
回收成本高
7)与CMS对比
| 维度 | CMS | G1 |
|---|---|---|
| 内存结构 | 连续老年代 | Region |
| 回收方式 | 标记--清除 | 标记--整理 |
| 碎片 | 多 | 极少 |
| 停顿预测 | 不可控 | 可预测 |
| Full GC | 高风险 | 尽量避免 |
8)优缺点
**优点:**停顿可预测、大堆友好、减少碎片
缺点:实现复杂、额外内存开销(RSet)、小堆下未必比 Parallel 快
9)小结
G1 是一种面向服务端的大堆、低停顿垃圾收集器,通过 Region 化内存、并发标记以及按回收收益选择 Region 的方式,在可控停顿时间内完成垃圾回收,克服了 CMS 的碎片和停顿不可预测问题。
(6)ZGC收集器(现代低延迟)
1)目标:在任意堆大小下,将 GC 停顿时间控制在 10ms 以内
2)特点是:几乎全并发、STW是毫秒级的(不受队内存大小影响、牺牲了一些吞吐量换来的)、彩色指针+读屏障、Region化(ZPage)、并发标记+并发重定位
3)内存模型:
ZPage(不是传统 Region):
-
堆被划分为 ZPage
-
Page 大小不固定(小 / 中 / 大)
-
Page 可随时重定位
地址视图(Address View):
ZGC 把虚拟地址空间分成多个逻辑视图:
-
Marked
-
Remapped
-
Finalizable
这为 并发移动对象 提供基础。
4)彩色指针(最核心)
ZGC 在 64 位指针中"偷位"存状态信息:
| 位含义 | 说明 |
|---|---|
| Marked | 是否已标记 |
| Remapped | 是否已重定位 |
| Finalizable | 是否可终结 |
对象状态放在指针里,而不是对象头中。
5)ZGC 的读屏障(Read Barrier)
a、为什么必须读屏障
因为对象在GC过程中会被并发移动。用户可能访问到旧地址或者已被移动的对象。
b、读屏障干了什么
当用户线程执行时
java
Object o = obj.field;
JVM 会:
-
检查指针颜色
-
如果对象已被移动:
-
自动跳转到新地址
-
修正指针(自愈)
-
这个过程对 Java 代码 完全透明
6)回收流程
java
STW: Pause Mark Start (极短)
并发标记
STW: Pause Mark End (极短)
并发重定位(移动对象)
并发重映射(修正引用)
关键:对象移动是并发进行的,且没有传统意义上的"整理阶段STW"
7)如何避免G1和CMS的问题
| 问题 | CMS | G1 | ZGC |
|---|---|---|---|
| 碎片 | 严重 | 少 | 无 |
| 停顿预测 | 差 | 可预测 | 几乎恒定 |
| 堆大小影响 | 大 | 中 | 几乎无 |
| 对象移动 | STW | STW | 并发 |
8)代价
a、读屏障开销
-
每次对象读取都有额外检查
-
对吞吐量略有影响
b、只支持 64 位 JVM
- 依赖指针"偷位"
c、较新(JDK 11+)
9)适用场景
-
超大堆(几十 GB~TB)
-
低延迟系统
-
金融、实时分析、在线服务
10)小结
ZGC 是一种面向超大堆和低延迟场景的垃圾收集器,通过彩色指针和读屏障机制,实现对象并发标记与并发重定位,从而将 GC 停顿时间控制在极低水平,几乎不随堆大小增长。