探秘 JVM:垃圾收集机制

JVM 中的程序计数器、java 虚拟机栈、本地方法栈均属于线程私有,其生命周期控制在线程生命周期范围内,并且 java 虚拟机栈和本地方法栈中的的栈帧会随着对应方法的执行而出栈和入栈,由生到灭,所以这些区域的内存使用是确定性的(编译期已知)。然而,Java 堆和方法区是线程共享的,对象的创建也是动态的,所以垃圾收集的主战场集中在 java 堆和方法区。

相对于 java 堆而言,方法区的垃圾收集性价比要低很多,JVM 规范甚至不强制要求 JVM 在方法区实现垃圾收集。不过对于复杂应用,尤其是大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP,以及 OSGi 这类频繁自定义类加载器的场景中,对于方法区实现垃圾收集还是有必要的。方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。对于一个类型是否不再被使用,JVM 在判定时需要同时满足以下 3 个条件:

  1. 该类所有的实例都已经被回收了,也就是说堆中不存在该类及其派生子类的任何实例。
  2. 加载该类的类加载器已经被回收了。
  3. 该类对应的 Class 类对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

引用类型与可达性分析

Java 中的引用类型

我们一般对于引用的定义是存放某个对象地址的对象,而 java 对于引用的定义则更加细化。自 JDK 1.2 起,Java 将引用细分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference),以及虚引用(Phantom Reference) 4 种,强度逐级由强到弱。

  • 强引用 :通常在程序中存在的引用都是强引用,比如 Object ref = new Object();,这类引用的特点是只要存在,垃圾收集器就不会对被引用的对象执行回收操作。
  • 软引用 :通过继承 SoftReference 类实现,描述一些还有用但非必须的对象,JVM 会在发生 OOM 之前将这些对象纳入回收范围,如果这部分对象回收后仍然内存不足,才会抛出 OOM 异常。
  • 弱引用 :通过继承 WeakReference 类实现,描述一些非必须的对象,被弱引用的对象不管当前是否有足够的内存,都会被下一次 GC 操作所回收。典型的应用场景就是 ThreadLocal,ThreadLocal 维护了一个线程私有的内存数据库来记录线程私有的对象,而对象的 key 是一个弱引用的对象。
  • 虚引用 :通过继承 PhantomReference 类实现,虚引用并不能影响一个对象的生命周期,我们也无法通过虚引用来获取被引用对象的实例,其存在的唯一目的在于能在被引用的对象被回收时收到一个系统通知。

所有的引用类型都继承自 java.lang.ref.Reference 抽象类,它定义了一个 Reference#get 方法用于获取当前引用指向的目标对象,但是虚引用除外,因为其 PhantomReference#get 方法始终返回 null。

对象可回收性判定策略

一个对象只有在无效的情况下才可以被回收,如何判定一个对象是无效的,主要有两种思路:引用计数和可达性分析。

引用计数法

引用计数法是比较简单的一种判定对象是否无效的策略。通过为每一个对象设置一个计数器,当一个对象被引用则计数加 1,反之则减 1,当一个对象的引用计数是 0 时,我们可以将其视为无效并回收。然而,该策略存在循环引用的问题,当两个对象互相引用时,即使没有被其它对象所引用,这两个对象的引用计数也至少是 1,无法被回收。

可达性分析

可达性分析采用连通图的思想,我们可以把每个对象都看作是图上的一个结点,而引用则可以看作图上的边。在这个图中存在一些特殊的结点,被称为 GC Roots(可以简单将其理解为由堆外指向堆内的引用),如果某个对象不与任何一个 GC Roots 连通,则视为该对象无效,可以被回收。

在 JVM 中,可以被视为 GC Roots 的结点包括下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中所引用的对象。
  2. 方法区中类静态(static)属性所引用的对象。
  3. 方法区中常量所引用的对象。
  4. 本地方法栈中 native 方法所引用的对象。
  5. 内部引用,例如类所属的 Class 对象、一些常驻内存的异常对象,以及系统类加载器所引用的对象。
  6. 锁对象,即被同步锁持有的对象。
  7. 反映 JVM 内部情况的 JMMXBean、JVMTI 中注册的回调,以及本地代码缓存等。

