Hotspot虚拟机- 垃圾收集算法和垃圾收集器

引言

当提到Java虚拟机的时候,我们首先应该区分2件事情。一个是抽象的Java虚拟机规范,另一个是根据这个规范具体的实现。市面上有很多根据这个规范实现的虚拟机,比如jRockit、IBM J9、Hotspot等。那么在这篇文章中,我只介绍关于Hotspot虚拟机的行为,以及它里面提供的垃圾收集器。

什么是垃圾收集?

相信每一个现实中生活的人都知道什么是垃圾收集。顾名思义,就是找到没有用的东西(垃圾)并把它扔掉。但是,在JVM中的垃圾收集是完全相反的,它首先找到所有仍然使用的对象并标记下来,然后清理掉没有标记的垃圾。

那么对于一个垃圾收集器来说,它主要有三个工作需要完成:

  1. 分配内存
  2. 确保任何被引用的对象在内存中,不被垃圾收集器收集
  3. 释放不再使用的对象的内存

被引用的对象我们称它是活着的(live),不再被引用的对象我们称它为死的(dead),或垃圾。找到并释放这些垃圾的过程叫做垃圾收集

Mark and Sweep

  • Marking : 遍历所有从GC roots开始可到达的对象并把这些对象标记为存活的对象
  • Sweeping : 确保不可达对象占用的内存能在下一次内存分配中可以被重新利用。

在Java中,GC Roots对象可以为:

  • Local variable and input parameters of the currently executing methods
  • Active threads
  • Static fields
  • JNI references

JVM中不同的垃圾收集算法实现细节有些不同,但是大体上来说,所有的垃圾收集算法都遵循着Marking和Sweeping,等我下面介绍这些垃圾收集算法和垃圾收集器的时候大家就能感觉到了。

Mark and Sweep

相比于Reference Counting来说,可达性分析也可以收集循环引用的垃圾,如上图所示。由于Hotspot虚拟机并没有用Reference Counting来判断一个对象是否存活,这里我就不介绍这种方法了,大家可以参考一下维基百科上的解释Reference Counting,概念也很简单。

理想的垃圾收集器特性

在我介绍Hotspot虚拟机中具体的垃圾收集器之前,我们应该首先了解一个理想的垃圾收集器应该具有什么样的特点才能满足非常复杂的垃圾收集过程。

一、垃圾收集器应该是安全的。也就是说,live对象占用的内存不能被释放,垃圾对象占用的内存要尽快释放。这个特点是最重要的一点,如果不能满足这点,其它特点再牛逼,也可以say goodbye了。

二、垃圾收集器应该有效地收集垃圾。也就是说:不能让我们自己的应用在垃圾收集的过程中停顿太长的时间(stop the world)。然而,这就象很多计算机系统一样,通常在时间、空间和频率上有一个trade-offs. 比如,如果垃圾收集器收集的堆容量很小,那么收集过程会很快,但是,堆很快就会被填满,就会需要更加频繁的垃圾收集。反之,在堆容量很大的情况下,需要更长的时间才能填满堆,因此垃圾收集的次数很少,但是每次收集就需要更多的时间。

三、垃圾收集器应该有效解决fragmentation的问题。fragmentation的意思就是说,垃圾收集器在收集完垃圾以后会产生很多不连续的内存空间,如果下次分配一个大对象有可能出现没有任何一个连续的空间能容纳这个对象,这会导致内存空间的浪费,同时也会触发下一次垃圾收集过程。有一个方法可以消除fragmentation,它叫做compaction,下面我会介绍的。

四、可扩展性也是很重要的一个特点。 由于现在很多应用中都是多核处理器或者多个处理器,那么我们的垃圾收集器可以利用这些优势去并行分配内存和收集垃圾。

compaction

分代收集(Generational Collection)相关概念

在Java8的HotSpot虚拟机中一共包括了5个垃圾收集器,它们每一个都是基于分代收集的思想。在这一节中,我主要介绍一下各个分代区域以及对象是怎样被分配到这些区域的。这是官方文档给出的5个可得到的收集器:5 Available Collectors,并介绍了如何针对自己的应用选择出一个合适的收集器。

