本文章为《深入浅出 Java 虚拟机》系列课程学习笔记,侵删。学习地址为 深入浅出 Java 虚拟机
以下场景很极端,但却经常发生。
在发生 Minor GC 时,由于 Survivor 区已经放不下了,多出的对象只能提升(promotion)到老年代。但是此时老年代因为空间碎片的缘故,会发生 concurrent mode failure 的错误。这个时候,就需要降级为 Serail Old 垃圾回收器进行收集。这就是比 concurrent mode failure 更加严重的 promotion failed 问题。
一次简单的 Major GC,竟然能演化成耗时最长的 Full GC。最要命的是,这个停顿时间是不可预知的。
有没有一种办法,能够首先定义一个停顿时间,然后反向推算收集内容呢?有滴,G1 垃圾回收器了解一下,它不要求每次都把垃圾清理的干干净净,它只是努力做它认为对的事情。
我们要求 G1,在任意 1 秒的时间内,停顿不得超过 10ms,这就是在给它制定一个目标。G1 会尽量达成这个目标,它能够推算出本次要收集的大体区域,以增量的方式完成收集。
这也是使用 G1 垃圾回收器不得不设置的一个参数:-XX:MaxGCPauseMillis=10
G1 垃圾回收器用于取代 CMS 垃圾回收器。
G1 垃圾回收器与其他的垃圾回收器在对堆的划分上有一些不同。其他的回收器,都是对某个年代的整体收集,收集时间上自然不好控制。G1 把堆切成了很多份,把每一份当作一个小目标,部分上目标很容易达成。
那么,G1 有年轻代和老年代的区分吗?
如图所示,G1 也是有 Eden 区和 Survivor 区的概念的,只不过它们在内存上不是连续的,而是由一小份一小份组成的。
这一小份区域的大小是固定的,名字叫作小堆区(Region)。小堆区可以是 Eden 区,也可以是 Survivor 区,还可以是 Old 区。所以 G1 的年轻代和老年代的概念都是逻辑上的。
每一块 Region,大小都是一致的,它的数值是在 1M 到 32M 字节之间的一个 2 的幂值数。
但假如我的对象太大,一个 Region 放不下了怎么办?注意图中有一块面积很大的黄色区域,它的名字叫作 Humongous Region,大小超过 Region 50% 的对象,将会在这里分配。
那么,回收的时候,到底回收哪些小堆区呢?是随机的么?这当然不是。事实上,垃圾最多的小堆区,会被优先收集。这就是 G1(GarbageFirst GC)名字的由来。
在逻辑上,G1 分为年轻代和老年代,但它的年轻代和老年代比例,并不是那么“固定”,为了达到 MaxGCPauseMillis 所规定的效果,G1 会自动调整两者之间的比例。
G1 的回收过程主要分为 3 类:
RSet 是一个空间换时间的数据结构。
之前我们提到过一个叫作卡表(CardTable)的数据结构,用来解决跨代引用的问题。RSet 的功能与此类似,它的全称是 RememberedSet,用于记录和维护 Region 之间的对象引用关系。
但 RSet 与 Card Table 有些不同的地方。Card Table 是一种 points-out(我引用了谁的对象)的结构。而 RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象),有点倒排索引的味道。
你可以把 RSet 理解成一个 Hash,key 是引用的 Region 地址,value 是引用它的对象的卡页集合。
有了这个数据结构,在回收某个 Region 的时候,就不必对整个堆内存的对象进行扫描了。它使得部分收集成为了可能。
对于年轻代的 Region,它的 RSet 只保存了来自老年代的引用,这是因为年轻代的回收是针对所有年轻代 Region 的,没必要画蛇添足。所以说年轻代 Region 的 RSet 有可能是空的。
而对于老年代的 Region 来说,它的 RSet 也只会保存老年代对它的引用。这是因为老年代回收之前,会先对年轻代进行回收。这时,Eden 区变空了,而在回收过程中会扫描 Survivor 分区,所以也没必要保存来自年轻代的引用。
RSet 通常会占用很大的空间,大约 5% 或者更高。不仅仅是空间方面,很多计算开销也是比较大的。
G1 还有一个 CSet 的概念。它的全称是 Collection Set,即收集集合,保存一次 GC 中将执行垃圾回收的区间(Region)。GC 是在 CSet 中的所有存活数据(Live Data)都会被转移。
年轻代回收是一个 STW 的过程,它的跨代引用使用 RSet 数据结构来追溯,会一次性回收掉年轻代的所有 Region。
JVM 启动时,G1 会先准备好 Eden 区,程序在运行过程中不断创建对象到 Eden 区,当所有的 Eden 区都满了,G1 会启动一次年轻代垃圾回收过程。
年轻代的收集包括下面的回收阶段:
当整个堆内存使用达到一定比例(默认是 45%),并发标记阶段就会被启动。这个比例也是可以调整的,通过参数 -XX:InitiatingHeapOccupancyPercent 进行配置。
Concurrent Marking 是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。具体标记过程如下:
如果在并发标记阶段,又有新的对象变化,该怎么办?这是由算法 SATB 保证的。
SATB 即 Snapshot At The Beginning,用于保证在并发标记阶段的正确性。
这个快照是逻辑上的,主要是有几个指针,将 Region 分成个多个区段。如图所示,并发标记期间分配的对象,都会在 next TAMS 和 top 之间。
能并发清理老年代中的整个整个的小堆区是一种最优情形。混合收集过程,不只清理年轻代,还会将一部分老年代区域也加入到 CSet 中。
通过 Concurrent Marking 阶段,我们已经统计了老年代的垃圾占比。在 Minor GC 之后,如果判断这个占比达到了某个阈值,下次就会触发 Mixed GC。这个阈值,由 -XX:G1HeapWastePercent 参数进行设置(默认是堆大小的 5%)。因为这种情况下, GC 会花费很多的时间但是回收到的内存却很少。所以这个参数也是可以调整 Mixed GC 的频率的。
还有参数 G1MixedGCCountTarget,用于控制一次并发标记之后,最多执行 Mixed GC 的次数。
在系统切换到 G1 垃圾回收器之后,线上发生的严重 GC 问题已经非常少了,这归功于 G1 的预测模型和它创新的分区模式。但预测模型也会有失效的时候,它并不是总如我们期望的那样运行,尤其是你给它定下一个苛刻的目标之后。
另外,如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个 Heap 的回收,那么 G1 要做的工作量就一点也不会比其他垃圾回收器少,而且因为本身算法复杂了,还可能比其他回收器要差。
在 ZGC 中,连逻辑上的年轻代和老年代也去掉了,只分为一块块的 page,每次进行 GC 时,都会对 page 进行压缩操作,所以没有碎片问题。ZGC 还能感知 NUMA 架构,提高内存的访问速度。与传统的收集算法相比,ZGC 直接在对象的引用指针上做文章,用来标识对象的状态,所以它只能用在 64 位的机器上。
现在在线上使用 ZGC 的还非常少。即使是用,也只能在 Linux 平台上使用。等待它的普及,还需要一段时间。