除了上述这些固定的 GC Roots 之外,根据用户所选择的垃圾收集器和当前回收的内存区域不同,还可以将一些对象作为临时的 GC Roots。

通过可达性分析判定的对象无效实际上是给对象判了一个死缓,并没有立即执行回收,一些对象还可以在后续通过良好的表现而无罪释放。真正需要对一个对象执行死刑需要经过 两次标记 过程:

  • 第 1 次标记 :被判定为无效的对象将经过一次筛选,筛选出那些有必要执行 finalize 方法的对象(有必要是指该对象覆盖了 finalize 方法,且该方法还没有被 JVM 调用过,一个对象的 finalize 方法最多只能被 JVM 调用一次),这些对象被筛选出来之后就被放进 F-Queue 队列中;
  • 第 2 次标记 :由一个 JVM 自动创建的、低优先级的 Finalizer 线程去依次调用 F-Queue 队列中对象的 finalize 方法,我们可以认为 finalize 方法是对象最后一个改过自新的地方,如果对象在这里重新让自己被引用则复活,否则就几乎只有等死了。

下面是一个验证上述过程的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class FinalizeDemo {

public static FinalizeDemo saveHook;

@Override
protected void finalize() throws Throwable {
System.out.println("do finalize method");
super.finalize();
saveHook = this;
}

private static void isAlive() {
System.out.println(null == saveHook ? "Sorry, I'm died!" : "yes, I'm still alive!");
}

public static void main(String[] args) throws Exception {
saveHook = new FinalizeDemo();

/* 第一次改过自新 */
saveHook = null; // 去掉引用
System.gc(); // 触发finalize方法
TimeUnit.SECONDS.sleep(1); // finalize方法优先级较低,暂停等待1秒钟
isAlive();

/* 第二次改过自新 */
saveHook = null; // 去掉引用
System.gc(); // 触发finalize方法,因为finalize只会被调用一次,所以本次不会调用finalize,自救失败
TimeUnit.SECONDS.sleep(1); // finalize方法优先级较低,暂停等待1秒钟
isAlive();
}

}

执行结果:

1
2
3
do finalize method
yes, I'm still alive!
Sorry, I'm died!

上述示例在第一次 GC 时触发调用了对象的 finalize 方法,我们在该方法中为 saveHook 变量带来了第二春(即 saveHook = this),所以该对象从死缓中被保释了出来。然而,这货不老实,放出来之后又被抓进去了(即 saveHook = null),这一次就没有那么幸运了,因为一个对象的 finalize 方法最多只能被系统调用一次,所以这一次只能坐以待毙了。

注意 :一般不推荐在 finalize 方法中添加自定义逻辑,因为该方法的执行是不确定的,推荐将这些逻辑写到 finally 块中。

虽然可达性分析能够解决引用计数存在的循环引用问题,但在具体实现时仍然存在一些其它需要解决的问题。例如,在多线程环境下线程可能会并发更新对象的引用,从而可能导致将对象设置为 null 造成误报,或者将引用设置为未被访问过的对象造成漏报。

传统的 JVM 垃圾收集算法在解决这一问题方面采用的是一种简单粗暴的方式,即停止所有非垃圾收集线程的工作直到垃圾收集操作完成,这也就是臭名昭著的 Stop-The-World(简称 STW)。JVM 中的 STW 是通过安全点(safepoint)机制来实现的,当 JVM 收到 STW 请求,便会等待所有的线程都到达安全点,然后允许请求 STW 的线程进行独占的工作。

垃圾收集算法

垃圾收集的过程会中断正常业务逻辑的执行来查找和回收垃圾对象,因为回收时机的不确定性,如果收集的过程耗时较长会引起系统的卡顿,影响用户体验,所以一般收集过程不会一次性彻底执行,而是采用渐进式回收策略,将一次收集过程分摊到多次执行,控制每次执行的时间长度,以尽量避免用户感觉到这一过程。