Generational Hypothesis

对于Generational Hypothesis的概念,Jeff Hammerbacher在Quora上已经给出一个很好地答案,我把它翻译一下。

Generational Hypothesis是一个关于对象生命周期分布的假设。准确地说,这个假设认为对象生命周期的分布是双峰的:大部分的对象要么是短命的,要么就是一直存活的。

Generational Hypothesis

基于这个假设,Hotspot虚拟机把内存分为年轻代(Young Generation)和老年代(Old Generation)。有了这样的内存区域划分,我们可以针对不同的区域选择合适的算法来进行垃圾收集,从而大大提高垃圾收集的效率。注意:分代收集是基于上面的假设来进行的,如果你的应用完全不符合上面的假设,那么你的垃圾收集效率一定很低。

因为年轻代空间通常很小,包含很多短命的对象,所以它的收集要频繁一些。经过了几轮年轻代收集,依然存活的对象被晋升(promoted)或者tenured到老年代。因为老年代的空间要比年轻代大很多并且它的对象大部分都是长命的,所以它的收集是不频繁的。由于年轻代的收集很频繁,因此针对这个区域的收集算法要很快。另一方面,由于老年代的收集不是很频繁的并且它占用了大多数的堆空间,因此这一区域的算法针对低频的垃圾收集要空间有效的。

在介绍各个分代区域之前,大家先看看下面这张图。

Generational Collection

注意:在Java 8中已经移除了永久代。

年轻代

年轻代是由一个Eden区域 + 2个survivor区域组成。大部分的对象最初都被分到Eden区域(特别大的对象可能直接被分配到老年代)。对于2个survivor区域来说,它们中的一个必须始终是空的。并且每个survivor区域中的对象至少是经历过一次年轻代垃圾收集的。假设几分钟前垃圾收集器已经进行了一次年轻代的垃圾收集了,Eden区域和其中的1个survivor区域都有对象,另一个survivor区域为空。现在,又要进行一次垃圾收集了,收集器做的就是:把Eden区域和那个有对象的survivor区域中活着的对象找出来并复制到另一个空的survivor区域中,然后清空Eden区域和先前有对象的那个survivor区域。

如果空的这个survivor区域的空间不够装下Eden区域和另一个survivor区域中活着的对象,那么收集器会把容纳不下的对象直接分配到老年代。如果老年代也容不下这些对象,那么会触发老年代的垃圾收集,然后去容纳这些对象。

由于Java应用支持多线程,那么在多线程应用的情况下对象的分配就会出现一些问题。比如,我上一个线程分配的对象会被下一个线程所分配的对象覆盖。如果用一个全局锁来把整个年轻代锁住,那么分配一个对象到年轻代的性能会非常低下。因此,虚拟机团队想出了一个解决方案叫做Thread-Local Allocation Buffers (TLABs).

Thread-Local Allocation Buffers

如上图所示,每一个线程都有一个自己的TLAB,分配对象时用指针碰撞(bump-the-pointer)技术去在各自的TLAB中分配对象,大大提升了年轻代分配对象的效率。设置‐XX:+UseTLAB来启用TLAB,通过‐XX:TLABSize来设置其大小,默认大小为0,0表示虚拟机动态计算其大小。

经过了几次垃圾收集还没有被回收的对象就被promoted到老年代了。那么如何去判断一个对象是否足够老可以晋升到老年代呢?垃圾收集器追踪每个活着对象被收集的次数,每挺过一次垃圾收集,对象的年龄就加1,当一个对象的年龄超过了指定的阙值(tenuring threshold),它就会被晋升到老年代。通过设置XX:+MaxTenuringThreshold来指定一个上限,如果设置为0,那么经过1次垃圾收集以后马上被晋升。

老年代

老年代的空间是非常大的并且它里面存在的对象成为垃圾的可能性很小。老年代的垃圾收集次数要比年轻代少很多,并且由于老年代的对象很少会成为垃圾对象,年轻代的做法(在survivor区域不断copy)并不适合老年代。老年代具体的收集算法我会在下面具体的垃圾收集器中介绍。

永久代

