JVM垃圾收集器概述

liang @ 2017年08月31日

概述

1999年,串行GC第一个被引入HotSpot的垃圾收集器,并作为JDK 1.3.1的组成部分。2002年,并行GC和并发标记清除GC(CMS)被引入JDK 1.4.2。这3种垃圾收集器基本覆盖了GC最重要的3中使用场景:"内存占用空间以及并发开销最小化"、"应用吞吐量最大化"和"GC相关中断时间最小化"。2012年,G1垃圾收集器被引入JDK 7u4,G1是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。2017年,JDK 9 GA发布,G1开始被设定为默认的垃圾回收器。

术语

要研究HotSpot的垃圾回收器,首先要搞清楚三个术语:并行、stop-the-world、并发。

并行的意思是,这是个多线程的垃圾收集算法。当一个垃圾收集事件活动被描述为并行,就意味着它正用多线程来执行。就HotSpot的垃圾收集器而言,几乎所有多线程GC操作都由Java VM(JVM)的内部线程处理。与此相比,一个很重要的例外就是G1垃圾收集器,在G1中某些后台的垃圾收集工作能够由应用线程来承担。

stop-the-world的意思是在一个垃圾收集事件中,所有的JAVA应用线程全部被暂停。一个stop-the-world垃圾收集器就意味着当它执行垃圾收集操作时会停掉所有Java应用线程。当一个垃圾收集阶段或事件被描述为stop-the-world,也就意味着在这个特定的垃圾收集阶段或事件中,所有Java应用线程都会被暂停。

并发的意思是在Java应用执行过程中垃圾收集活动也同时在进行。一个并发垃圾收集阶段或事件意味着这个垃圾收集阶段或事件可以和应用在同一时间执行。

一个垃圾收集器可以用以上3个术语中的任意一个或多个组合来描述。比如,一个并行并发收集器是多线程的(并行部分),同时还可以与应用同一时间执行(并发部分)。

串行垃圾收集器

串行垃圾收集器与并行垃圾收集器很相似,只是它是用一个单线程做所有工作。这种单线程的方式意味着垃圾收集器实现的复杂度更低,以及需要非常少的外部运行时数据结构,其内存占用空间大小(footpoint)也是所有HotSpot垃圾收集器里最低的。当然,串行垃圾收集器面对的挑战与并行收集器也非常类似,中断时间可能很长,同时随着堆的大小以及活跃数据的数量变化,中断时间会呈现线性增加或者减少。另外,串行垃圾收集器引发的长时间暂停会更加明显,因为所有垃圾收集工作都是在一个线程里完成的。

因为很少占用内存,在Java HotSpot客户端虚拟机中默认使用串行垃圾收集器,同时它还被用于大量嵌入式场景的需求。通过HotSpot命令行选项-XX:+UseSerialGC,明确指定了使用串行垃圾收集器来做垃圾收集。

串行垃圾收集器

上图描述了Java应用线程(蓝色箭头)被暂停,同时1个GC线程(绿色箭头)接管过去做垃圾收集工作,这台机器上运行了8个Java应用线程。因为它是单线程的,所以相对于并行垃圾收集器来说,大部分情况下串行垃圾收集器执行一个垃圾收集事件需要花费更长时间,而并行垃圾收集器可以将回收工作分摊到多个线程上。

并行垃圾收集器

并行垃圾收集器是一种并行stop-the-world的收集器,也就是说没发生一次垃圾收集,它会停掉所有应用的线程并用多个线程执行垃圾回收工作。因此垃圾回收工作可以不受任何中断非常高效地完成。对相关的应用来说,这通常也是最小化垃圾收集开销时间的最好方式。然而在个别情况下,因垃圾回收而导致的应用中断也可能非常长。

在并行垃圾收集器中,年轻代和老年代的回收都是并行的,而且会stop-the-world。老年代的回收还会同时进行压缩动作。压缩可以将邻近的对象移动到一起,以消除它们之间被浪费的空间,形成一个最理想的堆布局。然而压缩可能会花费相当长的时间,这通常跟Java堆的大小以及老年代中存活对象的数量和大小有关。

在并行垃圾收集器被引入HotSpot的时候,只有年轻代会使用并行stop-the-world收集器。老年代的回收是使用一个单线程的stop-the-world收集器。我们回到最初并行垃圾收集器被引入的时候,激活并发垃圾收集器的HotSpot命令行选项是-XX:+UseParallelGC