在 JVM 实现中并没有采取单一的 GC 算法,而是采用了分代回收的策略。JVM 将 java 堆分为新生代和老年代(以 HotSpot 为例,默认大小比例为 1:2,可以通过 -XX:NewRatio 参数进行设置),并针对不同的区域(代)采取不同的 GC 算法,比如新生代中对象生命周期较短,适合采用标记复制算法,而老年代中对象大部分时间都是处于存活状态,而且很多都是大对象,所以比较适合采用标记清除和标记整理算法。

分代回收理论建立在以下假说之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难消亡。
  3. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

其中第 3 点主要是说明待收集的对象并不是孤立的,对象之间存在跨代引用的可能性,例如新生代中的对象引用老年代中的对象,反之亦然。此种场景如果发生的较为频繁的话,那么仅对新生代,或仅对老年代进行回收是不合理的。

在分代回收理论的前提下,针对不同内存区域的回收会划分出不同的回收类型,具体分类如下:

  • Minor GC :对新生代实施垃圾收集。
  • Major GC :对老年代实施垃圾收集,目前只有 CMS 收集器会单独收集老年代,也称为 CMS GC。
  • Mixed GC :对整个新生代和部分老年代实施垃圾收集,目前只有 G1 收集器采用这一策略。
  • Full GC :对整个 java 堆和方法区进行垃圾收集。

其中 Minor GC、Major GC 和 Mixed GC 又可以统称为 Partial GC ,即部分收集,对 java 堆的部分区域进行收集,与 Full GC 相对应。

说明:虽然这里对 Major GC 和 Full GC 在概念上进行了区分,但是实际实现层面二者通常是等价的。

下图描绘了新建一个对象在 JVM 层面的执行流程:

image

在为对象分配内存时,如果 Eden 区域内存不够则会触发 Minor GC,而 Full GC 的触发场景可以概括为以下几种:

  1. 调用 System#gc 方法:该方法会建议 JVM 执行 Full GC,但不一定会执行。
  2. 老年代空间不足:老年代在新生代对象晋升,或创建大对象、大数组时可能会出现内存不足的情况,为避免 OOM,JVM 一般会先执行一次 Full GC。
  3. 方法区空间不足:当系统要加载、反射调用大量类和方法时,可能会导致方法区出现内存不足的情况,为避免 OOM,JVM 一般会先执行一次 Full GC。
  4. 新生代晋升至老年代的对象平均大小大于老年代当前可用内存:当对新生代执行 Minor GC 时,部分存活很久的对象会晋升至老年代,如果在此之前历次晋升的对象平均大小不大于当前老年代可用内存,则依据空间分配担保策略,本次可以先尝试执行一次 Minor GC,否则需要执行一次 Full GC 以让老年代空出更多的可用空间。
  5. 在对新生代执行 Minor GC 时,剩余存活对象大于 Survivor 容量,所以需要将这些对象复制到老年代,但是老年代的可用内存不足,所以需要执行一次 Full GC。

Minor GC 不用对整个堆进行垃圾收集,但是如果老年代的对象引用了新生代中的对象,则不能对这些(新生代)对象进行回收。此时,我们需要依赖某种机制进行发现,最简单的方法就是对整个老年代进行全表扫描,但是这样效率较低。HotSpot 引入了一种被称为卡表(Card Table)的技术,将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表以记录每张卡的一个标识位(用于标识对应的卡是否存在指向新生代对象引用的可能)。这样在执行 Minor GC 时则无需扫描整个老年代,只需要扫描卡表即可。

常见的垃圾收集算法总结:

算法 优点 缺点 适用区域 垃圾收集器
标记清除 简单,GC 停顿时间短 内存碎片化问题,影响后续内存分配,降低内存访问效率 老年代 CMS
标记复制 效率高 对象复制开销(新生代下此缺点不明显),内存利用率低(可以优化) 新生代 大部分收集器对于新生代的垃圾收集均采用该算法
标记整理 内存区域规整,利于内存分配,内存访问效率较高 整理过程开销较大,导致 GC 停顿时间较长 老年代 Serial Old, Parallel Old, G1,Shenandoah, ZGC

