之前文章介绍过垃圾回收机制,这篇文章垃圾回收的实际执行者–垃圾回收器。按照是否可并行,垃圾回收器可分为串行垃圾回收器和并行垃圾回收器;按照作用区域,可分为新生代垃圾回收器和老年代垃圾回收器。下图是按照作用区域将几款经典的收集器进行划分。

Serial

Serial是新生代串行回收器,采用标记-复制算法,它也是最早的垃圾收集器,目前会用在客户端模式下。串行收集器表明该收集器在执行时只会使用一条线程去晚上垃圾收集工作,而且在进行垃圾收集时,必须暂停用户其他所有的线程,直到它收集结束。“Stop The World”是为了配合可达性分析,但这项工作是在用户不可知的情况下,由虚拟机自发启动和完成的,对于实时性要求比较高的场景,STW带给用户很糟糕的体验。后续的每个新设计出来的虚拟机都在把降低STW的时间作为目标之一

ParNew

ParNet是新生代的并行收集器,更确切的说她只是Serial收集器的多线程版本,除了多收集垃圾以外,采用算法等其他功能都与Serial是一样的。所以ParNew在执行垃圾收集也需要暂停用户线程,但因为是多线程收集,暂停的时间减少了(在多核cpu环境下),在单核CPU中,ParNew的收集效率就没有Serial强了,因为每个时刻CPU都只能执行一个线程,ParNew线程之间的切换会产生时间开销。

Parallel Scavenge

Parallel Scavenge 是新生代多线程收集器,采用标记-复制算法。该收集器的目标是达到一个可控的吞吐量(ThroughPut),吞吐量就是:处理器用于处理用户代码的时间与处理器总消耗时间(用户代码花费时间与垃圾收集花费时间的和)的比值,很明显,比值越大,垃圾收集占用的时间就越小,程序的响应速度就越好。为了达到这个目的,该收集器提供了两个参数用于精确控制吞吐量:

最大垃圾收集停顿时间(-XX:MaxGCPauseMillis) : Parallel Scavenge会调整java堆的大小或者其他参数的大小,尽可能把垃圾回收时间控制在最大垃圾收集停顿时间之内。但是最大收集停顿时间如果设置的太小,则虚拟机会频繁地执行垃圾回收,从而增大了总的垃圾收集时间,降低了吞吐量。

垃圾收集时间占比(-XX:GCTimeRatio):这个参数直接设置的垃圾回收不得超过多长时间。

Parallel Scavenge还支持自适应的垃圾收集策略,使用 -XX:UseAdaptiveSizePolicy可以打开自适应策略。在该模式下,新生代的大小,eden和Survivor区的比例,晋升老年代的对象年龄等参水会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点,在手工调优比较难的场景下,可以使用中模式让虚拟机自己完成调优工作。

Serial Old

Serial Old是Serial收集器的老年代版本,采用标记-整理算法,同样也是个串行收集器。

Parallel Old

Parallel Old是Parallel Scavenge收集器的老年代版本,采用标记-整理算法。

CMS

CMS(Concurrent Mark Sweep)收集器采用标记-清除算法的老年代并发垃圾收集器,它的目标是要获取最短回收停顿时间。目前大多数java应用集中在基于浏览器的B/S系统的服务端上,这类系统都对api的响应速度有很高的要求,CMS收集器很符合这类应用的需求。CMS收集器在JDK5中发布。

垃圾收集过程

CMS收集器的执行过程分为以下几步:

  1. 初始标记(CMS initial mark):仅仅标记下GC Roots能直接关联到的对象,速度很快。这个阶段需要STW配合。
  2. 并发标记(CMS concurrent mark):这个过程就是荣GC Roots直接关联的对象开始遍历整个图对象的过程,这个过程很长但是不需要暂停用户线程。( 其实就是标记各个引用链上都有哪些对象)。
  3. 重新标记(CMS remark):这个过程是为了修正在并发标记阶段,因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录(之前标记是活的对象,可能在并发标记阶段已经死了,要修改这些对象的标记记录)。这个过程也需要STW配合,暂停时间也要比初始标记阶段的暂停时间长,但远远小于并发阶段花费的时间。
  4. 并发清除(CMS concurrent sweep)清除到标记阶段已经判定死亡的对象。这个过程可以与用户应用程序并发执行。

因为在耗时最长的并发标记和并发清除阶段中,垃圾收集器线程和用户线程可以并发执行,所以总体上说,CMS收集器的内存回收过程是和用户线程一起并发执行的。

