java垃圾收集器

  • Post author:
  • Post category:java

目录

简介

Serial 收集器

ParNew 收集器

并行(Parallel)

并发(Concurrent)

Parallel Scavenge 收集器

Serial Old 收集器

Parallel Old 收集器

CMS收集器

G1收集器

G1简介

G1细节

G1 Minor GC流程

G1 Mixed GC流程

G1特点

G1与CMS的区别与选择

ZGC收集器

垃圾处理器总结


简介

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本 的虚拟机所提供的垃圾收集器都可能会有很大的差别,并且一般都会提供参数供用户根 据自己的应用特点和要求组合出各个年代所使用的收集器。这里讨论的收集器基于Sun HotSpot虚拟机1.6Update 22,这个虚拟机包含的所有收集器,如图

注意:这个关系不是一成不变的,由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS- ParNew+Serial Old这两个组合声明为废弃(JEP 173) ,并在JDK 9中完全取消了这些组合的支持(JEP214)。

展示了 7种作用于不同分代的收集器包括JDK 1.6_Updatel4后引入的 Early AccessG1收集器),如果两个收集器之间存在连线就说明它们可以搭配使用。

在介绍这些收集器各自的特性之前,我们先来明确一个观点:虽然我们是在对各个 收集器进行比较,但并非为了挑选一个最好的收集器出来。因为直到现在为止还没有最 好的收集器出现,更加没有万能的收集器,所以我们选择的只是对具体应用场景最合适的收 集器。这点不需要多加解释就能证明:如果有一种放之四海皆准、任何场景下都适用的 完美收集器存在,那HotSpot虚拟机就没必要实现那么多不同的收集器了。

Serial 收集器

Serial收集器是最基本、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是虚拟机 新生代收集的唯一选择。大家看名字就知道,这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾收 集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程Sim将这件 事情称之为“Stop The World”)直到它收集结束“Stop The World”这个名字也许听 起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见 的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是难以接受的。你想 想,要是你的电脑每运行一个小时就会暂停响应5分钟,你会有什么样的心情?图36 示意了 Serial / Serial Old收集器的运行过程。

对于“Stop The World”带给用户的恶劣体验,虚拟机的设计者们表示完全理解,但 也表示非常委屈「’你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上 或房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完吗? ”这确实是 一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个性质的,但实 际上肯定还要比打扫房间复杂得多啊!

新生代采用标记-复制算法,老年代采用标记-整理算法。

JDK1.3, HotSpot虚拟机开发团队 为消除或减少工作线程因内存回收而导致停顿的努力一直在进行着,从Serial收集器 到Parallel收集器,再到Concurrent Mark Sweep (CMS)现在还未正式发布的Garbage First (G1)收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器的出现, 用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除(这里暂不包括RTSJ中 的收集器)。寻找更优秀的垃圾收集器的工作仍在继续!

写到这里,笔者似乎已经把Serial收集器描述成一个老而无用,食之无味弃之可惜 的鸡肋了,但实际上到现在为止,它依然是虚拟机运行在Client模式下的默认新生代收 集器。它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对 于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收 集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理 的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的 内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫 秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial收集器对于运行在 Client模式下的虚拟机来说是一个很好的选择

ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾 收集之外,其余行为包括Serial收集器可用的所有控制参数

(例如:XX:SurvivorRatio-XX:PretenureSizeThreshold, -XX:HandlePromotionFailure 等)、收集算法、Stop The World.对象分配规则、回收策略等都与Serial收集器完全一样,实现上这两种收集器也 共用了相当多的代码。ParNew收集器的工作过程如图

新生代采用标记-复制算法,老年代采用标记-整理算法。

ParNew收集器除了支持多线程并行收集之外,其他与Seril收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的Hotspot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收器外,目前只有它能与CMS收集器配合工作。

在JDK 5发布时, HotSpot推出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器-CMS收集器。这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。

