问题
对象对齐有什么规范吗?对象对齐是8个字节吗?
基础知识
许多硬件实现要求对数据的访问是对齐的,即确保所有 N 字节宽度的访问都在 N 的整数倍的地址上完成。即使对于普通的数据访问没有特别要求,特殊操作(特别是原子操作)通常也有对齐约束。
例如,x86 通常可以接受未对齐的读取和写入,同时跨越两个缓存行的未对齐 CAS 仍然有效,但它会降低吞吐量性能。其他架构会直接拒绝执行此类原子操作,从而产生SIGBUS或其他硬件异常。x86 也不保证跨越多个缓存行的值的访问原子性,当访问未对齐时,这种情况是可能发生的。另一方面,Java 规范要求大多数类型都具有访问原子性,并且绝对要求所有volatile访问都具有访问原子性。
因此,如果 Java 对象中有long字段,并且它在内存中占用 8 个字节,那么出于性能原因,我们必须确保它按 8 个字节对齐。如果该字段是volatile ,那么出于正确性原因,也必须这样做。简单来说,要使这一点成立,需要发生两件事:对象内部的字段偏移量应按 8 个字节对齐,并且对象本身应按 8 个字节对齐。如果我们查看java.lang.Long实例,就会看到这一点:
$ java -jar jol-cli.jar internals java.lang.Long
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]java.lang.Long object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 004 4 (object header) 00 00 00 008 4 (object header) ce 21 00 f812 4 (alignment/padding gap)16 8 long Long.value 0
Instance size: 24 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
这里, value字段本身的偏移量为 16(它是 8 的倍数),并且对象按 8 对齐。
即使没有需要特殊处理的字段,仍然有对象头需要原子访问。从技术上讲,可以将大多数 Java 对象按 4 个字节对齐,而不是按 8 个字节对齐,但实现这一点所需的运行时工作量非常巨大。
因此,在 Hotspot 中,最小对象对齐是 8 个字节。但是它可以更大吗?当然可以,VM 选项可以实现这一点: -XX:ObjectAlignmentInBytes 。它会带来两个后果,一个是负面的,一个是正面的。
实例大小变大
一旦对齐变大,就意味着每个对象浪费的平均空间也会增加。例如,对象对齐增加到 16 和 128 字节:
$ java -XX:ObjectAlignmentInBytes=16 -jar jol-cli.jar internals java.lang.Longjava.lang.Long object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 004 4 (object header) 00 00 00 008 4 (object header) c8 10 01 0012 4 (alignment/padding gap)16 8 long Long.value 024 8 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 4 bytes internal + 8 bytes external = 12 bytes total
$ java -XX:ObjectAlignmentInBytes=128 -jar jol-cli.jar internals java.lang.Longjava.lang.Long object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 004 4 (object header) 00 00 00 008 4 (object header) a8 24 01 0012 4 (alignment/padding gap)16 8 long Long.value 024 104 (loss due to the next object alignment)
Instance size: 128 bytes
Space losses: 4 bytes internal + 104 bytes external = 108 bytes total
每个实例 128 个字节,但只有 8 个字节的有用数据,这里又会有明显的内存空间的浪费。
压缩引用阈值发生偏移
通过将引用移几位来在大于 4 GB 的堆上启用压缩引用。移位的长度取决于引用中有多少低位为零。也就是说,对象是如何对齐的!默认情况下,使用 8 字节对齐,3 个低位为零,我们移位 3 位,得到 2 ( 32 + 3 ) 字节 = 32 G B 2^ {(32+3)}字节 = 32 GB 2(32+3)字节=32GB的压缩引用可寻址空间。使用 16 字节对齐,我们有 2 ( 32 + 4 ) 字节 = 64 G B 2^{(32+4)}字节 = 64 GB 2(32+4)字节=64GB的压缩引用堆!
实验
测试用例
对象对齐会使实例大小膨胀,从而增加堆占用率,但允许在更大的堆上使用压缩引用,从而减少堆占用率!这些因素会相互抵消吗?取决于堆的结构。我们可以使用之前使用的相同测试,尝试找出容纳给定数量对象的最小堆。
源码
import java.io.*;
import java.util.*;public class CompressedOopsAllocate {static final int MIN_HEAP = 0 * 1024;static final int MAX_HEAP = 100 * 1024;static final int HEAP_INCREMENT = 128;static Object[] arr;public static void main(String... args) throws Exception {if (args.length >= 1) {int size = Integer.parseInt(args[0]);arr = new Object[size];IntStream.range(0, size).parallel().forEach(x -> arr[x] = new byte[(x % 20) + 1]);return;}String[] opts = new String[]{"","-XX:-UseCompressedOops","-XX:ObjectAlignmentInBytes=16","-XX:ObjectAlignmentInBytes=32","-XX:ObjectAlignmentInBytes=64",};int[] lastPasses = new int[opts.length];int[] passes = new int[opts.length];Arrays.fill(lastPasses, MIN_HEAP);for (int size = 0; size < 3000; size += 30) {for (int o = 0; o < opts.length; o++) {passes[o] = 0;for (int heap = lastPasses[o]; heap < MAX_HEAP; heap += HEAP_INCREMENT) {if (tryWith(size * 1000 * 1000, heap, opts[o])) {passes[o] = heap;lastPasses[o] = heap;break;}}}System.out.println(size + ", " + Arrays.toString(passes).replaceAll("[\\[\\]]",""));}}private static boolean tryWith(int size, int heap, String... opts) throws Exception {List<String> command = new ArrayList<>();command.add("java");command.add("-XX:+UnlockExperimentalVMOptions");command.add("-XX:+UseEpsilonGC");command.add("-XX:+UseTransparentHugePages"); // faster this waycommand.add("-XX:+AlwaysPreTouch"); // even faster this waycommand.add("-Xmx" + heap + "m");Arrays.stream(opts).filter(x -> !x.isEmpty()).forEach(command::add);command.add(CompressedOopsAllocate.class.getName());command.add(Integer.toString(size));Process p = new ProcessBuilder().command(command).start();return p.waitFor() == 0;}
}
运行结果
在堆容量可达 100+ GB 的大型机器上运行此测试将产生可预测的结果。让我们从平均对象大小开始来设置叙述。请注意,这些是该特定测试中的平均对象大小,该测试分配了大量小的byte[]数组。如下图所示:
增加对齐确实会使平均对象大小膨胀:16 字节和 32 字节对齐已成功“稍微”增加了对象大小,而 64 字节对齐则使平均值大幅增加。请注意,对象对齐基本上说明了最小对象大小,一旦最小值增加,平均值也会增加。
压缩引用通常会在 32 GB 左右失败。但请注意,更高的对齐会延长这一时间,对齐越高,失败所需的时间就越长。例如,16 字节对齐会在压缩引用中发生 4 位移位,并在 64 GB 左右失败。32 字节对齐将发生 5 位移位,并在 128 GB 左右失败。 [ 3 ]在此特定测试中,在某些对象计数中,由于更高对齐导致的对象大小膨胀与由于压缩引用处于活动状态而导致的较低占用空间相平衡。当然,当压缩引用最终被禁用时,对齐成本就会赶上来。
在“最小堆大小”图中可以更清楚地看到它:
在这里,我们清楚地看到了 32 GB 和 64 GB 的失败阈值。请注意,在某些配置中,16 字节和 32 字节对齐占用的堆更少,这得益于更高效的引用编码。这种改进并不普遍:当 8 字节对齐足够或压缩引用失败时,更高的对齐会浪费内存。
结论
对象对齐是一件有趣的事情。虽然它大大增加了对象大小,但一旦压缩引用出现,它也可以降低整体占用空间。有时,稍微增加对齐是有意义的,以获得占用空间的好处。然而,在许多情况下,这会降低整体占用空间。需要仔细研究给定的应用程序和给定的数据集,以确定增加对齐是否有用。