java heap size 设置_Java heap size为什么不建议设置大于32G?

场景分为使用默认 GC,以及使用 ZGC 或者 Shenandoah GC。

默认 GC 情况下,虽然超过 32G 是可行的,但是在这个场景下缺乏 JVM 调优经验,可供参考的实例不多,需要对 JVM 有全面深入的理解才能调优好。但是大部分应用可以水平扩展,不需要垂直管理这么多内存。 ES 本身就是分布式的。如果没有调优好,或者调优的参数没有通用性,只能针对某些场景。例如对于日志型 ES 搜索场景和业务型搜索是两种完全不一样的场景,索引更新频率,索引新增,数据访问,请求大小等等完全不一样,调优的方式也不一样。而且,越大的内存, JVM 参数起的作用其实就越大。并且,很多 JVM 参数是根据 32G 内存以下默认设置的值,并不适用于更高内存。

ZGC 或者 Shenandoah GC 采用了对象大小区分分配的思想,并且在 GC 各个环节尽量实现并行。这样能更高效管理更大的内存,但是在大内存场景也不是完全不需要调优的。并且由于并行带来的 GC 压力被分散到各个时间片从而单独 GC 时间短的特性会造成吞吐量低,实际上要实现大吞吐量成本相对于分布式反而提高了。并且,这类 GC 算法所需的预热时间,在大内存一般比较长才能发挥最好的效果。

所以,与其说费力气调高单个实例的堆内存,从硬件成本还有调优成本上看,都不如水平扩展。

下面我们来详细分析下。

1. 类型字压缩指针与 JVM 最大内存

压缩指针这个属性默认是打开的,可以通过-XX:-UseCompressedOops关闭。

首先说一下为何需要压缩指针呢?32 位的存储,可以描述多大的内存呢?假设每一个1代表1字节,那么可以描述 0~2^32-1 这 2^32 字节也就是 4 GB 的内存。

但是呢,Java 默认是 8 字节对齐的内存,也就是一个对象占用的空间,必须是 8 字节的整数倍,不足的话会填充到 8 字节的整数倍。也就是其实描述内存的时候,不用从 0 开始描述到 8(就是根本不需要定位到之间的1,2,3,4,5,6,7)因为对象起止肯定都是 8 的整数倍。所以,2^32 字节如果一个1代表8字节的话,那么最多可以描述 2^32 * 8 字节也就是 32 GB 的内存。

这就是压缩指针的原理。如果配置最大堆内存超过 32 GB(当 JVM 是 8 字节对齐),那么压缩指针会失效。 但是,这个 32 GB 是和字节对齐大小相关的,也就是-XX:ObjectAlignmentInBytes配置的大小(默认为8字节,也就是 Java 默认是 8 字节对齐)。-XX:ObjectAlignmentInBytes可以设置为 8 的整数倍,最大 128。也就是如果配置-XX:ObjectAlignmentInBytes为 24,那么配置最大堆内存超过 96 GB 压缩指针才会失效。

编写程序测试下:

A a = new A();

System.out.println("------After Initialization------\n" + ClassLayout.parseInstance(a).toPrintable());

首先,以启动参数:-XX:ObjectAlignmentInBytes=8 -Xmx16g执行:

------After Initialization------

com.hashjang.jdk.TestObjectAlign$A object internals:

OFFSET SIZE TYPE DESCRIPTION VALUE

0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)

4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

8 4 (object header) 48 72 06 00 (01001000 01110010 00000110 00000000) (422472)

12 4 (alignment/padding gap)

16 8 long A.d 0

Instance size: 24 bytes

Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

可以看到类型字大小为 4 字节48 72 06 00 (01001000 01110010 00000110 00000000) (422472),压缩指针生效。

首先,以启动参数:-XX:ObjectAlignmentInBytes=8 -Xmx32g执行:

------After Initialization------

com.hashjang.jdk.TestObjectAlign$A object internals:

OFFSET SIZE TYPE DESCRIPTION VALUE

0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)

4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

