JVM垃圾收集器
垃圾收集器
对象死亡的判断方法
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
缺点:单纯的引用计数就很难解决对象之间相互循环引用的问题。如 objA.instance = objB; objB.instance = objA;
1 | |
在VM加上参数-Xlog:gc*或者-XX:+PrintGCDetails可以看到
1 | |
1 | |
这两个对象实际上还是被回收了,说明JVM用的不是引用计数法判断对象是否存活。
可达性分析
通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
下图的Object5,Object6,Object7没有GC Roots能够到达,因此这三个对象已经死亡。
哪些对象可以作为 GC Roots 呢?
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
对象可以被回收,就代表一定会被回收吗
- 可以回收 (Eligible for Collection):这是一个状态。当垃圾收集器进行可达性分析后,发现某个对象到GC Roots没有任何引用链,就从逻辑上判定该对象已经“死亡”,不再被使用。此时,对象就进入了“可以被回收”的状态。这是程序逻辑上的确定性。
- 一定回收 (Will be Collected):这是一个动作。即使对象已经被判定为垃圾,垃圾收集器也不一定会立即执行回收动作。何时回收、是否回收,取决于GC器的具体实现、触发条件和工作策略。这是执行时机上的不确定性。
finalize() 方法
如果一个类重写了 finalize() 方法,那么它的对象回收过程会变得非常特殊且缓慢:
- 第一次GC时,JVM发现它不可达,会把它放入一个叫
F-Queue的队列,但不会立即回收它。 - 由一个低优先级的
Finalizer线程去慢慢执行队列中各个对象的finalize()方法。 - 执行完后,对象仍然处于不可达状态, then 需要等到下一次GC时才会被真正回收。
这意味着一个本可回收的对象,至少会熬过两次GC才会被真正释放。正因为这种巨大的不确定性和性能风险,finalize()方法已被官方标记为弃用。
JDK9之后,JEP421中,finalize()方法已经被弃用
引用的概念
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。
JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱),强引用就是 Java 中普通的对象,而软引用、弱引用、虚引用在 JDK 中定义的类分别是 SoftReference、WeakReference、PhantomReference。
强引用
以代码Object o = new Obejct();为例,指在程序代码之中普遍存在的引用赋值。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
用途:绝大部分的对象引用。
软引用
它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
用途:实现内存敏感的缓存。例如缓存图片、数据等大型对象。缓存的数据可以一直存在,直到需要为新对象腾出空间为止。
弱引用
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
用途:
- 实现规范化映射(Canonicalizing Mappings),如
WeakHashMap。它的键是弱引用的,一旦键对象没有其他强引用,它就可以被GC回收,对应的键值对也会从Map中自动移除。 - 用于监控对象是否已被回收。
虚引用
它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
完全不会影响对象的生命周期。你甚至无法通过 PhantomReference.get() 方法获取到原始对象(该方法总是返回 null)。如果一个对象仅被虚引用关联,那么它可以在任何时候被GC回收,就像没有引用一样。
用途:用于跟踪对象被垃圾收集的时机。当GC准备回收一个对象时,如果发现它还有虚引用,就会在回收它之后,把这个虚引用加入到与之关联的 ReferenceQueue 中。程序通过监控这个队列,就能知道某个对象何时被回收了。
引用队列(ReferenceQueue)
- 软引用、弱引用和虚引用都可以在创建时关联一个
ReferenceQueue。 - 当被引用对象被垃圾回收器处理之后,这个引用对象本身(如
WeakReference实例)就会被加入到这个队列中。 - 程序可以通过监控这个队列,来了解哪些对象已经被回收,从而执行一些后续的清理工作(例如,从缓存映射中移除对应的条目)。
回收方法区
字符常量池
在JDK 7及以后版本,字符串常量池从永久代(PermGen)移到了Java堆(Heap)中,因此字符串常量池中的字符串也会被回收。
假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了
1 | |
intern()方法
intern()方法会返回字符串常量池
1 | |
运行时常量池
运行时常量池在JDK8之后被移到元空间中,主要回收的是类的类型,那么如何判断一个类是无用的类的呢?
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader已经被回收。 - 该类对应的
java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收
垃圾收集算法
标记-清除算法
它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
主要缺点:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片。