永久代在Java 8以前存在。 JVM用这里存储一些类的元数据还有一些被内在化的字符串。What is String interning?详细地解释了什么是内在化字符串。Hotspot虚拟机用永久代实现了方法区,因此如果你用动态代理技术或CGLib产生大量的增强代理类,都会使永久代出现异常。比如,当你用Spring的AOP时,它都会为想要增强的类产生一个代理类从而达到增强的目的,如果产生的类很多,你的永久代将会溢出。

永久代给Java开发者制造了很多的麻烦,因为很难预测出它将需要多少内存空间。如果出现溢出:产生java.lang.OutOfMemoryError: Permgen space.的错误。

Metaspace

由于上面永久代的缺点,它在Java 8中被移除,取而代之的是Metaspace,这块内存区域位于本地内存中。默认情况下,Metaspace的大小只被Java进程可得到的本地内存所限制。因此,这个区域并不会因为稍微增加一个类就导致溢出。注意:Metaspace没有限制地增长将会导致本地内存溢出。 你可以设置-XX:MaxMetaspaceSize来限制其大小。

垃圾收集算法的种类

从上面的分类收集中我们可以看出,不同的分代区域具有不同的特点,因此我们可以利用这些特点来选出具有针对性地垃圾收集算法,使得收集更加有效。本节我会介绍Hotspot虚拟机中的垃圾收集器会用到的算法,介绍它们具体的实现过程。

Serial vs Parallel

即使你的电脑有多个CPU,Serial只用一个CPU来进行垃圾收集。对于Parallel来说,它会把一个垃圾收集任务拆分成几部分,在多个CPU上并行执行这些任务。Parallel收集将会使垃圾收集更快地完成,但是,它同时也增加了额外的复杂性和潜在的fragmentation.

Concurrent versus Stop-the-world

对于Stop-the-world来说,在垃圾收集期间,我们自己的应用完全被停止。对于Concurrent来说,它可以并发的执行垃圾收集和我们的应用。Stop-the-world垃圾收集要比Concurrent垃圾收集简单的多,因为我们自己的应用不需要继续运行,那么在垃圾收集期间就不会向堆中加入对象,这大大简化了垃圾收集的过程。“成也萧何,败也萧何”,它的优点也是它的缺点,因为Stop-the-world需要停止我们自己的应用程序,而一些应用需要快速做出响应,而Stop-the-world会导致应用停止,这并不是我们所期望的。相应的,由于Concurrent是并发地处理垃圾收集任务和应用,所以Concurrent的停顿时间要短,但是由于在垃圾收集的同时,应用还向堆中加入对象,这就好比自己在打扫屋子的同时,别人还向里面扔垃圾,真的是很烦人啊! 这会增加额外的开销并且会需要更大的堆内存。

Compacting versus Non-compacting versus Copying

当垃圾收集过后,有可能会出现不连续的内存空间。Compacting的做法就是把所有活着的对象放到一起,之后就可以用简单并且快速的指针碰撞来分配对象了。由于它需要移到活着的对象,这会导致垃圾收集过程需要更多的时间。

non-compacting的做法正好与Compacting相反,它在垃圾收集完毕以后并不会把活着的对象放到一起。这样做的好处就是它更快地完成垃圾收集,但是缺点就会导致潜在的fragmentation,那么下次向堆中分配对象时,会需要更长的时间,因为它需要搜索堆内存从而找到一块连续的内存足以容纳新分配的对象。

Copying的做法是把活着的对象复制到一个新的内存区域,这样做的好处就是由于这块新的区域是空的,我可以从头到尾快速的分配对象并不会产生fragmentation现象。但是缺点也很明显,就是它需要额外的复制时间和额外的内存空间。

性能指标

评价一道美食需要看它的色、香、味,有了这套指标,我们就可以综合评价这道食物是好是坏。同样地,对于垃圾收集器来说,也有一套指标来评价其性能,从而判断一个垃圾收集器是好是坏,本节将会介绍这套指标及其对应的意义。

  • Throughput - 在一段很长的时间内,这段时间没有花在垃圾收集上的时间的百分比
  • Garbage collection overhead - 与Throughput相反,花费在垃圾收集上的时间的百分比
  • Pause time - 当垃圾收集时,我们自己的应用被停止的时间
  • Frequency of collection - 垃圾收集多久出现一次
  • Footprint - 大小的度量,例如堆大小
  • Promptness - 一个对象变成垃圾到它的这块内存被释放所需要的时间