8 4 (object header) a0 5b c6 00 (10100000 01011011 11000110 00000000) (12999584)

12 4 (object header) b4 02 00 00 (10110100 00000010 00000000 00000000) (692)

16 8 long A.d 0

Instance size: 24 bytes

Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

可以看到类型字大小为 8 字节,压缩指针失效:

a0 5b c6 00 (10100000 01011011 11000110 00000000) (12999584)

b4 02 00 00 (10110100 00000010 00000000 00000000) (692)

修改对齐大小为 16 字节,也就是以-XX:ObjectAlignmentInBytes=16 -Xmx32g执行:

------After Initialization------

com.hashjang.jdk.TestObjectAlign$A object internals:

OFFSET SIZE TYPE DESCRIPTION VALUE

0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)

4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

8 4 (object header) 48 72 06 00 (01001000 01110010 00000110 00000000) (422472)

12 4 (alignment/padding gap)

16 8 long A.d 0

24 8 (loss due to the next object alignment)

Instance size: 32 bytes

Space losses: 4 bytes internal + 8 bytes external = 12 bytes total

可以看到类型字大小为 4 字节48 72 06 00 (01001000 01110010 00000110 00000000) (422472),压缩指针生效。

2. GC 算法选择

Java 8 默认的 CMS 不适合管理大内存,一般默认对象对齐的情况下,超过 8G 堆内存就会有显著性能下降。 Java 11 之后默认的 G1 GC 采用了分区的思想,每次 GC 回收部分分区,但是场景一般面向于 32G 堆内存以下的,对于更高内存,如果不调整默认 reigon 大小,那么会 reigon 非常多,每个 reigon 的分析数据导致管理的 GC 信息也会占用很大内存。如果调高了 region 大小, G1 GC 为了达到只回收部分 reigon,每个 region 都需要 RememberSet 来记录各 region 之间的引用,这个大小会随着分区大小与每个对象大小以及对象间引用复杂度而上升。这个内存的开销其实还是挺大的。可以通过 Java native memory tracking 查看 GC 占用内存:

- GC (reserved=19963500KB, committed=19963500KB)