标记-复制算法
它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
主要缺点:
- 这种复制回收算法的代价是将可用内存缩小为了原来的一半
- 不适用于老年代,如果对象很大,则性能很差
Apple式回收:Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次MinorGC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。
同时依赖于动态年龄阈值算法,让Survivor中占用空间超过一半的对象年龄作为阈值,当年龄大于阈值时,直接进入老年代。

标记-整理算法
根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
主要缺点:
因为多了整理的步骤,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。
由于标记-整理的整理移动[2]需要STW(Stop-The-World),需要暂停所有线程,因此效率低下。

分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择“复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
HotSpot实现细节
根节点枚举
根节点枚举是垃圾收集过程中的第一步,它的任务是找出所有“GC Roots”对象。
GC Roots(根节点) 是一组必须存活的对象引用,它们是遍历对象图的起点。如果一个对象被GC Roots直接或间接地引用着,那它就是存活的;否则,它就是可回收的垃圾。
| GC Roots 类别 | 具体例子 | 为什么必须是根? |
|---|---|---|
| 虚拟机栈中的引用 | 各个线程的方法栈帧中的局部变量、参数等。 | 这代表了程序正在执行的上下文,这些对象必须存活。 |
| 本地方法栈中的引用 | JNI(Java Native Interface)引用的对象。 | native方法正在使用的对象,必须存活。 |
| 方法区中静态引用 | 类的静态变量(static fields)。 |
类信息是长期存在的,其静态变量自然也不能被回收。 |
| 方法区中常量引用 | 被 final 修饰的常量。 |
常量是稳定不变的基础,必须存活。 |
| 被锁持有的对象 | 正在被同步锁(synchronized)持有的对象。 |
锁机制依赖的对象,必须存活以保证同步正确性。 |
| Java虚拟机内部引用 | 基本类型对应的Class对象、系统类加载器等。 | JVM运行的基础,必须存活。 |
为什么根节点枚举必须“停顿所有用户线程”?
这是您问题中最关键的部分。根节点枚举过程必须在一个能确保一致性的瞬间快照中完成。但是这个过程在HotSpot中非常快,因为GC Roots的数量相对于整个堆中的对象来说是非常少的,并且现代JVM有高效的优化手段(如使用OopMap数据结构直接记录引用位置),使得停顿时间极短。
安全点
JVM 并不需要在指令流的任意位置都能进行 GC,它只需要在一些特定的、被称为“安全点”的位置上暂停线程即可。在这些位置上,JVM 能够完整地掌握线程的执行状态。
如果没有安全点,JVM 要进行 GC 时,它需要:
- 向所有线程发出中断信号。
- 线程收到信号后,必须在接收到信号的当下这条指令执行完后,立刻在任意位置暂停。
- GC 线程开始工作。
这听起来合理,但实际上有个致命问题:在任意位置暂停时,JVM 可能无法准确知道当前线程的栈帧和寄存器里哪些是对象引用(GC Roots)。
如果采用OopMap来唯一任意时刻的任意指令的根节点枚举,那么维护这个OopMap的代价过大,也不可持续。因此,指令只能在安全点处才能进行GC。JVM规定了线程只有在执行到安全点时,才能被安全地挂起以进行 GC。在这些位置上,线程的内存状态是确定的、可枚举的。
安全点的位置:
- 方法调用(Call Instructions)
- 循环回边(Loop Backedges)
- 异常抛出(Exception Throwing)
抢先式中断
先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
主动式中断
主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
内存保护陷阱
由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。当需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。
现代JVM工作流程
现代 JVM 普遍采用 “主动式中断” 策略。它的流程如下:
JVM 发出 GC 请求:
当需要发生 GC 时,JVM 设置一个全局标志位(SafepointSynchronize::_state),表示现在需要进入安全点。线程轮询(Polling):
- 每个线程在运行过程中,会主动地、频繁地去检查这个全局标志位。
- 但是,它不会在每条指令后都检查,那样开销太大。检查只发生在即将执行安全点指令之前(比如方法调用前、循环跳转前)。
- 线程响应与挂起:
当线程执行到安全点指令并准备检查时,如果发现全局标志位已被设置,它就不会继续执行原来的业务逻辑。
而是立即在这个安全点挂起自己,并记录下自己当前精确的执行状态(包括下一条要执行的指令地址、寄存器值、栈帧等)。
- JVM 等待与确认:
- JVM 等待所有线程都成功挂起到最近的安全点上。
- 一旦所有线程都挂起,JVM 就获得了整个堆和所有线程栈的一个一致性的快照。
- 执行 GC:
- 在这个绝对静止的时刻,GC 线程开始工作,进行根节点枚举和后续的垃圾回收。
- 因为每个线程都停在了安全点,JVM 可以轻松地使用 OopMap 来找到每个线程栈帧和寄存器中的对象引用(GC Roots),而无需猜测。
- 恢复执行:
- •GC 完成后,JVM 清除全局标志位。
- •所有线程被唤醒,从安全点继续执行原来的业务代码。
安全区域
用户在不可执行的线程下如何进行GC,典型的场景便是用户线程处于Sleep状态或者Blocked状态(例如处在Thread.sleep(), Object.wait(),或者被IO阻塞),这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
- 当一个线程执行到安全区域时,它会标识自己已经进入了安全区域。
- JVM需要发起GC时,它完全不需要关心已经处于安全区域内的线程。
- 当这些线程要离开安全区域时,它必须检查GC是否已经完成。如果GC尚未完成,它必须等待,直到GC完全结束后才能离开。
记忆集与卡表
跨代引用
假设发生了一次只针对年轻代的 Minor GC,但是老年代中的对象可能引用了年轻代的对象,那么这些被引用的对象是不能回收的。因此JVM采用了记忆集来记录这种引用关系。
记忆集
记忆集是一种抽象数据结构,用于记录从非收集区域指向收集区域的指针的集合,即上述假设中从老年代指向年轻代的引用。
精度:记忆集只是一种抽象概念,它可以通过多种数据结构实现,实现不同的记录精度:
- 字长精度:记录精确的机器字长地址,该字包含跨代指针。
- 对象精度:记录包含跨代引用的整个对象,该对象里有字段含有跨代指针。
- 卡精度:这是最常用的,也就是下面要讲的“卡表”,该区域内的对象里有字段含有跨代指针。
卡表
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
写屏障
JVM在创建卡表将整个堆内存(Heap)划分成很多个连续的大小固定的“卡页”(Card Page),比如每个卡页是512字节。这些卡页的集合就是“卡表”,通常是一个字节数组(byte[])。然后在当程序执行过程中,只要有更新对象字段的操作(例如 objA.field = objB),并且这个操作有可能产生跨代引用(比如让一个老年代对象引用了一个年轻代对象),JVM就会在写操作之后,执行一个写屏障[3](Write Barrier)。
写屏障:在程序执行特定操作(通常是对象引用写入)时自动执行的额外代码。它与Spring中的AOP切面编程类似,一旦对象增加引用,就会自动触发写屏障。
意味着在程序运行中,如果有一个老年代的对象引用了年轻代的对象,就会触发写后屏障,写后屏障查询老年代位于哪个卡页,然后将卡表中的该卡页条目置为“脏卡”。
伪共享
伪共享是计算机体系结构中的一个概念,与CPU缓存有关。
- CPU缓存行(Cache Line):CPU从主内存(RAM)中读取数据时,不是按字节读取,而是按一块固定大小的数据块来读取,这个块称为“缓存行”。常见的大小是64字节。
- 核心问题:如果两个无关的变量(例如
变量A和变量B)恰好位于同一个64字节的缓存行上,那么:- 线程1(在CPU核心1上运行)只想修改
变量A。 - 线程2(在CPU核心2上运行)只想修改
变量B。 - 根据缓存一致性协议(如MESI),当一个核心修改了缓存行中的任何数据,都会导致其他核心中整个缓存行失效,需要重新从内存或L3缓存中加载。
- 尽管线程1和线程2修改的是不同的变量,但它们却因为共享同一个缓存行而互相干扰,导致缓存频繁失效,性能急剧下降。这种现象就是“伪共享”。
- 线程1(在CPU核心1上运行)只想修改
因此如果一个缓存行为64B,而一个卡表元素为1B,意味着可以存64(元素个数)*512Bytes(分页大小)=32KB的卡页大小,如果不同线程的更新刚好位于这一个卡页内,就会频繁出现缓存失效的问题。
解决方法:条件检查和卡表填充
条件检查
更新卡表前需要先检查脏标记是否已经为脏。
1 | |
但是,如果两个线程同时去对一个卡表位置写“脏”,此时会发生:
- “检查”操作本身就需要读取缓存行。两个线程都要先执行
if (card_table[card_index] != DIRTY)。这需要将包含该卡表条目的缓存行加载到它们各自的CPU核心缓存中。 - 即使检查失败,写操作也可能发生。如果两个线程都看到该卡表条目是0(干净),它们都会执行接下来的写操作
card_table[card_index] = DIRTY。 - “写”操作会导致缓存行失效。当一个线程(例如CPU核心1)成功写入后,根据MESI协议,该缓存行在另一个线程(CPU核心2)的缓存中会立即失效,变为“已修改”状态。
- 核心2需要重新加载失效的缓存行。这会导致缓存未命中(Cache Miss),必须从更慢的L3缓存或主内存中重新加载数据,从而造成延迟。
虽然条件检查不能避免伪共享,但是依然带来两个好处:
- 减少缓存污染(Reduce Cache Pollution):
- 如果一个卡表条目已经被标记为脏了,条件屏障会跳过后续的写入操作。
- 这避免了多个线程反复地对同一个已经是脏状态的缓存行进行写入。虽然第一次写入已经造成了伪共享,但后续的写入可以避免,这仍然能减少总线的流量和缓存同步的压力。
- 提升应用线程速度:
- 写屏障是嵌入在应用程序的每一次对象字段写操作之后的额外开销。
- 如果卡表已经是脏的,跳过写入操作可以节省掉一次内存存储(Memory Store)的开销,让应用线程的执行速度更快一些。
卡表填充
通过增加无用的填充字节,人为地将可能会被不同线程并发修改的卡表元素隔离到不同的缓存行。
假设缓存行是64字节。我们可以这样设计卡表:
- 不是存储
[B0, B1, B2, B3, ..., B63](一个缓存行) - 而是存储
[B0, 填充63字节], [B1, 填充63字节], [B2, 填充63字节], ... - 这样,每个卡表元素
Bx都独占一个缓存行。一个线程修改B0永远不会影响另一个线程修改B1,因为它们在不同的缓存行上。
JVM通过条件检查和卡表填充相互组合来完成卡表更新。
工作流程
- 写操作拦截:
- 程序运行中,一个老年代对象
OldA的字段被修改,指向了一个年轻代对象YoungB:OldA.field = YoungB。 - 写屏障 被触发,它发现
OldA位于老年代,而YoungB在年轻代,这是一个跨代引用。 - 写屏障查询
OldA的地址,计算出它属于哪个“卡页”。 - 然后将卡表中对应该卡页的条目标记为
1(脏卡)。
- 程序运行中,一个老年代对象
- Minor GC 触发:
- 年轻代Eden区满了,需要触发Minor GC。
- 在根节点枚举阶段,GC器除了从传统的GC Roots(栈、静态变量等)开始遍历,还会加入一个额外的根集合:脏卡对应的内存区域。
- 扫描脏卡:
- GC器并不扫描整个老年代,而是只扫描那些被标记为“脏”的卡页。
- 它会遍历这些脏卡页中的每一个对象(比如
OldA),然后把这些对象当作额外的GC Roots。 - 然后从这些“根”出发,去遍历它们引用的对象(比如从
OldA找到YoungB)。如果YoungB被OldA引用,那么它就会被标记为存活,不会被回收。
- 清理卡表:
- 在GC结束后,JVM可能会清理卡表中的脏标记位,为下一轮记录做准备。
并发的可达性分析
所有标记算法都必须依靠可达性分析算法来判断对象是否存活,而可达性分析又依赖于一致性快照,意味着在标记时,必须STW(Stop The World),而这一STW时间与堆的大小成正比,为了减少这一停顿,提出了三色标记算法。
三色标记算法(Tri-Color Marking)
除GC Root之外的所有对象都被标记为三种颜色:
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
三色标记法会带来两个问题:浮动垃圾和对象消失。
- 浮动垃圾:当三色标记和用户线程并行时,有黑色节点新指向白色节点,由于黑色不会重新扫描,意味着白色节点在本次GC中存活,出现浮动垃圾(可以容忍,下次GC时有机会清理)。
- 对象消失:1)赋值器插入了一条或多条从黑色对象到白色对象的新引用;2)赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。如下图所示。就会导致原本应该存活的节点被标记为白色,导致了对象被错误回收。为此我们有两种解决方法,分别为增量更新和原始快照。