优缺点

CMS的优点是并发收集低停顿。但也有三个很明显的缺点:

  1. CMS对处理器资源很敏感。对处理器资源敏感是并发程序的通病。多线程垃圾回收会占用一部分用户的线程,这样会导致用户程序运行变慢。处理器越少、核数越少,多线程回收对用户程序的影响越大。
  2. CMS无法处理“浮动垃圾(Floating Garbage)”,因为在并发清除阶段,用户程序仍在运行,这可能会产生新的垃圾,但是这部分垃圾是在标记过程结束后产生的,CMS无法收集他们,只能等待下一次CMS垃圾收集时在清理掉。
  3. CMS是基于标记-清除算法的,前面提到过,这种算法收集完对象后内存中会产生大量的空间碎片。这种情况不利于被大对象分配空间。若真遇到大对象在老年代请求内存空间,会触发一次Full GC。(Full GC:收集整个java堆和方法区的垃圾)。

G1

G1(Garbage Frist)是一款主要面向服务端应用的垃圾收集器。它开创了面向局部的收集思路和面向region的内存布局模式。

设计思路

之前提到过:继Serial收集器以后,设计收集器都把降低“Stop World Time”作为目标之一,最终目的是提高收集器的效率。G1的设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾手机上的时间大概率不超过N毫秒这样的目标。

实现这个目标,G1做了两件事情:

  1. 基于Region的堆内存布局模式

    G1之前的回收器基于分代理论的设计,将内存分成很大的固定大小的两个区域:新生代和老年代,新创建的对象分配在新生代,达到晋升年龄或者很大的对象直接晋级到老年代。

    G1也遵循分代收集理论的设计,也有新生代和老年代的概念。不同的是,G1将堆内存划分为多个大小相等的独立区域(Region),每个Region根据需要可以扮演Eden空间、Survivor空间,或者老年代空间。Region中还有一类特殊的Humongous区域,专门用来存储大对象,G1的大多数行为都把Humongous Region作为老年代的一部分看待。基于Region的堆内存布局模示意图如下图所示(E:Eden空间、S:Survivor空间、H:Humongous区域)G1将Region作为单次回收的最小单元,也就是说每次收集到了内存空间都是Region大小的整数倍。这样G1就能建立可预测的停顿时间模型。

    image-20200516110349592

    2004年Sun实验室发表第一篇关于G1的论文,提出了”化整为零“的思路,直到2012年4月JDK 7 update 4发布,用了将近10年时间才捣腾出能够商用的G1收集器。(细节实现还是很难的)

  2. 面向局部的收集思路

    G1之前出现的垃圾收集器要么是新生代收集器(Minor GC)、要么是老年代收集器(Major GC)、再要么是整堆收集器(Full GC),都是对自己的目标区域做全收集。

    G1收集以Region为最小单位,每次收集都是收集了一个或者多个Region区域,简而言之,G1收集的区域是由多个Regions(可以连续,也可以不连续)组成的回收集,从而避免了全堆收集。而且G1执行垃圾回收,衡量的标准不再是它属于那个分代,而是哪快内存中存放的垃圾数量最多,回收的收益最大,这就是G1收集器的混合收集模式(Mixed GC)。更具体的处理思路是:让G1收集器去跟踪Region里面垃圾堆积的”价值“大小,价值即回收所得的空间大小和回收所需时间的经验值,然后在后台维护一个优先级列表,每次都优先处理那些回收价值收益最大的Region,这也是Garbage Frist名字的由来。

这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1在有限的时间内回去尽可能高的收集效率。

垃圾收集过程

G1将堆划分成一个个的区域,每次回收的时候,只收集其中几个区域,以此来控制垃圾回收时产生的一次停顿的时间。

  • 初始标记:标记从根节点直接可达的对对象(先执行)。之后会执行一次新生代GC,新生代GC结束后,Eden空间被清空,Survivor空间也会被收集一部分数据,存活对象被移入另一个Survivor区域。(该阶段会暂停用户线程,但耗时很短)
  • 并发标记:从GC root开始对堆对象进行可达性分析,递归扫描整个对里的对象图,找出要收集的对象,这个阶段耗时很长。(该阶段与用户线程并发执行)
  • 重新标记:因为并发标记时,用户线程依旧在运行,因此,标记结果可能需要修正,这个阶段是对并发标记的结果做补充,耗时很短。(该阶段会暂停用户线程)。
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,更具用户所期望的停顿时间来指定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那部分Region中存活的对象复制到空的region中,再清理掉旧Regin的全部空间。(该阶段会暂停用户线程)