垃圾收集类型

由于Hotspot虚拟机的垃圾收集是基于分代思想的,那么在不同的分代区域收集会产生不同的垃圾收集类型,本节我将会介绍这些垃圾收集类型以及它们发生的时机。

minor gc

发生在年轻代的垃圾收集叫做minor gc,它具体的细节是什么样呢?

  • 当JVM不能为一个新对象分配空间时,minor gc被触发。例如:Eden区域被填满时。因此,你的应用分配对象的频率越高,minor gc发生的越频繁。
  • 在minor gc期间,老年代实际上被忽略。因此,从老年代到年轻代的引用被当作GC roots,而从年轻代到老年代的引用在标记阶段被忽略。
  • minor gc会触发stop-the-world的发生,致使应用线程停止。如果在Eden区域中的大部分对象都被标记为垃圾,既符合上面的假设,那么停顿时间是可以忽略不计的。但是,如果与假设相反,在Eden区域依然大部分的对象都是活着的,那么minor gc会花费很多的时间。

full gc

清理整个堆的过程叫做full gc,有时也叫做major collection. 当老年代太满了而不能要接受所有来自年轻代晋升的对象时,所有的收集器(除了CMS)将停止年轻代的收集算法运行,而是用老年代的收集算法清理整个堆内存。(CMS垃圾收集器的老年代收集算法不能收集年轻代)。

Hotspot虚拟机中的垃圾收集器

上面我已经介绍了不同的垃圾收集算法以及执行的过程。本节我将介绍Hotspot虚拟机中的垃圾收集器和各个收集器中所使用的收集算法,以及主导垃圾收集的过程。

serial collector

对于serial collector来说,年轻代和老年代的收集都是serial的,并且会导致Stop-the-world的出现。

serial collector之年轻代收集

这个收集器在年轻代的收集用mark-copy,也就是我上面说的在2个survivor区域之间来回复制。mark阶段就是标记出所有活着的对象,copy阶段就是把这些活着的对象copy到空的那个survivor区域。注意:如果空的这个survivor区域已经被来自Eden区域和另一个survivor区域中的对象填满了,那么剩下活着的对象将被晋升到老年代,不管这些对象已经在几次minor gc中生存下来。

serial collector之老年代收集

这个收集器在老年代的收集用mark-sweep-compact. 在mark阶段,收集器标记出还存活的对象; 在sweep阶段,清理掉所有的没被标记的垃圾; 在compact阶段,收集器移动所有活着的对象到老年代的起始端,因此以后在向老年代分配对象时就可以用快速且有效的指针碰撞技术了。下图是compact阶段前后的样子。红X的对象已死。

Compaction of the old generation

什么时候使用serial collector

用-XX:+UseSerialGC参数来启用serial collector

serial collector用单个线程去执行所有的垃圾收集,由于没有了线程之间的交流开销,这使得它稍微高效一些。它最适合在单个处理器的机器上工作,因为它不能利用多处理器硬件的优势。对于堆空间很小的应用(大约100M),即使在多处理器的硬件上,它也是可行的。

Parallel Collector

Parallel Collector也被叫做throughput collector,对于Parallel Collector来说,年轻代和老年代的收集都是Parallel的,用多个线程去执行收集任务,这会大大减少垃圾收集时间。和serial collector一样,年轻代和老年代的收集都会导致Stop-the-world的出现。

Parallel Collector之年轻代收集

和serial collector一样,它在收集年轻代时,用的也是mark-copy,只不过是用多个CPU并行而已。相比于serial collector,它确实利用了现代机器多处理器的优势,大大减少了垃圾收集时的Stop-the-world的时间。由于Parallel Collector在以下2点有效地利用了系统资源从而提高了Throughput.

  1. 在垃圾收集期间,所有的CPU都在执行垃圾收集任务,减小了Stop-the-world的时间
  2. 在各个垃圾收集循环之间(即应用运行的时候),它并没有像CMS一样去占用系统的资源