在并行垃圾收集器被引入时,主要是为了应对服务端要求吞吐量最优化的使用场景,因此并行垃圾收集器成为了HotSpot服务端虚拟机的缺省收集器。同时,绝大多数Java堆的尺寸已经趋向于512M~2G,这使得并行垃圾收集器的中断时间保持一个相对低的水平,哪怕是单线程的stop-the-world收集器。当时对延迟方面的需求比现今更加宽松。对于Web引用来说,忍受因垃圾收集器导致的额外1s的延迟是非常平常的事,很多的时候甚至会有3s~5s。

随着Java堆的尺寸以及老年代中存活对象的数量和大小的增长,老年代的垃圾收集时间越变越长。与此同时,硬件的发展进步使得我们有更多可用的硬件线程。于是,通过增加一个多线程的老年代收集器与多线程的年轻代收集器同时使用的方式,并行垃圾收集器得到了增强。这使并行垃圾收集器降低了收集和压缩堆的时间开销。

增强的并行垃圾收集器随着Java 6更新发布一同交付。它通过一个新的命令行选项-XX:+UseParallelOldGc来激活。当-XX:+UseParallelOldGc选项被激活后,并行的年轻代收集也被激活。这也是我们今天所认为的HotSpot中的并行垃圾收集器、一个多线程的stop-the-world年轻代收集器与一个多线程的stop-the-world老年代收集器的组合。

Java 7 update release 4(通常被称为Java 7u4或者JDK 7u4)中,-XX:+UseParallelOldGC被作为缺省的垃圾收集器以及并行垃圾收集器的标准操作模式。从Java 7u4开始,指定-XX:+UseParallelGC也会激活-XX:+UseParallelOldGC,同样地,-XX:+UseParallelOldGC也会激活-XX:+UseParallelGC

在以下场景中应该优先选择并行垃圾收集器:

  1. 对应用吞吐量的要求远远高于对延迟的要求。
    批处理的引用就是一个很好的例子,因为它是非交互性质的。当你启动一个批量执行,你会希望它越快执行越好。
  2. 如果能满足应用的最差延迟要求,并行垃圾收集器将提供最佳吞吐量。最差延迟要求包含2个方面:最差延迟时间和中断发生的频度。比如说:一个引用可能会有这样的延迟需求,"超过500ms的暂停每2小时不能多于1次,同时所有的暂停不能超过3s"。

一个交互式应用拥有足够的小块活动数据,因此并行垃圾收集器的一个full GC事件就能满足甚至超额达成对应用垃圾收集导致的最差延迟要求,这就是此使用场景下的一个良好的例子。然而,活跃数据的数量与Java堆的大小有极高的相关性,所以符合这种情况的应用类型也是有限的。

对于能满足这些要求的应用来说,并行垃圾收集器可以工作的很好。但对于那些不满足要求的应用来说,暂停时间会变得很长,因为一次full GC必须标记整个Java堆空间,同时还要压缩老年代空间。导致的一个结果就是,随着Java堆空间的增大,暂停事件也会随之增加。

并行垃圾收集器

上图描述了Java应用线程(蓝色箭头)被暂停,同时GC线程(绿色箭头)接管过去做垃圾收集工作。在这张图里有8个并行GC线程和8个Java应用线程,然而在大多数应用里,应用线程的数量通常超过GC线程的数量,特别是遇到某些应用线程可能是闲置的情况。当一个垃圾收集活动开始,所有的应用线程都会被暂停,然后开始执行多个GC线程。

并发标记清除(CMS)垃圾收集器

随着越来越多的应用要求有一个垃圾收集器,它能比串行或并行垃圾收集器有更短的最坏情况的中断时间,牺牲一些应用的吞吐量来消除或极大地减少漫长的GC中断数量也是能够接受的,针对这种情况,CMS垃圾收集器被开发出来。

在CMS垃圾收集器中,年轻代的垃圾收集与并行收集器很类似,它们是并行的而且会stop-the-world,也就是说在年轻代的垃圾收集过程中所有的Java应用线程都会被暂停,而垃圾收集工作会用多线程的方式来执行。需要注意的是,你可以给CMS垃圾收集器配置一个单线程模式的年轻代收集器,但在Java 8中并不推荐这个方式,这个选项在Java 9中被移除了。

