在本文中,我们将讨论 JVM 如何在内存中存储对象:它们的对齐方式。 对象表示是理解 JVM 底层机制的重要主题,它提供了有助于应用程序调优的见解。
这里,我们主要关注填充和对齐,而不是 JVM 如何在内存中表示对象。要获取有关内存布局的更多信息,请查看这篇文章。
对象对齐
操作系统出于缓存、性能原因和硬件效率的原因使用对齐。**尽管 JVM 是底层操作系统的抽象,但在内部,出于相同的原因,它应该以类似的方式处理对齐。 **
对象对齐在 JVM 上是可配置的。 我们可以使用 -XX:ObjectAlignmentInBytes 提供自定义值。 对齐应该是 2 的幂,并且在 64 位架构上不能超过 4 个字节。
内存消耗
同时,padding 是不含任何有用信息的内存,并且根据对齐大小,padding 大小可能会有所不同。假设我们有以下对象:
public class SimpleObject {
public int i;
}
我们按照32位系统4字节对齐的组件来计算一下:
空对象的大小 = Mark Word + Klass Pointer + Padding + Instance fields + Padding
图 1:32 位架构上具有 4 字节对齐的简单 int 持有者
当对象自然对齐到 4 个字节时,JVM 会忽略填充。
让我们对具有 8 字节对齐和 4 字节(压缩)指针的 64 位架构进行类似的计算:
图2:64 位架构上具有 8 字节对齐的简单int持有者
在这种情况下,对象未对齐,需要额外的八个填充字节。 因此,填充将占用对象大小的三分之一。虽然这听起来像是一个巨大的浪费,但它只对小对象有意义,并且填充大小不能大于对齐。
同时,这意味着64位架构上的以下类不会占用任何额外的空间:
public class SimpleObject {
public int i;
public bool b;
}
此类将具有以下布局。在大多数情况下,出于性能原因,布尔值占用一个字节:
图3:64 位架构上具有 8 字节对齐的简单int和bool持有者
由于我们使用了之前分配的填充区域,因此对象的大小没有改变。同时,在 32 位系统上,我们会看到另一张图:
图4:32 位架构上具有 8 字节对齐的简单int和bool持有者
添加另一个bool字段会导致对象大小增加四个字节:一个字节用于布尔值本身,三个字节用于填充。
在 64 位系统上,mark word 将占用 8 个字节。 类指针的大小可能因对齐配置、堆大小和压缩指针的使用而有所不同。如果我们关闭压缩指针,我们将获得以下布局:
*** 图 5:64 位架构上的简单 int 和 bool 持有者,具有 8 字节对齐和未压缩的(8 字节)指针***
由于我们之前使用了填充,对象的大小没有增加。 我们可以想象压缩会对大量引用类型数组产生怎样的影响,例如 Strings :
public class Person {
private String firstName;
private String lastName;
private Address address;
// constructors, getters, setters, etc.
}
在 64 位系统上使用压缩指针时,对象布局将如下所示:
*****图 6:64位架构上的*Person*对象,具有 8 字节对齐和压缩指针*****
然而,如果我们关闭它们,对象的尺寸就会增加:
*****图 7:64位架构上的*Person*对象,具有 8 字节对齐和未压缩的指针*****
在这种情况下,未压缩的引用将增加 8 个额外字节。但是,如果我们想象一个引用类型的列表或数组,我们就能明白它对我们的应用程序有何影响。
参考尺寸和性能
这并不意味着 64 位系统会因为消耗更多内存而变慢。标记字可以包含有关对象的更多信息,例如哈希码和与垃圾回收相关的数据,从而避免往返或额外查找。
1. 创建率低
我们首先回顾一下不会产生太多垃圾的基准:
@Benchmark
public void filteringList(NumberFilteringState state, Blackhole blackhole){
filterList(state.integers, blackhole);
}
filterList方法将大列表中的偶数和奇数分开:
private static void filterList(List<Integer> integers, Blackhole blackhole)
{
int even = 0;
int odd = 0;
for (final Integer integer : integers) {
if (integer % 2 == 0) {
even++;
} else {
odd++;
}
}
blackhole.consume(even);
blackhole.consume(odd);
}
我们可以使用不同的对齐大小以及压缩和非压缩指针来比较其性能。然而,我们使用压缩这一事实导致了最大的差异:
对象对齐性能(8 GB 堆)
对象对齐 | 压缩指针 (ops/s) | 未压缩指针 (ops/s) |
---|---|---|
8 个字节 | 193.393 | 242.080 |
16 字节 | 194.309 | 243.021 |
32 字节 | 194.631 | 242.330 |
2. 高创建率
现在,让我们使用另一个基准来查看类似的设置。此基准的创建率较高:
@Benchmark
public void creatingList(NumberFilteringState state, Blackhole blackhole) {
List<Integer> linkedList = createLinkedList();
blackhole.consume(linkedList);
}
该基准测试使用以下方法创建一个包含一百万个随机整数的 LinkedList :
@NotNull
private static LinkedList<Integer> createLinkedList() {
return new LinkedList<>(ThreadLocalRandom.current()
.ints(ONE_MILLION)
.boxed()
.collect(Collectors.toList()));
}
如果我们分析一下表现,我们就会得到不同的图景:
对象对齐性能(8 GB 堆)
对象对齐 | 压缩指针 (ops/s) | 未压缩指针 (ops/s) |
---|---|---|
8 个字节 | 30.352 | 29.026 |
16 字节 | 30.747 | 28.922 |
32 字节 | 30.746 | 28.772 |
与压缩指针的交互
有趣的是,通过增加对齐大小,我们可以减少内存消耗。这是因为我们减少了放置对象的位置数量。这同时导致可以使用压缩指针。
例如,对于具有 16 字节对齐的 64 GB 堆,我们可以使用带压缩的 32 位指针。我们不需要存储地址的最后四位。它类似于 JVM 默认使用的通常压缩的指针,但由于我们更改了对齐方式,因此我们可以进一步压缩它们。
例如,我们可以用 28 位指针引用具有 48 字节偏移量的对象:
图 8:4 位压缩
当使用 16 字节对齐时,四个最低有效位将始终为零,因此我们可以忽略它们并仅存储 28 位。要恢复原始引用,我们只需进行位移位即可恢复它。
结论
了解对象的对齐方式可让我们深入了解 JVM 的内部工作原理。这些知识可能有助于我们更好地理解,并让我们了解性能配置中经常使用的调优背景和不同的 VM 参数。
以上内容翻原文链接