Loading

04-垃圾回收(2)

1. 前置知识

1.1 根节点枚举

固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在 Java 应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。

现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但「根节点枚举」始终还是必须在一个能保障一致性的快照中才得以进行

这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的 CMS、G1、ZGC 等收集器,枚举根节点时也是必须要停顿的。

1.2 安全点/安全区域

在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举,但实际上 HotSpot 也的确没有为每条指令都生成 OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

  • 「抢先式中断」不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。
  • 「主动式中断」的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

这些特定的安全点位置主要有以下几种:

  1. 方法返回之前
  2. 调用某个方法之后
  3. 抛出异常的位置
  4. 循环的末尾

1.3 记忆集与卡表

讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。

事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如 G1、ZGC 和 Shenandoah 收集器,都会面临相同的问题。

记忆集是一种用于记录从「非收集区域」指向「收集区域」的指针集合的抽象数据结构。如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构:

class RememberedSet {
    Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。关于卡表与记忆集的关系,读者不妨按照 Java 语言中 HashMap 与 Map 的关系来类比理解。

卡表最简单的形式可以只是一个字节数组,而 HotSpot 虚拟机确实也是这样做的。以下这行代码是 HotSpot 默认的卡表标记逻辑:CARD_TABLE [this address >> 9] = 0;

字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以 2 的 N 次幂的字节数,通过上面代码可以看出 HotSpot 中使用的卡页是 2 的 9 次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512)。那如果卡表标识内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF 的卡页内存块,如下图所示。

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。

1.4 写屏障

我们已经解决了如何使用记忆集来缩减 GC Roots 扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

卡表元素何时变脏的答案是很明确的 —— 有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?

如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中

在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot 虚拟机的许多收集器中都有使用到写屏障,但直至 G1 收集器出现之前,其他收集器都只用到了写后屏障。

void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的。

1.5 并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。

在根节点枚举这个步骤中,由于 GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如 OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从 GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。

要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。

但想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历

引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系 —— 即修改对象图的结构,这样可能出现两种后果。

  1. 把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。
  2. 把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。

并发出现“对象消失”问题的示意:

Wilson 于 1994 年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

  • 「增量更新」要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
  • 「原始快照」要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索(=> 浮动垃圾~)。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过「写屏障」实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。

  • 写屏障·实现SATB】当对象的成员变量的引用发生变化时,比如引用消失,我们可以利用写屏障,将对象原来成员变量的引用对象记录下来;
    void pre_write_barrier(oop* field) {
        // 获取旧值
        oop old_value = *field;
        // 记录原来的引用对象
        remark_set.add(old_value);
    }
    
  • 写屏障·实现增量更新】当对象的成员变量的引用发生变化时,比如新增引用,我们可以利用写屏障,将新的成员变量引用对象记录下来;
    void post_write_barrier(oop* field, oop new_value) {
        // 记录新引用的对象
        remark_set.add(new_value);
    }
    

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

对于读写屏障,以 Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新
  • G1,Shenandoah:写屏障 + SATB
  • ZGC:读屏障

工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

为什么 G1 用 SATB?CMS 用增量更新?

