G1 原理—G1 回收器的分区机制
1.G1 垃圾回收器的分区(Region 大小+G1 分区+Region 过大过小和计算)
(1)G1 垃圾回收器的简单介绍(垃圾优先回收器)
一.垃圾回收优先
G1 垃圾回收器:也可以叫垃圾回收优先回收器(Garbage-First,G1)。一句话概括就是:这种垃圾回收器会优先回收垃圾,不会等空间全部占满然后再进行回收。
二.停顿预测模型
预测一次回收可以回收的分区数量,以满足我们对停顿时间的要求。G1 最大的特点:可以设置每次垃圾回收时的最大停顿时间,以及指定在一个长度为 M 毫秒的时间片段内,垃圾回收时间不超 N 毫秒。
三.化整为零的分区机制
ParNew + CMS 这种回收器的分区是:新生代、老年代、S 区。G1 则是把一整块内存分成 N 个相同大小、灵活可变的分区(Region),这种灵活可变的 Region 机制,是 G1 能够控制停顿时间的核心设计。
(2)传统的分代模型和 G1 的内存模型对比
一.HeapRegion 是 G1 内存管理的基本单位
G1 有如下几种的分区类型:
新生代分区:Young Heap Region
自由分区:Free Heap Region
老年代分区:Old Heap Region
大对象分区:Humongous Heap Region
其中新生代分区又可分为 Eden 和 Survivor,大对象分区又可分为大对象头分区和大对象连续分区。
二.ParNew +CMS 与 G1 的内存模型对比
ParNew + CMS 优势:(小内存停顿很小)
它不用做很多复杂的分区管理,而且小内存垃圾回收不会造成很大停顿。
ParNew + CMS 劣势:(大内存停顿很大)
如果给 JVM 分配 64G 内存,那么 Eden 区可能就有 20~30G,回收一次 Eden 区可能就要 2~3s,这时请求都可能直接超时了。
传统的分代模型是按照块状来做内存分配的,这种分配方式会存在不足,比如在大内存机器中,会出现一次 GC 时间过长,导致 STW 时间较长。严重情况下,还会对用户体验造成比较大的影响。
所以针对大内存场景,诞生了 G1 这种垃圾回收器。G1 的分代模型,通过化整为零,将一个大内存块分割成 N 个小内存块。然后根据需求,动态分配给新生代、老年代、大对象来使用。同时 G1 会根据垃圾回收情况动态改变新生代的大小(Region 个数),当然也可能会因此动态改变老年代、大对象分区的大小。
比如需要分配对象时:可能会对新生代进行扩展新增几个 Region,也可能会减少几个 Region。
比如需要垃圾回收时:如果发现回收时间比较长,GC 压力太大,这时就可考虑少给一些 Region,从而保证回收和程序运行的一个平衡。
(3)G1 如何设置 HeapRegion(HR)的大小
一.手动式设置
通过设置参数 G1HeapRegionSize 来指定大小,默认为 0。Region 的大小范围是 1~32M,同时需要满足 2 的 N 次幂。
二.启发式推断
G1 可以通过算法,根据堆内存大小、分区个数,自动计算出 Region 大小。
注意:Region 的大小只能在 1-32M 之间,不能小于 1M 也不能大于 32M,且要满足 2 的 N 次方,即 1、2、4、8、16、32 这几个值。如果指定的值不是这几个值,G1 会根据一定的算法规则自动调整。
(4)HR 大小的影响以及为什么要设置成 2 的 N 次幂
如果 HR 过大,那么一个 HR 可以存放多个对象,分配效率高,但是回收时所花费的时间长。如果 HR 过小,则会导致分配效率低下。
一.Region 过小可能会影响对象分配的性能
比如 Region 只有 256K,而系统运行创建的对象是几十到几百 K,那么 JVM 在为创建的对象分配空间时:
第一.找到可以使用的 Region 的难度增加了
导致分配一个对象时要查找 Region 的次数增多。
第二.跨分区存储的概率增加了
分配对象时,可能需要找多个 Region 分区。分区越小,就说明同样的 Region,可以存储的对象越少。还可能会出现稍微大一点的对象就超过了一个 Region 的大小,那么就只能跨分区存储。如果一个对象要用多个 Region 存储,这时分配对象的开销还是比较大的。
二.Region 过大可能会影响 GC 的性能
比如 Region 为 64M,那么 JVM 在进行 GC 回收时:
第一.Region 回收价值的判定很麻烦
对大 Region 进行回收性价比判断要比小 Region 难。
第二.回收的判定过程会更加复杂
GC Roots 时需要追踪标记对象,然后标记垃圾对象。如果遇到跨代、跨区的对象,还要做一些额外的处理。判断这些对象是否需要回收的过程就会更加复杂,导致回收时间更长。
所以要平衡对象分配效率和垃圾回收效率,设置合理的 Region 大小来保证对象分配和垃圾回收性能。
三.为什么要设置成 2 的 N 次幂
如果不设置为 2 的 N 次幂,那么:
一.可能会造成内存碎片内存浪费的问题
一般内存的分配都是几个 G,比如 2048M、4096M 或者 8G、32G 等。如果一个 Region = 3M、15M、23M,那么就不能整除出有多少个分区。有可能分区数量不是整数,从而导致内存碎片,有一部分内存没利用上。此外如果需要扩展内存,也是按照 2 的倍数去进行扩展的。
二.无法利用 2 进制计算速度快的特性
计算机底层是二进制的,如果使用非 2 的 N 次幂的数字。那么在计算 Region 数量或自动扩展 Region 数量时,会无法利用 2 进制计算速度快的特性。因为位运算速度非常快的,计算 2 * 2 只需要进行 1 << 2,所以设置一个比较合理的 Region 大小很重要。
(5)Region 的大小到底是如何计算的
一.堆分区的个数默认是 2048 个
计算 RegionSize 时,会直接用默认的 2048 计算。
二.堆内存的大小默认最大是 96M,最小为 0M
设置 G1 的 InitialHeapSize 相当于设置 Xms,设置 G1 的 MaxHeapSize 相当于设置 Xmx。
三.RegionSize 大小的计算公式
最小堆和最大堆的平均大小除以 2048,然后计算出的 RegionSize 大小需要在 1~32 范围。
四.举几个例子
第一种:只指定 Region 大小,假如设置 Region 大小为 2M。则 G1 的总内存大小为:2048 * 2M = 4G,分区个数 2048。
第二种:指定堆内存大小,且最大值等于最小值。假如堆内存最大值设置为 32G,最小值也设置为 32G,则 RegionSize = max((32G + 32G) / 2 / 2048), 1M) = 16M。
第三种:指定堆内存大小,且最大值不等于最小值。假如堆内存最大最设置为 Xms = 128G,最小值设置为 Xmx = 128G,则 RegionSize = max((32G + 128G) / 2 / 2048, 1M) = 32M,并且由于 G1 垃圾回收器会自动计算分区个数,所以分区个数的范围在 32G / 32M = 1024 ~ 128G / 32M = 4096 之间。
2.Region 大小的计算原理(先转字节再确定 2 的 n 次幂再通过 1 左移 n 位)
(1)RegionSize 如果不符合规则 G1 怎么处理
如果将 RegionSize 设置成 3M、1.5M、64M,和给定范围不同会怎么样?如果堆内存给 3G,计算出的 RegionSize 是非 2 的 N 次幂,G1 会怎么处理?如果计算出的 RegionSize 是一个非 2 的 N 次幂,那么 G1 会自动和 2^N 对齐。
那么对齐的规则到底是什么?像 1.5、1.9 这种数字,肯定要对应不同的值,是四舍五入还是怎么操作?如果超过了大小范围会怎么办?超过 G1 的 RegionSize 上下限会怎么样?
(2)对齐的规则是什么+超过大小限制会怎么做
关键词:向 2 的 N 次幂对齐。也就是说计算 RegionSize 得到的结果不是 2^N 的话,那么就会向 2^N 对齐。具体的对齐规则:从计算得到的数字中找到数字里包含的最大的 2^N 幂。
举个例子:
如果计算出 1.5M,则 RegionSize 就是 1M。
如果计算出 3M,则 RegionSize 就是 2M。
如果计算出 9M,则 RegionSize 就是 8M。
对于手动设置的 RegionSize 规则也一样。
(3)从源码中探索一下具体的 RegionSize 实现
计算时,第一步会按照上面的公式进行计算,然后对计算结果进行修正。
如果 region_size = 1.5M:
因为 2^20 < 1.5 * 1024 * 1024 < 2^21,所以 region_size_log = 20。计算出最大指数 region_size_log,就会通过位运算来计算出 region_size。所以 1 左移 region_size_log 位,就是 region_size = 2^20 = 1M。
如果 region_size = 64M:
因为 2^26<=64M,所以 region_size_log=26,region_size=2^26=64M。又因为 Region 的大小必须要在 1-32M 之间,所以最终 region_size = 32M。
问题:由于 region_size 是一个固定值,而且 TARGET_REGION_NUMBER 也是固定值 2048,那么怎么理解分区数量会跟随内存动态扩展来变化?因为堆内存会有个初始大小和最大大小,而且堆内存初始大小默认是 0。
(4)基于 RegionSize 的分区数量的变动过程
首先要明确一点,我们从源码中可以看出来:在计算 RegionSize 时,会使用一个参数的默认值 2048 来计算 RegionSize,然后 RegionSize 会被动态调整成一个合理的值。
所以 2048 只是一个默认值,在使用 2048 这个值完成计算后:如果 RegionSize 没有调整,并且堆内存不会动态扩展时,堆分区的数量才是 2048,否则分区的数量是会动态变化的。
总结:如果要计算分区大小 RegionSize,肯定需要 HeapSize。有了 HeapSize,就能自动计算出来有多少个分区。因为堆内存很可能会出现变化,所以分区数量会随着堆内存变化而变化。
这其实就是 G1 扩展内存的方式,扩展新的分区以达到扩展内存的效果。
注意:G1 不能手动指定分区个数。按照默认值计算,G1 可以管理的最大内存为 2048 * 32M = 64G。假设设置 xms = 32G,xmx = 128G,则每个 Region 分区的大小为 32M,Region 分区个数动态变化范围从 1024 到 4096 个。
如果 Region 越大,那么分配效率就越高,回收效率越低、回收时间越长。
如果 Region 越小,那么分配效率就越低,回收效率越高、回收时间越短。
3.新生代分区及自动扩展(新生代动态扩展机制)
(1)G1 基于逻辑分代模型的设计
G1 也基于分代模型来实现,JVM 在设计 G1 时使用了逻辑分区的概念。即一部分 Region 属于新生代、一部分 Region 属于老年代、一部分 Region 属于大对象,还有一部分 Region 属于自由分区。G1 给对象分配内存时,也是先进入新生代的 Eden 区进行分配。
(2)新生代内存分配方式
Xmx 是堆内存最大值,Xms 是堆内存最小值。InitialHeapSize 是堆内存的初始大小,等于 Xms,默认是 0。MaxHeapSize 是堆内存的最大值,等于 Xmx,默认是 96M。
一.参数指定方式
第一种:指定堆内存新生代的大小
具体参数:MaxNewSize、NewSize。
需要注意:如果设置了 Xmn,在 G1 里则认为是设置了 MaxNewSize = NewSize = Xmn。所以 G1 中如果设置了 Xmn,说明新生代内存的大小是固定的。新生代大小固定则意味着 YGC 时,很有可能停顿预测模型没有办法生效。
停顿预测模型 + 动态调整机制,是 G1 能够控制停顿时间的关键,所以一般不会设置 G1 的 Xmn。
第二种:指定新生代的占比
具体参数:NewRatio。这个参数是用来设置老年代比新生代的比例的,例如-XX:NewRatio=4 代表老年代 : 新生代 = 4 : 1。
需要注意:如果只设置了 NewRatio,则对于新生代而言,MaxNewSize = NewSize,也就是新生代最大值最小值相等,即新生代 = HeapSize / (NewRatio + 1)。如果设置了 MaxNewSize、NewSize 以及 NewRatio,则忽略 NewRatio。
一般在 G1 里,也不推荐直接指定新生代大小,并且指定成一个固定值。
二.G1 启发式推断
第三种:没有指定新生代最大值和最小值,或者只设置其中一个
G1 会根据 G1MaxNewSizePercent 的值和 G1NewSizePercent 的值来计算新生代大小,G1MaxNewSizePercent 的默认值是 60%,G1NewSizePercent 的默认值是 5%。
如果没有设置新生代的大小或只设置 MaxNewSize 和 NewSize 其中一个,此时新生代初始化的大小就是 5%的堆内存空间,然后最大就是 60%。
如果只设置了 NewRatio,其实也无法达到自动计算新生代空间的效果。一般都是设定 G1 堆内存的大小即可,然后新生代比例、新生代内存大小,让 G1 自动进行推断。除非系统运行了很长时间,发现了一个非常合理的新生代范围。此时就可考虑设置新生代内存,但也要让 MaxNewSize 和 NewSize 不相等。比如 MaxNewSize = 100,NewSize = 10。
三.老年代内存是多少
老年代内存没有一个固定大小,也没有具体的参数来设置。除非设置了 NewRatio 这个参数,因为这个参数会间接设置老年代的大小。
-XX:InitiatingHeapOccupancyPercent=45:
该参数代表老年代的内存占用 45%时会触发 Mixed GC,也就是混合回收。此时老年代内存使用的比例,默认最高就是 45%。
(3)应该如何设置 G1 的新生代内存大小
必须要满足动态扩展机制 + 停顿预测模型,才能满足设置的停顿时间。
一.如何满足 G1 新生代的动态扩展机制
不要指定新生代大小为固定值、不要直接指定 Xmn,也不要直接只设置 NewRatio、不要指定 MaxNewSize = NewSize。
如果确实需要设置新生代的值,那么可设置成范围。比如 MaxNewSize = 100 及 NewSize = 10,但这个范围如果设置得不很合理,还是很有可能会有性能问题。
二.为什么要满足 G1 新生代的动态扩展
为满足设定的停顿时间,就要进行垃圾回收时间和程序运行时间的平衡。控制回收时间在一个范围内,根据回收时间和内存大小来综合计算。然后动态调整内存分区的占比,来满足回收时间。
如果不做动态调整,那么 GC 时间过长,就没办法满足停顿时间。动态增加,动态减少,才能调整到一个合理的值。一旦超过了时间范围,就再调整一下。G1 新生代的动态扩展,可以实现:动态调整 YGC 所需要的时间。
下面是一个具体的示例分析,例如:
新生代 500 个 Region,期望停顿时间 100ms,新生代填满后要进行 GC。多次 GC 后,G1 发现 GC 时间都不是很长(50ms),系统运行时间也特别短。也就是 GC 频率比较高,但 GC 耗时非常短,说明此时的 GC 还不是很合理。G1 希望让程序运行时间长一些、GC 不要那么频繁、同时满足停顿时间。这时候就可以扩展分区,在新生代增加一些 Region。原来新生代有 500 个 Region,扩展为 1000 个 Region。
多次 GC 后,G1 发现 GC 时间特别长(100ms),甚至有时都超过停顿时间。这时说明 GC 压力太大了(所以 GC 时间才特别长),需要减少一些 Region。这时就可以移除新生代的一些 Region,让对象填满新生代的速度变快,系统程序运行的时间可以短一点。原来新生代有 500 个 Region,缩减为 250 个 Regiion,从而让 GC 的时间满足小于期望停顿时间。
三.新生代的动态扩展使用分区列表实现
例如直接设置 MaxNewSize = 100M,NewSize = 10M,此时新生代最小值和最大值都指定了。如果最小值是 10 个分区(Region)占 10M,最大值 100 个分区占 100M。当需要扩展分区时(此时是最小分区数量),就需要拿一些分区给新生代。
每一种类型的分区都会有对应的一个分区列表:新生代分区列表、老年代分区列表、大对象分区列表、自由分区列表。
如果新生代需要扩展:这时就会从自由分区拿一些 Region 出来,加入到新生代分区列表中。如果自由分区列表没有 Region 了,无法给新生代提供分区了。这时就要找 JVM 拓展新分区,然后加入新生代分区列表中,继续分配。
问题:堆内存什么时候扩展新的分区?
扩展分区时机一:分配对象时发现不够用,会尝试扩展分区,扩展分区后才继续分配对象。
扩展分区时机二:会有一个线程专门抽样处理预测新生代 Region 数量有多少,并动态调整。也就是根据对象创建的速率,去预测新生代 Region 数量应该给多少,然后动态调整新生代。
扩展分区时机三:在 GC 之后可能会直接动态调整。
(4)G1 是怎么扩展新分区的 + 有什么规则限制
一.扩展新分区的规则是什么
根据-XX:GCTimeRatio 这个参数去控制。这个参数表示 GC 时间与应用运行时间比值,G1 中这个值默认是 9。意思是如果 GC 时间占应用运行时间比例不超 10%,就不需要动态扩展。如果 GC 时间占比超过了这个阈值,就需要做动态扩展(自适应扩展空间)。
二.扩展的内存大小分区数量有什么限制
G1ExpandByPercentOfAvailable 参数的默认值是 20,表示每次扩展时都从未使用的内存中申请 20%的空间。而且最小不能小于 1M,最大不能超过已经使用内存的一倍。
例如现在堆内存最大是 64G,使用了 32G,准备要做一次扩展。那么就要从未使用的 64G - 32G = 32G 里面申请 20%的空间出来。如果计算发现 20%乘以未使用的内存,小于 1M,此时就给 1MB。
每次扩展的内存大小是未使用内存的 20%,而且还要满足:每次扩展的内存大小的下限是 1M,上限是当前已经分配的内存的一倍。
(5)G1 新生代扩展流程(新生代分区扩展流程)
时机一:
新生代分区列表不够 -> 需要新生代分区列表扩展 -> 找自由分区列表 -> 自由分区列表不够 -> 从堆内存中申请新的分区 -> 加入新生代分区列表中
时机二:
后台线程抽样 -> 程序运行时间 : GC 时间 < 9 : 1 (即 GC 时间超过 10%) -> 自动扩展新生代分区列表 -> 找自由分区列表 -> 自由分区列表不够了 -> 从堆内存中申请新分区 -> 加入到新生代分区列表中
4.停顿预测模型(衰减算法)保证预期停顿时间
(1)G1 新生代内存总结
新生代内存分配的方式:
一.参数指定方式
二.G1 启发式推断
我们应该怎么设置 G1 新生代内存的大小?
一.不能随便设置参数破坏新生代动态扩展机制
二.满足用户设定的停顿时间(期望停顿时间)
三.空闲列表+扩展新分区实现新生代动态扩展
四.扩展新分区规则(未使用的 20%) + 一次扩展的大小上下限(1M 和一倍)
五.自适应扩展空间的依据是-XX:GCTimeRatio,系统运行:GC 时间=9:1
(2)如何满足用户设定的停顿时间
期望停顿时间只是期望值,G1 会努力在这个目标停顿时间内完成 GC。但 G1 不能保证,即也可能完不成,比如设置的期望停顿时间太小。
一.首先要预测
预测在停顿时间范围(200ms)内,G1 能回收多少垃圾?比如 G1 预测能在 200ms 内回收 2G 的垃圾,那就选择 2G 内存对应的的 Region 来进行回收。
二.预测的依据是什么
预测的依据是 GC 相关的历史数据,所以要获取历次 GC 相关的运行数据。比如曾经发生的 GC、每次 GC 多久、回收多少垃圾、总的 GC 时间是多少。
三.应该怎么预测 + 拿到历史数据该怎么用
基本逻辑是:如果目标停顿时间短、就少收点分区,目标停顿时间长、就多收点分区。也就是说,必须要知道,回收能力是多少。
此时历次回收相关的历史数据就派上用场了。可以根据这些历史数据进行计算,看看平均每秒能回收多少垃圾。比如发生 3 次 GC,总共用了 200ms 回收 2G 垃圾,那么回收能力是 10G/s。然后结合停顿时间,就能计算这次 GC 在期望停顿时间下能回收多少垃圾。所以需要一个历史数据的分析算法,来帮助 G1 分析回收能力。
四.一个简单的历史数据的分析算法模型
求过去 10 次 GC 造成多少停顿时间,最终计算出平均每秒能回收多少垃圾。例如过去 10 次一共收集了 10G 内存,一共花费了 1s,那么 200ms 能够回收的垃圾就是 2G。于是就可以根据这个计算值,选择一定数量的 Region 分区。
根据内存动态扩展机制,线性算法是否合理?
由于新生代内存可能会动态增加至最大值,新生代和老年代的 Region 数量也可能在变化。新生代越小回收时间肯定越快,越大需要回收的时间必然越久。而且系统在不断运行时,有时候是高峰期,有时候是低谷期。所以直接简单粗暴的求平均是不合适的。
不能仅使用历次回收的总大小除以总回收时间的平均值作为回收能力,仅仅使用平均值来作为停顿预测模型其实是不太合理的,因为:
第一.G1 本身是一个不断扩展的模型
第二.同时系统也一直在不断地运行,有时是高峰期,有时是低谷期
(3)如何设计一个合理的预测算法
一.距离本次预测越近的 GC 其影响权重就越高
比如已经发生了 3 次 GC,现在要预测第 4 次 GC。那么第一次权重是 0.2,第二次权重是 0.3,第三次 GC 的权重可能就是 0.5。
二.G1 使用了衰减标准差算法来实现距离本次预测越近权重越高
衰减标准差算法有一个衰减因子叫α,α是一个小于 1 的固定值。简单理解就是:衰减因子越小,那么最新的数据对结果的影响就越大,G1 的停顿预测模型就是以衰减标准差为理论基础来实现的。
三.具体计算模型
衰减平均计算公式:
上述公式中的α为历史数据权值,1-α为最近一次数据权值。α越小,最新的数据对结果影响越大,最近一次的数据对结果影响最大。
例如α = 0.6,GC 次数为 3,三次分别为:
第一次回收 2G,用时 200ms。
第二次回收 5G,用时 300ms。
第三次回收 3G,用时 500ms。
那么计算结果就如下:
从这个演变过程中也能看出:计算出来的平均值 davg(3)中,权重最大的就是最后一次 GC。这样就可以以最合理最精准的方式,预测出本次 GC 在目标停顿时间范围内能回收多少垃圾。
(4)基于衰减算法模型的垃圾回收过程
在两种不同的预测模型中:很显然,衰减预测模型更能反应出当前 JVM 的 GC 运行情况。因此衰减预测模型可以更好地帮助 G1 完成垃圾回收,并且能更好地满足目标停顿时间。
文章转载自:东阳马生架构
评论