下图解释了serial collector和Parallel Collector在年轻代收集之间的不同。

Comparison between serial and parallel young generation collection

Parallel Collector之老年代收集

它和serial collector一样,用mark-sweep-compact算法来进行老年代的收集。注意:Parallel Collector在老年代的收集并不是并行的。

虽然老年的收集是不频繁的,但是一旦它被触发,就有可能出现很长的Stop-the-world时间,这在一些要求低延迟的应用是不可接受的。如果在你的应用场合需要更低的延迟,那么相比于Parallel Collector,CMS可能是个很好的选择。

什么时候使用Parallel Collector

用-XX:+UseParallelGC参数来启用Parallel Collector

如果你有一个多处理器的机器,并且你的应用并不需要停顿时间限制,那么Parallel Collector是个不错的选择。

Parallel Compacting Collector

Parallel Compacting Collector几乎和Parallel Collector一样,除了Parallel Compacting Collector用一个新的算法针对老年代的收集。Sun公司官方说:Parallel Compacting Collector将最终取代Parallel Collector.

Parallel Compacting Collector之年轻代收集

parallel collector一样。

Parallel Compacting Collector之老年代收集

与Parallel Collector不同的是,Parallel Compacting Collector可以并行地对老年代进行收集。

什么时候使用Parallel Compacting Collector

用-XX:+UseParallelOldGC参数来启用Parallel Compacting Collector

它和Parallel Collector的使用场景一样。但是,对于有停顿时间限制的应用,Parallel Compacting Collector会更加合适。

Concurrent Mark-Sweep (CMS) Collector

CMS Collector也叫做low-latency collector,它是专门为老年代设计的。因为年轻代的stop-the-world时间不会太长,而对于老年代来说,虽然它的收集并不频繁,但是每次收集都可能会出现较长的停顿时间,尤其是在堆空间很大的时候。而CMS Collector的出世就是解决老年代停顿时间长的问题。解决这个问题它主要通过下面2个手段:

  1. 当老年代收集过后,CMS Collector并不会去compacting老年代,而是用空闲列表(free-lists)去管理被释放的空间。
  2. 它在mark-and-sweep阶段大部分的时候都是与我们自己的应用并发执行。

如果你应用的主要目标就是降低延迟,那么CMS Collector是个非常不错的选择。注意:CMS Collector是靠着与我们的应用程序并行才减少了老年代的停顿时间,由于我们的应用一直在运行,所以老年代中的对象也在动态地变化着,因此CMS Collector需要更加小心地进行垃圾收集,这无疑会增加开销。用stop-the-world的方法收集老年代时,虽然它的停顿时间会长一些,但是它一下就清理干净了,然后继续我们的应用程序。而CMS Collector给我的感觉有点像“细水长流”,它虽然减少了停顿时间,但是它基本上会一直和我们的应用程序一起消耗着CPU资源,相比于Parallel Collector,CMS Collector会有更低的throughput

CMS Collector之年轻代收集

parallel collector一样。

CMS Collector之老年代收集

CMS Collector在收集老年代的时候总共需要4个阶段,其中有2个阶段依然会出现stop-the-world的现象。下图是CMS Collector与serial collector之间在老年代收集之间的比较,大家先仔细观察一下,看看有什么不同。之后我会详细介绍这4个阶段的细节并介绍在每个阶段会出现的问题。