标记清除算法

标记清除算法是最基础的垃圾收集算法,其执行过程可以分为标记和清除 2 个阶段,算法在第 1 阶段先标记出所有可以回收的对象,然后在第 2 阶段对这些对象进行统一回收。

标记清除算法的缺点主要分为 2 个方面:

  1. 标记和清除过程的效率都不高,尤其是当堆中包含大量对象,且其中的大部分都需要被清除的时候。
  2. 清除操作会产生大量不连续的内存碎片,影响后续内存分配的效率。

标记复制算法

标记复制算法解决了标记清除算法在面对大量可回收对象时效率不高的问题。常规的标记复制算法(也叫半区复制)将内存区域分为大小 1:1 的两块,每次仅在其中一块上进行分配,当需要 GC 时就将当前存活的对象全部复制到另外一块上去,然后对之前的那一块内存实施一次性清理。

常规的标记复制算法主要有以下两个缺点:

  1. 存在对象复制的开销。
  2. 内存利用率不高,只有 50%。

针对缺点 1,考虑到新生代每次 GC 只有少部分对象存活,所以复制压力要小很多,因此被大多数 JVM 用于回收新生代。

针对缺点 2,一种优化措施(即 Appel 式回收)是将新生代大小分为 3 块,一块大的 Eden 区域和两块小的 Survivor 区域(From Survivor 和 To Survivor),每次都使用 Eden 和其中一块 Survivor 进行内存分配。当发生 Minor GC 时,Eden 和 from 指针指向的 Survivor 区域的存活对象(即不应该被 GC 的对象)会被复制到 to 指针所指向的 Survivor 区域,然后交换 from 和 to 指针,所以任何时候总有一个 Survivor 区域是空闲的。

以 HotSpot 虚拟机为例,Eden 和 Survivor 区域的大小比例为 8:1(通过 java -XX:+PrintFlagsFinal -version 命令可以查看 JVM 所有默认参数设置)。当然,JVM 也支持动态分配的策略,根据对象的生成速率和 Survivor 区域的使用情况动态调整 Eden 区和 Survivor 区域的比例。同时,JVM 也支持通过参数 -XX:SurvivorRatio 来固定这个比例。

实际中可能会出现一块 Survivor 不够用的情况,即存活的对象大小大于 Survivor 区域的大小,这个时候就需要将多出来的对象直接送入老年代。然而老年代不一定有足够的容量能够容纳这些对象,如果容纳不了就需要执行一次 Full GC。然而,为了避免不必要的 Full GC,JVM 通过空间分配担保机制先尝试赌一把 Minor GC,如果赌失败了再走 Full GC 也不迟。

所谓 空间分配担保机制 是指 JVM 在执行 Minor GC 前会检查老年代最大连续空间是否大于新生代所有对象的总空间,如果大于则可以确保 Minor GC 能够安全执行。否则,就会继续检查 -XX:HandlePromotionFailure 参数是否允许开启担保机制,如果允许则继续检查老年代最大连续空间是否大于历次晋升至老年代对象的平均大小(相当于借鉴历史经验值),如果大于则尝试执行 Minor GC,如果小于或者 -XX:HandlePromotionFailure 参数设置为不允许担保,则执行一次 Full GC。

然而,参数 -XX:HandlePromotionFailure 在 JDK 6 Update 24 之后已经被废弃,此后只要老年代的连续空间大于新生代对象的总大小,或者大于历次晋升的平均大小,就会执行 Minor GC,否则执行 Full GC。

标记整理算法

上面介绍的标记复制算法适用于新生代的垃圾收集,但是对于老年代来说,每次 GC 都可能存在大量存活的对象,使用标记复制不仅复制开销大,而且内存利用效率不高。老年代一般采用标记整理算法,相对于标记清除算法而言,标记整理算法会将所有存活的对象移动到内存的一端,然后对剩余的内存进行清理。

标记整理算法仍然避免不了对象的复制操作,尤其是对于老年代这种每次 GC 之后仍然有大量对象存活的场景,复制存活对象并更新所有引用这些对象的地方将会是一件极其复杂且高开销的操作,为了保证线程安全必须全程在暂停用户程序的情况下执行,期间会 STW。