SATB 相对增量更新效率会高(当然 SATB 可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而 CMS 对增量引用的根对象会做深度扫描,G1 因为很多对象都位于不同的 region,CMS 就一块老年代区域,重新深度扫描对象的话 G1 的代价会比 CMS 高,所以 G1 选择 SATB 不深度扫描对象,只是简单标记,等到下一轮 GC 再深度扫描。

2. 经典垃圾收集器

如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。

《Java虚拟机规范》中对垃圾收集器应该如何实现并没有做出任何规定,因此不同的厂商、不同版本的虚拟机所包含的垃圾收集器都可能会有很大差别,不同的虚拟机一般也都会提供各种参数供用户根据自己的应用特点和要求组合出各个内存分代所使用的收集器。

本节标题中“经典”二字并非情怀,它其实是讨论范围的限定语,这里讨论的是在 JDK 7 Update 4 之后(在这个版本中正式提供了商用的 G1 收集器,此前 G1 仍处于实验状态)、JDK 11 正式发布之前,OracleJDK 中的 HotSpot 虚拟机所包含的全部可用的垃圾收集器。

使用“经典”二字是为了与几目前仍处于实验状态,但执行效果上有革命性改进的高性能低延迟收集器区分开来,这些经典的收集器尽管已经算不上是最先进的技术,但它们曾在实践中千锤百炼,足够成熟,基本上可认为是现在到未来两、三年内,能够在商用生产环境上使用的全部垃圾收集器了。各款经典收集器之间的关系如图所示。

上图展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于「新生代收集器」抑或是「老年代收集器」。

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

2.1 Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是 HotSpot 虚拟机新生代收集器的唯一选择。

大家只看名字就能够猜到,这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。“Stop The World”这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。

但事实上,迄今为止,它依然是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

2.2 ParNew 收集器

ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio-XX:PretenureSizeThreshold-XX:HandlePromotionFailure 等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

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

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

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

随着垃圾收集器技术的不断改进,更先进的 G1 收集器带着 CMS 继承者和替代者的光环登场。从此,ParNew 和 CMS 只能互相搭配使用,再也没有其他收集器能够和它们配合了。读者也可以理解为从此以后,ParNew 合并入 CMS,成为它专门处理新生代的组成部分。

ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越 Serial 收集器。

当然,随着可以被使用的处理器核心数量的增加,ParNew 对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如 32 个,现在 CPU 都是多核加超线程设计,服务器达到或超过 32 个逻辑核心的情况非常普遍)的环境中,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

2.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓“吞吐量”就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

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

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

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

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

2.4 Serial Old 收集器

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

这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用。如果在服务端模式下,它也可能有 2 种用途:

  1. 在 JDK 5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用;
  2. 作为 CMS 收集器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

2.5 Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合。

2.6 CMS 收集器

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

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

由于在整个过程中耗时最长的「并发标记」和「并发清除」阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的(对吞吐量有影响)。通过上图可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的阶段。

重新标记阶段涉及到的一个 VM 参数:-XX:CMSScavengeBeforeRemark,通过设置该参数,让重新标记之前先来一次新生代的垃圾回收。

CMS 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。CMS 收集器是 HotSpot 虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下 3 个明显的缺点:

(1) CMS 收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。

  • CMS 默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在 4 个或以上,并发回收时垃圾收集线程只占用不超过 25% 的处理器运算资源,并且会随着处理器核心数量的增加而下降。
  • 但是当处理器核心数量不足 4 个时,CMS 对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。

(2) 由于 CMS 收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的 Full GC 的产生。

在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。

同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。

执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在〈并发标记〉和〈并发清理〉阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发 Full GC,也就是“Concurrent Mode Failure”,此时会进入 STW,用 Serial Old 垃圾收集器来回收。

  • 在 JDK5 的默认设置下,CMS 收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数 -XX:CMSInitiatingOccu-pancyFraction 的值来提高 CMS 的触发百分比,降低内存回收频率,获取更好的性能。
  • 到了 JDK6 时,CMS 收集器的启动阈值就已经默认提升至 92%。但这又会更容易面临另一种风险:要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数 -XX:CMSInitiatingOccupancyFraction 设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

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


CMS 的相关核心参数:

  1. -XX:+UseConcMarkSweepGC:启用 CMS;
  2. -XX:ConcGCThreads:并发的 GC 线程数;
  3. -XX:+UseCMSCompactAtFullCollection:FullGC 之后做压缩整理(减少碎片);
  4. -XX:CMSFullGCsBeforeCompaction:多少次 FullGC 之后压缩一次,默认是 0,代表每次 FullGC 后都会压缩一次;
  5. -XX:CMSInitiatingOccupancyFraction:当老年代使用达到该比例时会触发 FullGC(默认是 92,这是百分比);
  6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction 设定的值),如果不指定,JVM 仅在第 1 次使用设定值,后续则会自动调整;
  7. -XX:+CMSScavengeBeforeRemark:在 CMS GC 前启动一次 Minor GC,目的在于减少老年代对年轻代的引用,降低 CMS GC 的标记阶段时的开销,一般 CMS 的 GC 耗时 80% 都在标记阶段;
  8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短 STW;
  9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短 STW;

3. Garbage First 收集器

Garbage First(简称 G1)收集器开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。

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

3.1 Region

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