增量更新(Incremental Update)
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照(Snapshot At The Beginning,SATB)
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
这两种方案都是通过写屏障来实现的,在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CM S是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。
经典垃圾收集器
如果两个收集器之间存在连线,就说明它们可以搭配使用。上图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
Serial收集器
顾名思义Serial就是串行收集器,不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
新生代采用标记-复制算法,老年代采用标记-整理算法。

虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
ParNew
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
新生代采用标记-复制算法,老年代采用标记-整理算法。

它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
并行和并发概念补充:
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
Parallel Scavenge
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CM S等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
$$
吞吐量=\frac{用户代码运行时间}{用户代码运行时间+GC收集时间}
$$
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial Old
Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old
Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS(Concurrnet Mark Sweep)
从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
初始标记: 短暂停顿,标记直接与 root 相连的对象(根对象);
并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

优点:
- 并发收集
- 低停顿
缺点:
- 对 CPU 资源敏感;
- 无法处理浮动垃圾,导致
Concurrent ModeFailure; - 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
Concurrent ModeFailure可能原因及方案 :
- 原因1:CMS触发太晚
- 方案:将-XX:CMSInitiatingOccupancyFraction=N调小 (达到百分比进行垃圾回收);
- 原因2:空间碎片太多
- 方案:开启空间碎片整理,并将空间碎片整理周期设置在合理范围;-XX:+UseCMSCompactAtFullCollection (空间碎片整理) -XX:CMSFullGCsBeforeCompaction=n
- **原因3:**垃圾产生速度超过清理速度 晋升阈值过小;Survivor空间过小,导致溢出;Eden区过小,导致晋升速率提高;存在大对象;
CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除。
G1(Garbage First)
特点:
并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体(所有Region之和)来看是基于“标记-整理”算法实现的收集器;从局部上(两个Region之间)来看是基于“标记-复制”算法实现的。
可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
工作流程:
初始标记: 短暂停顿(Stop-The-World,STW),标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象
并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。
最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
**从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器
ZGC(低延迟GC)
与 CMS、ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。
ZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。
ZGC 在 Java11 中引入,处于试验阶段。经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java15 已经可以正式使用了。
不过,默认的垃圾回收器依然是 G1。你可以通过下面的参数启用 ZGC:
1 | |
在 Java21 中,引入了分代 ZGC,暂停时间可以缩短到 1 毫秒以内。
你可以通过下面的参数启用分代 ZGC:
1 | |
- 通常标记-清除算法也是需要停顿用户线程来标记、清理可回收对象的。问题不在于清除部分(清除部分没有线程会重新访问可回收的对象,此时不存在并发问题),而在于标记部分,1)会导致浮动垃圾:GC线程已经标记完了对象A和它引用的对象B。然后,用户线程断开了A指向B的引用。结果:对象B实际上已经死了,但因为已经被标记为存活,所以本次GC不会回收它。它成了“浮动垃圾”,只能等到下次GC才能被清理。这是可以接受的;2)GC线程在遍历对象图时,访问了一个对象O,发现它没有指向任何其他对象(比如
obj.field = null),于是GC线程继续往前走。就在这个时候,用户线程执行了obj.field = anotherObj,建立了一个新的引用关系。结果:这个新引用的anotherObj对象,因为GC线程已经完成了对它的父对象的检查,所以它不会被标记为存活。即使现在有活跃的用户线程正在访问它,它也会在后续的清理阶段被错误地回收!这会导致程序访问一个不存在的对象,引发不可预知的崩溃。这是绝对无法接受的! ↩ - 区别于低延迟收集器的“读屏障”和“解决并发乱序问题”的内存屏障。 ↩