如果不执行整理操作,仅标记清除,则会极大降低 GC 的时间,但是这样会导致内存空间碎片化,让内存分配变得更加复杂,同时影响内存访问的效率(内存访问是一个高频的操作)。因此这是一个矛盾的问题,需要结合场景去抉择,例如关注吞吐量的 Parallel Scavenge 收集器基于标记整理算法实现,而注重延迟的 CMS 收集器则基于标记清除算法实现。另外一种思路是平时多数时间都采用标记清除策略,暂时容忍内存碎片的存在,直到碎片化程度严重到影响内存分配时再执行一次标记整理,这也是 CMS 收集器所采用的策略。

垃圾收集器

垃圾收集器 作用区域 收集算法 适用场景 优点 缺点
Serial 新生代 标记复制 桌面应用,虚拟内存分配较小的微服务应用 简单、高效,内存开销小 单线程,GC 停顿时间长
Serial Old 老年代 标记整理 与 Serial 收集器搭配使用 Serial 收集器面向老年代的版本 单线程,GC 停顿时间长
ParNew 新生代 标记复制 服务端模式下 HotSpot 的首选新生代垃圾收集器,尤其是在 JDK 7 之前 Serial 的多线程版本 GC 停顿时间长
Parallel Scavenge 新生代 标记复制 注重吞吐量,或处理器资源较为稀缺的场景 高吞吐,相对于 ParNew 收集器能够实现对吞吐量的精确控制,以及自适应的调整策略 GC 停顿时间长
Parallel Old 老年代 标记整理 与 Parallel Scavenge 收集器搭配使用 Parallel Scavenge 收集器面向老年代的版本 GC 停顿时间长
CMS 老年代 标记清除 互联网服务端应用 并发收集,GC 停顿时间短 并发策略占用 CPU 资源,导致用户程序变缓;并发失败会启用 Serial Old 作为备选方案,导致较长时间的 STW;标记清除算法导致的内存碎片化
G1 新生代、老年代 整体采用标记整理算法,局部采用标记复制算法 服务端应用 新的设计思想,致力于做全功能的收集器
Shenandoah 新生代、老年代 标记整理 服务端应用 GC 停顿时间极短,致力于在尽可能对吞吐量影响不大的前提下,实现在任意堆对内大小下都可以将 GC 停顿时间控制在 10ms 以内 只有 OpenJDK 才会包含,受 Oracle 官方排斥
ZGC 新生代、老年代 标记整理 服务端应用 GC 停顿时间极短,致力于在尽可能对吞吐量影响不大的前提下,实现在任意堆对内大小下都可以将 GC 停顿时间控制在 10ms 以内

在 CMS 和 G1 之前的垃圾收集器存在的通病就是 GC 停顿时间长。CMS 和 G1 分别使用了增量更新和原始快照技术实现了标记阶段的并发,不会因为管理的堆内存变大,要标记的对象增多而导致 GC 停顿时间随之变长。然而,对于标记之后的处理,这两款收集器各自仍然存在需要解决的难题。其中 CMS 虽然使用标记清除算法避免了整理操作所导致的长时间停顿,但是无法绕开与标记清除算法并存的内存碎片化问题,随着内存碎片化程度的逐渐严重,势必需要对内存进行整理,从而导致长时间 GC 停顿。G1 虽然通过缩小回收区域的粒度来降低 GC 停顿的时长,但是仍然避免不了停顿。后继者 Shenandoah 和 ZGC 都致力于在尽可能对吞吐量影响不大的前提下,实现在任意堆对内大小下都可以将 GC 停顿时间控制在 10ms 以内。

考虑每种垃圾收集器有自己擅长的领域,所以实际中一般针对新生代和老年代分别选择对应的垃圾收集器组合使用。常用的垃圾收集器组合如下表所示:

新生代 老年代 参数设置
Serial Serial Old -XX:+UseSerialGC
Parallel Scavenge Parallel Old -XX:+UseParallelGC-XX:+UseParallelOldGC
Parallel New CMS -XX:+UseParNewGC-XX:+UseConcMarkSweepGC
G1 G1 -XX:+UseG1GC

