JVM的自动内存管理得益于不断发展的垃圾回收器,从最初的单线程收集到现在并发收集,垃圾回收器的开发者们一直在致力于如何降低GC过程中的停顿时间(STW)以及提高吞吐量,但直到现在也不存在一款完美的垃圾回收器,只能根据不同的场景选择最合适的。所以需要了解每款垃圾回收器出现的背景、原因,并掌握各种垃圾回收器的设计原理、算法实现细节以及各个垃圾回收器的优劣对比,这样才能让我们在调优时做出最合适的选择。这部分内容博主准备分为两篇文章进行总结讲解,本篇主要是对垃圾收集算法的思想以及目前稳定商用的垃圾回收器的讲解。
上文分析了JVM判断对象存活的两种算法:引用计数和可达性分析。因此垃圾收集算法的实现也对应的分为引用计数式收集和追踪式收集,而目前JVM中都没有使用引用计数算法,所以后面讲解的算法都属于追踪式收集。其细分又分为标记-复制、标记-清除、标记-整理、分代回收。
复制算法最初的理论是将可用内存分为1:1的两块,每次只使用其中一块,当这块内存满后,就先标记存活对象并将其复制到另一块内存,然后将满的内存释放掉。这种算法非常简单高效,只需要将标记的存活对象复制到另一半空间,同时内存始终保持规整,不会出现内存碎片,但缺点也很明显,可用内存减少了一半,另外复制的对象不能太大,否则复制的效率会比较低。
因为新生代中的对象大多“朝生夕死”,在JVM新生代中的垃圾收集器都是采用的复制算法。但是为避免浪费的空间太多,提出了一种更为优化的复制算法,称为Appel式回收。该算法不再是简单的“半区复制”,而是将新生代分为了三块:一块Eden区和两块Survivor区(分别标记为from和to),默认的分配比例是8:1:1(-XX:SurvivorRatio=8表示两个Survivor区和Eden区比例为2:8,即每个Survivor占10%),每次分配对象都只使用Eden区和其中一块Survivor区(from区)。其中Eden区最大,新对象都在该区域创建,当Eden区满后,会进行一次MinorGC,并将Eden区和from区中存活对象都复制到to区中,然后调换from和to指针。当然肯定是存在to区装不下一次MinorGC存活对象的情况,这时就需要老年代进行分配担保(相关概念在上一篇已经讲过)。
从上面的算法过程中堵着门应该会有一个疑惑:为什么需要两个Survivor区?这里以假设法进行分析。如果没有Survivor区,那么新生代每次GC后存活对象会直接进入老年代,导致老年代迅速填满,频繁的触发FullGC;如果只有一块Survivor区,那么为了保证复制算法的特性(内存规整和高效),Eden区经过一次MinorGC后会将对象复制到Survivor区,这时新对象只能在Survivor区创建,否则无法保证内存规整,但又由于Survivor区非常小,就会导致很快又触发有一次MinorGC;而如果有两块Survivor区就很好的解决了上面所说的问题,而更多的Survivor区就没有必要了。
标记清除是最早出现的垃圾回收算法,由Lisp之父提出。这个算法也很简单,首先标记存活的对象,然后统一回收未被标记的对象。相较于复制算法的缺点也很明显,效率更低,同时会导致内存碎片。为什么效率更低了呢,好比你删除文件,直接格式化文件夹快还是去文件夹中找到文件一个个删除更快?另外内存碎片会导致堆中明明还有足够的内存,但却没有足够的连续内存来存放大对象,导致对象直接进入老年代。
这个算法就是建立在标记清除的基础之上,多了一步整理的工作,标记完成后首先将存活的对象移动到一边,然后清理掉另一边的内存,解决了内存碎片带来的问题。标记-清除和标记-整理都适合用在老年代中,而前者相较于后者不用移动内存,而移动内存是一种非常“危险”的操作,需要暂停其它用户线程的执行,确保内存指向的正确性,所以这就是STW出现的原因,就好比你不能在你妈妈打扫屋子的同时边往地上扔垃圾。
分代回收严格意义上并不算一种算法,而是各回收算法的实践理论。它建立在两个分代假说之上:
上面两个假说共同确定了垃圾收集器一致的设计原则,即新生代和老年代。在新生代中使用复制算法,如上所说,大部分对象朝生夕灭,所以只需要将少量存活对象复制到另一块区域后再统一格式化之前的区域;而老年代因为大量对象存活,只能采用标记清除或标记整理算法。
分代回收可以避免垃圾回收时总是进行全堆扫描,但是也带来另外一个问题,不同代之间可能会存在引用,若没有其它的处理手段,那么在进行新生代垃圾回收时除了遍历GC Roots外,不得不再额外遍历整个老年代中的对象,所以为解决这个问题,需要添加第三条经验法则:
两个相互依赖的对象,基本上是应该是同生同灭的,所以跨代引用数应该是比较少的,那么JVM在扫描时自然就没必要也不应该去扫描整个老年代,可以在收集区域维护一个数据结构,记录从非收集区域指向收集区域的引用,那么在扫描时只需要额外扫描这个数据结构就行了,该结构被称为记忆集(相关细节下一篇分析)。
垃圾回收器是垃圾回收算法的实现,在虚拟机规范中并没有定义要如何实现垃圾回收器,所以各大厂商对垃圾回收器的实现有很大差别,但都是在朝着一个方向努力:低延迟、高吞吐量。
上图中展示的就是目前主流的垃圾回收器,有连线的代表两者可以搭配使用,而打“X”的表示在JDK9中已经废弃的组合,另外从图中我们还可以发现除了G1,其它垃圾回收器都只能作用于新生代或老年代中的其中一个区域,那么G1是不是表示废除了分代理论呢?下面来逐个介绍。
这两个是最早出现的垃圾回收器,如其名,它们都是单线程的垃圾回收器,只适合几十兆到一两百兆的堆空间的垃圾回收,如果用于更大的堆空间会导致系统停顿时间较长,想象一下系统每隔一段时间就要停止处理请求几分钟甚至更长时间,你能接受么?下图是他们的工作原理:
可以看到新生代或老年代在进行垃圾回收时都会暂停所有的用户线程,图中的SafePoint表示线程能够安全暂停的时机,即JVM要进行垃圾回收时,不可能随意暂停所有的线程,必须要确保线程处于安全点才能暂停它。这里先有这个概念,细节在下一篇进行阐述。
该组合可以通过-XX:+UseSerialGC参数开启。
该收集器就是Serial的多线程版本,但在单核处理器环境中表现还不如Serial(涉及线程的切换)。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
另外需要注意的是它是除了Serial之外唯一可以与CMS配合的垃圾收集器,在激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它,在JDK9以后ParNew成为了CMS的一部分。
Parallel Scavenge与其它垃圾收集器不同,其它的是追求尽可能小的GC停顿时间,而它主要关注吞吐量,所谓吞吐量就是代码运行时间/(代码运行时间 + 垃圾回收时间)。比如虚拟机运行100分钟,垃圾回收耗时1分钟,那么吞吐量就是99%。但是这款收集器在JDK1.6之前比较尴尬,没有与之对应的并行的老年代收集器,只能采用SerialOld老年代收集器,使得表现比不上PareNew+CMS的组合。直到ParallelOld出现后,Parallel Scavenge才能真正的展现它吞吐量的优势。
Parallel Scavenge有以下几个重要的参数:
CMS(Concurrent Mark Sweep)是第一款并发垃圾收集器,并发是指垃圾收集可以和用户线程同时进行。同时它也是唯一采用标记清除算法对老年代进行回收的垃圾回收器。它包含了以下几个阶段:
从上面我们可以发现CMS的整个过程中只有初始标记和重新标记是需要暂停用户线程的,而初始标记只是标记与GC Roots直接关联的对象,所以耗时只和GC Roots的数量有关,非常快;重新标记的耗时会比初始标记略长,但也远远比并发标记用时短,所以CMS就是通过细分GC的阶段来降低GC的停顿时间。
你可能会好奇为什么需要重新标记并且暂停所有用户线程,因为在与用户线程并发执行的同时肯定会存在引用变动的情况,而要处理这个问题,都是必须要暂停用户线程的,关于引用变动的处理在下一篇会详细分析。
CMS可以说是一款跨时代的垃圾收集器,可以回收几个G到-20G左右的堆空间,但它存在以下几个明显的缺点:
G1是目前最前沿且可商用的垃圾收集器,另外还有ZGC等更为前沿的垃圾收集器还处于试验阶段。它与其它垃圾收集器不同的是,他将堆空间化整为零,将内存区域划分为多个大小相等的独立区域(Region),使得它可以回收堆中的任何一个区域,而不是像其它的垃圾收集器要么只能回收新生代,要么只能回收老年代。但不是说G1就没有新生代和老年代了,它的每个Region都可以根据需要扮演Eden、Survivor或老年代,垃圾收集器也会针对不同角色的Region采用不同的策略去处理。
每个Region的大小可以通过-XX:G1HeapRegionSize设定,取值范围为1M~32M,且必须为2的N次幂。超过单个Region一半容量的对象即为大对象,而对于超过整个Region的对象将会使用多个连续的Humongous空间存放,G1大多数情况下都把Humongous作为老年代一部分看待。
G1的运行过程如上,它也包含了以下4个步骤:
G1适合上百G的堆空间回收,与CMS的权衡在6~8G之间,较大的堆内存才能凸显G1的优势,可以通过-XX:+UseG1GC参数开启。
本篇是对常用垃圾收集器的实现原理的整体性分析比较,这一部分是必须掌握的,下一篇则是关于算法的实现细节,如三色标记是什么、并发标记过程中引用变动如何解决、跨代引用如何处理等等一系列问题。