遗憾的是, CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器ParallelScavenge配合工作[1],所以在JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。ParNew收集器是激活CMS后(使用-Xx: +UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-Xx: +/-UseParNewGC选项来强制指定或者禁用它。

可以说直到CMS的出现才巩固了ParNew的地位,但成也萧何败也萧何,随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。所以自JDK 9开始, ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被G1所取代,甚至还取消了ParNew加Serial old以及Serial加CMS这两组收集器组合的支持(其实原本也很少人这样使用) ,并直接取消了xx: +UseParNewGC参数,这意味着ParNew和CMS从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。

读者也可以理解为从此以后, ParNew合并入CMS,成为它专门处理新生代的组成部分。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。

ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。当然,随着可以被使用的处理器核心数量的增加, ParNew对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如32个,现在CPU都是多核加超线程设计,服务器达到或超过32个逻辑核心的情况非常普遍)的环境中,可以使用-xx: ParallelGCThreads参数来限制垃圾收集的线程数。

注意 从ParNew收集器开始,后面还将会接触到几款并发和并行的收集器。在大家可能 产生疑惑之前,有必要先解释两个名词:并发和并行。这两个名词都是并发编程中的概 念,在谈论垃圾收集器的上下文语境中,他们可以解释为:

并行Parallel)

指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。其实指垃圾收集线程,内部多线程工作。 

并发Concurrent)

指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能 会交替执行),用户程序继续运行,而垃圾收集程序运行于另一个CPU上。指垃圾收集线程与用户线程,两者多线程工作。

Parallel Scavenge 收集器

Parallel Scavenge收集器也是一个新生代收集器它也是使用复制算法的收集器又是并行的多线程收集器……看上去和ParNew都一样那它有什么特别之处呢

-XX:+UseParallelGC

    使用 Parallel 收集器+ 老年代串行

-XX:+UseParallelOldGC

    使用 Parallel 收集器+ 老年代并行

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的 关注点尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目 标则是达到一个可控制的吞吐量(Throughput)。

所谓吞吐量就是CPU用于运行用户代 码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/ (运行用户代码时 间+垃圾收集时间),虚拟机总共运行了 100分钟,其中垃圾收集花掉1分钟,那吞吐 量就是99%

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体 验;而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适 合在后台运算而不需要太多交互的任务。

Parallel Scavenge收集器提供了两个参数用壬精确控制吞吐量,分别是控制 最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。

MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回 收花费的时间不超过设定值。不过大家不要异想天开地认为如果把这个参数的值设置得 稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和 新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB 快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100 毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也 降下来了。

GCTimeRatio参数的值应当是一个大于0小于100的整数,也就是垃圾收集时间占 总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为19,那允许的最大GC时 间就占总时间的5% (即1/ (1 + 19)),默认值为99,就是允许最大1% (即1 / (1+99)) 的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称为“吞吐量 优先”收集器。除上述两个参数之外,Parallel Scavenge收集器还有一个参 数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就 不需要手工指定新生代的大小Xmn)EdenSurvivor区的比例XX:SurvivorRatio)晋升老年代对象年龄XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当 前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或 最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)

如果 读者对于收集器运作原理不太了解,手工优化存在困难的时候,使用Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个很 不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用 MaxGCPauseMillis参数(更关注最大停顿时间)或GCTimeRatio参数(更关注吞吐量) 给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调 节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

新生代采用标记-复制算法,老年代采用标记-整理算法。

这是 JDK1.8 默认收集器

使用 java -XX:+PrintCommandLineFlags -version 命令查看

-XX:InitialHeapSize=262921408 -XX:MaxHeapSize=4206742528 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)

JDK1.8 默认使用的是 Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC 参数,则默认指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC 来禁用该功能 

Serial Old 收集器

Serial OldSerial收集器的老年代版本,它同样是一个单线程收集器,使用“标 记-整理”算法。

这个收集器的主要意义也是被Client模式下的虚拟机使用

