垃圾收集器简介

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范中对于垃圾收集器应该如何实现并没有规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都有可能不同。

垃圾收集器汇总

图中是 HotSpot 虚拟机中的垃圾收集器,它们作用于不同分代,如果两个收集器之间存在连线,那么就代表它们可以搭配使用。在 JDK 8 中,它们常见的搭配组合如下:

新生代 老年代 JVM Options
Serial Serial Old -XX:+UseSerialGC
Parallel New CMS -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
Parallel Scavenge Parallel Old -XX:+UseParallelGC -XX:+UseParallelOldGC
G1 G1 -XX:+UseG1GC

Serial

Serial 是最基本、发展历史最长的垃圾收集器,在 JDK 1.3.1 之前是新生代垃圾收集的唯一选择。它是一个单线程的收集器,同时它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

Serial

虽然 Serial 是一个串行的收集器,但是它简单而高效(相对于其他收集器的单线程来说),对于单个 CPU 的环境来说,Serial 收集器由于没有线程切换的开销,专心做垃圾收集可以获得较高的收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

Parallel New

ParNew 垃圾收集器其实就是 Serial 垃圾收集器的多线程版本,除此之外并没有太多创新之处,很多运行在 Server 模式的虚拟机选择使用它作为新生代垃圾收集器的一个很重要的原因就是,除了 Serial 收集器外,只有它能够与 CMS 收集器配合工作。

ParNew

ParNew 收集器在单 CPU 环境中比 Serial 收集器效果差,但是当 CPU 数量增加时,它可以在 GC 时更有效地利用系统资源。它默认开启的收集线程数与 CPU 的数量相当,当然也可以通过参数 -XX:ParallelGCThreads 自定义垃圾收集的线程数。

Parallel Scavenge

Parallel Scavenge 也是新生代的垃圾收集器,它同样使用的是复制算法,同时也是并行的多线程收集器,但是它的关注点与其他收集器不同,像 CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 的目的则是为了达到一个可控制的吞吐量(Throughout)。所谓吞吐量是指 CPU 用于运行用户代码的时间与 CPU 总的消耗时间的比值,即吞吐量 = 运行用户代码的时间 / (运行用户代码的时间 + 垃圾收集的时间),如果虚拟机总共运行了 100 分钟,其中垃圾收集花费了 1 分钟,那么吞吐量就是 99 %。

它提供了两个参数用于精确控制吞吐量,一个是用于控制最大垃圾收集停顿时间(-XX:MaxGCPauseMillis)的参数和一个直接设置吞吐量大小的 -XX:GCTimeRatio 参数。MaxGCPauseMillis 可以是一个大于 0 的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过该值,但是我们需要明白 GC 停顿时间的缩短是以牺牲吞吐量和新生代空间来换取的,比如我们把新生代设置的小一点,将原先的 500 MB 设置为 300 MB,这样垃圾收集的速度肯定快了,但是这也可能导致新生代由于内存空间不足而垃圾收集更加频繁,原来 10 秒收集一次,每次停顿 100 毫秒,现在变成了 5 秒收集一次,每次停顿 70 毫秒,停顿时间是下降了,但是吞吐量也跟着下降了。

GCTimeRatio 参数应当是一个大于 0 且小于 100 的整数 N,它将垃圾回收时间与应用程序运行的总时间的比率设置为 1 / (1 + N),所以当 N 为 19 时,表示垃圾回收的时间占总时间的比率为 5 %。N 的默认值为 99,即只有 1 % 的时间用于垃圾收集。

除了这两个参数外,还有一个 -XX:UseAdaptiveSizePolicy 参数,当该参数开启时,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 的比例(-XX:SurvivorRatio)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态地调整这些参数,这种调节方式称为 GC 自适应的调节策略(GC Ergonomics)。

Serial Old

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程的收集器,使用“标记-整理”算法,它对于运行在 Client 模式下的虚拟机的来说是一个很好的选择,而在 Server 模式下,它可以在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,或者作为 CMS 收集器的后备预案。

Parallel Old

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用“标记-整理”算法,在 JDK 1.6 才开始提供,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态,因为它除了和 Serial Old 收集器搭配外别无选择。由于老年代的 Serial Old 收集器在服务端性能上的“拖累”,使得即使是使用了 Parallel Scavenge 收集器也未必能在整体上获得吞吐量最大化的效果。

CMS

CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器,它基于“标记-清除”算法实现,它的运作过程相对于前面的几种收集器来说更为复杂,整个过程分为四个步骤:初始标记(CMS initial mark)、并发标记(CMS concurrent mark)、重新标记(CMS remark)和并发清除(CMS concurrent sweep),其中初始标记和重新标记都需要“Stop The World”。

初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记则是为了修正并发标记期间因用户程序继续执行而导致标记产生变动的哪些对象的标记,这个阶段停顿的时间一般比初始标记要稍长,但又远比并发标记时间短。整个过程中,并发标记和并发清除阶段的收集器线程都可以与用户线程同时运行,所以总体上来说,CMS 收集器的内存回收过程几乎是与用户线程一起并发执行的。

CMS

CMS 是一款优秀的垃圾收集器,它可以并发收集,能够做到低停顿,但是它有几个明显的缺点,比如对于 CPU 资源非常敏感(面向并发设计的程序对于 CPU 资源都比较敏感)。CMS 默认启动的回收线程数为(CPU 数 + 3) / 4,在并发阶段,它虽然不会导致用户线程停顿,但是有可能会因为占用了一部分 CPU 资源而导致用户程序变慢。