并行垃圾收集器与CMS垃圾收集器最主要的区别是老年代的收集上。CMS收集器的老年代收集活动试图避免应用线程的长时间中断。为了实现这个目的,CMS老年代收集器在应用线程执行的同时做了大部分工作(垃圾收集线程与应用线程同时工作),除了少量相对短的GC同步暂停。通常来说,绝大多数情况下CMS是并发的,老年代收集的某些阶段会暂停应用线程,比如初始标记和重新标记阶段。在CMS最初的实现中,初始标记和重新标记阶段都是单线程的,但现在它们都已经被改为多线程的。激活多线程的初始标记和重新标记阶段的HotSpot命令行选项分别是-XX:+CMSParallelInitial Mark Enalbed-XX:CMSParallelRemarkEnabled,当通过命令行选项-XX:+UseConcurrentMarkSweepGC激活CMS垃圾收集器时也会缺省自动激活这两个选项。

有可能,或者说极有可能会在一个老年代并发收集正在进行的时候,又发生了一个年轻代收集。一旦发生这种情况,老年代并发收集会被年轻代收集所中断,直到后者结束之后立即恢复执行。CMS GC的缺省年轻代收集器被称为ParNew收集器。

使用CMS垃圾收集器面临的一个挑战就是要在应用消耗完Java的可用堆空间之前完成并发收集工作。因此对CMS来说有个很棘手的部分,就是找到一个合适的时机来启动这个并发工作。这种并发方式往往导致一个结果,就是处理同一个应用,CMS GC会比并行GC多占用10%~20%的Java堆空间。这也是为了缩短垃圾收集暂停时间所付出的代价。

CMS垃圾收集器的另一个挑战是如何处理老年代中的空间碎片,也就是当老年代中对象空间的碎片太小,以至于无法容纳从年轻代晋升上来的对象,因为在CMS的并发收集循环中并不执行压缩,哪怕是增量或局部压缩。一旦无法找到可用空间,就会使CMS回过来使用串行GC,触发一次full收集,导致一个漫长的暂停。伴随CMS碎片的另一个很不幸的挑战就是上述问题完全无法预测。同样都是老年代碎片,某些应用可能没有经历过一次full GC,而有些可能时不时就要经历一次。

对CMS垃圾收集器做些调整,对应用做些优化改动,诸如避免生成大尺寸对象,会有助于延缓空间碎片的产生。当然,调优工作就是个不同寻常的工作,对专业能力有很高的要求,所以改动应用来避免碎片也是个不小的挑战。

CMS垃圾收集器

上图描述了由于年轻代垃圾收集(绿色箭头)、CMS初始标记,重新标记阶段以及老年代垃圾收集stop-the-world阶段,导致Java应用线程被暂停。CMS垃圾收集器的老年代收集活动从一个stop-the-world的初始标记阶段开始。一旦完成初始标记,就进入并发标记阶段,在这个阶段允许Java应用线程和CMS标记线程同时执行。图中,在标记/预清理标签下方,前两个比较长的黑色箭头就是并发标记线程。一旦并发标记完成,CMS线程就执行并发预清理,即图中标记/预清理标签下发两个较短的绿色箭头。需要注意的是,如果有足够的可用硬件线程,CMS线程的执行成本并不会对Java应用线程的性能产生太大影响。但如果硬件线程是饱和的或被高度利用的,CMS线程就会和Java应用线程竞争CPU周期。一旦并发预清理完成,stop-the-world的重新标记阶段就会开始。在重新标记阶段会标记初始标记,并发标记以及并发预清理过程中可能错过的对象。当重新标记阶段结束后,并发清除启动,释放所有死亡对象的空间。

G1垃圾收集器

G1垃圾收集器采用一个略微不同的手段来解决并行、串行以及CMS GC的众多缺陷。G1将堆拆成一系列的分区,这样在一个时间段内,大部分的垃圾收集操作就只是一个分区内执行,而不是整个堆或整个(老年)代。

在G1里,年轻代就是一系列的内存分区,这意味着不用再要求年轻代是一个连续的内存块。类似地,老年代同样也是由一系列的分区阻塞。这样也就不需要在JVM运行时考虑哪些分区是老年代,哪些是年轻代。事实上,G1的运行状态是映射G1分区的虚拟内存随着时间的推移在不同的代之间前后切换。一个G1分区最初被指定为年轻代,经过一次年轻代的回收后,整个年轻代分区都被划入到未被使用的分区中,那它也就可以被使用在别的地方了。

G1年轻代的收集方式是并行stop-the-world。前面提到过,在垃圾收集线程执行过程中,并行stop-the-world回收将暂停所有Java应用线程,而垃圾回收的工作也将通过多个线程来分担。与其他HotSpot垃圾收集器一样,一旦发生一次年轻代垃圾收集,整个年轻代都会被回收。

