GC 的目标区域,哪里需要动态分配 / 回收内存?#
JVM 的内存划分中,程序计数器、JVM 栈、本地方法栈与线程绑定,栈帧的结构,分配内存大侠也可以在类结构确定是基本就可确定,这几个区域的内存分配与回收具有确定的性。当线程结束 / 方法结束时,回收内存。
Java 堆和方法区有着较高的不确定性:一个接口的多个实现类所需内存可能不同,方法执行的不同条件分支所需内存也可能不同,只有运行时才能确定内存需求,需要动态分配 / 回收内存。也即是 GC 的主要目标区域。
GC 的目标对象,哪些引用算引用?#
Strongly Reference,强引用,在数据类型部分介绍的最为标准、普通的引用,只要从 GC Root 到强引用对象存在强引用关系,就不会被回收。
Soft Reference,软引用,用来描述有用,但非必须的对象,这种被软引用关联的对象在系统将发生 OOM 时,会将其例入回收范围,进行第二次回收,如果这次回收完还是内存不够,才会抛出 OOM。SoftRefernce类实现软引用,这种对象的一种使用场景是缓存。
Weak Reference,弱引用,相比软引用更弱一些,这种被弱引用关联的对象,只能生存到下一次 GC。无论内存是否足够,都会回收只被弱引用关联的对象。WeakReference类实现弱引用。
Phantom Reference,虚引用,也称幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用存在对其生存不会有任何影响,也无法通过虚引用来获取该对象实例。为对象设置虚引用的唯一目的在于能够在其被回收时收到一个系统通知。PhantomReference类用来实现虚引用。
GC 的目标对象,如何判定对象是否是垃圾?#
引用计数#
在对象中添加一个引用计数器,每有一个对该对象的引用,就给计数器 +1,当回收时,判断哪些对象的引用计数器为 0,计数器为 0 的对象就是 GC 的目标。
但是存在这样一个问题:
对象 A 和对象 B 相互引用,没有任何其他对这两对象的引用,事实上对程序已经没有作用,应该被回收,但是两者相互引用,计数器均为 1,无法被回收。

