不管卷不卷,面试还是得问问你 G1 原理!
所有的垃圾回收器的目的都是朝着减少 STW 的目的而前进,G1(Garbage First)回收器的出现颠覆了之前版本 CMS、Parallel 等垃圾回收器的分代收集方式,从 2004 年 Sun 发布第一篇关于 G1 的论文后,直到 2012 年 JDK7 发布更新版本,花了将近 10 年的时间 G1 才达到商用的程度,而到 JDK9 发布之后,G1 成为了默认的垃圾回收器,CMS 也变相地相当于被淘汰了。
G1 结构
G1 抛弃了之前的分代收集的方式,面向整个堆内存进行回收,把内存划分为多个大小相等的独立区域 Region。
一共有 4 种 Region:
自由分区 Free Region
年轻代分区 Young Region,年轻代还是会存在 Eden 和 Survivor 的区分
老年代分区 Old Region
大对象分区 Humongous Region
每个 Region 的大小通过-XX:G1HeapRegionSize
来设置,大小为 1~32MB,默认最多可以有 2048 个 Region,那么按照默认值计算 G1 能管理的最大内存就是 32MB*2048=64G。
对于大对象的存储,存在 Humongous 概念,对 G1 来说,超过一个 Region 一半大小的对象都被认为大对象,将会被放入 Humongous Region,而对于超过整个 Region 的大对象,则用几个连续的 Humongous 来存储(如下图 H 区域)。
G1 优势
上面我们也提到,垃圾回收器的最终目的都是为了减少 STW 造成的停顿,比如之前老的垃圾回收器 CMS 这种带来的停顿时间是不可预估的。
而 G1 最大的优势就在于可预测的停顿时间模型,我们可以自己通过参数-XX:MaxGCPauseMillis
来设置允许的停顿时间(默认 200ms),G1 会收集每个 Region 的回收之后的空间大小、回收需要的时间,根据评估得到的价值,在后台维护一个优先级列表,然后基于我们设置的停顿时间优先回收价值收益最大的 Region。
那么,这个可预测的停顿时间模型怎么计算和建立的?主要是基于衰减平均值的理论基础,衰减平均是一种数学方法,用来计算一个数列的平均值,给近期的数据更高的权重,强调近期数据对结果的影响,代码如下:
davg
表示衰减值
sigma
表示一个系数,代表信贷度,默认值为 0.5
dsd
表示衰减标准偏差
confidence_factor
表示可信度系数,用于当样本数据不足(小于 5 个)时取一个大于 1 的值,样本数据越少该值越大。
基于这个模型,G1 希望根据用户设置的停顿时间(只是期望时间,尽量努力在这个范围内完成 GC)来选择需要对哪些 Region 进行回收,能回收多大空间。
比如过去 10 次回收 10G 内存花费 1s,如果预设的停顿时间是 200ms,那么就最多可以回收 2G 的内存空间。
空间分配 &扩展
既然 G1 还是存在新生代和老年代的概念,那么新生代和老年代的空间是怎么划分的呢?
在 G1 中,新增了两个参数G1MaxNewSizePercent
、G1NewSizePercent
,用来控制新生代的大小,默认的情况下G1NewSizePercent
为 5,也就是占整个堆空间的 5%,G1MaxNewSizePercent
默认为 60,也就是堆空间的 60%。
假设现在我们的堆空间大小是 4G,按照默认最大 2048 个 Region 计算,每个 Region 的大小就是 2M。
初始新生代的大小那么就是 200M,大约 100 个 Region 格子,动态扩展最大就是 60%*4G=2.4G 大小。
不过显然,事情不是这么简单,实际上初始化新生代的空间大小逻辑还是挺复杂的。
首先,我们通过原有参数-Xms
设置初始堆的大小,-Xmx
设置最大堆的大小还是生效的,可以设置堆的大小。
可以通过原有参数
-Xmn
或者新的参数G1NewSizePercent
、G1MaxNewSizePercent
来设置年轻代的大小,如果设置了-Xmn
相当于设置G1NewSizePercent
=G1MaxNewSizePercent
。接着看是不是设置了
-XX:NewRatio
(表示年轻代与老年代比值,默认值为 2,代表年轻代老年代大小为 1:2),如果 1 都设置了,那么忽略NewRatio
,反之则代表G1NewSizePercent
=G1MaxNewSizePercent
,并且分配规则还是按照NewRatio
的规则。如果只是设置了
G1NewSizePercent
、G1MaxNewSizePercent
中的一个,那么就按照这两个参数的默认值 5%和 60%来设置。如果设置了
-XX:SurvivorRatio
,默认为 8,那么 Eden 和 Survivor 还是按照这个比例来分配
按照这个规则,我们新生代和老年代的空间分配基本就完成,如果说新生代走默认的规则,每次动态扩展空间大小怎么办?
有一个参数叫做-XX:GCTimeRatio
表示 GC 时间与应用耗费时间比,默认为 9,就是说 GC 时间和应用时间占比超过 10%才进行扩展,扩展比例为 20%,最小不能小于 1M。
回收过程
G1 的回收过程分为以下四个步骤:
初始标记:标记 GC ROOT 能关联到的对象,需要 STW
并发标记:从 GCRoots 的直接关联对象开始遍历整个对象图的过程,扫描完成后还会重新处理并发标记过程中产生变动的对象
最终标记:短暂暂停用户线程,再处理一次,需要 STW
筛选回收:更新 Region 的统计数据,对每个 Region 的回收价值和成本排序,根据用户设置的停顿时间制定回收计划。再把需要回收的 Region 中存活对象复制到空的 Region,同时清理旧的 Region。需要 STW。
总的来说这是一个偏向记忆的回收过程,知道就行了。
相对于之前我们存在分代概念的 GC 来说,G1 其实也是类似的过程,总体可以分为这两种:
年轻代 GC,年轻代 Region 在超过我们默认设置的最大大小之后就会触发 GC,还是用的我们熟悉的复制算法,Eden 和 Survivor 来回倒腾,这里不再赘述。
Mixed GC 混合回收,混合回收类似于之前我们的 Full GC 概念,既会回收年轻代的 Region,也会回收老年代的 Region,还有我们新的 Humongous 大对象区域。触发规则根据参数
-XX:InitiatingHeapOccupancyPercent
(默认 45%)值,也就是说老年代 Region 达到整个堆内存的 45%时触发 Mixed GC。
其他问题
上面应该把基本概念都解释完了。
比如什么是 G1?G1 有什么特点?他的优点是什么?划分 Region 后怎么分配空间?怎么进行垃圾回收?什么时候进行 YGC?什么时候进行 FGC?可靠的停顿时间模型建立方式?
除此之外,其实还有一些较为复杂的问题,比如之前我们说分代收集有跨代引用的问题,划分 Region 之后应该也有对不对,那怎么解决的?
还有之前我们说并发收集阶段怎么解决用户线程和收集线程互不干扰的?
这些更深一点的问题其实在现在已经卷到需要问三色标记了吗?已经说到了很多了,下面我们再详细点说明下在 G1 中的一些不同点。
记忆集
在这篇文章中我们提到过一次关于 Remembered Set 的概念,为了避免 GC 时扫描整个堆内存,用来标志哪些区域存在跨代引用,对于 G1 来说也一样,只不过 G1 的记忆集会更复杂一点。
每个 Region 中都存在一个 Hash Table 结构的记忆集,Key 为其他 Region 的起始地址,Value 是其他 Card Table 卡表的索引集合。
原来我们的卡表指向的是卡页的内存地址段,代表我引用了谁,现在的记忆集则是代表着谁引用了我,因此收集的过程会更复杂一点,并且需要额外的 10%~20%的堆内存空间来维持。
维护记忆集的方式也和卡表类似,通过写屏障来实现。
原始快照 SATB
在三色标记中我们也提到过,并发标记用户线程和收集线程一起工作会产生问题,解决方案 CMS 使用的是增量更新,G1 则是用原始快照。
总结
写这些东西比较费劲,因为总在想在理解的基础上怎么写的更通俗易懂,但是发现好像并不容易,因为自己也都是看完没过多久就忘记了,所以记录下来,能看懂就行了,实在不行就去看书。
周老师的深入 Java 虚拟机写的比较简单,很多东西要去搜资料和书结合看才能看明白,另外一本书写的也不是很好,作者感觉只是堆砌知识点,看起来很费劲,美团写的那篇文章也是一大堆名词,不知道的人看的简直蛋疼。
我应该,比他们写的更通俗一点就好了?
参考:
彭成寒《JVM G1 源码分析和调优》
周志明《深入理解 Java 虚拟机第三版》
版权声明: 本文为 InfoQ 作者【艾小仙】的原创文章。
原文链接:【http://xie.infoq.cn/article/a67ca0dae583d5ba0bd30bc53】。文章转载请联系作者。
评论