Serial / Serial Old

Serial 收集器是最基础,历史最悠久的垃圾收集器,也是 HotSpot 虚拟机迄今为止在客户端模式下的默认新生代垃圾收集器。Serial 收集器是一个以单线程工作的收集器,在执行垃圾收集时必须暂停其它所有的线程,直到垃圾收集过程结束。Serial 收集器的优点在于简单、高效,是所有收集器中额外内存消耗最小的垃圾收集器。对于单核处理器或处理器核心数较少的环境来说,Serial 收集器由于没有线程交互的开销,专心致力于垃圾收集,从而能够获得最高的单线程收集效率。

Serial Old 收集器是 Serial 收集器针对老年代开发的版本,因此同样是单线程的工作模式,使用标记整理垃圾收集算法。Serial Old 收集器与 Serial 收集器一样适用于客户端模式,如果应用于服务端模式则主要用于:

  1. 在 JDK 5 以及之前与 Parallel Scavenge 收集器搭配使用。
  2. 作为 CMS 收集器在失败后的备选方案。

ParNew

ParNew 收集器本质上是 Serial 收集器的多线程版本,除了同时使用多个线程执行垃圾收集之外,与 Serial 在设计和实现上完全一致,包括控制参数(eg. -XX:SurvivorRatio, -XX:PretenureSizeThreshold, -XX:HandlePromotionFailure)、收集算法、STW、对象分配规则,以及回收策略等。

ParNew 收集器在 JDK 7 之前,几乎是服务端模式下 HotSpot 虚拟机首选的新生代垃圾收集器,其中一个很重要的原因是除了 Serial 收集器之外,它是唯一能够与 CMS 收集器配合工作的。然而,随着 G1 收集器的出现,在 JDK 9 之后,“ParNew + CMS”组合不再是官方推荐的服务端模式下的解决方案了。

Parallel Scavenge / Parallel Old

Parallel Scavenge 收集器同样致力于对新生代进行垃圾收集,其诸多特性从表面上看起来与 ParNew 收集器非常相似,但是 Parallel Scavenge 收集器的目标是达到一个可控的吞吐量。所谓吞吐量可以描述为:用户程序运行时间 / (用户程序运行时间 + GC 时间)。由此可以看出 GC 时间越短,则吞吐量越高,也就越适合需要与用户交互或需要保证服务质量的场景。

Parallel Scavenge 收集器提供了两个参数用于对吞吐量进行精确的控制:

  1. -XX:MaxGCPauseMillis:用于控制最大 GC 停顿时间,是一个大于 0 的毫秒值,收集器会尽力保证每次 GC 的时间不超过该值。需要注意的是,降低 GC 时间是以牺牲吞吐量和缩小新生代空间为代价的,导致的结果就是 GC 会更加频繁。
  2. -XX:GCTimeRatio:用于直接设置 GC 时间占总运行时间的比率,是一个大于 0 且小于 100 的整数。

此外,Parallel Scavenge 收集器还提供了 -XX:UseAdaptiveSizePolicy 参数用于开启自适应策略,依据当前系统的运行状态对新生代大小、Eden 与 Survivor 区域的比例,以及晋升老年代对象的大小等参数进行自动调节。

Parallel Old 收集器是 Parallel Scavenge 收集器针对老年代开发的版本,基于标记整理算法,并于 JDK 6 开始提供,此前都是“Parallel Scavenge + Serial Old”的组合。

CMS

CMS 收集器致力于最短的 GC 停顿时间,以给用户带来良好的交互体验,基于标记清除算法实现, 首次实现了让 GC 线程与用户线程并发执行 。CMS 的标记清除过程分为 3 次标记和 1 次清除:

  1. 初始标记 :标记 GC Roots 能直接关联到的对象,速度很快;
  2. 并发标记 :从 GC Roots 直接关联的对象开始遍历整个对象关联图;
  3. 重新标记 :修正并发标记期间因为用户程序运行而导致的之前标记过期的部分对象的标记记录;
  4. 并发清除 :清除标记为已经死亡的对象。

