G1 原理—G1 是如何提升垃圾回收效率
提升 G1 垃圾回收器 GC 效率的黑科技
G1 设计了一套 TLAB 机制 + 快速分配机制用来提升分配对象的效率
G1 设计了一套记忆集 + 位图 + 卡表 + DCQ + DCQS 机制用来提升垃圾回收的效率
1.G1 为了提升 GC 的效率设计了哪些核心机制
(1)记忆集 RSet(Remember Set)
一.可达性分析算法在分代模型下的问题
JVM 中判断对象是否存活的算法是 GC Roots 可达性分析算法,即从 GC Roots 出发标记所有存活对象。然后遍历对象的每个字段继续标记,直到所有对象标记完毕。标记完毕后,一个对象要么是存活对象,要么是垃圾对象。
在分代模型的 JVM GC 中,老年代和新生代的回收,一般都是不同的。新生代会被先触发,实在无法腾出足够空间,才会进行老年代 GC 或 FGC。
G1 中的垃圾回收会分成三种:新生代回收、混合回收和 FGC。新生代回收总是收集所有新生代分区,混合回收会收集所有新生代分区和部分老年代分区,FGC 则是会对所有分区进行处理。
G1 也采用分代模型:首先进行新生代回收,然后不够再进行 Mixed GC,最后才可能会 FGC。
问题:回收新生代时为什么需要遍历老年代?
因为新生代对象不一定只有新生代对象在引用,老年代对象也可能会引用新生代对象。在触发新生代 GC 时,老年代里的一些对象也会在新生代的 GC Roots 中。所以回收新生代时,根据可达性算法,遍历老年代就是为了完善 GC Roots。
而这又会面临新的问题:遍历跨代的所有对象浪费时间。由于新生代和老年代 GC 的阶段是不同的,当只需要回收新生代时,如果还是按照可达性分析算法,那么就会把老年代也都全标记一遍,而此时并不回收老年代。同样如果只需要回收老年代,也要把新生代的所有对象遍历一遍,这样就非常浪费时间。
如何才能拿到老年代中引用新生代的那些对象呢?
二.记忆集是针对分代模型垃圾回收设计的用于解决跨代引用的数据结构
如何找出引用了新生代对象的所有老年代对象?难道只能遍历老年代?RSet 记忆集,就是为了解决这个问题而设计的。RSet 记忆集,会使用一组 key-value 结构来记录跨代引用的引用关系。这样在 GC 时就可以借助 GC Roots + 记忆集,快速解决同代引用及跨代引用的可达性分析问题。如下图示:
(2)位图 BitMap
一.JVM 应该如何标记内存的使用状态
由于 JVM 管理的是内存,而内存的使用状态,其实是不太好标记的。为了标记 JVM 中的内存使用状态,最笨的方法就是:直接遍历整个内存块,看看它到底有没有内容。没有内容就认为它是空闲的,有内容就认为它是在使用中的。
但这种简单粗暴、直接遍历内存查看内存是否被使用的方式不适合 JVM,如果 JVM 想查看哪些内存被使用,还要去遍历内存,这样效率就太低了。
二.JVM 使用位图来标记内存的使用状态
为了描述内存的使用状态,JVM 使用了位图,也就是 JVM 会在一个位图里记录所有内存的使用状态。如果 JVM 要要知道某一块内存是否被使用,就直接访问位图中这块内存对应的坐标内容来进行判断,看是否已使用。如下图示:
三.什么是位图
位图就是通过"位"来描述某一块内存的状态的图。计算机存储数据的最小单位是字节,一个字节占用了 8 个二进制位,每个二进制位的值是 0 或 1。位图就是一组二进制位数据,位图的每个二进制位的 0 和 1 都标识其描述内容的状态,如内存是否使用。
G1 便使用了位图来描述内存的使用状态,G1 会在混合回收的并发标记阶段使用位图,以此来快速判断内存是否已被使用。
(3)卡表 CardTable
一.卡表与位图的区别
卡表类似于位图,都是通过使用一段数据去描述一块内存的情况。
卡表和位图不一样的地方是:位图只能使用一个二进制位来描述内存的状态,也就是只能记录使用或未使用。卡表则可以描述更多的信息,比如内存是否使用、内存的引用关系等。卡表使用 8 个二进制位也就是一个字节来描述一块内存的使用情况。比如内存是否使用、使用了多少内存、内存的引用关系等。
所以,本质上卡表在数据结构层面和位图其实并没有什么太大区别,只不过卡表的描述符比位图长、描述的内容比位图多而已。
二.G1 的卡表
G1 的卡表会用 1 个字节(8 位)来描述 512 字节的内存使用情况以及对象的引用关系,并且 G1 的卡表是一个全局卡表,也就是整个堆内存会共用一个卡表。G1 会使用一个全局卡表来描述整个堆内存的使用情况以及对象的引用关系。
由于 512 字节的内存可能会被引用多次,即同一个对象被多个对象引用。所以卡表的描述,可以理解为一个大概的引用关系描述,卡表并不记录每个对象的具体引用关系。
G1 使用了记忆集 + 卡表,来解决分代模型中,跨代引用关系的不好判定、不好追踪问题。
(4)这些数据结构是怎么提升 GC 效率的
一.标记过程很影响 GC 效率 + 记忆集可避免跨代遍历提升标记过程效率
JVM 的垃圾回收效率主要体现在以下两方面:
第一.标记垃圾对象
第二.清理垃圾 + 整理内存
其中标记过程会分成多个步骤:初始标记、并发标记、重新标记、并发回收。可见标记过程是非常耗时的,不然没有必要分成这么多个阶段。
那么标记过程的耗时具体体现在哪里呢?其实标记过程耗时就是体现在引用关系不好判断、内存是否使用不好判断上面。
首先,初始标记要从 GC Roots 出发,标记所有被直接引用的对象。然后,并发标记要追踪所有被间接引用的对象。如果出现新生代对象被老年代引用了,通过 RSet 就可以避免对老年代的遍历。
二.为节约记忆集的内存会使用卡表
引用关系会通过记忆集 RSet 来进行查找,但是记忆集 Rset 里不能直接记录哪个对象引用了哪个对象。否则如果一个系统 1000w 个对象,引用关系还特别复杂,那么一个 Rset 可能占用的内存就会特别大。
所以这个时候卡表 CardTable 就出现了,卡表 CardTable 里可以用 1 个字节来描述 512 字节内存的引用关系。这样 RSet 里就可以只记录 CardTable 相关内容,这样就能节省很多内存。比如发现老年代有一块 512 字节的空间里的对象引用了新生代的一个对象,那么记忆集 Rset 只需要直接记录这个 512 字节的空间在卡表里的位置即可。
三.并发标记阶段会使用位图
位图和并发标记阶段是息息相关的。在并发标记阶段,可以借助位图描述内存使用情况,避免内存使用冲突,也避免 GC 线程无效遍历一些未使用的内存。
(5)总结
G1 为了提升 GC 标记效率做了哪些设计:
一.记忆集 RSet—避免跨代遍历对象
二.位图 BitMap—描述内存使用(避免并发冲突 + 避免无效遍历未使用内存)
三.卡表 CardTable—节约记忆集的内存(8 个位 1 个字节描述 512 字节的引用关系)
问题:Rset 到底是什么?它具体是怎么实现的?它里面会记录什么内容?
2.G1 中的记忆集是什么
(1)怎么标记一个对象
如何判断一个对象需要被标记为垃圾对象,或者需要被标记为存活对象?其实就是通过可达性分析算法来判断对象是否存活,也就是通过追踪 GC Roots 引用关系来标记。如果一个对象被 GC Roots 引用或者被 GC Roots 引用的对象引用了,那么就标记为存活对象,否则就标记为垃圾对象。
那么如何知道引用关系呢?最简单最准确无误的方式就是,从 GC Roots 出发,一个一个对象去遍历。把每个对象的每个字段引用的内容都进行遍历,找出所有引用关系。
但如果出现了跨代引用则会有问题,老年代的一个对象引用了新生代,那么用新生代的 GC Roots 不能找全。这时老年代的一些对象,也要加入到新生代的 GC Roots 里面,才能找全。
(2)跨代引用下存在存活对象找不全的问题
由于 G1 本身是有分代的,在某种特殊情况下,有一个老年代的对象引用了新生代的对象。那么此时如果要触发 G1 的 YGC,怎么才能找到这个老年代的对象呢?如果找不到这个老年代的对象,就没办法找到它引用了哪些新生代对象。
由于 YGC 针对的是整个新生代的空间,也就是会选择所有新生代 Region,拿到 GC Roots,然后遍历整个新生代。所以如果找不到老年代对新生代的引用关系,垃圾回收时就可能误操作。要么多清理要么少清理,不管是多清理或者少清理,其实都比较麻烦。如果多清理了,系统就会直接报错。如果少清理了,垃圾对象占用新生代,可能会更加频繁 GC。所以这个跨代引用关系是必须知道的。
应该怎么获取这个跨代引用关系呢?想要获取这些引用关系,那么就要找到哪些老年代的对象引用了新生代的对象,也就是要找到老年代里引用了新生代对象的那些对象。
最简单的方式就是直接把老年代也遍历一遍来看看引用关系。但是此时做的是新生代的回收,却要把老年代也遍历一遍,就不合适了。不仅标记时间长,且遍历老年代,从分代隔离回收的思路来看也不合适。那么应该怎么记录跨代的引用关系?
(3)怎么记录跨代的引用关系—RSet 记忆集
一.记忆集会通过记录跨代的引用关系来避免遍历整个分代如老年代
举个例子:在 G1 中,如果老年代的对象引用了新生代的对象,那么直接针对被引用对象开辟一块内存,用来存储到底是谁引用了该对象。
当准备遍历新生代所有对象时,直接把这块内存里的老年代对象,也加入到 GC Roots 中,然后进行遍历。这样就能避免遍历整个老年代,而且从效率和分代隔离角度都非常合理。
二.记忆集的维度应该是什么
是针对新生代和老年代各创建一个?还是针对 Region,每个 Region 都创建一个?
G1 会为每个 Region 都创建一个 RSet 记忆集,G1 的每个 RSet 记忆集都会用来存储对应 Region 里所有对象的引用关系。
G1 针对 Region 来创建记忆集的原因如下:
原因一.老年代、新生代、大对象的 Region 在 GC 后可能会发生变化。
原因二.如果针对每个分代创建记忆集,因为 Region 不断变化可能有并发问题。
原因三.如果针对每个分代创建记忆集,老年代回收时会有额外遍历降低效率。
除了新生代的回收是需要选择所有新生代的 Region 之外,老年代回收需要寻找性价比高的 Region 来回收,即选择一部分去回收。这时如果还要遍历整个老年代的记忆集来筛选引用关系,效率就会很低。
总结:
G1 选择 RSet 记忆集去记录跨代的引用关系,大大减少了不必要的遍历操作。
G1 会针对 Region 去创建 RSet 记忆集,这也继续减少了不必要的遍历操作。
(4)在 G1 中有多少种引用关系 + 哪些需要记录
此时需要考虑如下问题:
G1 的引用关系一共就这么几种,那么到底哪些需要记录?如果不记录,会不会造成漏标记或漏回收的情况?如果会出现这个现象,那么就必须要记录下来了。所以针对上述的几种情况,进行如下分析:
一.Region 内部的引用关系
因为是内部的,不管是做新生代回收、老年代回收、还是 FGC 回收,在 GC Roots 标记的过程中,是一定可以追踪到这种引用关系的。因为被选中的 Region 分区,它里面的所有对象一定都会被遍历到。所以 Region 内部的引用关系,不需要记录;
二.新生代和新生代间的引用关系
这个和 Region 内部的原理是一样的。新生代 GC 时肯定可以全部遍历一遍,所以也不需要记录,老年代 GC 又和这种新生代之间的引用关系没什么相关联的地方。
三.新生代到老年代的引用关系
假如要进行新生代的回收,因为是新生代引用了老年代对象,所以即便遍历到了,新生代 GC 也不会去处理老年代的对象,并且这种引用关系在新生代回收过程中,也一定会被遍历到。
假如要进行老年代的回收,老年代 Region 需要知道谁引用了自己,不然可能会出错。但是我们知道 GC 回收过程是没有老年代的单独回收的,所以如果要回收老年代,肯定会带着一次新生代回收或者直接 FGC 的,所以也不需要记录。
四.老年代到新生代的引用关系
这个很明显是需要记录的。
五.老年代到老年代的引用关系
假如进入到 Mixed 回收阶段,那么此时只会选择老年代的部分 Region 来回收。由于只需选择部分 Region 进行回收,所以其他 Region 尽量就不要去遍历。因此需要记录老年代到老年代的引用,避免 Mixed 回收时遍历老年代。
(5)总结
G1 中的记忆集:
一.记忆集是什么
二.标记对象怎么标记
三.跨代引用带来的问题
四.怎么解决跨代引用的问题
五.记忆集 RSet 里面记录了什么 + 哪些引用关系需要记录 + 哪些不需要记录
问题:
RSet 里面到底是怎么存的?
G1 是怎么设计引用关系存储结构的?
3.G1 中的位图和卡表
(1)引入位图可以做什么
位图就是通过"位"来描述某一块内存的状态的图,位图就是一组二进制位数据,位图的每一个二进制位的 0 和 1 都标识了其描述内容的状态。
假设有两个分区,分区 1 有一个对象 student,分区 2 有一个对象 score。并且 student.score = score,此时这两个分区就有了引用关系了。那么问题来了,如果要找到分区 1 是怎么引用到分区 2,应该怎么做?
一.对分区 1 的内存按照一个字一个字地遍历
注意:一个字节 8 个位,在 64 位的电脑上,一个字是 8 个字节即 64 位。为什么用一个字一个字地进行遍历?虽然计算机底层存储单位是字节,但由于 JVM 中的对象会做对齐操作,所以不需要按照字节来移动。一个字其实就是一个机器字长,即处理器的寻址位数,如 32 位或 64 位。
所以为了找到分区 1 是怎么引用分区 2,可以按一个字一个字地遍历分区 1。在遍历时再看看每个字里的值是不是指向分区 2,显然这种方式效率很低。这种方式其实就是按照一定的步长去遍历整个内存,然后步长就是一个字(即 8 个字节=64 位)。
二.优化后的遍历
还是按照一定的步长去遍历整个内存,但是步长优化成了对象的长度。所以每次遍历时,都会按照当前遍历对象的长度来进行遍历。例如 student 长度为 512K,那么在遍历 student 后的一个对象时,就从 512K 之后开始遍历,这样效率就会高出很多。
上面这种方式虽然经过了优化,但内存越大或者 Region 越大时遍历操作的效率就会越低。所以也不合适,因为找一个引用关系就要遍历一次,时间复杂度是 O(n)。所以就有了第三种方式,借助位图来找到分区 1 是怎么引用到分区 2。
三.用位图记录 A 和 B 的内存块之间是否发生引用
可以考虑在位图里用一个位,描述一个字的是否被引用的状态。比如在分区 A 中维护一个 BitMap,BitMap 的每一个位记录一下分区 B 对 A 中的一个字,是否存在引用关系。如果有引用关系,BitMap 的位就记为 1,如果没有就记位 0。
这样查分区 A 是否被分区 B 引用时的时间复杂度就是 O(1),因为直接通过位图就可以找到是否引用了,而不用遍历整个分区 B 才能确定是否存在引用分区 A。
问题:这种位图的方式,会造成什么问题?
(2)位图在对象引用方面的应用会有什么问题(描述内容很少 + 位图过大)
一.描述内容太少
因为位图只有 0 和 1 这样的标记,只能证明是否有引用关系。但是 JVM 在垃圾回收时不单单只需要是否引用这个信息,它还需要一些其它的描述信息:比如使用量、内存起始位置等。否则 GC 可能因为信息缺乏,导致无法正常回收。
二.增加描述位又会存在位图过大问题
对于描述信息太少的问题,只能通过增加描述位来增加描述信息。比如不用一个位来描述一个字,而是使用一个字节来描述一个字。从使用 1 个二进制位描述 64 位内存的信息,到使用 8 个二进制位来描述 64 位内存的信息,这样就可以解决描述信息太少的问题。
但是新的问题来了:此时一个位图占用的内存过大了,例如一个 Region 的大小是 1M。如果用一个字节描述一个字,就意味着需要 128K 来描述一个 Region(1M)。这就相当于占用 12.5%的 Region 空间去描述另外一个 Region,这个内存占比就太大了,如果是在 32 位的机器上,甚至需要 25%的空间。
如果要描述一个 Region 的内存情况:内存为 1M 就需要用掉 128K 额外内存来描述。内存为 1G 就需要用掉 128M 额外内存来描述,内存为 10G 就需要用掉 1G 多的描述数据。所以使用位图的最大问题,就是额外占用的内存空间太大了。
从图中可以看出来,一个字节描述一个字,还是很耗费内存的。相当于把描述能力降低了 8 倍,把内存空间上升了 8 倍。
(3)怎么优化位图
既然发现了内存占用空间太大的问题,那么就可以考虑把它优化掉。一般情况下,对象肯定不可能只占用一个字这么小的空间。所以可以直接把一个字节描述一个字的内存使用情况,优化成一个字节描述一个对象的内存使用情况。
但是对象本身又是不规则的,不一定多大,所以不能直接用对象的大小来作为描述的粒度。
最终 JVM 确定的是:用一个字节来描述 512 字节的内存,也就是用 8 位来描述 512*8 位的内存信息。这样就大大缩减在内存上的占用,使用 2K 的位图就可描述 1M 的内存空间。同时能兼顾引用关系查找的速度,不用采取遍历的方式来找到引用关系。
在查询分区 A 是否引用分区 B 时,就可以通过访问位图里快速确认是否有对应的引用关系。如果分区的大小是 1M,那么只需要遍历 2K 的位图即可以确认,这时不用再遍历 1M 的 Region 内存块了。
(4)什么是卡表
G1 中的卡表其实就是前面说的位图优化,就是把整个堆内存,按照 512 字节的粒度,拆分成一个个 card,然后一个个 card 组合起来就形成了一个全局的卡表。卡表中每 8 个二进制位描述 512 字节内存的引用关系、使用情况等。
(5)卡表和位图到底是什么关系
卡表其实就是位图思想的一种实现方式,只是粒度不同罢了。位图使用每一个位来描述对应数据的状态,卡表使用一个字节来描述 512 字节内存的状态、引用等相关数据。总结来说就是,卡表是位图的增强版。
(6)总结
G1 中的位图和卡表:
一.引入位图可以干什么
二.位图在对象引用方面的应用及存在的问题
三.怎么优化位图
四.什么是卡表
五.卡表和位图到底是什么关系
问题:卡表和 Rset 记忆集是怎么结合起来使用的?其实 Rset 中存储的就是卡表地址。
4.记忆集和卡表有什么关系
(1)卡表和记忆集到底存储了什么
记忆集 RSet 里面存储的是"谁引用了当前 Region"的引用信息,G1 可以通过这些引用信息去找到引用当前 Region 的对象。
G1 通过当前 Region 的记忆集可以找到:引用了当前 Region 对象的所有对象所在的 Region 以及这些对象的位置。
一.记忆集的数据结构和存储结构
那记忆集里面的数据结构到底是什么?存储的数据又是什么呢?
RSet 全称叫 Remember Set,它是一个 Set 结构,RSet 可以理解成一个类似于 Hash 表的结构。一个 Region 会对应一个 RSet,一个 RSet 记录了引用其 Region 的其他 Region 的对象引用关系。
一个 RSet 是由一个个 key-value 对组成的:key 是引用了当前 Region(被引用方)的其他 Region(引用方)的地址。value 是一个数组,数组元素是引用方的对象所在的卡页在卡表中的坐标,卡页就是 512 字节的内存页。如下图示:
也就是说,在遍历对象时,可以直接通过对象所在 Region 的记忆集,就能拿到所有引用了当前对象的对象所在 Region 的卡表数据,即卡页对应的 512B 内存块地址在卡表中的坐标。
所以记忆集存储的不是哪一些对象引用了当前 Region,记忆集存储的是对当前 Region 有引用关系的对象的大概位置信息。也就是对象所在的 Region 地址 + 对象所在的卡页在卡表中的坐标,粒度相比对象来说会稍微大一些。
二.记忆集的存储结构和作用
一旦发现老年代的对象引用了一个新生代的 Region 中的对象,那么 G1 就会在这个新生代的 Region 的记忆集中维护一个 key-value 对。其中 key 是引用方对象对应的 Region 的地址,也就是那个老年代的对象所在 Region 的地址。其中 value 是一个数组,里面存储的数组元素是老年代对象所在卡页(512 字节)在全局卡表中的坐标。
通过记忆集的 key,可以快速定位 Region,从而避免遍历老年代。通过记忆集的 value,可以快速定位对象,从而避免遍历 Region。
G1 在遍历一个新生代的 Region 时,就能根据这个 Region 的记忆集,快速定位到引用该 Region 的对象所在的 Region 及这些对象所在的卡页,从而避免对老年代进行全局扫描。
三.卡表中存储了什么
G1 中的卡表存储的不是引用关系信息,而是卡页的内存使用情况,比如是否使用、使用多少、GC 中的状态信息、以及对应哪一块内存。
四.记忆集中存储了什么
G1 中的记忆集存储的是内存之间的引用关系,也就是 Region 和卡页之间的映射关系。
因为记忆集已存储了引用关系,所以卡表的功能就是描述内存的使用。卡表会记录内存是否使用,对象位于哪个卡页,和 GC 过程中的状态信息。
通过记忆集可以描述出引用关系,通过卡表可以描述 GC 的状态信息,对象所处的内存区域,如第几个卡页。借助这些信息能快速知道内存使用情况,如是否使用、使用多少卡页等。结合记忆集和卡表,就能轻易获取引用关系和内存使用情况的详细信息。
(2)记忆集和卡表是怎么关联的
简单来说,RSet 记忆集其实就是存储了一些卡表的信息。具体就是对当前 Region 有引用关系的对象的大概位置信息,也就是对象所在的 Region 地址 + 对象所在的卡页在卡表中的坐标。
下面举个例子:
一.新生代 Region2 中有一个对象 Obj2。
二.老年代 Region1 和老年代 Region5 里有 Obj1、Obj3、Obj4、Obj5,其中 Obj1、Obj3、Obj4、Obj5 均引用了 Obj2 这个对象。
三.其中 Obj3、Obj4 位于老年代 Region1 的同一个卡页 CardPage 里,该卡页 CardPage 在全局卡表的坐标是 666。
四.Obj1 和 Obj5 分别位于老年代 Region5 的两个卡页 CardPage 里面,这两个卡页 CardPage 在全局卡表的坐标为:1788 和 1756。
对象的引用关系以及对象所在的卡表坐标在卡表中的信息如下所示,通过这个卡表,我们就能准确的找到对象所在的 CardPage 页。如下图示:
Region2 的 RSet 存储的信息,如下图示:
当 G1 在进行新生代垃圾回收时,就可以通过新生代 Region2 的记忆集 RSet,结合新生代的 GC Roots,来找到新生代的所有引用关系,以及老年代跨代引用的所有引用关系。这样就不会漏找,同时不需要在进行新生代回收时去遍历整个老年代。
问题:按照 RSet 的这个存储逻辑,也不能在回收时直接定位到老年代引用新生代的对象到底有哪些。
(3)RSet 在空间和时间上做的平衡
一.如果没有 RSet + 卡表
那么回收新生代时是需要遍历整个老年代的对象的,非常耗时。为了解决这个问题才引入了 RSet + 卡表这两个额外的存储结构。
二.如果把 RSet + 卡表的存储粒度,按每一个对象来存储,也不太合适
因为 RSet + 卡表会占用很大的额外内存。
三.所以 RSet + 卡表的机制是对空间和时间做了平衡后设计出来的结果
按照该设计,每次找老年代的引用对象时只需遍历一下对应的卡页即可。而且还可以结合对象的长度去做遍历,也就是按照一个对象长度的内存空间为步长去遍历来提升性能。
(4)总结
G1 中的记忆集和卡表关系:
一.卡表和记忆集到底存储了什么
二.卡表和记忆集之间是怎么关联的
三.RSet 在空间和时间上做的平衡
大量的引用关系变更时,更新 RSet 有可能会产生并发安全问题,以及并发性能问题。所以 RSet 什么时候维护、以怎样的方式维护,非常重要。
问题:Remember Set 的数据到底是怎么维护的?什么时候维护?如果出现了大量的引用关系变更,怎么处理?大量引用关系变更的时候会不会出现性能问题,性能问题应该怎么优化?
RSet 是一个垃圾回收的时候非常重要的数据,所以引用关系变更时,为了保证准确性,是否需要更改这个 RSet?
5.RSet 记忆集是怎么更新的
(1)引用关系发生改变时 RSet 是否需要更新
如果有对象引用变更,比如新增对象的引用,或失去对象的引用。此时 RSet 肯定是要更新的,否则就会出现回收时引用关系错误。回收时可能就会出现把正常对象当垃圾对象,或把垃圾对象当正常对象。所以在引用关系变更时,一定需要更新 Rset。
例子如下:
一.新生代 Region2 中有一个对象 Obj2。
二.老年代 Region1 和老年代 Region5 里有 Obj1、Obj3、Obj4、Obj5,其中 Obj1、Obj3、Obj4、Obj5 均引用了 Obj2 这个对象。
三.其中 Obj3、Obj4 位于老年代 Region1 的同一个 CardPage 里面,这个 CardPage 在全局卡表的坐标位是 666。
四.Obj1 和 Obj5 分别位于老年代 Region5 的两个 CardPage 里面,这两个 CardPage 在全局卡表的坐标是 1788 和 1756。如果 Obj5 这个对象,执行了 Obj5.Obj2 = null,那么此时 Obj5 对 Obj2 的引用关系就会消失。此时引用关系发生变更,肯定是需要更新 Rset 的,那么到底应该怎么更新?更新的策略又是什么?如下图示:
所以接下来有两个问题:
一.每次更新引用关系,RSet 一定要立即更新吗?如果不立即更新会有什么影响?
二.如果立即更新会产生什么问题?如果不立即更新的话,又有什么合理的方案?
(2)每次引用关系变更都更新
由于一个 Region 会有一个 RSet,一旦发生同一个对象或同一个 Region 内对象的引用关系变更,那么是会出现并发访问同一个 RSet 的情况的。
所以第一个需要解决的问题就是并发问题,而解决并发问题的最好方式要么是串行化处理,要么是分而治之,时间不敏感可用异步队列。
问题一:假如使用串行化处理,那么每次引用关系变更,是否可以立即更新 RSet?
如果每次更新引用关系后立即去加一把锁,然后修改 RSet,则肯定是能保证 RSet 的准确性的。如下图示,此时 Obj5 这个对象所在的 CardPage 已经从新生代 Region2 的 RSet 中移除了。
但是这种方式最大的问题是:虽然保证了准确性,但对象的创建和更新等一系列操作是非常频繁的。如果用这种加锁的方式更新 RSet,那么肯定会造成性能问题,毕竟一个 Region 共享一个 RSet。每一次对象的创建和更新等操作,肯定都可能去访问这个 RSet,这样对 RSet 加锁进行串行化处理,必然会造成整体性能的下降。
所以不能采取串行化处理的方式,那么应该采取什么方式来处理呢?
问题二:假如使用分治思想来处理,那么是否能保证系统运行的效率?
使用分治思想,那么就需要把一个 RSet 分割开来,交给多个线程去处理,同时在最后进行汇总。
但是在这个场景下,肯定不能这么做。
原因一:首先分治操作需要把 RSet 分开,那么按照什么维度分开呢?
原因二:分治思想因为要分开处理,最后汇总,所以肯定要通过几个线程专门来处理,并且这几个线程最好是固定的,那么这些线程是不是都是系统的工作线程?系统的工作线程专门用来做分治是否合适?如果专门新开几个线程,但本来就有上千个 Rset,现在还要分治,那要增加多少线程?
原因三:分治处理后的结果如何汇总、何时汇总?
所以也不能采取分治思想来处理,那么还有没有什么其他的方案比较合适呢?
(3)应该什么时候处理 Rset 的更新操作
问题:RSet 的数据什么时候才会用到的?
JVM 最核心的两大操作就是对象分配和垃圾回收,下面分析 JVM 的这两个核心过程。
一.分配对象时是否需要用到 RSet 的数据
分配对象时是并不需要用到 RSet 的数据的。因为 G1 在分配一个对象时,只需要看内存够不够,剩余空间是否能够分配一个对象。分配完成后,直接更新一下内存的使用情况即可,并不需要借助 RSet。
二. 垃圾回收时是否需要用到 RSet 的数据
RSet 本身就是为了垃圾回收时更加方便、不需要遍历空间而设计的,所以在垃圾回收时肯定需要用到 RSet。
那么可以得出一个结论:在大多数时间里,RSet 即使需要更新而没有把它更新,其实也无所谓,因为不影响程序运行。
如下图示,当新创建一个对象 Obj6 时,完全和 RSet 没有任何关系,直接创建即可。
只有在垃圾回收时,才需要使用最新的 RSet,否则就会出错,如下图示:
如果此时需要垃圾回收,而 Rset 没有更新最新的引用关系,肯定会出错。上图 RSet 里的值,很明显 1756 这个值是需要剔除的。因为 Obj5 已经执行过 Obj5.obj2 = null 操作了,它已经不引用 Obj2 了。所以为了保证 RSet 最新,这时 GC 的遍历过程可能会有额外的遍历。
因此这个更新 RSet 的操作应该在垃圾回收前完成,既然是在垃圾回收之前完成,那在程序运行到需要垃圾回收的长时间里,就可以通过后台线程把这个更新的事情处理好即可。
(4)G1 的脏数据队列—异步消费更新 RSet
基于前面的分析,可知只要在垃圾回收前把所有引用的更新处理好即可。而在垃圾回收前,G1 是有大量的时间慢慢更新这个引用关系的。
所以 G1 就设计了一个叫做 DCQ(Dirty Card Queue)的队列。在每次有引用关系变更时,就把这个变更操作,发送一个消息到 DCQ 里,然后有一个专门的线程去异步消费。
这样 G1 的回收线程在读 RSet 时就能读到正确数据,同时不影响系统工作,如下图示:
(5)总结
RSet 记忆集的更新问题:
一.如果引用关系发生了改变,RSet 是否需要更新,应该怎么更新
二.每次引用关系变更都需要更新 RSet
三.Rset 的更新操作应该在垃圾回收前完成
四.G1 的脏数据队列—异步消费更新 RSet
问题:异步消费是谁在消费?应该给多少个线程才不会损耗 CPU 大量的资源?如果到了最后垃圾回收时还是没有消费完毕,应该怎么办?
6.DCQ 机制的底层原理是怎样的
概述:
DCQ 机制:
一.DCQ 有多少个
二.谁来消费 DCQ
三.消费线程的数量是怎么确定的
四.DCQ 到底是怎么写进去的
RSet 的更新并不是同步完成的。G1 会把所有的引用关系都先放入一个队列中,称为 DCQ,然后使用线程来消费这个 DCQ 队列以完成更新。正常会有 G1ConcRefinementThreads 个线程处理,除了 Refine 线程会更新 RSet,GC 线程或 Mutator(Java 的应用线程 Mutator)也可能会更新 RSet。DCQ 会通过 DCQS 来管理,为了能并发地处理,每个 Refine 线程只负责 DCQS 中的某几个 DCQ。
(1)异步消费是谁在消费
G1 有个 Refine 并发线程池,线程数默认是 G1ConcRefinementThreads+1。Refine 线程的主要工作,就是去消费 DCQ 里的消息,然后去更新 RSet。
当然 Refine 线程还有其他的作用。Refine 线程还会进行新生代分区的抽样工作,动态扩展新生代。Refine 线程会在满足响应时间的前提下,根据抽样数据(GC 时间、垃圾数量等),去调整新生代的 Region 个数。
总结:Refine 线程的主要工作是管理 RSet,即消费 DCQ 里的消息然后更新 RSet 里的引用关系。Refine 线程中会有一个线程一直进行新生代分区的抽样。
问题:如果 Refine 线程忙不过来了怎么办?DCQ 中的变更消息很多,Refine 线程超负荷工作无法全部消费变更消息会怎样?
(2)多个线程可能会消费同一个 DCQ 里的数据
平常只会有少量的 Refine 线程去消费 DCQ 里的数据。当系统并发非常高的时候,DCQ 里面的变更消息可能会非常非常多,那么此时 Refine 线程肯定是忙不过来的。这时就需要有多个 Refine 线程一起处理引用变更数据,甚至还有可能会有其他线程参与进来一起处理。
我们可以指定 Refine 线程的数量,Refine 线程的默认数量是 G1ConcRefinementThreads + 1。
如果实在忙不过来,那么就只能在指定参数时多设置几个 Refine 线程了。因为 G1 一般是大内存、多核机器,所以设置 4-5 个线程其实也无所谓。
如果 Refine 线程已经够多了,此时还是忙不过来,那么 G1 就会借助系统的工作线程。即创建完对象后,这个线程很可能没有去等着接收其他请求,而是帮忙去消费 DCQ 了。
(3)DCQ 的长度和 DCQ 的并发消费问题
如果一个队列无限长无限增加,单独一个 DCQ,很多线程都往里面写。由于 DCQ 的消费必须保证顺序性,而且只有一个 DCQ。那么不管是写入还是消费,都会有并发写和并发消费的问题的,所以 G1 设计了二级缓存来解决并发冲突的问题。
一.第一级缓存是在线程这一层
每一个工作线程都会关联一个 DCQ,一个 DCQ 对应一个 Region,多个工作线程可能会对应同一个 DCQ。每个线程在执行引用更新操作时,都会往自己那个 DCQ 里写入变更信息。如果没有第一级缓存,那么所有工作线程都会关联到一个 DCQ。DCQ 长度默认是 256,如果一个 DCQ 写满,就重新申请一个新的 DCQ,并把这个满了的 DCQ 提交到第二级缓存 DCQ Set。
二.第二级缓存是在一个 DCQ Set 里面
一般叫这个二级缓存为 DCQS,在线程持有的 DCQ 满了以后,会把 DCQ 提交到 DCQS 中去。
这样就解决了并发写的问题,因为每个线程只写自己持有的 DCQ 即可,写满了就提交到 DCQS,顶多这时会加一个锁。
此外,由于一个 DCQ 会对应一个 Region,所以按照时间去消费 DCQS 里的 DCQ 时,不需要考虑是否会顺序消费的问题。
当 Refine 线程实在是忙不过来时是怎么处理的?由于 Refine 线程是直接从 DCQS 取 DCQ 去消费的,那么如果 Refine 线程忙不过来,也就意味着:DCQS 已经不能再放下更多满了的 DCQ 了。
此时工作线程的 DCQ 满时,就会去判断能否提交到 DCQS。如果发现不能提交,工作线程就会自己去处理这个 DCQ,然后更新 RSet。
(4)DCQ 里的数据到底是怎么写进去的(写屏障)
G1 会给每个变更操作前都加了一层写屏障,注意这个写屏障和内存屏障可不一样,这个写屏障是类似于增强代码或者 AOP 切面的一个概念。
写屏障的意思是:我们在修改了内存值时(就是往内存里写东西时),额外执行的一些操作。比如判断是否需要写到 DCQ(新生代与新生代的引用就不需要写),比如判断本次的写入操作是否改变了引用,比如发送一条消息到 DCQ 等。这么一层加强代码,就是所谓的写屏障。
那么这层加强代码到底由什么用呢?作用其实很大,因为不是任何写内存的操作都会改变引用关系什么的。如果没有改变引用关系,就不需要写这么一条数据到 DCQ 里面了。这样写屏障就可以大大减少后续 Refine 线程处理 DCQ 的压力,同时写屏障也避免 DCQ 快速填满导致 Refine 线程被快速启动。
所以这个写屏障的作用主要有两点:第一过滤掉不需要写的引用变更操作,比如新生代到新生代的引用、同一分区内的引用等这些引用是不用写的,第二就是把正常的变更数据写入到 DCQ 里。
(5)总结
DCQ 机制:
一.谁来消费 DCQ
二.消费线程的数量是怎么确定的
三.DCQ 里的数据到底是怎么写进去的
问题:DCQS 到什么时候算满?多少个 Refine 线程去处理 DCQS 才合适?如果到了 GC 时,Refine 线程还是没有处理完,应该怎么办?
7.DCQS 机制及 GC 线程对 DCQ 的处理
概述:
DCQS 机制:
一.DCQS 是什么
二.DCQS 是怎么更新的
三.如果 DCQ 没有处理完毕就进入 GC 会怎么样
JVM 声明了一个全局的静态变量 DCQS,DCQS 里面存放的是 DCQ。为了性能考虑,所有处理引用关系的线程共享一个 DCQS。每个线程(Java 的应用线程 Mutator)在初始化时都会关联这个 DCQS,每个线程都会关联一个 DCQ。
DCQ 的最大长度默认 256,最多存放 256 个引用关系对象。在本线程中如果产生新的对象引用关系,则把引用者放入 DCQ 队列中。当本线程的 DCQ 队列满 256 个时,就把这个 DCQ 队列放入到 DCQS 中。DCQS 可以被所有线程共享,所以放入 DCQ 时需要加锁。而 DCQ 的处理则是通过 Refine 线程来处理。
(1)DCQS 是什么
G1 设计了一个二级缓存来解决 DCQ 写入引用关系变更数据时的并发冲突。第一层缓存是在线程这一层,每一个工作线程都会关联一个 DCQ。每个线程在执行引用更新操作时,都会往自己那个 DCQ 里写入变更信息。
DCQ 的长度默认是 256,如果写满了,就重新申请一个新的 DCQ,并把这个满了的 DCQ 提交到第二级缓存,也就是一个 DCQ Set 里面去。
这个二级缓存叫 DCQS,所以所谓的 DCQS 其实不过就是一个存放 DCQ 的地方。
(2)DCQS 的白绿黄红四个挡位
一.设置 Refine 线程个数要考虑的问题
DCQS 肯定是有上限的。当达到一定的阈值不能再提交时,工作线程就得自己去处理了。这时说明系统负载已经很重了,而且系统的运行速度可能会比较慢,因为工作线程要去处理 DCQ 更新 RSet 的引用关系。
负载很重时,G1 就要考虑应该启动多少个线程:避免出现负担很重的情况、避免大量 Refine 线程导致 CPU 资源占用问题。
那么应该怎么设计 Refine 线程的个数?
如果启用 Refine 线程过多,则可能会导致程序整体性能一直都不高。如果启用 Refine 线程过少,则可能会导致运行一段时间,DCQS 就满了。从而导致工作线程要做一些额外的更新 RSet 操作,这时整体性能也不高。
二.G1 对 DCQS 的数量进行了四个区域划分
那么到底 Refine 线程的数量怎么设置?什么时候应该用多少个线程?针对这个问题 G1 对 DCQS 的数量进行了四个区域划分。
有三个参数来划分这四个区域:
G1ConcRefinementGreenZone
G1ConcRefinementYellowZone
G1ConcRefinementRedZone
这三个值默认都是 0,如果没有设置,G1 就会自动推断这个三个值。
区域一.白区:[0, green)
如果在这个区域,则 Refine 线程不会去处理 DCQ,只会进行新生代抽样。
区域二.绿区:[green, yellow)
Refine 线程开始启动,并根据 DCQS 的大小来计算启动 Refine 线程的个数。
区域三.黄区:[yellow, red)
所有 Refine 线程都会参与到 DCQS 的处理。
区域四.红区:[red, 正无穷)
所有 Refine 线程以及系统工作线程都会参与到 DCQ 的处理。
三.Refine 线程个数的推断
Refine 线程的个数可以通过参数来设置:
如果没有设置,就和 ParallelGCThreads 相等。如果 ParallelGCThreads 没有设置,则根据 CPU 核数来自动推断有多少个。
推断策略为:
注意:G1 会一直有 1 个 Refine 线程在处理抽样,下面说的 Refine 线程特指处理 DCQ。
G1 会根据 DCQS 的大小来启动不同的 Refine 个数,可以理解为让每个 Refine 线程处理多少个 DCQ。比如 DCQS 大小为 9,步长为 3,那么就会启动 3 个 Refine 线程,当然步长也可以通过参数设置。如果不设置步长,则会自动推断参与处理 DCQ 的 Rrefine 线程个数。
DCQS 的几个阈值会和 ParallelGCThreads 有关系:Green = ParallelGCThreads,Yellow = 3 * Green,Red = 6 * Green。
假如总共 4 个 Refine 线程处理 DCQS(还有一个线程在处理新生代的抽样),那么绿黄红按步长(步长和 Refine 总线程数量相等)来计算就是[4, 12, 24]:
DCQS 在少于 4 个 DCQ 时,不启动 Refine 线程;
在大于等于 4 个小于 9 个 DCQ 时启动 1 个线程;
在大于等于 9 个 DCQ 时,启动第二个线程;
在大于等于 11 个 DCQ 时,启动第三个线程;
在达到 24 个 DCQ 时工作线程也在处理 DCQ;
(3)如果 DCQS 直到 GC 时还没处理完会怎样
如果 DCQ 数量在小于绿区的阈值时,是没有 Refine 线程在处理的。如果 DCQS 直到 GC 时还没有处理完,这时也不需要 Refine 线程处理了。
G1 为了保证运行过程中的效率,会在 GC 时由 GC 线程来处理这些 DCQ。因为 GC 线程也是会有多个的,在 STW 时处理这些 DCQ,速度也不会很慢。
总结:启动了 Refine 线程,然而 DCQ 在 GC 开始时还是没有处理完。这时就会由 GC 线程参与处理没有完成处理的 DCQ,直到全部处理完成,然后才会进行下一步 GC 操作。
文章转载自:东阳马生架构
评论