G1 开创的“基于 Region 的堆内存布局(最多可以有 2048 个 Region)”是它能够实现这个目标的关键。

虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

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

虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。

Full GC 的时候除了收集年轻代和老年代之外,也会将 Humongous 区域一并回收。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。

更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数 -XX:MaxGCPauseMillis 指定,默认值是 200ms),优先处理回收价值收益最大的那些 Region,这也就是“Garbage First”名字的由来。

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

3.2 GC 过程

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

  • 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking):【同 CMS 的并发标记】从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。Old GC~
  • 最终标记(Final Marking):【同 CMS 的重新标记】对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  • 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中(存活对象越少,延时越短),再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

「筛选回收阶段」首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间(可用 -XX:MaxGCPauseMillis 指定)来制定回收计划,比如说老年代此时有 1000 个 Region 都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿 200ms,那么通过之前回收成本计算得知,可能回收其中 800 个 Region 刚好需要 200ms,那么就只会回收 800 个 Region(Collection Set,要回收的集合),尽量把 GC 导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个 Region 中的存活对象复制到另一个 Region 中,这种不会像 CMS 那样回收完因为有很多内存碎片还需要整理一次,G1 采用复制算法回收几乎不会有太多内存碎片(注意:CMS 回收阶段是跟用户线程一起并发执行的,G1 因为内部实现太复杂暂时没实现并发回收,不过到了 Shenandoah 就实现了并发收集,Shenandoah 可以看成是 G1 的升级版本)。

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

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来),比如一个 Region 花 200ms 能回收 10M 垃圾,另外一个 Region 花 50ms 能回收 20M 垃圾,在回收时间有限情况下,G1 当然会优先选择后面这个 Region 回收。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率。

被视为 JDK1.7 以上版本 Java 虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 StopTheWorld 停顿时间。部分其他收集器原本需要停顿 Java 线程来执行 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数 -XX:MaxGCPauseMillis 指定)内完成垃圾收集。

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

3.3 三个问题

3.3.1 跨 Region 引用

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

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

每个 Region 对应一个卡表(Card Table),Card Table 中的每个元素对应着其标识的内存区域中一块特定大小的内存块(512 Byte),这个内存块被称为“卡页”。只要卡页中有一个及以上对象的字段存在着跨 Region 引用,该卡页对应的卡表中的元素的值就标识为 1,即 Dirty。

【↑↓】记录完毕后把值为 1 的卡页 idx 作为 RSet 的 key 进行记录,并且 RSet 的 value 存储引用,从而提高跨代引用的查询效率。

G1 的记忆集(Remembered Set,RSet)在存储结构的本质上是一种哈希表,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是 key 指向的 Region 的卡表(存在“跨 Region 引用对象”的卡页对应的)索引号。

这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此 G1 收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1 至少要耗费大约相当于 Java 堆容量 10%~20% 的额外内存来维持收集器工作。时间换空间 ~


在变更引用时,Post-Write Barrier + Dirty Card Queue:

  1. 标记 Card Table 中对应索引位置值为 Dirty
  2. 将 Card 存储 Dirty Card Queue
  3. 白 / 绿 / 黄 / 红四个色
  4. 会有 Concurrent Refinement Threads 负责更新 Remembered Set

3.3.2 CM 阶段互不干扰

在并发标记(Concurrent Mark,CM)阶段如何保证收集线程与用户线程互不干扰地运行?

这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法笔者已经抽出独立小节来讲解过(见1.5节):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.3.3 停顿预测模型

概述中提到的“停顿预测模型”是怎么建立的?

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

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

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

3.4 G1 垃圾回收阶段

3.4.1 Young GC

YoungGC 并不是说现有的 Eden 区放满了就会马上触发,G1 会计算下现在 Eden 区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的 Region,继续给新对象存放,不会马上做 Young GC,直到下一次 Eden 区放满,G1 计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发 Young GC。

  1. 构建 Collection Set(Eden + Survivor)
  2. 扫描 GC Roots(初始标记)
  3. Update Remembered Set(排空 Dirty Card Queue)
  4. Process Remembered Set:找到那些被老年代对象所引用的对象
  5. Object Copy
  6. Reference Processing(软弱虚引用处理)