其中初始标记和重新标记都需要暂停用户程序,好在这两个过程耗时都比较短,所以给用户程序造成停顿的时间也比较短。并发标记过程虽然耗时较长,但是可以与用户程序并发执行,并发清除也是如此,所以这两个步骤都带有“并发”字样,可以理解为与用户程序并发执行,也就不会造成用户程序的停顿。

然而不可否认的是,CMS 收集器同样存在以下缺点:

  1. 对处理器资源非常敏感 :CMS 收集器的并发过程默认会使用到 (n + 3) / 4 的 CPU 资源,其中 n 为 CPU 的核心数,当应用程序负载较高时,会因为 CMS 收集器的运行而导致用户程序运行变缓。
  2. 浮动垃圾可能导致 CMS 执行失败,进而导致 Full GC :所谓浮动垃圾是指在并发标记期间,由于用户程序的并发执行导致期间产生的新的垃圾未被标记,这些垃圾将不会在本次并发清除阶段被清除,而需要留到下一次 GC。此外,由于 GC 与用户程序的并发执行,所以 CMS 收集器必须为当前用户程序的执行留出一定的老年代空间(可以通过 -XX:CMSInitiatingOccupancyFraction 参数设置),也就不能像其它老年代收集器一样等到老年代几乎快被用完了再执行 GC,如果预留的老年代空间无法容纳新对象,就会导致并发失败,此时 JVM 将启动备选方案,使用 Serial Old 收集器对老年代进行一次完整的收集,期间将 STW。
  3. 内存空间碎片化 :由于采用标记清除算法,如果不对内存空间进行整理,势必会因为碎片过多而触发 Full GC,从而导致停顿时间边长。

G1

Garbage First(简称 G1)收集器是一款主要面向于服务端应用的垃圾收集器,在 JDK 9 之后取代“Parallel Scavenge + Parallel Old”组合,成为服务端模式下默认的垃圾收集器,而 CMS 收集器自此开始沦落为不再推荐被使用的收集器。 G1 开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式

在 G1 之前,其它垃圾收集器均将 JVM 内存区域划分为新生代和老年代,对应的收集范围要么是整个新生代(Minor GC),要么是整个老年代(Major GC),甚至是是整个 java 堆(Full GC)。G1 收集器不再坚持按照固定大小和固定数量的分代区域划分策略,而是将连续的 java 堆划分成多个大小相等且独立的区域,称为 Region。每个 Region 都可以依据需要扮演不同的角色,例如新生代的 Eden 空间、Survivor 空间,亦或是老年代空间。G1 收集器能够依据具体 Region 所扮演的角色采用不同的策略对其执行垃圾收集。虽然,G1 仍然保留新生代和老年代的概念,但是其大小已经不再像之前的垃圾收集器一样是固定的,而是一系列(可以不连续的) Region 的动态集合。Region 中还有一类特殊的称为 Humongous 的区域,专门用于存储大对象。G1 收集器在执行垃圾收集时不再像之前的垃圾收集器一样按照新生代或老年代进行划分,而是计算哪块 Region 中存放的垃圾数量最多,回收收益最大,就对该 Region 或多个 Region 集合执行回收,这也是 Garbage First 命名的由来。

G1 收集器的运行机制大致可以分为四个步骤:

  1. 初始标记 :标记 GC Roots 能直接关联到的对象,并修改 TAMS(Top at Mark Start) 指针,从而让下一阶段用户线程并发执行时能够正确的在可用的 Region 中分配新对象,此阶段会暂停用户程序,但是耗时很短;
  2. 并发标记 :从 GC Roots 直接关联的对象开始遍历整个堆中对象关联图,找出可以回收的对象,该过程虽然比较耗时,但是可以与用户程序并发执行。当对象图扫描完成之后,还需重新处理 SATB(Snapshot-At-The-Beginning) 记录下的在并发期间有引用变动的对象。
  3. 最终标记 :处理并发阶段结束后仍遗留下来的少量的 SATB 记录,此阶段会短暂暂停用户程序;
  4. 筛选回收 :更新 Region 的统计数据,并按照回收价值和成本对各个 Region 进行排序,并基于用户所期望的停顿时间来制定回收计划,此阶段必须暂停用户程序。