从这几个阶段可以看出,G1收集器除了并发标记外,其余阶段都是要暂停用户线程的。所以,G1并非纯粹的追求低延时,官方给他设定的目标是在延时可控的情况下获得尽可能高的吞吐量。

期望停顿时间

可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望时间,可使得G1在不用应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。期望停顿时间设置过大,用户程序执行会变得缓慢,因为G1毕竟是要冻结用户线程的。期望停顿时间设置过小,会导致每次只回收堆内存很小的一部分,收集器的收集速度跟不上分配器的分配速度,结果需要更频繁的执行垃圾回收操作,甚至会因为堆被占满引发Full GC而降低性能。所以通常将期望停顿回收之间设置为100-300毫秒之间。

从G1开始,垃圾回收器的设计思路变为追求能够应付内存分配速率,而不是追求一次性把整个java堆全部清理干净。这样收集器的速度只要能跟得上分配器的分配速度,那就能运作的很完美。这种新的收集器设计思路从工程实现上看是G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

G1与CMS相比较

G1与CMS相比有点有很多,最大停顿时间、Region内存分布、按受益价值动态确定回收集,这些创新先都是CMS没有的。除此之外,但从回收算法上来看:CMS采用”标记-清除“算法,G1从整体看是基于”标记-整理“算法实现的收集器,从局部看(两个Region之间)是基于”标记-复制“算法实现的是极其,不管是采用哪种,都表示G1执行之后不会产生碎片空间。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易无法找到连续的内存空间而以前触发下一次收集。

G1相比于CMS的缺点:从内存角度看,每个Region都要维护一份卡表,而且Region的数量比CMS收集器的分代数量(两个)明显要多得多,因此G1收集器要比CMS需要更大的额外内存。根据经验,G1至少要耗费大约相当于Java对容量10%~20%的额外内存来维持收集器工作。

目前在小内存应用上CMS的表现大概率仍然要优于G1,而在大内存上G1则大多能发挥其优势,这个优势的Java堆容量平衡点通常在6GB~8GB之间。随着HotSpot的开发者对G1的不断优化,也会让对比结果继续想G1倾斜。

总结

  1. 垃圾收集器基于分代理论而设计,都有自己针对的目标区域。Serial、Parallel Scavenge、ParNew是新生代垃圾收集器,并且都采用”标记-复制“算法。Serial Old、Parallel Old、CMS是老年代收集器,其中Serial Old、Parallel Old采用”标记-整理“算法,CMS采用”标记-清除算法“。G1是将Java堆划分成Region,并以Region为最小收集单位执行垃圾回收的收集器,成局部看G1采用”标记-复制“算法,从全局看G1采用”标记-整理“算法。
  2. 收集器执行收集的过程分为:初始标记、并发标记、重新标记、并发清除(垃圾回收)这四个阶段。G1是并发标记阶段可以与用户线程并行、其他阶段都要暂停(SWT)用户线程。其他收集器都是初始标记和冲洗标记两个阶段暂停用户线程,并发标记和并发清除都可以与用户线程并行。
  3. 设计多线程垃圾收集器的目的是提高垃圾收集的效率(降低SWT的耗费时间间),但多线程垃圾回收器对硬件有要求(起码得是多核CPU)。
  4. CMS垃圾收集器的优点:并发收集、低停顿;缺点:对处理器资源有要求、无法收集”浮动垃圾“、垃圾收集后会产生碎片空间。
  5. G1垃圾收集器优点:并发收集、低停顿(最大停顿时间由用户设定,推荐100~300毫秒)、垃圾收集后不会产生碎片空间、可处理”浮动垃圾“(因为并发标记后会有从新标记修正结果,在标记清楚阶段是冻结用户线程的);缺点:G1收集器运行时需要额外的内存配合收集器运行(大小是java存的10%~20%)。
  6. G1是基于新思路设计出来的收集器,旨在追求在有限的时间内获取尽可能高的收集效率,因此设计出了 基于Region的堆内存分配模式面向局部的垃圾收集策略。自G1开始,最先进的垃圾收集器的设计思路都开始变为追求:收集器的收集速度可以应付应用的内存分配速度,而不是追求一次性将java堆全部清理干净。所以说G1是收集器技术发展的一个里程碑。