深入探究JVM之垃圾回收器

心已入冬 提交于 2020-08-14 11:43:19

@

前言

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出现的原因,就好比你不能在你妈妈打扫屋子的同时边往地上扔垃圾。

分代回收

分代回收严格意义上并不算一种算法,而是各回收算法的实践理论。它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消
    亡。

上面两个假说共同确定了垃圾收集器一致的设计原则,即新生代老年代。在新生代中使用复制算法,如上所说,大部分对象朝生夕灭,所以只需要将少量存活对象复制到另一块区域后再统一格式化之前的区域;而老年代因为大量对象存活,只能采用标记清除标记整理算法。

二、常用的垃圾回收器

垃圾回收器是垃圾回收算法的实现,在虚拟机规范中并没有定义要如何实现垃圾回收器,所以各大厂商对垃圾回收器的实现有很大差别,但都是在朝着一个方向努力:低延迟、高吞吐量。
在这里插入图片描述

上图中展示的就是目前主流的垃圾回收器,有连线的代表两者可以搭配使用,而打“X”的表示在JDK9中已经废弃的组合,另外从图中我们还可以发现除了G1,其它垃圾回收器都只能作用于新生代老年代中的其中一个区域,那么G1是不是表示废除了分代理论呢?下面来逐个介绍。

Serial/SerialOld

这两个是最早出现的垃圾回收器,如其名,它们都是单线程的垃圾回收器,只适合几十兆到一两百兆的堆空间的垃圾回收,如果用于更大的堆空间会导致系统停顿时间较长,想象一下系统每隔一段时间就要停止处理请求几分钟甚至更长时间,你能接受么?下图是他们的工作原理:
在这里插入图片描述
可以看到新生代或老年代在进行垃圾回收时都会暂停所有的用户线程,图中的SafePoint表示线程能够安全暂停的时机,即JVM要进行垃圾回收时,不可能立马就停止所有的线程,那样是非常危险的,必须要确保线程处于安全点才能暂停它。这里先有这个概念,细节在下一篇进行阐述。
该组合可以通过-XX:+UseSerialGC参数开启。


ParNew

该收集器就是Serial的多线程版本,但在单核处理器环境中表现还不如Serial(涉及线程的切换)。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
在这里插入图片描述
另外需要注意的是它是除了Serial之外唯一可以与CMS配合的垃圾收集器,在激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它,在JDK9以后ParNew成为了CMS的一部分。

Parallel Scavenge/ParallelOld

Parallel Scavenge与其它垃圾收集器不同,其它的是追求尽可能小的GC停顿时间,而它主要关注吞吐量,所谓吞吐量就是代码运行时间/(代码运行时间 + 垃圾回收时间)。比如虚拟机运行100分钟,垃圾回收耗时1分钟,那么吞吐量就是99%。但是这款收集器在JDK1.6之前比较尴尬,没有与之对应的并行的老年代收集器,只能采用SerialOld老年代收集器,使得表现比不上PareNew+CMS的组合。直到ParallelOld出现后,Parallel Scavenge才能真正的展现它吞吐量的优势。
在这里插入图片描述
Parallel Scavenge有以下几个重要的参数:

  • -XX:MaxGCPauseMillis:该参数的值是一个大于0的毫秒数,收集器尽量保证GC停顿时间不超过该值,但是不要天真的认为该值越小越好。该值设置的太小会导致每次GC的回收率降低,垃圾堆积,GC发生的越来越频繁。比如原先需要100ms收集500M空间,现在设置为50ms,那么可能就只能回收300M或者更小的垃圾。
  • -XX:GCTimeRatio:控制垃圾回收时间比率。比如允许最大垃圾回收时间占总时间的5%,那么需要将该值设置为19(公式是1/(1 + 19))。
  • -XX:+UseAdaptiveSizePolicy:这个参数激活后,就不再需要我们手动设定新生代各区(Eden、from、to)的比例(-XX:SurvivorRatio),晋升老年代对象的大小(-XX:PretenureSizeThreshold),虚拟机会监控运行时的状态,进行动态的调整,这种方式称为垃圾收集的自适应调节策略(GC Ergonomics)。

CMS

CMS(Concurrent Mark Sweep)是第一款并发垃圾收集器,并发是指垃圾收集可以和用户线程同时进行。同时它也是唯一采用标记清除算法对老年代进行回收的垃圾回收器。它包含了以下几个阶段:

  • 初始标记:STW,只标记与GC Roots直接关联的对象
  • 并发标记:和用户线程同时运行,进行可达性分析
  • 重新标记:STW,暂停用户线程,修正上一阶段变动的对象
  • 并发清除:最后是并发的清除掉垃圾