从现阶段的表现来看,G1 相对于 CMS 而言并没有压倒性的优势,而是各自有属于自己的应用场景,但是从长期发展来说,G1 取代 CMS 是趋势。

Shenandoah

Shenandoah 收集器是第一款由非 Oracle 官方虚拟机团队领导开发的 HotSpot 垃圾收集器,由于受 Oracle 官方排斥,所以只能包含在 OpenJDK 中。Shenandoah 收集器致力于在任何堆大小下都可以将 GC 停顿时间控制在 10ms 以内,在设计和实现层面更像是 G1 收集器的继承者,二者在堆内存布局、初始标记、并发标记等多方面都高度一致,甚至还共享了一部分代码。然后,在管理堆内存方面,Shenandoah 相对于 G1 存在以下 3 点不同:

  1. 支持并发整理。
  2. 默认不使用分代收集。
  3. 在跨 Region 的引用关系记录层面,使用连接矩阵全局数据结构,相对于 G1 更加高效和节省资源。

Shenandoah 收集器在工作流程上分为初始标记、并发标记、最终标记、并发清理、并发回收、初始引用更新、并发引用更新、最终引用更新,以及并发清理 9 个阶段。

ZGC

ZGC 收集器与 Shenandoah 收集器一样致力于在尽可能对吞吐量影响不大的前提下,实现在任意堆对内大小下都可以将 GC 停顿时间控制在 10ms 以内,是一款基于 Region 内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记整理算法的,以低延迟为首要目标的垃圾收集器。

ZGC 收集器在工作流程上可以并发标记、并发预备重分配、并发重分配,以及并发重映射 4 个阶段。

内存分配策略

首先, 对象优先在 Eden 区域进行分配 。一般情况下对象会首先在新生代 Eden 区域进行分配,当 Eden 空间不足时,JVM 将发起一次 Minor GC。

其次, 大对象直接进入老年代 。对于大对象(需要大量连续内存空间的对象),JVM 采取的策略是直接进入老年代(可以通过 -XX:PretenureSizeThreshold 参数设置,大于该阈值的对象直接进入老年代,但需要注意该参数只对 Serial 和 ParNew 两款收集器有效),因为新生代中的对象只有在一定的 GC 次数之后仍然存活才能进入老年代,而在此之前大对象会频繁在两个 Survivor 区域之间复制,这样会降低效率;然而,对于一个朝生夕灭的大对象,直接进入老年代也不是好事,实际上就算分配在新生代上也不是好事,所以编程中应该尽量避免这种朝生夕灭的大对象。

最后, 长期存活的对象进入老年代 。因为采用分代存储,所以 JVM 需要知道哪些对象应该放在新生代,哪些对象应该放在老年代。JVM 一般会根据对象熬过的 GC 次数作为判定依据,并且会为该对象设置一个对象年龄计数器。如果某个对象在第 1 次 Minor GC 后仍然存活,并且能够被 Survivor 区域所容纳则进入 Survivor 区域,同时设置对象年龄计数器为 1。以后每熬过 1 次 Minor GC 则对象年龄计数器加 1,当年龄达到一定值(默认为 15,可以通过 -XX:+MaxTenuringThreshold 参数设置)则进入老年代。但这也不是绝对的,如果单个 Survivor 区域使用率超过 50%(对应 -XX:TargetSurvivorRatio 配置),则复制次数较多的对象也会被移入老年代。此外,如果 Survivor 空间中相同年龄的所有对象大小之和大于 Survivor 空间的一半,则年龄大于或等于这些对象的对象可以直接进入老年代。

参考

  1. Java 虚拟机规范(Java SE 8 版)
  2. 深入理解 java 虚拟机(第 2 版)
  3. 深入理解 java 虚拟机(第 3 版)
  4. 极客时间:深入拆解 java 虚拟机