所以在 Java 各种主流虚拟机实现中,都没有使用该方法进行 GC。
可达性分析#
基本思路是通过将一系列属于 “GC Root” 集合的对象作为起始节点,从这些节点开始根据引用关系进行向下搜索,搜索路径称为 “引用链”Reference Chain,引用链中不存在的,即以 GC Root 中任何一个节点为起始点都不可达的对象,即为回收目标。下图中,objH 和 objI 以及 objH 均为不可达的节点,是回收目标。
存在的问题就是,哪些对象属于 GC Root 集合?
基于经验和具体需求,固定属于 GC Root 的对象包括:
- 在 JVM Stack 中引用的变量(栈帧中的本地变量表),如当前运行方法的局部变量、参数、临时变量等。
- 在 Method Area 中类静态属性引用的对象,譬如 Java 类的引用类型静态变量,下面代码块中的
someClazz。
public class SomeClazz {
public static String str = "hello, 2022";
}
public class T {
//someClazz 属于 GC Root 集合
public static SomeClazz someClazz = new SomeClazz();
}
- 在 Method Area 中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中 JNI(即 Native 方法)引用的对象。
- JVM 内部的引用,如基本类型对应的 Class 对象,常驻异常对象(如 NullPointException、OutOfMemeryError),类加载器等。
- 被同步锁(synchronized 关键字)持有的对象。
- 反映 JVM 内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
此外,根据所选 GC 实现不同以及内存回收区域不同,部分对象会临时加入 GC Root 集合。
finalize,被标记为回收对象的行为机制#
可达性分析会标记那些不可达的对象,在进行实际清除前,会进行筛选,这些对象还有一次免除被回收的机会,GC 判断对象的 finalize() 方法是否被重写,如果没重写,则会进行清除,如果重写了,就判断是否执行过 finalize() 方法,如果没执行,就将对象放入一个名为 F-Queue 的队列,等待执行 finalize() 方法,稍后 JVM 会创建一条 Finalizer 线程取执行队列中对象的 finalize() 方法。不过 JVM 只需要看到这个对象开始执行,但不保证会等它执行结束,防止 finalize 执行缓慢或陷入死循环导致整个内存回收 crash。
当 GC 将重写且未执行 finalize 方法的对象放入 F-Queue 之后,稍等片刻,GC 会再次扫描这个 F-Queue 中的对象,如果这些对象在 finalize 执行时进行了某些操作,让自己变得可达(重新与引用链上任何一个对象建立关联),就会被移出本次 GC 对象的集合,完成 “自救”。
也许再过一段时间,完成 “自救” 的对象再次被标记为不可达,这时候,该对象不会被放入 F-Queue,必定会被回收,因为它已经执行过一个 finalize() 方法了。
“自救” 只是一种表述,实际上官方并不推荐使用 finalize () 方法,这与 C/C++ 中的析构函数有所不同,它运行代价高,各个对象调用顺序不确定。也许只是 Java 诞生之初为了使 C/C++ 编程者更容易接收所做出的妥协。
将其称为 “可用来最后回收资源” 的说法也并不可靠,这项行为可以用更专业更好用的 try-finally 完成。
Method Area 回收#
对方法区进行回收是一个可选项,不同的 GC 有不同的选择,有的选择完全实现,有的实现一部分,有的则没有实现。
对方法区的回收性价比相对堆的内存回收而言较低,因为对方法区的回收较为严格。
方法区回收分为废弃常量回收和不再使用的类型回收。
类型回收时需要满足三个条件:
- 该类的所有实例都已经被回收,也就是 JVM Heap 中不存在该类或其派生子类的任何实例。
- 加载该类的类加载器已被回收,这是个十分刁钻的情景,只有精心设计的可替换类加载器的情况下,例如 OSGi、JSP 的重加载等,否则通常是很难达成的。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
只有在这三种情况下,JVM 才允许 GC 对其进行回收,但是具体是否回收由 GC 具体实现决定。
HotSpot 虚拟机的 -Xnoclassgc 参数可以控制是否回收。
在大量使用反射、动态代理、CGLib 等字节码框架的情况下,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 JVM 具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
GC 指导思想#
从如何判断对象要回收的角度看,GC 算法可分为 “引用计数式”(Reference Count)和 “追踪式”(Tracing)两类,也称 “直接 GC” 和 “间接 GC”。此处主要讨论 “追踪式 GC”。
分代收集理论(假说)#
这是一个由经验指导的结论。
它有两个假设:
- 弱分代假说(Weak Generation Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generation Hypothesis):熬过越多次垃圾收集过程的对象就越难消亡。
即强者愈强,弱者愈弱。
基于这样的经验结论假说,多款 GC 都将 Java 堆划分出不同的区域,将回收对象依据其年龄(熬过回收的次数)分配到不同的区域中存储。
显而易见,如果一个区域中大多数对象都短命,将其几种放在一起,每次回收时只关注如何保留少量存活的对象,而不用考虑标记大量被回收的对象,就能以较低代价回收大量空间。如果剩下的都是长命的对象,将其放在一起,虚拟机就以较低的频率进行 GC。这样同时兼顾了 GC 的时间开销和内存空间的有效利用。
在 HotSpot 中,即为新生代和老年代两个区域。
在 JVM 堆划分出不同区域之后,GC 就需要实现每次只回收某一个或某部分区域。
不过这种分代方式存在问题:对象之间存在跨代引用。
这使得单独回收某一区域的变得困难,因为可能在新生代中的对象被老年代引用,要回收新生代就需要遍历老年代获取信息,这样的代价是很大的。
因此分代收集假说进行了补充:
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说,仅占极少数。
如果存在老年代引用新生代的情况,那么因为老年代的引用,新生代会随着年龄增长进入老年代,跨代引用会被消除。
由此,不应该在回收新生代时遍历整个老年代,也无需记录每个对象是否存在,存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(“记忆集”,Remembered Set),这个结构将老年代进一步划分为若干块,标识出老年代的那一块存在跨代引用。
当发生新生代 GC 时,只有包含了跨代引用的小块内存中的对象被加入 GC Root 进行扫描。
这种方式需要在改变对象引用关系时进行一定操作以维护数据的正确性,会增加一些运行时开销,但相比于扫描整块老年代,是很划算的。
基于分代收集假说的 GC 算法#
标记 - 清除算法#
最基础的方式,就是哪些需要回收,对其进行标记,随后清除。
这种方式存在两个显著问题:
一是执行效率不稳定,如果 JVM Heap 中对象太多,而且其中大部分都是需要被回收的,这时候必须进行大量标记和清除动作。
二是内存碎片化的问题,多次清理后,内存产生大量不连续碎片,这会导致碎片化空间无法满足一个大对象的时候触发新一次 GC。
标记 - 复制算法#
也称为 “半区复制”,基本思路是将内存化为两部分,只使用其中一部分,当进行 GC 时,就标记可以留下的对象,然后将这些存活对象复制到另外一部分,之后使用另外一部分,从而有助于解决的内存碎片的问题。