(malloc=1107836KB #29639)

(mmap: reserved=18855564KB, committed=18855564KB)

只看 GC 这一项就好。

ZGC 使用了着色指针,在分区思想的基础上提出了 Page 概念,根据分配对象大小,进入不同 Page 使用不同的分配策略。回收的时候根据染色指针,并发转移可以不用 STW,也做到了真正的将 GC 压力并行均摊到每个时间片。但是由于还是基于分区,内存越大, GC 所需要的内存开销也是比较大的。并且 不同区域之间的分配,预热时间比较长,需要 JVM 调优。

3. TLAB Resize 计算以及大小稳定收敛以及 CPU 缓存行命中匹配问题

3.1. 对象的分配

大部分对象在堆上的 TLAB分配,还有一部分在 栈上分配 或者是 堆上直接分配,可能 Eden 区也可能年老代。同时,对于一些的 GC 算法,还可能直接在老年代上面分配,例如 G1 GC 中的 humongous allocations(大对象分配),就是对象在超过 Region 一半大小的时候,直接在老年代的连续空间分配。

3.2. TLAB 分配

TLAB 是从堆上 Eden 区的分配的一块线程本地私有内存。线程初始化的时候,如果 JVM 启用了 TLAB(默认是启用的, 可以通过 -XX:-UseTLAB 关闭),则会创建并初始化 TLAB。同时,在 GC 扫描对象发生之后,线程第一次尝试分配对象的时候,也会创建并初始化 TLAB 。在 TLAB 已经满了或者接近于满了的时候,TLAB 可能会被释放回 Eden。GC 扫描对象发生时,TLAB 会被释放回 Eden。TLAB 的生命周期期望只存在于一个 GC 扫描周期内。在 JVM 中,一个 GC 扫描周期,就是一个epoch。那么,可以知道,TLAB 内分配内存一定是线性分配的。

TLAB 的最小大小:通过MinTLABSize指定

TLAB 的最大大小:不同的 GC 中不同,G1 GC 中为大对象(humongous object)大小,也就是 G1 region 大小的一半。因为开头提到过,在 G1 GC 中,大对象不能在 TLAB 分配,而是老年代。ZGC 中为页大小的 8 分之一,类似的在大部分情况下 Shenandoah GC 也是每个 Region 大小的 8 分之一。他们都是期望至少有 8 分之 7 的区域是不用退回的减少选择 Cset 的时候的扫描复杂度。对于其他的 GC,则是 int 数组的最大大小,这个和为了填充 dummy object 表示 TLAB 的空区域有关。

为何要填充 dummy object ?

由于 TLAB 仅线程内知道哪些被分配了,在 GC 扫描发生时返回 Eden 区,如果不填充的话,外部并不知道哪一部分被使用哪一部分没有,需要做额外的检查,如果填充已经确认会被回收的对象,也就是 dummy object, GC 会直接标记之后跳过这块内存,增加扫描效率。反正这块内存已经属于 TLAB,其他线程在下次扫描结束前是无法使用的。这个 dummy object 就是 int 数组。为了一定能有填充 dummy object 的空间,一般 TLAB 大小都会预留一个 dummy object 的 header 的空间,也是一个 int[] 的 header,所以 TLAB 的大小不能超过int 数组的最大大小,否则无法用 dummy object 填满未使用的空间。

TLAB 的大小: 如果指定了TLABSize,就用这个大小作为初始大小。如果没有指定,则按照如下的公式进行计算: Eden 区大小 / (当前 epcoh 内会分配对象期望线程个数 * 每个 epoch 内每个线程 refill 次数配置)

当前 epcoh 内会分配对象期望线程个数,也就是会创建并初始化 TLAB 的线程个数,这个从之前提到的 EMA (Exponential Moving Average 指数平均数)算法采集预测而来。算法是:

采样次数小于等于 100 时,每次采样:

1. 次数权重 = 100 / 次数

2. 计算权重 = 次数权重 与 TLABAllocationWeight 中大的那个

3. 新的平均值 = (100% - 计算权重%) * 之前的平均值 + 计算权重% * 当前采样值

采样次数大于 100 时,每次采样:

新的平均值 = (100% - TLABAllocationWeight %) * 之前的平均值 + TLABAllocationWeight % * 当前采样值

可以看出 TLABAllocationWeight 越大,则最近的线程数量对于这个下个 epcoh 内会分配对象期望线程个数影响越大。

每个 epoch 内期望 refill 次数就是在每个 GC 扫描周期内,refill 的次数。那么什么是 refill 呢?

在 TLAB 内存充足的时候分配对象就是快分配,否则在 TLAB 内存不足的时候分配对象就是慢分配,慢分配可能会发生两种处理:

1.线程获取新的 TLAB。老的 TLAB 回归 Eden,之后线程获取新的 TLAB 分配对象。

2.对象在 TLAB 外分配,也就 Eden 区。

这两种处理主要由TLAB最大浪费空间决定,这是一个动态值。初始TLAB最大浪费空间 = TLAB 的大小 / TLABRefillWasteFraction。根据前面提到的这个 JVM 参数,默认为TLAB 的大小的 64 分之一。之后,伴随着每次慢分配,这个TLAB最大浪费空间会每次递增 TLABWasteIncrement 大小的空间。如果当前 TLAB 的剩余容量大于TLAB最大浪费空间,就不在当前TLAB分配,直接在 Eden 区进行分配。如果剩余容量小于TLAB最大浪费空间,就丢弃当前 TLAB 回归 Eden,线程获取新的 TLAB 分配对象。refill 指的就是这种线程获取新的 TLAB 分配对象的行为。

那么,也就好理解为何要尽量满足 TLAB 的大小 = Eden 区大小 / (下个 epcoh 内会分配对象期望线程个数 * 每个 epoch 内每个线程 refill 次数配置)了。尽量让所有对象在 TLAB 内分配,也就是 TLAB 可能要占满 Eden。在下次 GC 扫描前,refill 回 Eden 的内存别的线程是不能用的,因为剩余空间已经填满了 dummy object。所以所有线程使用内存大小就是 下个 epcoh 内会分配对象期望线程个数 * 每个 epoch 内每个线程 refill 次数配置,对象一般都在 Eden 区由某个线程分配,也就所有线程使用内存大小就最好是整个 Eden。但是这种情况太过于理想,总会有内存被填充了 dummy object而造成了浪费,因为 GC 扫描随时可能发生。假设平均下来,GC 扫描的时候,每个线程当前的 TLAB 都有一半的内存被浪费,这个每个线程使用内存的浪费的百分比率(也就是 TLABWasteTargetPercent),也就是等于(注意,仅最新的那个 TLAB 有浪费,之前 refill 退回的假设是没有浪费的):

1/2 * (每个 epoch 内每个线程期望 refill 次数) * 100

那么每个 epoch 内每个线程 refill 次数配置就等于 50 / TLABWasteTargetPercent, 默认也就是 50 次。

当 TLABResize 设置为 true 的时候,在每个 epoch 当线程需要分配对象的时候, TLAB 大小都会被重新计算,并用这个最新的大小去从 Eden 申请内存。如果没有对象分配则不重新计算,也不申请(废话~~~)。主要是为了能让线程 TLAB 的 refill 次数 接近于 每个 epoch 内每个线程 refill 次数配置。这样就能让浪费比例接近于用户配置的 TLABWasteTargetPercent.这个大小重新计算的公式为: TLAB 最新大小 * EMA refill 次数 / 每个 epoch 内每个线程 refill 次数配置。

在默认的 GC 情况下,如果内存比较大,那么 TLAB resize 会很多,如果不调整TLABWasteIncrement等一系列参数,在 TLAB 外分配的次数会变多,导致对象分配效率下降。

3.3. Allocation Prefetch

现在我们对于对象分配有了一定的认识,然后,我们在这里再简单的提一下 CPU 缓存的结构。

CPU 只能直接处理寄存器中的数据,从上面这些缓存读取,其实就是从这些缓存复制数据到寄存器。就像数据库和缓存关系相似,存在L1缓存,L2缓存,L3缓存来缓存内存中的数据。 级别越小,CPU访问越快:

上面说读取其实就是从这些缓存复制数据到寄存器,从内存读取数据也是一样,从内存复制到缓存中。但是这个复制,并不是一个字节一个字节复制的,而是一行一行复制的,这个行就是 缓存行 。 缓存行: CPU缓存并不是将内存数据一个一个的缓存起来,而是每次从内存中取出一行内存,称为缓存行(Cache Line),对于我的电脑,缓存行长度是 64 Bytes:

为了保证内存可见一致性,所有缓存遵循缓存一致性协议。缓存一致性协议可以参考:CPU Cache Flushing Fallacy。这里总结来说,就是:在多核环境下,如果一个 CPU 需要访问的内存已经被另一个 CPU 进行缓存,那么这个 CPU 可以直接访问另一个 CPU 的缓存,避免访问内存。

不考虑 Allocation Prefetch 的情况下,创建一个对象:在 TLAB 中撞针分配所需大小,拿到这块内存的起始和终止指针。

根据内存的起始指针,填充对象头信息

初始化对象的所有 field,如果指定了 ZeroTLAB 这个 JVM flag,就填充 0

在第二步的时候,系统底层有一些用户看不到的操作。首先就是,操作的这块内存,可能不存在于所有 CPU 的缓存中,需要从内存中载入。也就是在写入一块内存的时候,需要先读取这块内存到 CPU 缓存中。

这种读取就是Allocation Prefetch。那么很明显,如果堆内存变大,或者内存对齐ObjectAlignmentInBytes变大,或者压缩指针失效,这样 Allocation Prefetch 的 CPU 缓存就更难与其他 CPU 缓存里面的内存区分开,增加了 CPU 内存交换与Allocation Prefetch的寻址消耗。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值