如果 Server模式下,它主要还有两大用途:一个是在JDK 1.5及之前的版本中与Parallel Scavenge收集器搭配使用。(需要说明一下, Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial old收集器,但是这个PS MarkSweep收集器与Serial old的实现几乎是一样的,所以在官方的许多资料中都是直接以Serial old代替PS MarkSweep进行讲解,这里笔者也采用这种方式。

另外一个就是作为CMS收集器的后备预案,在并发收集发 生Concurrent Mode Failure的时候使用。这两点都将在后面的内容中详细讲解。Serial Old收集器的工作过程如图

Parallel Old 收集器

Parallel OldParallel Scavenge收集器的老年代版本使用多线程和标记整理算法。

这个收集器是在JDK 1.6中才开始提供的在此之前新生代的Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge收集器, 老年代除了 Serial Old (PS MarkSweep)收集器外别无选择(还记得上面说过Parallel Scavenge收集器无法与CMS收集器配合工作吗?)。由于单线程的老年代Serial Old收 集器在服务端应用性能上的“拖累”,即便使用了 Parallel Scavenge收集器也未必能在整 体应用上获得吞吐量最大化的效果,又因为老年代收集中无法充分利用服务器多CPU的 处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定 有ParNewn CMS的组合“给力”。

直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应 用组合,在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel ScavengeParallel Old收集器。Parallel Old收集器的工作过程如图

CMS收集器

CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收 集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用 尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收 集器就非常符合这类应用的需求。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字(包含“MarkSweep”)上就可以看出CMS收集器是基于“标记-清除”算 法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为5个 步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 并发预清理
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。(第一个,第三个步骤暂停,第二个,第四个并发

初始标记仅仅只是标记一下GC Roots能直接关联到的对象,以及「年轻代」指向「老年代」的对象​​​​​​​,速度很快,暂停

并发标记阶段就是进行GC Roots Tracing的过程,同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方,并发。

「并发预处理」这个阶段主要想干的事情:希望能减少下一个阶段「重新标记」所消耗的时间

因为下一个阶段「重新标记」是需要Stop The World的

「并发标记」这个阶段由于用户线程是没有被挂起的,所以对象是有可能发生变化的

可能有些对象,从新生代晋升到了老年代。可能有些对象,直接分配到了老年代(大对象)。可能老年代或者新生代的对象引用发生了变化…

那这个问题,怎么解决呢?

针对老年代的对象,其实还是可以借助类card table的存储(将老年代对象发生变化所对应的卡页标记为dirty)

所以「并发预处理」这个阶段会扫描可能由于「并发标记」时导致老年代发生变化的对象,会再扫描一遍标记为dirty的卡页

对于新生代的对象,我们还是得遍历新生代来看看在「并发标记」过程中有没有对象引用了老年代..

不过JVM里给我们提供了很多「参数」,有可能在这个过程中会触发一次 minor GC(触发了minor GC 是意味着就可以更少地遍历新生代的对象)

 

 而重新标记阶段则是为了修正并发标记期间,因用户程序继续运 作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始 标记阶段稍长一些,但远比并发标记的时间短,暂停。(注意hotspot算法细节里的增量更新)

这个过程的停顿时间其实很大程度上取决于上面「并发预处理」阶段(可以发现,这是一个追赶的过程:一边在标记存活对象,一边用户线程在执行产生垃圾)

并发清除阶段是使用标记-清除的方法清理垃圾,注意不是标记-整理!这个过程是并发的。

这个过程,还是有可能用户线程在不断产生垃圾,但只能留到下一次GC 进行处理了,产生的这些垃圾被叫做“浮动垃圾”

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户 线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地 执行的。通过图3-10可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的 时间。

CMS是一款优秀的收集器,它的最主要优点在名字上已经体现出来了:并发收集、 低停顿Sun的一些官方文档里面也称之为并发低停顿收集器(Concurrent Low Pause Collector)但是CMS还远达不到完美的程度,它有以下四个显著的缺点:

  • 对 CPU 资源敏感
  • 无法处理浮动垃圾,可能会导致serial old
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生
  • 需要预留的空间,给用户线程使用

CMS收集器对CPU资源非常敏感。其实,面向并发设计的程序都对CPU资源比 较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分 线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启 动的回收线程数是(CPU数量+3)/4,也就是当CPU4个以上时,并发回收 时垃圾收集线程最多占用不超过25%CPU资源。但是当CPU不足4个时(譬 如2个),那么CMS对用户程序的影响就可能变得很大,如果CPU负载本来就 比较大的时候,还分出一半的运算能力去执行收集器线程,就可能导致用户程序 的执行速度忽然降低了 50%,这也很让人受不了。为了解决这种情况,虚拟机提 供了一种称为“增量式并发收集器” (Incremental Concurrent Mark Sweep / i-CMS) CMS收集器变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模 拟多任务机制的思想一样,就是在并发标记和并发清理的时候让GC线程、用户 线程交替运行,尽量减少GC线程的独占资源的时间,这样整个垃圾收集的过程 会更长,但对用户程序的影响就会显得少一些,速度下降也就没有那么明显,但 是目前版本中,iCMS已经被声明为“deprecated”,即不再提倡用户使用。

CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还 在运行着,伴随程序的运行自然还会有新的垃圾不断产生,这一部分垃圾出现在 标记过程之后,CMS无法在本次收集中处理掉它们,只好留待下一次GC时再将 其清理掉。这一部分垃圾就称为“浮动垃圾”。

也是由于在垃圾收集阶段用户线 程还需要运行,即还需要预留足够的内存空间给用户线程使用,因此CMS收集 器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一 部分空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代 使用了 68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代 增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来 提髙触发百分比,以便降低内存回收次数以获取更好的性能。

要是CMS运行期 间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失 败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年 代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupan cyFraction设置得太高将会很容易导致大量uConcurrent Mode Failure”失败,性 能反而降低。

还有最后一个缺点,在本节的开头曾提到, CMS是一款基于”标记-清除”算法实现的收集器,如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。

为了解决这个问题,CMS收集器提供了一个-xx: +UseCMS-CompactAtFullCcollection开关参数(默认是开启的,此参数从- JDK 9开始废弃) ,用于在CMS收集器不得不进行FullGC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象, (在Shenandoah和ZGc出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长

因此虚拟机设计者们还提供了另外一个参数-xx: CMSFullGCsBeforeCompaction (此参数从JDK 9开始废弃) ,这个参数的作用是要求CMs收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)

G1收集器

G1简介

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

HotSpot开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉JDK5中发布的CMS收集器。现在这个期望目标已经实现过半了, JDK9发布之日, G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMs则沦落至被声明为不推荐使用(Deprecate)的收集器。如果对JDK 9及以上版本的HotSpot虚拟机使用参数-XX: +UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型” (PausePrediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java (RTSJ)的中软实时垃圾收集器特征了。

那具体要怎么做才能实现这个目标呢?首先要有一个思想上的改变,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC) ,要么就是整个老年代(Major GC) ,再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1 收集器采用一种不同的方式来管理堆内存.

堆内存被划分为多个大小相等的 heap 区,每个heap区都是逻辑上连续的一段内存(virtual memory). 其中一部分区域被当成收集器相同的角色(eden, survivor, old), 但每个角色的区域个数都不是固定的。这在内存使用上提供了更多的灵活性。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-xx: G1HeapRegionSize设·定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中, G1的大多数行为都把Humongous Regon作为老年代的一部分来进行看待,如图所示。

G1细节

1 将Java堆分成多个独立Region后, Region里面存在的跨Region引用对象如何解决?

解决的思路我们已经知道 :使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记亿集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

G1的记忆集在存储结构的本质上是一种哈希表, Key是别的Region的起始地址, Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂

同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验, G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

2 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法笔者已经讲解过 : CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的。

此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建, G1为每一个Region设计了两个名为TAMS (Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。

G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的”Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间”Stop The World”。

3 怎样建立起可靠的停顿预测模型?

用户通过-xx: MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?

G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中, G1收集器会记录每个Region的回收耗时、每个Regon记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。

这里强调的”衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说, Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

G1 Minor GC流程

G1的Minor GC其实触发时机跟前面提到过的垃圾收集器都是一样的

等到Eden区满了之后,会触发Minor GC。Minor GC同样也是会发生Stop The World的

要补充说明的是:在G1的世界里,新生代和老年代所占堆的空间是没那么固定的(会动态根据「最大停顿时间」进行调整)

这块要知道会给我们提供参数进行配置就好了

所以,动态地改变年轻代Region的个数可以「控制」Minor GC的开销

Minor GC我认为可以简单分为为三个步骤:根扫描、更新&&处理 RSet、复制对象

第一步应该很好理解,因为这跟之前CMS是类似的,可以理解为初始标记的过程

第二步涉及到「Rset」的概念

从上一次我们聊CMS回收过程的时候,同样讲到了Minor GC,它是通过「卡表」(cart table)来避免全表扫描老年代的对象

因为Minor GC 是回收年轻代的对象,但如果老年代有对象引用着年轻代,那这些被老年代引用的对象也不能回收掉

同样的,在G1也有这种问题(毕竟是Minor GC)。CMS是卡表,而G1解决「跨代引用」的问题的存储一般叫做RSet

只要记住,RSet这种存储在每个Region都会有,它记录着「其他Region引用了当前Region的对象关系」

对于年轻代的Region,它的RSet 只保存了来自老年代的引用(因为年轻代的没必要存储啊,自己都要做Minor GC了)

而对于老年代的 Region 来说,它的 RSet 也只会保存老年代对它的引用(在G1垃圾收集器,老年代回收之前,都会先对年轻代进行回收,所以没必要保存年轻代的引用)

那第二步看完RSet的概念,应该也好理解了吧?

无非就是处理RSet的信息并且扫描,将老年代对象持有年轻代对象的相关引用都加入到GC Roots下,避免被回收掉

到了第三步也挺好理解的:把扫描之后存活的对象往「空的Survivor区」或者「老年代」存放,其他的Eden区进行清除

在G1还有另一个名词,叫做CSet。

它的全称是 Collection Set,保存了一次GC中「将执行垃圾回收」的Region。CSet中的所有存活对象都会被转移到别的可用Region上

在Minor GC 的最后,会处理下软引用、弱引用、JNI Weak等引用,结束收集

 

G1 Mixed GC流程

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作) , G1收集器的运作过程大致可划分为以下四个步骤:

 

初始标记(Initial Marking) :仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

这个过程是「共用」了Minor GC的 Stop The World(Mixed GC 一定会发生 Minor GC),复用了「扫描GC Roots」的操作。并且在这个过程中,老年代和新生代都会扫。

并发标记(Concurrent Marking) :从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。

最终标记(Final Marking) :对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。如果在开始时,G1就认为它是活的,那就在此次GC中不会对它回收,即便可能在「并发阶段」上对象已经变为了垃圾。

所以,G1也有可能会存在「浮动垃圾」的问题。但是总的来说,对于G1而言,问题不大(毕竟它不是追求一次把所有的垃圾都清除掉,而是注重 Stop The World时间)

标记阶段完成后,G1就可以知道哪些heap区的empty空间最大。

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

G1使用暂停预测模型(pause prediction model)来达到用户定义的目标暂停时间,并根据目标暂停时间来选择此次进行垃圾回收的heap区域数量.

需要强调的是, G1并不是一款实时垃圾收集器(real-time collector). 能以极高的概率在设定的目标暂停时间内完成,但不保证绝对在这个时间内完成。 基于以前收集的各种监控数据, G1会根据用户指定的目标时间来预估能回收多少个heap区. 因此,收集器有一个相当精确的heap区耗时计算模型,并根据该模型来确定在给定时间内去回收哪些heap区

筛选回收(Live Data Counting and Evacuation) :负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

一般来说,Mixed GC会选定所有的年轻代Region,部分「回收价值高」的老年代Region(回收价值高其实就是垃圾多)进行采集

被G1标记为适合回收的heap区将使用转移(evacuation)的方式进行垃圾回收. G1将一个或多个heap区域中的对象拷贝到其他的单个区域中,并在此过程中压缩和释放内存,基于标记-整理

那G1会什么时候发生full GC?

如果在Mixed GC中无法跟上用户线程分配内存的速度,导致老年代填满无法继续进行Mixed GC,就又会降级到serial old GC来收集整个GC heap

不过这个场景相较于CMS还是很少的,毕竟G1没有CMS内存碎片这种问题

从上述阶段的描述可以看出, G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望

毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率Allocation Rate) ,而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

G1特点

它具备一下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 内存分区:将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
  • 无空间碎片:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 不一定内存耗尽才GC:G1的设计原则是”首先收集尽可能多的垃圾(Garbage First)”。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
  • 允许部分收集:G1的收集都是STW(低停顿)的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。

G1与CMS的区别与选择

G1收集器是垃圾收集器理论进一步发展的产物,它与前面的CMS收集器相比有两 个显著的改进:

G1收集器是基于“标记-整理”算法实现的收集器,也就是说它 不会产生空间碎片,这对于长时间运行的应用系统来说非常重要。

它可以非常精确 地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收 集上的时间不得超过N毫秒,这几乎已经是实时Java (RTSJ)的垃圾收集器的特征了

也有缺点

用户程序运行过程中, G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

1 内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;

相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的

2 在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障, CMS用写后屏障来更新维护卡表;

而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。

相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。

由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。

ZGC收集器

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

停顿时间不超过10ms;

停顿时间不会随着堆的大小,或者活跃对象的大小而增加;

支持8MB~4TB级别的堆(未来支持16TB)。

从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。

与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。

ZGC垃圾回收周期如下图所示:

ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。

ZGC关键技术

ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。

着色指针

着色指针是一种将信息存储在指针中的技术。

ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:

其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。

当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。

与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。

 

ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

读屏障

读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。

读屏障示例:

Object o = obj.FieldA   // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o  // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i =  obj.FieldB  //无需加入屏障,因为不是对象引用

ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。

 ZGC并发处理演示

接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:

初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为M1,而非M0。

着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。

垃圾处理器总结

Serial,单线程,Client默认新生代处理器,新生代,复制算法,可与CMS(jdk9之后不行),Serial Old搭配。

ParNew,多线程,Serial的多线程版,新生代,复制算法,可与CMS,Serial Old(jdk9之后不行)搭配。

Parallel Scavenge,多线程,注重停顿时间和吞吐量,新生代,复制算法,可与Parallel Old,Serial Old搭配。

Serial Old,单线程,Client默认老年代处理器,Serial的老年版本,CMS的后备方案,老年代,标记整理,可与Serial,ParNew,Parallel Scavenge搭配。

Parallel Old,多线程,注重停顿时间和吞吐量,Parallel Scavenge的老年版本,老年代,标记整理,可与Parallel Scavenge搭配。

CMS,多线程,初始标记(单),并发标记(并发),重新标记(多线程,但与用户线程不并发),并发清除(并发,失败使用Serial Old),老年代,标记清除,可与Serial,ParNew搭配。

G1,多线程,内存分区(各分区大小相同,同一分区逻辑角色相同,都是新生代等,回收以分区为单位,复制到另一个分区),首先回收垃圾最多的分区,低停顿,使用暂停预测模型。新老年代,整体标记整理,局部复制,独自工作

分工,G1独自工作。Serial,ParNew两个新生代,CMS,Serial Old两个老年代,可以两两搭配。Parallel Scavenge搭配Parallel Old和Serial Old。


版权声明:本文为xushiyu1996818原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。