还有就是它无法处理浮动垃圾(Floating Garbage),可能会导致出现 Concurrent Mode Failure 从而引发一次 Full GC。由于在 CMS 在并发清除阶段用户线程还在运行,自然就会有新的垃圾不断产生,这部分垃圾出现在标记过程之后,CMS 无法在当次集中回收它们,只好等待下一次 GC。因此,CMS 收集器不能像其他收集器那样在老年代快要填满时才进行收集,它需要预留一部分空间给并发收集期间的用户线程使用。通过 -XX:CMSInitiatingOccupancyFraction 参数可以调整触发收集时老年代的使用比例。在 JDK 1.5 中,该阈值为 68 %,而在 JDK 1.6 中则提高到了 92 %,并且如果 CMS 运行期间预留的内存无法满足需要时,会出现 Concurrent Mode Failure 并启动预备方案:临时启动 Serial Old 重新进行老年代的垃圾收集。

由于 CMS 是基于“标记-清除”算法实现的,这就意味着垃圾收集后可能会产生大量的内存空间碎片,这会给后来的大对象内存分配带来麻烦,可能老年代还有很大的内存空间剩余,但是却无法找到足够大的连续空间进行分配,从而不得不提前触发一次 Full GC。为了解决这个问题,CMS 提供了内存碎片合并整理的功能,但是整理的过程是无法并发的,碎片问题没有了,但是停顿时间也增加了。

G1

G1 在 JDK 1.7 中正式发布,它的使命是替换掉 JDK 1.5 中发布的 CMS 收集器。G1(Garbage First)是一款分代、增量、并行、大部分时间并发的垃圾收集器,从整体上看它是基于“标记-整理”算法。

G1 中虽然还有分代的概念,但是它进行垃圾收集的范围却是整个堆,它将堆划分为多个大小相等的独立区域(Region),每个 Region 内都是一些连续的虚拟内存。Region 是进行内存分配和回收的基本单位,在某个特定的时间点,一个 Region 可能是空闲的(图中浅灰色区域),也有可能被分配到了某个分代(generation)中,可能是新生代或者老年代。当收到内存分配的请求时,内存管理器就会将空闲的 Region 分配给某个分代,然后将它们作为可分配自身的可用空间返回给应用程序。

G1 Heap Layout

图中的红色区域和标有 S 的红色区域分别为新生代的 Eden 区和 Survivor 区,这些 Region 在内存中通常是物理上不连续的。浅蓝色的 Region 组成了老年代,标有 H 的浅蓝色区域代表的是大对象(Humongous)Region,这些对象可以横跨多个 Region。

G1 进行垃圾收集的生命周期可以分为两个阶段:Young-only 阶段和 Space-reclamation 阶段。

G1 垃圾收集的生命周期

Young-only 阶段从新生代的一些 Young GC 开始。Young GC 采用复制算法,并行收集,垃圾收集时会发生 STW 暂停,活跃的对象被复制到 Survivor 区域,如果达到晋升的阈值,对象会晋升到老年代的区域。与其他垃圾收集器不同的是,G1 会根据预测的停顿时间动态地调整新生代的大小。当老年代空间的占用率达到一个阈值时(具体来说是整个堆的空间占用达到一个阈值,该阈值通过 -XX:InitiatingHeapOccupancyPercent 参数控制,默认为 45,即 45 %)就会开始 Young-only 阶段到 space-reclamation 阶段的转换,这个过程的第一个子阶段就是 Concurrent Start。

在 Concurrent Start 阶段,G1 除了会进行 Young GC 外,还会根据新生代 Survivor 区以及老年代的 Remembered Set(记录对象的引用关系,加快活跃对象的遍历)开始并发标记的过程,此时会启动多个并发的标记线程,每个线程每次只扫描一个 Region,标记出存活对象。这个过程不需要 STW,因此用户线程可以与标记线程并发执行。

这里需要说明的是,在并发标记之前的 Young GC 其实也是初始标记的过程,因为在 Young GC 的过程中,首先会查找直接可达的一些根对象(栈对象、全局对象、JNI 对象等),然后暂停用户线程进行初始标记并进行内存回收。其实混合回收(Mixed GC)就是借用了新生代回收的结果,即将新生代进行垃圾回收后的 Survivor 区作为根。

接下来就是 Remark 阶段,在这个阶段中 G1 需要 STW,找出所有未被访问的存活对象,做全局的引用处理和类卸载,回收全空的 Region 并清理内部的数据结构,计算老年代可回收的空间,最终结束并发标记的过程。然后就是清理阶段(Cleanup),该阶段也需要 STW,并在该停顿中决定是否要开始 Space-reclamation 阶段。

Space-reclamation 阶段由多个 Mixed GC 组成,Mixed GC 不光回收新生代的 Region,同时也会回收老年代的 Region。当 G1 发现无法回收更多的老年代内存时,该阶段就结束了,接着会重新回到 Young-only 阶段开始一个新的回收周期。同时为了以防万一,在收集存活对象的时候,如果应用内存耗尽了,G1 也会像其他收集器那样进行整个堆的 STW 压缩,即 Full GC。

ZGC 和 Shenandoah

附录

JDK 版本 默认的垃圾收集器
JDK 7 Parallel Scavenge + Parallel Old
JDK 8 Parallel Scavenge + Parallel Old
JDK 9 G1
JDK 11 G1

参考

GC Algorithms: Implementations

G1 垃圾收集器架构和如何做到可预测的停顿