但这样做的代价是内存的可用容量下降。
现在商用 JVM 很多都使用这种方式去回收新生代的对象。
IBM 有一项对新生代 “朝生夕灭” 的特点进行量化阐释的研究,新生代中对象 98% 熬不过第一轮回收,因此不需要按照 1 的比例划分新生代内存。
1989 年,Andrew Appel 针对具有 “朝生夕灭” 的对象提出了更优化的半区复制分代策略,称为 “Appel 式回收”:
将新生代划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配只使用 Eden 和一块 Survivor。
发生 GC 时,将 Eden 和 Survivor 中仍然存活的对象复制到另外一块 Survivor 上,然后直接清理掉 Eden 和已用的 Survivor 部分。

HotSpot 的 Serial、ParNew GC 等新生代收集器均采用 “Appel 式回收” 策略设计新生代内存布局。其默认 Eden 与 Survior 分区比例为 8:1。即新生代 10% 的内存是被 “浪费” 的。
不过这种设计的还有一种场景问题,如果 所有对象都不可被回收,那么将 Eden 和 Survivor 分区中的对象复制到另外一个 Survivor 中空间是不够的。它还有设计有一个兜底机制 “逃生门”:
当一个 Survivor 空间不足以存下此次 GC 的存活对象时,就要依赖于其他内存区域(如老年代,相当于开启新生代到老年代的晋升通道)进行分配担保。
标记 - 复制的另外一个问题就是复制操作是耗时的,这在对象存活率不高的情况下,这不成问题。
标记 - 整理算法#
对标记 - 复制的选择,如果不想浪费大量时间用于复制,就需要尽可能缩小备用区的大小,这就导致必须对意外情况进行兜底,但是在老年代中,是没东西可以兜底的。
因此在老年代中选用的是标记 —— 整理算法,在回收时对所有存活对象进行标记,将其向内存空间另外一侧移动,直接清除标记之外的内存。
移动与复制的差别并不大,但是这能够免除需要兜底机制的问题。
与标记清除相比,它可以解决内存碎片的问题。
因此在老年代回收中被采用。
另外一种方案就是,一般情况下使用标记 - 清除算法,暂时容忍内存碎片,当内存碎片过多时,使用标记 - 整理算法进行清除。CMS 收集器在面对大量内存碎片的方案即是如此。
一些细节问题#
GC Root 根节点枚举#
GC Root 固定包含的一些对象(全局性引用、执行上下文),其内容是十分庞大的,如果直接对 GC Root 进行遍历,再查找子节点,是较为耗时的操作。
而进行这样的检查时,为了保证此次 GC 的正确性,就需要暂停用户线程(否则用户线程可能同时在更改引用关系),至少在进行 GC Root 扫描时,它用来扫描的引用关系,不应该改变,就像冻结于某个时间点。
当用户线程停下来之后,JVM 实际上不需要一个不漏的检查所有执行上下文和全局的引用位置,这太费劲了,我们希望有这样一种数据结构,让 JVM 可以有办法得知哪些地方存放着对象引用。
在 HotSpot 中,使用了一组称为 OopMap 的数据结构来实现这个目的。
一旦类加载完成,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译中,会在特定的位置记录下栈内和寄存器里哪些位置是引用。这样收集器就可以得知这些信息。
SafePoint 安全点#
在 OopMap 的协助下,HotSpot 可以快速准确完成 GC Root 枚举,但是,每个时间段,引用关系是变化的,OopMap 的内容就要随之变化。如果每个指令都维护一个 OopMap,内存和时间成本都太大。
因此,我们只需要在某些特定位置记录这些信息。这些特定的位置称为 “安全点”。只有一些特别的指令才会有安全点。
只有当用户线程处于安全点时,才能进行 GC。
这引出了第二个问题,如何保证每个线程都处在安全点下?
两种选择:抢占式中断和主动式中断
抢先式中断不需要线程的执行代码主动配合,在 GC 时,系统首先把所有用户线程全部中断,如果有线程不处于中断点,就让其继续跑,稍后再中断,直到跑到安全点上。
主动式中断是在 GC 时,设置一个标志位,线程执行中不断轮询(该操作被优化为单个汇编指令)该标志位,一旦发现中断标志就自己在最近的安全点上主动中断。这有点类似于 CPU 中中断的实现。
SafeRegion 安全区#
安全点对于运行中的线程时有效的,但是挂起的线程(比如通过 Thread.sleep),无法响应 JVM 的中断请求,JVM 也不可能总是等着线程被激活再走到安全点。
因此引入安全区的机制。在安全区内,引用关系不会发生变化,在安全区任意一点进行 GC 都是等效、安全的。
当用户线程执行到安全区内的代码时,将自己标识为处于安全区中,这时候,GC 就不需要再关心这些具有安全区标识的线程了。
当用户线程要离开安全区时,要检查 JVM 是否完成 GC Root 枚举(或是 GC 中其他需要暂停用户线程的阶段),完成了,那就继续执行,没完成就停留在安全区等待信号。(这里有点像 “线程同步”)
RememberedSet 记忆集与卡表#
记忆集用于解决跨代引用的记录问题,需要记录非目标区域中对目标区域的对象有哪些引用。
在实际实现中,记忆集使用名为卡表的数据结构来实现。(类似于内存中的分页分块机制)