G1老年代的垃圾收集方式与其他HotSpot垃圾收集器有着极大的不同,它采用混合垃圾收集(Mixed GC)。G1老年代的收集不会为了释放老年代的空间就要求对整个老年代做回收。相反,在任一时刻只有一部分老年代分区会被回收。并且,这部分老年代分区将与一次年轻代收集一起被收集。

混合垃圾收集就是用来描述一部分老年代分区与年轻带收集结合在一起进行的收集。因此,混合GC就是在一次垃圾收集事件中,所有年轻代分区以及一部分老年代分区将会被回收。换句话说,混合GC就是讲要被回收的年轻代与老年代分区的组合。

与CMS GC类似,当遇到一些极端情况时,诸如老年代空间被消耗完了,会有一个安全措施来收集和压缩整个老年代。

撇开安全模式下的收集不谈,一个G1老年代收集是由一系列阶段组成,某些是并行stop-the-world,某些是并行并发的。也就是说,某些阶段是多线程的同时会暂停所有应用线程,而其他阶段是多线程的,但可以与应用线程同时运行。

当超过Java堆的占用阈值,G1就会启动一次老年代收集。要注意到有一点非常重要,那就是G1中的堆占用阈值,这是根据老年代占用空间与整个Java堆空间相比较得出的。熟悉CMS GC的读者会记得,CMS触发老年代收集所用的占用阈值只是相对于老年代空间本身而言的。在G1中,一旦达到或超过内存堆的占用阈值,一次并发stop-the-world方式的初始比较阶段就会被安排执行。

初始标记阶段会跟着下一次年轻代收集同时进行。一旦初始标记阶段结束,就会触发一个并发多线程的标记阶段,标记老年代中所有的存活对象。当并发标记阶段结束,并行stop-the-world的重新标记阶段就会被启动,标记哪些因为在标记阶段同时执行的应用线程导致产生错过的对象。到重新标记阶段结束,G1就拥有了老年代分区的完整信息。如果碰巧老年代分区里一个存活对象都没有,那么在下一个阶段——清除阶段,不用做额外的垃圾收集工作就可以被回收在利用。

同样也是在重新标记阶段结束,G1能识别出最适合回收的老年代分区集合。

G1 GC

在垃圾收集过程中收集的分区集合可以被称为收集集合(CSet)。

选择哪些分区被包含在一个CSet里,是基于有多少空间可以被释放以及G1暂停时间目标。在完成CSet识别之后,G1就在接下来的几次年轻代垃圾收集过程中对CSet中的分区进行回收。也就是说,在接下来的几个年轻代垃圾收集中,除了年轻代分区,还有一部分的老年代分区也将被回收。这就是前面提到的垃圾收集事件中的混合GC类型。

不管是年轻代还是老年代,G1会把每个收集过垃圾的分区中的存活对象转移到一个可用分区中。一旦存活对象呗转移掉,那这个年轻代分区(可能还有老年代分区)就会被回收为可用分区。

将各老年代分区中的存活对象转移到可用分区会带来一个很有吸引力的结果—— 在虚拟地址空间里每个转移对象都是前后相连的,对象和对象之间没有碎片化的空余空间。我们回忆一下,CMS,并行以及串行垃圾收集器都需要一个full GC来压缩老年代,而这个压缩动作需要扫描整个老年代空间。

因为G1以每个分区为基础做垃圾收集操作,所以它适用于大尺寸的Java堆。垃圾收集工作的数量可以被限制在一个小范围的分区集合内,哪怕Java对尺寸可能会相当大。

G1暂停时间的最大来源是年轻代收集和混合收集,所以G1的设计目标之一就是允许用户设置GC暂停时间目标。G1会尝试通过调账Java堆尺寸大小的方式来满足设定的暂停时间目标。它会根据暂停时间目标自动调整年轻代的尺寸和总的Java堆尺寸。暂停时间目标越短,年轻代空间就越小,总的堆空间就越大,使得老年代空间相对就越大。

G1的设计目标就是把必要的调整限定在设置最大的Java对空间和指定GC暂停时间目标上。另外,G1还被设计成可以通过一个内部的启发式算法来做自我调整。在写这本书的时候,G1的启发式算法就是HotSpot GC开发活动开展的最活跃的地方。

综上,对于大的Java堆来说,通过将Java堆拆分成一个个分区,G1会被其他垃圾收集器有更好的综合表现。在局部压缩的帮助下,G1解决了Java堆碎片,它的绝大部分工作都通过多线程的方式完成。