Comparison between serial and CMS old generation collection
  1. initial mark - 会出现stop-the-world
    • 标记出应用代码直接可达的活着的对象,这个过程需要很短的时间,因此stop-the-world的时间基本可以忽略。initial mark和remark都会出现stop-the-world的现象,但是从上图我们可以看出,remark用的是多线程并行标记,而initial mark却用单线程去标记,这是为什么呢?其实很简单,如果你用并发去做,就有些“杀鸡用宰牛刀”的意味。说直白一点,即使是你用单线程去做这个任务,它依然只有很短时间,可以忽略不计; 相反,如果你用多线程去做,你需要为各个任务去分配线程,线程之间还需要通信开销,你提前做了这么多的工作最后却干了一件“鸡毛蒜皮的小事”,这甚至有可能会导致你的标记时间相比于单线程来说要增加,所以这里用单线程去做。
  2. concurrent mark - 与我们的应用程序并发执行
    • 由于在initial mark阶段已经找到了GC Roots,那么在这个阶段会从GC Roots出发,沿着引用链去找剩下存活的对象。1、由于我们的程序在这个阶段下也一直在运行,因此有可能会继续向老年代中添加对象; 2、虽然在remark阶段之后,CMS Collector会保证所有活着的对象被标记,但是在这个过程中一些被认为活着的对象可能已经死了,因此只能等到下一次老年代的收集才能被回收。由于这2点原因,CMS Collector无疑会比其它的收集器需要更大的堆空间。
  3. remark - 会出现stop-the-world
    • 由于在concurrent mark阶段期间,我们自己的应用程序一直在运行,它会更新一些对象的引用,因此在concurrent mark结束后并不能保证所有活着的对象被标记,那么这个阶段就是来解决这个问题的。在这个阶段中,它会重新访问所有在concurrent mark期间被改动的对象,保证让所有活着的对象被标记到。由于这个阶段的任务是头“牛”,因此就如上图所示,这个阶段是用多线程(宰牛刀)去执行的。
  4. concurrent sweep - 与我们的应用程序并发执行
    • 这个阶段的任务就是释放掉垃圾占用的空间。CMS Collector为了减小停顿时间,它在这个阶段并没有进行compacting,而是用空闲列表去维护被释放的空间。相比于指针碰撞,它在分配对象的时候需要更多的时间。在大多数情况下(有一些大对象直接被分配到老年代),对象分配到老年代是因为年轻代的对象晋升了。由于你用空闲列表的方式去分配内存会需要更多的时间,因此也会增加年轻代收集的开销。

什么时候使用CMS Collector

用-XX:+UseConcMarkSweepGC参数来启用CMS Collector

相比于parallel collector,CMS Collector减少了老年代的停止时间,但是同时,它也作出了一些牺牲,比如:增加了年轻代的停止时间、减少了throughput和需要更大的堆空间。

如果你的应用需要更短的垃圾收集停顿时间,那么CMS是个不错的选择。

总结

至此,我已经介绍完了Hotspot虚拟机中的垃圾收集算法和垃圾收集器。这里面没有哪个收集器可以吃遍天下的,选择哪个收集器要取决于你的应用,你手中现有的硬件资源。调节垃圾收集器的参数就像我们实际开发中选择哪个算法也样,我们结合自己的应用,在时间和空间上要找到一个适合我们自己的tradeoff.

大家在调节GC的时候,千万不要凭借自己的感觉随便选择一个GC相关的参数。而是应该遵循下面这几个简单的步骤,从而使你在优化GC的道路上进入一个正确的方向。

  1. 明确你的性能目标。Oracle官方的建议就是在开始的时候do nothing,让GC自己去动态地调节,如果不能满足你的性能目标的时候再去调节GC. 性能指标分为3类,Latency、Throughput、capacity
  2. 用你当前的GC参数去做测试,记录下你的性能结果
  3. 把你的测试结果与你自己定义的目标做比较
  4. 如果没有达到你定的目标,调节相应参数,回到步骤2

过一段时间我会专门写一篇关于如何调节GC参数的文章并用具体的实例来演示整个过程。

还有一点就是:上面的垃圾收集器中我并没有提到Garbage-First(G1) Garbage Collector,Oracle官方的计划就是让G1收集器完全取代CMS Collector. 在2009年的JavaOne大会上,Sun公司发布了Java SE 6 Update 14,在这个JDK的版本中包括了万众瞩目的G1收集器。G1是一个low-pause、low-latency的一款收集器,你可以给它设置一个停止时间,它会尽量去满足你的这个时间。注意:它不能保证达到这个目标。 如果你的CMS收集器目前没有问题,你完全没有理由用这个收集器。这里,我给大家一篇文章,它详细地描述了G1收集器垃圾收集的细节,G1: Java’s Garbage First Garbage Collector,我就不翻译了。