简单的卡表可以视作一个 byte 数组,每个元素都对应着一块特定大小的内存块,称为 “卡页”,数组中记录着对应卡页是否存在对其他区域的引用。在 HotSpot 中,一个卡页大小为 29,也就是 512 B。

对卡表进行扫描,只需要将卡表中标记为脏数据的卡页中的对象加入 GC Root 进行扫描。
写屏障#
即使有了卡表结构,但是每次引用修改的时候,还是需要维护卡表的内容,保证其正确性。
需要回答谁、何时将卡表变脏的问题。
当其他分代区域中对象引用了本区域对象时,对应的卡表元素应该变脏,理想情况是当引用类型字段赋值时,同时更新卡表。
在解释执行的情况下,虚拟机可以自行介入,但是在编译执行的情况下,执行的是机器码,虚拟机需要一种手段在机器码层面将维护卡表操作放入赋值操作。在 HotSpot 中,这种技术称为 “写屏障”。
写屏障,在引用对象赋值时产生一个环形通知(类似于 AOP),供程序进行额外操作,赋值前后都在写屏障的覆盖范围内。
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值
*field = new_value;
// 写后屏障,完成卡表状态更新
post_write_barrier(field, new_value);
}
应用写屏障后,虚拟机就会为所有有赋值操作生成相应指令。
伪共享问题,根据 CPU 的 Cache 设计,多个卡表可能会共享同一个缓存行,不同线程对不同对象进行引用对象的修改操作,导致需要写同一个卡表,两个线程可能命中同一个缓存行,就会彼此影响,导致写回、无效化或是同步,降低性能。
要避免伪共享,一种思路是不采用无条件的写屏障,而是先检查卡表标记,当卡表未标记时才将其变脏。
if (CARD_TABLE[this address >> 9] != 1) {
CARD_TABLE[this address >> 9] = 1
}
JDK 7 之后新增参数 -XX:+UseCondCardMark,可以选择是否开启卡表更新的条件判断。
并发情况下的可达性分析#
可达性分析需要能确保一致性的快照进行,假设某一个时刻引用关系如下,从根节点开始,进行 DFS 遍历,当前正在搜索的节点颜色置灰,未搜索节点为白色。
当进行可达性分析时,其过程大致如下

如果在分析过程中,对引用进行更改,就会导致结果错误。

因此可达性分析要保证正确性。
要么在分析过程中不改动引用关系,暂停用户线程。但是这种代价是昂贵的,如果堆很大,耗时也会很长,耗时长短取决于堆的大小。
要么在扫描完成后对结果进行修正,这可以不暂停或是降低暂停用户线程的时间。
并发进行可行性分析存在的问题只有两种,一是将本应该被 GC 的对象纳入到集合里面。二是将本不应该被 GC 的对象排除在集合外面。
第一个问题不会导致什么后果,这次没 GC 掉可以等下次,是可以容忍的。
第二个问题则可能导致 GC 后面仍然需要的对象,破坏程序的正确性。称为 “对象消失”
当且仅当同时满足以下两个条件时,会出现 “对象消失” 问题。
- 赋值时插入了一条或多条从黑色对象到白色对象的新引用。
- 删除了全部从灰色对象到该白色对象的直接或间接引用。
因此要解决该问题只需要破除两个条件其中一个即可。从而产生了两种方案:原始快照和增量更新。
增量更新,破除条件一,当黑色对象插入新的对白色对象的引用时,就将其记录下来,当扫描结束时以这些记录的黑色节点为根,再扫描一遍。(CMS 使用)
原始快照,破除条件二,当灰色对象删除对白色对象的引用关系时,将其记录下来,当扫描结束时以这些记录的灰色节点为根,再扫描一遍。(G1、Shenandoah 使用)