3.4.2 Mixed GC

不是 FullGC,老年代的堆占有率达到参数 -XX:InitiatingHeapOccupancyPercent 设定的值则触发,回收所有的 Young 和部分 Old(根据期望的 GC 停顿时间确定 Old 区垃圾收集的优先顺序)以及大对象区,正常情况 G1 的垃圾收集是先做 MixedGC,主要使用复制算法,需要把各个 Region 中存活的对象拷贝到别的 Region 里去,拷贝过程中如果发现没有足够的空 Region 能够承载拷贝对象就会触发一次 Full GC。

会对 E、S、O 进行全面垃圾回收:

3.4.3 Full GC

停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批 Region 来供下一次 MixedGC 使用,这个过程是非常耗时的(Shenandoah 优化成多线程收集了)。

3.5 Update G1

(1)8u20 字符串去重

优点:节省大量内存
缺点:略微多占用了 CPU 时间,新生代回收时间略微增加。

-XX:+UseStringDeduplication

String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
String s2 = new String("hello"); // char[]{'h','e','l','l','o'}

将所有新分配的字符串放入一个队列。当新生代回收时,G1 并发检查是否有字符串重复,如果它们值一样,让它们引用同一个 char[]。

注意,与 String.intern() 不一样:String.intern() 关注的是字符串对象,而字符串去重关注的是 char[]。在 JVM 内部,使用了不同的字符串表。

(2)8u40 并发标记类卸载

所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类 -XX:+ClassUnloadingWithConcurrentMark 默认启用。

(3)8u60 回收巨型对象

一个对象大于 Region 的一半时,称之为“巨型对象”。

G1 不会对巨型对象进行拷贝,回收时被优先考虑。

G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为0 的巨型对象就可以在新生代垃圾回收时处理掉。

(4)JDK9 并发标记起始时间的调整

并发标记必须在堆空间占满前完成,否则退化为 FullGC。

JDK 9 可以动态调整 -XX:InitiatingHeapOccupancyPercent 用来设置初始值,进行数据采样并动态调整,总会添加一个安全的空档空间。

3.6 参数设置

  • -XX:+UseG1GC:使用 G1 收集器;
  • -XX:ParallelGCThreads:指定 GC 工作的线程数量;
  • -XX:G1HeapRegionSize:指定分区大小(1MB~32MB 且必须是 2 的 N 次幂),默认将整堆划分为 2048 个分区;
  • -XX:MaxGCPauseMillis:目标暂停时间(默认 200ms);
  • -XX:G1NewSizePercent:新生代内存初始空间(默认整堆 5%);
  • -XX:G1MaxNewSizePercent:新生代内存最大空间;
    --XX:TargetSurvivorRatio:Survivor 区的填充容量(默认 50%),Survivor 区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了 Survivor 区域的 50%,此时就会把年龄n(含)以上的对象都放入老年代;
  • -XX:MaxTenuringThreshold:最大年龄阈值,默认 15;
  • -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认 45%),则执行新生代和老年代的混合收集 MixedGC,比如我们之前说的堆默认有 2048 个 Region,如果有接近 1000 个 Region 都是老年代的 Region,则可能就要触发 MixedGC 了;
  • -XX:G1MixedGCLiveThresholdPercent:Region 中的存活对象低于这个值(默认 85%)时才会回收该 Region,如果超过这个值,存活对象过多,回收的的意义不大;
  • -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认 8 次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
  • -XX:G1HeapWastePercent:GC 过程中空出来的 Region 是否到达阈值(默认 5%),在混合回收的时候,对 Region 回收都是基于复制算法进行的,都是把要回收的 Region 里的存活对象放入其他 Region,然后这个 Region 中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的 Region,一旦空闲出来的 Region 数量达到了堆内存的 5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。

3.7 总结

3.7.1 小结

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

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

G1 收集器常会被拿来与 CMS 收集器互相比较,毕竟它们都非常关注停顿时间的控制,官方资料中将它们两个并称为“The Mostly Concurrent Collectors”。在未来,G1 收集器最终还是要取代 CMS 的,而当下它们两者并存的时间里,分个高低优劣就无可避免。

相比 CMS,G1 的优点有很多,暂且不论可以指定最大停顿时间、分 Region 的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个 Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