在这里插入图片描述
从上面我们可以发现CMS的整个过程中只有初始标记重新标记是需要暂停用户线程的,而初始标记只是标记与GC Roots直接关联的对象,所以耗时只和GC Roots的数量有关,非常快;重新标记的耗时会比初始标记略长,但也远远比并发标记用时短,所以CMS就是通过细分GC的阶段来降低GC的停顿时间。
你可能会好奇为什么需要重新标记并且暂停所有用户线程,因为在与用户线程并发执行的同时肯定会存在引用变动的情况,而要处理这个问题,都是必须要暂停用户线程的,关于引用变动的处理在下一篇会详细分析。
CMS可以说是一款跨时代的垃圾收集器,可以回收几个G到-20G左右的堆空间,但它存在以下几个明显的缺点:


  • CPU敏感:虽然并发标记并发标记是和用户线程并发执行的,但是也因此占用了系统的资源,导致应用程序忽然变慢,降低吞吐量。CMS默认启动的线程数是(处理器核心数+3)/4,因此当核心数量大于等于4时,GC占用资源不超过25%,但核心数小于4时,就会占用大量系统资源。
  • 大量的内存碎片:因为CMS是使用标记清除算法实现垃圾回收,所以会产生大量的内存碎片。为了避免这个问题,CMS采用了一个折中的办法,即提供一个-XX:+UseCMS-CompactAtFullCollection参数,该参数默认开启,控制CMS在进行FullGC的同时进行空间整理,但这样又会导致停顿时间加长,所以还提供了-XX:CMSFullGCsBefore-Compaction参数,控制CMS在进行了多少次不带整理的FullGC后进行一次带整理的FullGC,默认值是0,即每次FullGC都会整理,该参数JDK9后被废弃。
  • 浮动垃圾:因为最终清除的过程也是和用户线程并发执行的,因此这个过程中必然会产生新的垃圾,这一部分垃圾需要预留空间来存放,等待下一次GC的时候再清理,因此会浪费一部分空间。在JDK5的默认配置下,当老年代使用空间超过68%时就会进行GC,到JDK6时,这个阈值就提高到了92%,另外也可以通过-XX:CMSInitiatingOccu-pancyFraction参数控制。但该值越高,那么并发清理过程中可使用的内存就越小,当放不下时,就会出现一次Concurrent Mode Failure,这时候虚拟机就会冻结线程并采用SerialOld进行垃圾回收,导致停顿时间变得更长。

Garbage First

G1是目前最前沿且可商用的垃圾收集器,另外还有ZGC等更为前沿的垃圾收集器还处于试验阶段。它与其它垃圾收集器不同的是,他将堆空间化整为零,将内存区域划分为多个大小相等的独立区域(Region),使得它可以回收堆中的任何一个区域,而不是像其它的垃圾收集器要么只能回收新生代,要么只能回收老年代。但不是说G1就没有新生代和老年代了,它的每个Region都可以根据需要扮演Eden、Survivor或老年代,垃圾收集器也会针对不同角色的Region采用不同的策略去处理。
在这里插入图片描述
每个Region的大小可以通过-XX:G1HeapRegionSize设定,取值范围为1M~32M,且必须为2的N次幂。超过单个Region一半容量的对象即为大对象,而对于超过整个Region的对象将会使用多个连续的Humongous空间存放,G1大多数情况下都把Humongous作为老年代一部分看待。
在这里插入图片描述
G1的运行过程如上,它也包含了以下4个步骤:



  • 初始标记:STW,也是只标记GC Roots直接关联的对象,并修改TAMS的指针值(G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上,垃圾回收时也不会回收这部分空间),这个过程耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:可达性分析找出要回收的对象,在对象扫描完成后,由于是与用户线程并发执行的,所以存在引用变动的对象,这部分对象会由SATB算法来解决(原始快照,下一篇详细分析)。
  • 最终标记:STW,处理并发阶段遗留的少量遗留的SATB记录。
  • 筛选回收:根据用户设定的-XX:MaxGCPauseMillis最大GC停顿时间对Region进行排序,并回收价值最大的Region,尽量保证满足参数设定的值(该值效果和Parallel Scavenge部分讲解的是一样的)。这里的回收算法就是讲存活的对象复制到空的Region中,即G1局部Region之间采用的是复制算法,而整体上采用的是标记整理算法

G1适合上百G的堆空间回收,与CMS的权衡在6~8G之间,较大的堆内存才能凸显G1的优势,可以通过-XX:+UseG1GC参数开启。

总结

本篇是对常用垃圾收集器的实现原理的整体性分析比较,这一部分是必须掌握的,下一篇则是关于算法的实现细节,如三色标记是什么、并发标记过程中引用变动如何解决、跨代引用如何处理等等一系列问题。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!