不过,G1 相对于 CMS 仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替 CMS 就可以得知这个结论。比起 CMS,G1 的弱项也可以列举出不少,如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

  • 就内存占用来说,虽然 G1 和 CMS 都使用卡表来处理跨代指针,但 G1 的卡表实现更为复杂,而且堆中每个 Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致 G1 的记忆集(和其他内存消耗)可能会占整个堆容量的 20% 乃至更多的内存空间;相比起来 CMS 的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。
  • 在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到「写屏障」,CMS 用〈写后屏障〉来更新维护卡表;而 G1 除了使用〈写后屏障〉来进行同样的(由于 G1 的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用〈写前屏障〉来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免 CMS 那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于 G1 对「写屏障」的复杂操作要比 CMS 消耗更多的运算资源,所以 CMS 的写屏障实现是直接的同步操作,而 G1 就不得不将其实现为类似于消息队列的结构,把〈写前屏障〉和〈写后屏障〉中要做的事情都放到队列里,然后再异步处理。

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

3.7.2 优化建议

假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的 60% 了,此时才触发 Young GC。

那么存活下来的对象可能就会很多,此时就会导致 Survivor 区域放不下那么多的对象,就会进入老年代中;或是你 Young GC 过后,存活下来的对象过多,导致进入 Survivor 区域后触发了「动态年龄判定规则」,达到了 Survivor 区域的 50%,也会快速导致一些对象进入老年代中。

所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证 Young GC 别太频繁的同时,还得考虑每次 GC 过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发 Mixed GC。

每秒几十万并发的系统如何优化 JVM?

Kafka 类似的支撑高并发消息系统大家肯定不陌生,对于 Kafka 来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署 Kafka 需要用大内存机器(比如 64G),也就是说可以给年轻代分配个 30~40G 的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于 Eden 区 的 Young GC 是很快的,这种情况下它的执行还会很快吗?

很显然,不可能,因为内存太大,处理还是要花不少时间的,假设 30~40G 内存回收可能最快也要几秒钟,按 Kafka 这个并发量放满 30~40G 的 Eden 区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为 Young GC 卡顿几秒钟没法处理新消息,显然是不行的。

那么对于这种情况如何优化了,我们可以使用 G1 收集器,设置 -XX:MaxGCPauseMills 为 50ms,假设 50ms 能够回收 3~4G 内存,然后 50ms 的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。

G1 天生就适合这种大内存机器的 JVM 运行,可以比较完美的解决大内存垃圾回收时间过长的问题。

3.7.3 适用场景

  1. 50% 以上的堆被存活对象占用
  2. 对象分配和晋升的速度变化非常大
  3. 垃圾回收时间特别长,超过1s
  4. 8GB 以上的堆内存(建议值)
  5. 停顿时间是 500ms 以内

4. JVM 实践

4.1 内存泄漏的排查思路

导致两种后果:① 启动闪退 ② 运行一段时间宕机

排查思路:

  1. 获取堆内存快照 dump(dump 文件是进程的内存镜像)
  2. VisualVM 分析 dump 文件
  3. 通过查看堆信息的情况,定位内存溢出问题

详细步骤:

(1)获取内存快照 dump

  • 使用 jmap 命令获取运行中程序的 dump 文件
    jmap -dump:format=b,file=heap.hprof <pid>
    
  • 使用 vm 参数获取 dump 文件
    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/home/app/dumps/
    

(2)通过 VisualVM 来分析 dump文件,VisualVM 可以加载离线的 dump 文件

(3)通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题

找到对应的代码,通过阅读上下文的情况,进行修复即可。

4.2 CPU 飙高的排查思路

(1)使用 top 命令查看占用 CPU 的情况,可以看到是哪一个进程占用 CPU 较高,上图所示的进程为:40940

(2)查看进程中的线程信息,可以看出:在进程 40940 中的线程 40950 占用 CPU 较高 /=> top -Hp 40940

(3)可以根据线程 id 找到有问题的线程,进一步定位到问题代码的源码行号 /=> jstack 40940 | grep '0x9ff6' -C5 --color

posted @ 2021-05-19 14:41  tree6x7  阅读(121)  评论(0编辑  收藏  举报