性能文章>一文聊透对象在JVM中的内存布局,以及内存对齐和压缩指针的原理及应用>

一文聊透对象在JVM中的内存布局,以及内存对齐和压缩指针的原理及应用转载

2年前
461803

本文我们将从计算机组成原理的角度详细阐述对象在 JVM 内存中是如何布局的,以及什么是内存对齐。如果我们头比较铁,就是不进行内存对齐会造成什么样的后果。最后引出压缩指针的原理和应用。同时我们还介绍了在高并发场景下,False Sharing 产生的原因以及带来的性能影响。

在我们的日常工作中,有时候我们为了防止线上应用发生 OOM,所以我们需要在开发的过程中计算一些核心对象在内存中的占用大小,目的是为了更好的了解我们的应用程序内存占用的一个大概情况。

 

进而根据我们服务器的内存资源限制以及预估的对象创建数量级计算出应用程序占用内存的高低水位线,如果内存占用量超过高水位线,那么就有可能有发生 OOM 的风险。

我们可以在程序中根据估算出的高低水位线,做一些防止 OOM 的处理逻辑或者发出告警。

 

那么核心问题是,如何计算一个 Java 对象在内存中的占用大小呢?

 

在为大家解答这个问题之前,笔者先来介绍下 Java 对象在内存中的布局,也就是本文的主题。

 

1. Java对象的内存布局

 

Java 对象的内存布局

 

如图所示,Java 对象在 JVM 中是用 instanceOopDesc 结构表示而 Java 对象在 JVM 堆中的内存布局可以分为三部分:

 

1.1 对象头(Header)

 

每个Java对象都包含一个对象头,对象头中包含了两类信息:

 

  • MarkWord:在 JVM 中用 markOopDesc 结构表示用于存储对象自身运行时的数据。比如:hashcode、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。在 32 位操作系统和 64 位操作系统中 MarkWord 分别占用 4B 和 8B 大小的内存;

  • 类型指针:JVM 中的类型指针封装在 klassOopDesc 结构中,类型指针指向了 InstanceKclass 对象,Java 类在 JVM 中是用 InstanceKclass 对象封装的,里边包含了 Java 类的元信息,比如继承结构、方法、静态变量、构造函数等。

    • 在不开启指针压缩的情况下(-XX:-UseCompressedOops)。在 32 位操作系统和 64 位操作系统中类型指针分别占用 4B 和 8B 大小的内存。
    • 在开启指针压缩的情况下(-XX:+UseCompressedOops)。在 32 位操作系统和 64 位操作系统中类型指针分别占用 4B 和 4B 大小的内存。
  • 如果 Java 对象是一个数组类型的话,那么在数组对象的对象头中还会包含一个 4B 大小的用于记录数组长度的属性。

 

由于在对象头中用于记录数组长度大小的属性只占4B的内存,所以Java数组可以申请的最大长度为:2^32。

 

1.2 实例数据(Instance Data)

 

Java 对象在内存中的实例数据区用来存储 Java 类中定义的实例字段,包括所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存。

 

Java 对象中的字段类型分为两大类:

 

  • 基础类型:Java 类中实例字段定义的基础类型在实例数据区的内存占用如下:

    • long、double 占用 8 个字节。
    • int、float 占用 4 个字节。
    • short、char 占用 2 个字节。
    • byte、boolean 占用 1 个字节。
  • 引用类型:Java 类中实例字段的引用类型在实例数据区内存占用分为两种情况:

    • 不开启指针压缩(-XX:-UseCompressedOops):在 32 位操作系统中引用类型的内存占用为 4 个字节。在 64 位操作系统中引用类型的内存占用为 8 个字节。
    • 开启指针压缩(-XX:+UseCompressedOops):在 64 为操作系统下,引用类型内存占用则变为为 4 个字节,32 位操作系统中引用类型的内存占用继续为 4 个字节。

 

为什么 32 位操作系统的引用类型占4个字节,而 64 位操作系统引用类型占 8 字节?

 

在 Java 中,引用类型所保存的是被引用对象的内存地址。在 32 位操作系统中内存地址是由 32 个 bit 表示,因此需要 4 个字节来记录内存地址,能够记录的虚拟地址空间是 2^32 大小,也就是只能够表示 4G 大小的内存。

 

而在 64 位操作系统中内存地址是由 64 个 bit 表示,因此需要 8 个字节来记录内存地址,但在 64 位系统里只使用了低 48 位,所以它的虚拟地址空间是 2^48 大小,能够表示 256T 大小的内存,其中低 128T 的空间划分为用户空间,高 128T 划分为内核空间,可以说是非常大了。

 

在我们从整体上介绍完 Java 对象在 JVM 中的内存布局之后,下面我们来看下 Java 对象中定义的这些实例字段在实例数据区是如何排列布局的。

 

2. 字段重排列

 

其实我们在编写 Java 源代码文件的时候,定义的那些实例字段的顺序会被 JVM 重新分配排列。这样做的目的其实是为了内存对齐,那么什么是内存对齐,为什么要进行内存对齐,笔者会随着文章深入的解读为大家逐层揭晓答案。

 

本小节中,笔者先来为大家介绍一下 JVM 字段重排列的规则:

 

JVM 重新分配字段的排列顺序受 -XX:FieldsAllocationStyle 参数的影响,默认值为 1。

 

实例字段的重新分配策略遵循以下规则:

 

    1. 如果一个字段占用 X 个字节,那么这个字段的偏移量 OFFSET 需要对齐至 NX;
偏移量是指字段的内存地址与 Java 对象的起始内存地址之间的差值。比如 long 类型的字段,它内存占用 8 个字节,那么它的 OFFSET 应该是 8 的倍数 8N。不足 8N 的需要填充字节。
  1. 在开启了压缩指针的 64 位 JVM 中,Java 类中的第一个字段的 OFFSET 需要对齐至 4N,在关闭压缩指针的情况下类中第一个字段的 OFFSET 需要对齐至 8N;

  2. JVM 默认分配字段的顺序为:long / double,int / float,short / char,byte / boolean,oops(Ordianry Object Point 引用类型指针),并且父类中定义的实例变量会出现在子类实例变量之前。当设置 JVM 参数 -XX +CompactFields 时(默认),占用内存小于 long / double 的字段会允许**入到对象中第一个 long / double 字段之前的间隙中,以避免不必要的内存填充。

 

CompactFields 选项参数在 JDK14 中以被标记为过期了,并在将来的版本中很可能被删除。详细细节可查看 issue:https://bugs.openjdk.java.net/browse/JDK-8228750

 

上边的三条字段重排列规则非常非常重要,但是读起来比较绕脑,很抽象不容易理解。笔者把它们先列出来的目的是为了让大家先有一个朦朦胧胧的感性认识,下面笔者举一个具体的例子来为大家详细说明下,在阅读这个例子的过程中也方便大家深刻的理解这三条重要的字段重排列规则。

 

假设现在我们有这样一个类定义:

public class Parent { long l; int i;}
public class Child extends Parent { long l; int i;}
 
    • 根据上面介绍的规则 3 我们知道父类中的变量是出现在子类变量之前的,并且字段分配顺序应该是 long 型字段 l,应该在 int 型字段 i 之前。
如果 JVM 开启了 -XX +CompactFields 时,int 型字段是可以插入对象中的第一个 long 型字段(也就是 Parent.l 字段)之前的空隙中的。如果 JVM 设置了 -XX -CompactFields 则  int 型字段的这种插入行为是不被允许的。
  • 根据规则 1 我们知道 long 型字段在实例数据区的 OFFSET 需要对齐至 8N,而 int 型字段的 OFFSET 需要对齐至 4N。

  • 根据规则 2 我们知道如果开启压缩指针 -XX:+UseCompressedOops,Child 对象的第一个字段的 OFFSET 需要对齐至 4N,关闭压缩指针时 -XX:-UseCompressedOops,Child 对象的第一个字段的 OFFSET 需要对齐至 8N。

 

由于 JVM 参数 UseCompressedOops 和 CompactFields 的存在,导致 Child 对象在实例数据区字段的排列顺序分为四种情况,下面我们结合前边提炼出的这三点规则来看下字段排列顺序在这四种情况下的表现。

 

2.1 -XX:+UseCompressedOops  -XX -CompactFields 开启压缩指针,关闭字段压缩

 

    • 偏移量 OFFSET = 8 的位置存放的是类型指针,由于开启了压缩指针所以占用 4 个字节。对象头总共占用 12 个字节:MarkWord(8 字节) + 类型指针(4 字节);

    • 根据规则 3:父类 Parent 中的字段是要出现在子类 Child 的字段之前的并且 long 型字段在 int 型字段之前;

    • 根据规则 2:在开启压缩指针的情况下,Child 对象中的第一个字段需要对齐至 4N。这里 Parent.l 字段的 OFFSET 可以是 12 也可以是 16;

    • 根据规则 1:long 型字段在实例数据区的 OFFSE T需要对齐至 8N,所以这里Parent.l字段的 OFFSET 只能是 16,因此 OFFSET = 12 的位置就需要被填充;Child.l字段只能在 OFFSET = 32 处存储,不能够使用 OFFSET = 28 位置,因为 28 的位置不是8的倍数无法对齐 8N,因此 OFFSET = 28 的位置被填充了 4 个字节。

规则1 也规定了 int 型字段的 OFFSET 需要对齐至 4N,所以 Parent.i 与 Child.i 分别存储以 OFFSET = 24和OFFSET = 40 的位置。

 

因为 JVM 中的内存对齐除了存在于字段与字段之间还存在于对象与对象之间,Java 对象之间的内存地址需要对齐至 8N。

 

所以 Child 对象的末尾处被填充了 4 个字节,对象大小由开始的 44 字节被填充到 48 字节。

 

2.2  -XX:+UseCompressedOops  -XX +CompactFields 开启压缩指针,开启字段压缩

 

  • 在第一种情况的分析基础上,我们开启了 -XX +CompactFields 压缩字段,所以导致 int 型的 Parent.i 字段可以插入到 OFFSET = 12 的位置处,以避免不必要的字节填充;

  • 根据规则 2:Child 对象的第一个字段需要对齐至 4N,这里我们看到 int 型的 Parent.i 字段是符合这个规则的;

  • 根据规则 1:Child 对象的所有 long 型字段都对齐至 8N,所有的 int 型字段都对齐至 4N。

 

最终得到 Child 对象大小为 36 字节,由于 Java 对象与对象之间的内存地址需要对齐至 8N,所以最后 Child 对象的末尾又被填充了 4 个字节最终变为 40 字节。

 

这里我们可以看到在开启字段压缩 -XX +CompactFields 的情况下,Child 对象的大小由 48 字节变成了 40 字节。

 

2.3 -XX:-UseCompressedOops  -XX -CompactFields 关闭压缩指针,关闭字段压缩

 

 

首先,在关闭压缩指针 -UseCompressedOops 的情况下,对象头中的类型指针占用字节变成了8 字节。导致对象头的大小在这种情况下变为了 16 字节。

 

  • 根据规则 1:long 型的变量 OFFSET 需要对齐至 8N。根据 规则 2:在关闭压缩指针的情况下,Child 对象的第一个字段 Parent.l 需要对齐至 8N。所以这里的 Parent.l 字段的 OFFSET  = 16;

  • 由于 long 型的变量 OFFSET 需要对齐至 8N,所以 Child.l 字段的 OFFSET 需要是 32,因此 OFFSET = 28 的位置被填充了 4 个字节。

 

这样计算出来的 Child 对象大小为 44 字节。但是考虑到 Java 对象与对象的内存地址需要对齐至 8N,于是又在对象末尾处填充了 4 个字节,最终 Child 对象的内存占用为 48 字节。

 

2.4  -XX:-UseCompressedOops  -XX +CompactFields 关闭压缩指针,开启字段压缩

 

在第三种情况的分析基础上,我们来看下第四种情况的字段排列情况:

 

由于在关闭指针压缩的情况下类型指针的大小变为了 8 个字节,所以导致 Child 对象中第一个字段 Parent.l 前边并没有空隙,刚好对齐 8N,并不需要 int 型变量的插入。所以即使开启了字段压缩 -XX +CompactFields,字段的总体排列顺序还是不变的。

 

默认情况下指针压缩 -XX:+UseCompressedOops 以及字段压缩 -XX +CompactFields 都是开启的。

 

3. 对齐填充(Padding)

 

在前一小节关于实例数据区字段重排列的介绍中为了内存对齐而导致的字节填充不仅会出现在字段与字段之间,还会出现在对象与对象之间。

 

前边我们介绍了字段重排列需要遵循的三个重要规则,其中规则 1、规则 2 定义了字段与字段之间的内存对齐规则。规则 3 定义的是对象字段之间的排列规则。

 

为了内存对齐的需要,对象头与字段之间,以及字段与字段之间需要填充一些不必要的字节。

 

比如前边提到的字段重排列的第一种情况 -XX:+UseCompressedOops -XX -CompactFields。

 

而以上提到的四种情况都会在对象实例数据区的后边在填充 4 字节大小的空间。原因是除了需要满足字段与字段之间的内存对齐之外,还需要满足对象与对象之间的内存对齐。

 

Java 虚拟机堆中对象之间的内存地址需要对齐至 8N(8 的倍数),如果一个对象占用内存不到 8N 个字节,那么就必须在对象后填充一些不必要的字节对齐至 8N 个字节。

 

虚拟机中内存对齐的选项为 -XX:ObjectAlignmentInBytes,默认为 8。也就是说对象与对象之间的内存地址需要对齐至多少倍,是由这个 JVM 参数控制的。

 

我们还是以上边第一种情况为例说明:图中对象实际占用是 44 个字节,但是不是8的倍数,那么就需要再填充 4 个字节,内存对齐至 48 个字节。

 

以上这些为了内存对齐的目的而在字段与字段之间,对象与对象之间填充的不必要字节,我们就称之为对齐填充(Padding)。

 

4. 对齐填充的应用

 

在我们知道了对齐填充的概念之后,大家可能好奇了,为啥我们要进行对齐填充,是要解决什么问题吗?

 

那么就让我们带着这个问题,来接着听笔者往下聊。

 

4.1 解决伪共享问题带来的对齐填充

 

除了以上介绍的两种对齐填充的场景(字段与字段之间,对象与对象之间),在 Java 中还有一种对齐填充的场景,那就是通过对齐填充的方式来解决 False Sharing(伪共享)的问题。

 

在介绍 False Sharing(伪共享)之前,笔者先来介绍下 CPU 读取内存中数据的方式。

 

4.1.1 CPU 缓存

 

根据摩尔定律:芯片中的晶体管数量每隔 18 个月就会翻一番。导致 CPU 的性能和处理速度变得越来越快,而提升 CPU 的运行速度比提升内存的运行速度要容易和便宜的多,所以就导致了CPU与内存之间的速度差距越来越大。

 

为了弥补 CPU 与内存之间巨大的速度差异,提高 CPU 的处理效率和吞吐,于是人们引入了 L1,L2,L3 高速缓存集成到 CPU 中。当然还有 L0 也就是寄存器,寄存器离 CPU 最近,访问速度也最快,基本没有时延。

 

CPU 缓存结构

 

一个 CPU 里面包含多个核心,我们在购买电脑的时候经常会看到这样的处理器配置,比如 4 核 8 线程。意思是这个 CPU 包含 4 个物理核心 8 个逻辑核心。4 个物理核心表示在同一时间可以允许 4 个线程并行执行,8 个逻辑核心表示处理器利用超线程的技术将一个物理核心模拟出了两个逻辑核心,一个物理核心在同一时间只会执行一个线程,而超线程芯片可以做到线程之间快速切换,当一个线程在访问内存的空隙,超线程芯片可以马上切换去执行另外一个线程。因为切换速度非常快,所以在效果上看到是 8 个线程在同时执行。

 

图中的 CPU 核心指的是物理核心。

 

从图中我们可以看到 L1Cache 是离 CPU 核心最近的高速缓存,紧接着就是 L2Cache、L3Cache、内存。

 

离 CPU 核心越近的缓存访问速度也越快,造价也就越高,当然容量也就越小。

其中 L1Cache 和 L2Cache 是 CPU 物理核心私有的(注意:这里是物理核心不是逻辑核心

 

而 L3Cache 是整个 CPU 所有物理核心共享的。

 

CPU 逻辑核心共享其所属物理核心的 L1Cache 和 L2Cache。

 

L1Cache

 

L1Cache 离 CPU 是最近的,它的访问速度最快,容量也最小。

 

从图中我们看到 L1Cache 分为两个部分,分别是 Data Cache 和 Instruction Cache。它们一个是存储数据的,一个是存储代码指令的。

 

我们可以通过 cd /sys/devices/system/cpu /来查看 Linux 机器上的 CPU 信息。

 

在 /sys/devices/system/cpu/ 目录里,我们可以看到 CPU 的核心数,当然这里指的是逻辑核心。

 

笔者机器上的处理器并没有使用超线程技术所以这里其实是 4 个物理核心。

 

下面我们进入其中一颗 CPU 核心(cpu0)中去看下 L1Cache 的情况:

 

CPU 缓存的情况在 /sys/devices/system/cpu/cpu0/cache 目录下查看:

 

 

index0 描述的是 L1Cache 中 DataCache 的情况:

 

 

  • level :表示该 cache 信息属于哪一级,1 表示 L1Cache;
  • type :表示属于 L1Cache 的 DataCache;
  • size :表示 DataCache 的大小为 32K;
  • shared_cpu_list :之前我们提到 L1Cache 和 L2Cache 是 CPU 物理核所私有的,而由物理核模拟出来的逻辑核是共享 L1Cache 和 L2Cache 的,/sys/devices/system/cpu/ 目录下描述的信息是逻辑核。shared_cpu_list 描述的正是哪些逻辑核共享这个物理核。

 

index1 描述的是 L1Cache 中 Instruction Cache 的情况:

 

我们看到 L1Cache 中的 Instruction Cache 大小也是 32K。

 

L2Cache

 

L2Cache 的信息存储在 index2 目录下:

 

L2Cache 的大小为 256K,比 L1Cache 要大些。

 

L3Cache

 

L3Cache 的信息存储在 index3 目录下:

 

 

到这里我们可以看到 L1Cache 中的 DataCache 和 InstructionCache 大小一样都是 32K 而 L2Cache 的大小为 256K,L3Cache 的大小为 6M。

 

当然这些数值在不同的CPU配置上会是不同的,但是总体上来说 L1Cache 的量级是几十 KB,L2Cache 的量级是几百 KB,L3Cache 的量级是几 MB。

 

4.1.2 CPU 缓存行

 

前边我们介绍了 CPU 的高速缓存结构,引入高速缓存的目的在于消除 CPU 与内存之间的速度差距,根据程序的局部性原理我们知道,CPU 的高速缓存肯定是用来存放热点数据的。

 

程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。

 

那么在高速缓存中存取数据的基本单位又是什么呢?

 

事实上热点数据在 CPU 高速缓存中的存取并不是我们想象中的以单独的变量或者单独的指针为单位存取的。

 

CPU 高速缓存中存取数据的基本单位叫做缓存行 cache line。缓存行存取字节的大小为 2 的倍数,在不同的机器上,缓存行的大小范围在 32 字节到 128 字节之间。目前所有主流的处理器中缓存行的大小均为 64 字节(注意:这里的单位是字节)。

 

 

从图中我们可以看到 L1Cache, L2Cache, L3Cache 中缓存行的大小都是 64 字节。

 

这也就意味着每次 CPU 从内存中获取数据或者写入数据的大小为 64 个字节,即使你只读一个 bit,CPU 也会从内存中加载 64 字节数据进来。同样的道理,CPU 从高速缓存中同步数据到内存也是按照 64 字节的单位来进行。

 

比如你访问一个 long 型数组,当 CPU 去加载数组中第一个元素时也会同时将后边的 7 个元素一起加载进缓存中。这样一来就加快了遍历数组的效率

 

long 类型在 Java 中占用 8 个字节,一个缓存行可以存放 8 个 long 型变量。

 

事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构,如果你的数据结构中的项在内存中不是彼此相邻的(比如链表),这样就无法利用 CPU 缓存的优势。由于数据在内存中不是连续存放的,所以在这些数据结构中的每一个项都可能会出现缓存行未命中(程序局部性原理)的情况。

 

Netty 利用数组实现的自定义 SelectedSelectionKeySet 类型替换掉了 JDK 利用 HashSet 类型实现的 sun.nio.ch.SelectorImpl#selectedKeys。目的就是利用 CPU 缓存的优势来提高 IO 活跃的 SelectionKeys 集合的遍历性能。

 

4.2 False Sharing(伪共享)

 

我们先来看一个这样的例子,笔者定义了一个示例类 FalseSharding,类中有两个 long 型的 volatile 字段 a,b。

 

public class FalseSharding { volatile long a; volatile long b;}
 

字段 a,b 之间逻辑上是独立的,它们之间一点关系也没有,分别用来存储不同的数据,数据之间也没有关联。

 

FalseSharding 类中字段之间的内存布局如下:

 

FalseSharding 类中的字段 a, b 在内存中是相邻存储,分别占用 8 个字节。

 

如果恰好字段a,b 被 CPU 读进了同一个缓存行,而此时有两个线程,线程a用来修改字段a,同时线程b用来读取字段 b。

 

 

在这种场景下,会对线程 b 的读取操作造成什么影响呢?

 

我们知道声明了 volatile 关键字的变量可以在多线程处理环境下,确保内存的可见性。计算机硬件层会保证对被 volatile 关键字修饰的共享变量进行写操作后的内存可见性,而这种内存可见性是由 Lock 前缀指令以及缓存一致性协议(MESI 控制协议)共同保证的。

 

  • Lock 前缀指令可以使修改线程所在的处理器中的相应缓存行数据被修改后立马刷新回内存中,并同时锁定所有处理器核心中缓存了该修改变量的缓存行,防止多个处理器核心并发修改同一缓存行;

  • 缓存一致性协议主要是用来维护多个处理器核心之间的 CPU 缓存一致性以及与内存数据的一致性。每个处理器会在总线上嗅探其他处理器准备写入的内存地址,如果这个内存地址在自己的处理器中被缓存的话,就会将自己处理器中对应的缓存行置为无效,下次需要读取的该缓存行中的数据的时候,就需要访问内存获取。

 

基于以上 volatile 关键字原则,我们首先来看第一种影响:

 

 

  • 当线程 a 在处理器 core0 中对字段 a 进行修改时,Lock 前缀指令会将所有处理器中缓存了字段 a 的对应缓存行进行锁定,这样就会导致线程 b 在处理器 core1 中无法读取和修改自己缓存行的字段 b;

  • 处理器 core0 将修改后的字段 a 所在的缓存行刷新回内存中。

 

从图中我们可以看到此时字段 a 的值在处理器 core0 的缓存行中以及在内存中已经发生变化了。但是处理器 core1 中字段 a 的值还没有变化,并且 core1 中字段 a 所在的缓存行处于锁定状态,无法读取也无法写入字段 b。

 

从上述过程中我们可以看出即使字段 a,b 之间逻辑上是独立的,它们之间一点关系也没有,但是线程 a 对字段 a 的修改,导致了线程 b 无法读取字段 b。

 

第二种影响:

 

 

当处理器 core0 将字段 a 所在的缓存行刷新回内存的时候,处理器 core1 会在总线上嗅探到字段 a 的内存地址正在被其他处理器修改,所以将自己的缓存行置为失效。当线程 b 在处理器 core1 中读取字段b的值时,发现缓存行已被置为失效,core1 需要重新从内存中读取字段 b 的值即使字段b没有发生任何变化。

 

从以上两种影响我们看到字段 a 与字段b实际上并不存在共享,它们之间也没有相互关联关系,理论上线程 a 对字段 a 的任何操作,都不应该影响线程b对字段b的读取或者写入。

但事实上线程 a 对字段 a 的修改导致了字段 b 在 core1 中的缓存行被锁定(Lock 前缀指令),进而使得线程 b 无法读取字段 b。

 

线程a所在处理器 core0 将字段 a 所在缓存行同步刷新回内存后,导致字段 b 在 core1 中的缓存行被置为失效(缓存一致性协议),进而导致线程b需要重新回到内存读取字段 b 的值无法利用 CPU 缓存的优势。

 

由于字段 a 和字段 b 在同一个缓存行中,导致了字段 a 和字段 b 事实上的共享(原本是不应该被共享的)。这种现象就叫做 False Sharing(伪共享)。

 

在高并发的场景下,这种伪共享的问题,会对程序性能造成非常大的影响。

 

如果线程 a 对字段 a 进行修改,与此同时线程 b 对字段 b 也进行修改,这种情况对性能的影响更大,因为这会导致 core0 和 core1 中相应的缓存行相互失效。

 

4.3 False Sharing 的解决方案

 

既然导致 False Sharing 出现的原因是字段 a 和字段 b 在同一个缓存行导致的,那么我们就要想办法让字段 a 和字段 b 不在一个缓存行中。

 

那么我们怎么做才能够使得字段 a 和字段 b 一定不会被分配到同一个缓存行中呢?

 

这时候,本小节的主题字节填充就派上用场了。

 

在Java8之前我们通常会在字段a和字段b前后分别填充7个long型变量(缓存行大小64字节),目的是让字段a和字段b各自独占一个缓存行避免False Sharing。

 

比如我们将一开始的实例代码修改成这个这样子,就可以保证字段a和字段b各自独占一个缓存行了。

 

public class FalseSharding { long p1,p2,p3,p4,p5,p6,p7; volatile long a; long p8,p9,p10,p11,p12,p13,p14; volatile long b; long p15,p16,p17,p18,p19,p20,p21;}
 

修改后的对象在内存中布局如下:

 

我们看到为了解决 False Sharing 问题,我们将原本占用 32 字节的 FalseSharding 示例对象硬生生的填充到了 200 字节。这对内存的消耗是非常可观的。通常为了极致的性能,我们会在一些高并发框架或者 JDK 的源码中看到 False Sharing 的解决场景。因为在高并发场景中,任何微小的性能损失比如 False Sharing,都会被无限放大。

 

但解决 False Sharing 的同时又会带来巨大的内存消耗,所以即使在高并发框架比如 disrupter 或者 JDK 中也只是针对那些在多线程场景下被频繁写入的共享变量

 

这里笔者想强调的是在我们日常工作中,我们不能因为自己手里拿着锤子,就满眼都是钉子,看到任何钉子都想上去锤两下。

 

 

我们要清晰的分辨出一个问题会带来哪些影响和损失,这些影响和损失在我们当前业务阶段是否可以接受?是否是瓶颈?同时我们也要清晰的了解要解决这些问题我们所要付出的代价。一定要综合评估,讲究一个投入产出比。某些问题虽然是问题,但是在某些阶段和场景下并不需要我们投入解决。而有些问题则对于我们当前业务发展阶段是瓶颈,我们不得不去解决。我们在架构设计或者程序设计中,方案一定要简单,合适。并预估一些提前量留有一定的演化空间。

 

4.3.1 @Contended 注解

 

在 Java8 中引入了一个新注解 @Contended,用于解决 False Sharing 的问题,同时这个注解也会影响到 Java 对象中的字段排列。

 

在上一小节的内容介绍中,我们通过手段填充字段的方式解决了 False Sharing 的问题,但是这里也有一个问题,因为我们在手动填充字段的时候还需要考虑 CPU 缓存行的大小,因为虽然现在所有主流的处理器缓存行大小均为 64 字节,但是也还是有处理器的缓存行大小为 32 字节,有的甚至是 128 字节。我们需要考虑很多硬件的限制因素。

 

Java8 中通过引入 @Contended 注解帮我们解决了这个问题,我们不在需要去手动填充字段了。下面我们就来看下 @Contended 注解是如何帮助我们来解决这个问题的。

 

上小节介绍的手动填充字节是在共享变量前后填充 64 字节大小的空间,这样只能确保程序在缓存行大小为 32 字节或者 64 字节的 CPU 下独占缓存行。但是如果 CPU 的缓存行大小为 128 字节,这样依然存在 False Sharing 的问题。

 

引入 @Contended 注解可以使我们忽略底层硬件设备的差异性,做到 Java 语言的初衷:平台无关性。

 

@Contended 注解默认只是在 JDK 内部起作用,如果我们的程序代码中需要使用到@Contended 注解,那么需要开启 JVM 参数 -XX:-RestrictContended 才会生效。

 
@Retention(RetentionPolicy.RUNTIME)@Target({ElementType.FIELD, ElementType.TYPE})public @interface Contended { //contention group tag String value() default "";}
 

@Contended 注解可以标注在类上也可以标注在类中的字段上,被 @Contended 标注的对象会独占缓存行,不会和任何变量或者对象共享缓存行。

 

  • @Contended 标注在类上表示该类对象中的实例数据整体需要独占缓存行。不能与其他实例数据共享缓存行;

  • @Contended 标注在类中的字段上表示该字段需要独占缓存行;

  • 除此之外 @Contended 还提供了分组的概念,注解中的 value 属性表示 contention group 。属于统一分组下的变量,它们在内存中是连续存放的,可以允许共享缓存行。不同分组之间不允许共享缓存行。

 

下面我们来分别看下@Contended 注解在这三种使用场景下是怎样影响字段之间的排列的。

 

@Contended标注在类上@Contendedpublic class FalseSharding { volatile long a; volatile long b;
volatile int c; volatile int d;}
 

当 @Contended 标注在 FalseSharding 示例类上时,表示 FalseSharding 示例对象中的整个实例数据区需要独占缓存行,不能与其他对象或者变量共享缓存行。

 

这种情况下的内存布局:

 

如图中所示,FalseSharding 示例类被标注了 @Contended 之后,JVM 会在 FalseSharding 示例对象的实例数据区前后填充 128 个字节,保证实例数据区内的字段之间内存是连续的,并且保证整个实例数据区独占缓存行,不会与实例数据区之外的数据共享缓存行。

 

细心的朋友可能已经发现了问题,我们之前不是提到缓存行的大小为 64 字节吗?为什么这里会填充 128 字节呢?

 

而且之前介绍的手动填充也是填充的 64 字节,为什么 @Contended 注解会采用两倍的缓存行大小来填充呢?

 

其实这里的原因有两个:

 

  • 首先第一个原因,我们之前也已经提到过了,目前大部分主流的 CPU 缓存行是 64 字节,但是也有部分 CPU 缓存行是 32 字节或者 128 字节,如果只填充64字节的话,在缓存行大小为 32 字节和 64 字节的 CPU 中是可以做到独占缓存行从而避免 FalseSharding 的,但在缓存行大小为 128 字节的 CPU 中还是会出现 FalseSharding 问题,这里 Java 采用了悲观的一种做法,默认都是填充 128 字节,虽然对于大部分情况下比较浪费,但是屏蔽了底层硬件的差异。
不过 @Contended 注解填充字节的大小我们可以通过 JVM 参数 -XX:ContendedPaddingWidth 指定,有效值范围 0 - 8192,默认为 128。
  • 第二个原因其实是最为核心的一个原因,主要是为了防止 CPU Adjacent Sector Prefetch(CPU 相邻扇区预取)特性所带来的 FalseSharding 问题。
    CPU Adjacent Sector Prefetch:https://www.techarp.com/bios-guide/cpu-adjacent-sector-prefetch/

 

CPU Adjacent Sector Prefetch 是 Intel 处理器特有的 BIOS 功能特性,默认是enabled。主要作用就是利用程序局部性原理,当 CPU 从内存中请求数据,并读取当前请求数据所在缓存行时,会进一步预取与当前缓存行相邻的下一个缓存行,这样当我们的程序在顺序处理数据时,会提高 CPU 处理效率。这一点也体现了程序局部性原理中的空间局部性特征。

 

当 CPU Adjacent Sector Prefetch 特性被 disabled 禁用时,CPU 就只会获取当前请求数据所在的缓存行,不会预取下一个缓存行。

 

所以在当 CPU Adjacent Sector Prefetch 启用(enabled)的时候,CPU 其实同时处理的是两个缓存行,在这种情况下,就需要填充两倍缓存行大小(128 字节)来避免 CPU Adjacent Sector Prefetch 所带来的的 FalseSharding 问题。

 

@Contended标注在字段上public class FalseSharding { @Contended volatile long a; @Contended volatile long b;
volatile int c; volatile long d;}
 

这次我们将 @Contended 注解标注在了 FalseSharding 示例类中的字段 a 和字段 b 上,这样带来的效果是字段 a 和字段 b 各自独占缓存行。从内存布局上看,字段 a 和字段b前后分别被填充了 128 个字节,来确保字段 a 和字段 b 不与任何数据共享缓存行。

 

而没有被 @Contended 注解标注字段c和字段d则在内存中连续存储,可以共享缓存行。

 

@Contended分组public class FalseSharding { @Contended("group1") volatile int a; @Contended("group1") volatile long b;
@Contended("group2") volatile long c; @Contended("group2") volatile long d;}
 

这次我们将字段 a 与字段 b 放在同一 content group 下,字段c与字段d放在另一个 content group 下。

 

这样处在同一分组 group1 下的字段 a 与字段 b 在内存中是连续存储的,可以共享缓存行。

 

同理处在同一分组 group2 下的字段c与字段d在内存中也是连续存储的,也允许共享缓存行。

 

但是分组之间是不能共享缓存行的,所以在字段分组的前后各填充 128 字节,来保证分组之间的变量不能共享缓存行。

 

5. 内存对齐

 

通过以上内容我们了解到Java对象中的实例数据区字段需要进行内存对齐而导致在 JVM 中会被重排列以及通过填充缓存行避免 false sharding 的目的所带来的字节对齐填充。

我们也了解到内存对齐不仅发生在对象与对象之间,也发生在对象中的字段之间。

 

那么在本小节中笔者将为大家介绍什么是内存对齐,在本节的内容开始之前笔者先来抛出两个问题:

 

  • 为什么要进行内存对齐?如果就是头比较铁,就是不内存对齐,会产生什么样的后果?

  • Java 虚拟机堆中对象的起始地址为什么需要对齐至 8 的倍数?为什么不对齐至4的倍数或 16 的倍数或 32 的倍数呢?

 

带着这两个问题,下面我们正式开始本节的内容。

 

5.1 内存结构

 

我们平时所称的内存也叫随机访问存储器(random-access memory)也叫 RAM。而 RAM 分为两类:

 

  • 一类是静态 RAM(SRAM),这类 SRAM 用于前边介绍的 CPU 高速缓存 L1Cache、L2Cache、L3Cache。其特点是访问速度快,访问速度为 1 - 30 个时钟周期,但是容量小、造价高。

  • 另一类则是动态 RAM(DRAM),这类 DRAM 用于我们常说的主存上,其特点的是访问速度慢(相对高速缓存),访问速度为 50 - 200 个时钟周期,但是容量大,造价便宜些(相对高速缓存)。

 

内存由一个一个的存储器模块(memory module)组成,它们插在主板的扩展槽上。常见的存储器模块通常以 64 位为单位(8 个字节)传输数据到存储控制器上或者从存储控制器传出数据。

 

 

如图所示内存条上黑色的元器件就是存储器模块(memory module)。多个存储器模块连接到存储控制器上,就聚合成了主存。

 

内存结构

 

而前边介绍到的 DRAM 芯片就包装在存储器模块中,每个存储器模块中包含 8 个 DRAM 芯片,依次编号为 0 - 7。

 

存储器模块

 

而每一个 DRAM 芯片的存储结构是一个二维矩阵,二维矩阵中存储的元素我们称为超单元(supercell),每个 supercell 大小为一个字节(8 bit)。每个 supercell 都由一个坐标地址(i,j)。

 

i 表示二维矩阵中的行地址,在计算机中行地址称为 RAS(row access strobe,行访问选通脉冲)。j 表示二维矩阵中的列地址,在计算机中列地址称为 CAS(column access strobe,列访问选通脉冲)。

 

下图中的 supercell 的 RAS = 2,CAS = 2。

 

DRAM 结构

 

DRAM 芯片中的信息通过引脚流入流出 DRAM 芯片。每个引脚携带 1 bit 的信号。

 

图中 DRAM 芯片包含了两个地址引脚(addr),因为我们要通过 RAS、CAS 来定位要获取的 supercell。还有 8 个数据引脚(data),因为 DRAM 芯片的 IO 单位为一个字节(8 bit),所以需要 8 个 data 引脚从 DRAM 芯片传入传出数据。

 

注意这里只是为了解释地址引脚和数据引脚的概念,实际硬件中的引脚数量是不一定的。

 

5.2 DRAM 芯片的访问

 

我们现在就以读取上图中坐标地址为(2,2)的 supercell 为例,来说明访问 DRAM 芯片的过程。

 

DRAM 芯片访问

 

  1. 首先存储控制器将行地址 RAS = 2 通过地址引脚发送给 DRAM 芯片;

  2. DRAM 芯片根据 RAS = 2 将二维矩阵中的第二行的全部内容拷贝到内部行缓冲区中;

  3. 接下来存储控制器会通过地址引脚发送 CAS = 2 到 DRAM 芯片中;

  4. DRAM 芯片从内部行缓冲区中根据 CAS = 2 拷贝出第二列的 supercell 并通过数据引脚发送给存储控制器。

 

DRAM 芯片的 IO 单位为一个 supercell,也就是一个字节(8 bit)。

 

5.3 CPU 如何读写主存

 

前边我们介绍了内存的物理结构,以及如何访问内存中的 DRAM 芯片获取 supercell 中存储的数据(一个字节)。

 

本小节我们来介绍下 CPU 是如何访问内存的。

 

CPU 与内存之间的总线结构

 

其中关于 CPU 芯片的内部结构我们在介绍 false sharding 的时候已经详细的介绍过了,这里我们主要聚焦在 CPU 与内存之间的总线架构上。

 

5.3.1 总线结构

 

CPU 与内存之间的数据交互是通过总线(bus)完成的,而数据在总线上的传送是通过一系列的步骤完成的,这些步骤称为总线事务(bus transaction)。

 

其中数据从内存传送到CPU称之为读事务(read transaction),数据从CPU传送到内存称之为写事务(write transaction)。

 

总线上传输的信号包括:地址信号,数据信号,控制信号。其中控制总线上传输的控制信号可以同步事务,并能够标识出当前正在被执行的事务信息:

 

  • 当前这个事务是到内存的?还是到磁盘的?或者是到其他 IO 设备的?
  • 这个事务是读还是写?
  • 总线上传输的地址信号(内存地址),还是数据信号(数据)?

 

还记得我们前边讲到的 MESI 缓存一致性协议吗?当 core0 修改字段 a 的值时,其他 CPU 核心会在总线上嗅探字段 a 的内存地址,如果嗅探到总线上出现字段 a 的内存地址,说明有人在修改字段 a,这样其他 CPU 核心就会失效自己缓存字段 a 所在的 cache line。

 

如上图所示,其中系统总线是连接 CPU 与 IO bridge 的,存储总线是来连接 IO bridge 和主存的。

 

IO bridge 负责将系统总线上的电子信号转换成存储总线上的电子信号。IO bridge 也会将系统总线和存储总线连接到 IO 总线(磁盘等 IO 设备)上。这里我们看到IO  bridge 其实起的作用就是转换不同总线上的电子信号。

 

5.3.2 CPU 从内存读取数据过程

 

假设 CPU 现在要将内存地址为 A 的内容加载到寄存器中进行运算。

 

CPU 读取内存

 

首先 CPU 芯片中的总线接口会在总线上发起读事务(read transaction)。该读事务分为以下步骤进行:

 

  1. CPU 将内存地址A放到系统总线上。随后 IO bridge 将信号传递到存储总线上;

  2. 主存感受到存储总线上的地址信号并通过存储控制器将存储总线上的内存地址A读取出来;

  3. 存储控制器通过内存地址 A 定位到具体的存储器模块,从 DRAM 芯片中取出内存地址 A 对应的数据 X;

  4. 存储控制器将读取到的数据X放到存储总线上,随后 IO bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递;

  5. CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中。

 

以上就是 CPU 读取内存数据到寄存器中的完整过程。

 

但是其中还涉及到一个重要的过程,这里我们还是需要摊开来介绍一下,那就是存储控制器如何通过内存地址 A 从主存中读取出对应的数据 X 的?

 

接下来我们结合前边介绍的内存结构以及从 DRAM 芯片读取数据的过程,来总体介绍下如何从主存中读取数据。

 

5.3.3 如何根据内存地址从主存中读取数据

 

前边介绍到,当主存中的存储控制器感受到了存储总线上的地址信号时,会将内存地址从存储总线上读取出来。

 

随后会通过内存地址定位到具体的存储器模块。还记得内存结构中的存储器模块吗?

 

内存结构

 

而每个存储器模块中包含了 8 个 DRAM 芯片,编号从 0 - 7。

 

存储器模块

 

存储控制器会将内存地址转换为 DRAM 芯片中 supercell 在二维矩阵中的坐标地址(RAS,CAS)。并将这个坐标地址发送给对应的存储器模块。随后存储器模块会将 RAS 和 CAS 广播到存储器模块中的所有 DRAM 芯片。依次通过 (RAS,CAS) 从 DRAM0 到 DRAM7 读取到相应的 supercell。

 

DRAM芯片访问

 

我们知道一个 supercell 存储了 8 bit 数据,这里我们从 DRAM0 到 DRAM7 依次读取到了8 个 supercell 也就是 8 个字节,然后将这 8 个字节返回给存储控制器,由存储控制器将数据放到存储总线上。

 

CPU 总是以 word size 为单位从内存中读取数据,在 64 位处理器中的 word size 为 8 个字节。64 位的内存也只能每次吞吐 8 个字节。

 

CPU 每次会向内存读写一个 cache line 大小的数据(64 个字节),但是内存一次只能吞吐 8 个字节。

 

所以在内存地址对应的存储器模块中,DRAM0 芯片存储第一个低位字节(supercell),DRAM1 芯片存储第二个字节……依次类推 DRAM7 芯片存储最后一个高位字节。

 

内存一次读取和写入的单位是 8 个字节。而且在程序员眼里连续的内存地址实际上在物理上是不连续的。因为这连续的 8 个字节其实是存储于不同的 DRAM 芯片上的。每个 DRAM 芯片存储一个字节(supercell)。

 

读取存储器模块数据

 

5.3.4 CPU 向内存写入数据过程

 

我们现在假设CPU要将寄存器中的数据X写到内存地址A中。同样的道理,CPU 芯片中的总线接口会向总线发起写事务(write transaction)。写事务步骤如下:

 

  1. CPU将要写入的内存地址A放入系统总线上。

  2. 通过IO bridge的信号转换,将内存地址A传递到存储总线上。

  3. 存储控制器感受到存储总线上的地址信号,将内存地址A从存储总线上读取出来,并等待数据的到达。

  4. CPU将寄存器中的数据拷贝到系统总线上,通过IO bridge的信号转换,将数据传递到存储总线上。

  5. 存储控制器感受到存储总线上的数据信号,将数据从存储总线上读取出来。

  6. 存储控制器通过内存地址A定位到具体的存储器模块,最后将数据写入存储器模块中的8个DRAM芯片中。

6. 为什么要内存对齐

 

我们在了解了内存结构以及 CPU 读写内存的过程之后,现在我们回过头来讨论下本小节开头的问题:为什么要内存对齐?

 

下面笔者从三个方面来介绍下要进行内存对齐的原因。

 

速度

 

CPU 读取数据的单位是根据 word size 来的,在 64 位处理器中 word size = 8 字节,所以CPU 向内存读写数据的单位为 8 字节。

 

在 64 位内存中,内存 IO 单位为 8 个字节,我们前边也提到内存结构中的存储器模块通常以 64 位为单位(8 个字节)传输数据到存储控制器上或者从存储控制器传出数据。因为每次内存 IO 读取数据都是从数据所在具体的存储器模块中包含的这 8 个 DRAM 芯片中以相同的(RAM、CAS)依次读取一个字节,然后在存储控制器中聚合成 8 个字节返回给 CPU。

 

读取存储器模块数据

 

由于存储器模块中这种由 8 个 DRAM 芯片组成的物理存储结构的限制,内存读取数据只能是按照地址顺序 8 个字节的依次读取——8 个字节 8 个字节地来读取数据。

 

内存 IO 单位

 

  • 假设我们现在读取 0x0000 - 0x0007 这段连续内存地址上的 8 个字节。由于内存读取是按照 8 个字节为单位依次顺序读取的,而我们要读取的这段内存地址的起始地址是 0(8 的倍数),所以 0x0000 - 0x0007 中每个地址的坐标都是相同的(RAS、CAS)。所以他可以在 8 个 DRAM 芯片中通过相同的(RAS、CAS)一次性读取出来。

  • 如果我们现在读取 0x0008 - 0x0015 这段连续内存上的8个字节也是一样的,因为内存段起始地址为 8(8 的倍数),所以这段内存上的每个内存地址在 DREAM 芯片中的坐标地址(RAS、CAS)也是相同的,我们也可以一次性读取出来。

 

注意:0x0000 - 0x0007 内存段中的坐标地址(RAS、CAS)与 0x0008 - 0x0015 内存段中的坐标地址(RAS、CAS)是不相同的。

 

  • 但如果我们现在读取 0x0007 - 0x0014 这段连续内存上的8个字节情况就不一样了,由于起始地址 0x0007 在 DRAM 芯片中的(RAS、CAS)与后边地址 0x0008 - 0x0014 的(RAS、CAS)不相同,所以 CPU 只能先从 0x0000 - 0x0007 读取 8 个字节出来先放入结果寄存器中并左移 7 个字节(目的是只获取 0x0007),然后 CPU 在从 0x0008 - 0x0015 读取 8 个字节出来放入临时寄存器中并右移 1 个字节(目的是获取 0x0008 - 0x0014)最后与结果寄存器或运算。最终得到 0x0007 - 0x0014 地址段上的 8 个字节。

 

从以上分析过程来看,当 CPU 访问内存对齐的地址时,比如 0x0000 和 0x0008 这两个起始地址都是对齐至8的倍数。CPU 可以通过一次 read transaction 读取出来。

 

但是当 CPU 访问内存没有对齐的地址时,比如 0x0007 这个起始地址就没有对齐至8的倍数。CPU 就需要两次 read transaction 才能将数据读取出来。

 

还记得笔者在小节开头提出的问题吗  ?"Java 虚拟机堆中对象的起始地址为什么需要对齐至 8的倍数?为什么不对齐至 4 的倍数或 16 的倍数或 32 的倍数呢?" 现在你能回答了吗?

 

原子性

 

CPU 可以原子地操作一个对齐的 word size memory。64 位处理器中 word size = 8 字节。

 

尽量分配在一个缓存行中

 

前边在介绍 false sharding 的时候我们提到目前主流处理器中的 cache line 大小为 64 字节,堆中对象的起始地址通过内存对齐至8的倍数,可以让对象尽可能的分配到一个缓存行中。一个内存起始地址未对齐的对象可能会跨缓存行存储,这样会导致 CPU 的执行效率慢 2 倍。

 

其中对象中字段内存对齐的其中一个重要原因也是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。

 

另外在 2. 字段重排列这一小节介绍的三种字段对齐规则,是保证在字段内存对齐的基础上使得实例数据区占用内存尽可能的小。

 

7. 压缩指针

 

在介绍完关于内存对齐的相关内容之后,我们来介绍下前边经常提到的压缩指针。可以通过 JVM 参数 XX:+UseCompressedOops 开启,当然默认是开启的。

 

在本小节内容开启之前,我们先来讨论一个问题,那就是为什么要使用压缩指针?

 

假设我们现在正在准备将 32 位系统切换到 64 位系统,起初我们可能会期望系统性能会立马得到提升,但现实情况可能并不是这样的。

 

在 JVM 中导致性能下降的最主要原因就是 64 位系统中的对象引用。在前边我们也提到过,64 位系统中对象的引用以及类型指针占用 64 bit 也就是 8 个字节。

 

这就导致了在 64 位系统中的对象引用占用的内存空间是 32 位系统中的两倍大小,因此间接的导致了在 64 位系统中更多的内存消耗以及更频繁的 GC 发生,GC 占用的 CPU 时间越多,那么我们的应用程序占用 CPU 的时间就越少。

 

另外一个就是对象的引用变大了,那么 CPU 可缓存的对象相对就少了,增加了对内存的访问。综合以上几点从而导致了系统性能的下降。

 

从另一方面来说,在 64 位系统中内存的寻址空间为 2^48 = 256T,在现实情况中我们真的需要这么大的寻址空间吗?好像也没必要吧~~

 

于是我们就有了新的想法:那么我们是否应该切换回32位系统呢?

 

如果我们切换回 32 位系统,我们怎么解决在 32 位系统中拥有超过 4G 的内存寻址空间呢?因为现在 4G 的内存大小对于现在的应用来说明显是不够的。

 

我想以上的这些问题,也是当初 JVM 的开发者需要面对和解决的,当然他们也交出了非常完美的答卷,那就是使用压缩指针可以在 64 位系统中利用 32 位的对象引用获得超过 4G 的内存寻址空间。

 

7.1 压缩指针是如何做到的呢?

 

还记得之前我们在介绍对齐填充和内存对齐小节中提到的,在 Java 虚拟机堆中对象的起始地址必须对齐至 8 的倍数吗?

 

由于堆中对象的起始地址均是对齐至8的倍数,所以对象引用在开启压缩指针情况下的 32 位二进制的后三位始终是 0(因为它们始终可以被 8 整除)。

 

既然 JVM 已经知道了这些对象的内存地址后三位始终是 0,那么这些无意义的 0 就没必要在堆中继续存储。相反,我们可以利用存储 0 的这 3 位 bit 存储一些有意义的信息,这样我们就多出 3 位 bit 的寻址空间。

 

这样在存储的时候,JVM 还是按照 32 位来存储,只不过后三位原本用来存储0的bit现在被我们用来存放有意义的地址空间信息。

 

当寻址的时候,JVM 将这 32 位的对象引用左移 3 位(后三位补 0)。这就导致了在开启压缩指针的情况下,我们原本32位的内存寻址空间一下变成了 35 位。可寻址的内存空间变为 2^32 * 2^3 = 32G。

 

压缩指针

 

这样一来,JVM虽然额外的执行了一些位运算但是极大的提高了寻址空间,并且将对象引用占用内存大小降低了一半,节省了大量空间。况且这些位运算对于 CPU 来说是非常容易且轻量的操作

 

通过压缩指针的原理我挖掘到了内存对齐的另一个重要原因就是通过内存对齐至8的倍数,我们可以在 64 位系统中使用压缩指针通过 32 位的对象引用将寻址空间提升至 32G。

 

从 Java7 开始,当 maximum heap size 小于 32G 的时候,压缩指针是默认开启的。但是当 maximum heap size 大于 32G 的时候,压缩指针就会关闭。

 

那么我们如何在压缩指针开启的情况下进一步扩大寻址空间呢?

 

7.2 如何进一步扩大寻址空间

 

前边提到我们在 Java 虚拟机堆中对象起始地址均需要对其至 8 的倍数,不过这个数值我们可以通过 JVM 参数 -XX:ObjectAlignmentInBytes 来改变(默认值为 8)。当然这个数值的必须是 2 的次幂,数值范围需要在 8 - 256 之间。

 

正是因为对象地址对齐至 8 的倍数,才会多出 3 位 bit 让我们存储额外的地址信息,进而将 4G 的寻址空间提升至 32G。

 

同样的道理,如果我们将 ObjectAlignmentInBytes 的数值设置为 16 呢?

 

对象地址均对齐至 16 的倍数,那么就会多出 4 位 bit 让我们存储额外的地址信息。寻址空间变为 2^32 * 2^4 = 64G。

 

通过以上规律,我们就能知道,在64位系统中开启压缩指针的情况,寻址范围的计算公式:4G * ObjectAlignmentInBytes = 寻址范围。

 

但是笔者并不建议大家贸然这样做,因为增大了 ObjectAlignmentInBytes 虽然能扩大寻址范围,但是这同时也可能增加了对象之间的字节填充,导致压缩指针没有达到原本节省空间的效果。

 

8. 数组对象的内存布局

 

前边大量的篇幅我们都是在讨论 Java 普通对象在内存中的布局情况,最后这一小节我们再来说下 Java 中的数组对象在内存中是如何布局的。

 

8.1 基本类型数组的内存布局

 

基本类型数组内存布局

 

上图表示的是基本类型数组在内存中的布局,基本类型数组在 JVM 中用 typeArrayOop 结构体表示,基本类型数组类型元信息用 TypeArrayKlass 结构体表示。

 

数组的内存布局大体上和普通对象的内存布局差不多,唯一不同的是在数组类型对象头中多出了 4 个字节用来表示数组长度的部分。

 

我们还是分别以开启指针压缩和关闭指针压缩两种情况,通过下面的例子来进行说明:

long[] longArrayLayout = new long[1];
 

开启指针压缩 -XX:+UseCompressedOops

 

 

我们看到红框部分即为数组类型对象头中多出来一个4字节大小用来表示数组长度的部分。

 

因为我们示例中的 long 型数组只有一个元素,所以实例数据区的大小只有 8 字节。如果我们示例中的 long 型数组变为两个元素,那么实例数据区的大小就会变为 16 字节,以此类推……

 

关闭指针压缩  -XX:-UseCompressedOops

 

 

当关闭了指针压缩时,对象头中的 MarkWord 还是占用 8 个字节,但是类型指针从 4 个字节变为了 8 个字节。数组长度属性还是不变保持 4 个字节。

 

这里我们发现是实例数据区与对象头之间发生了对齐填充。大家还记得这是为什么吗?

 

我们前边在字段重排列小节介绍了三种字段排列规则在这里继续适用:

 

  • 规则 1:如果一个字段占用 X 个字节,那么这个字段的偏移量 OFFSET 需要对齐至 NX。

  • 规则 2:在开启了压缩指针的 64 位 JVM 中,Java 类中的第一个字段的 OFFSET 需要对齐至 4N,在关闭压缩指针的情况下类中第一个字段的 OFFSET 需要对齐至  8N。

 

这里基本数组类型的实例数据区中是 long 型,在关闭指针压缩的情况下,根据规则 1 和规则 2 需要对齐至 8 的倍数,所以要在其与对象头之间填充 4 个字节,达到内存对齐的目的,起始地址变为 24。

 

8.2 引用类型数组的内存布局

 

引用类型数组的内存布局

 

上图表示的是引用类型数组在内存中的布局,引用类型数组在 JVM 中用 objArrayOop 结构体表示,基本类型数组类型元信息用 ObjArrayKlass 结构体表示。

 

同样在引用类型数组的对象头中也会有一个 4 字节大小用来表示数组长度的部分。

 

我们还是分别以开启指针压缩和关闭指针压缩两种情况,通过下面的例子来进行说明:

 

public class ReferenceArrayLayout { char a; int b; short c;}
ReferenceArrayLayout[] referenceArrayLayout = new ReferenceArrayLayout[1];
 
开启指针压缩 -XX:+UseCompressedOops

 

 

引用数组类型内存布局与基础数组类型内存布局最大的不同在于它们的实例数据区。由于开启了压缩指针,所以对象引用占用内存大小为 4 个字节,而我们示例中引用数组只包含一个引用元素,所以这里实例数据区中只有 4 个字节。相同的到道理,如果示例中的引用数组包含的元素变为两个引用元素,那么实例数据区就会变为 8 个字节,以此类推……

 

最后由于 Java 对象需要内存对齐至8的倍数,所以在该引用数组的实例数据区后填充了 4 个字节。

 

关闭指针压缩 -XX:-UseCompressedOops

 

当关闭压缩指针时,对象引用占用内存大小变为了 8 个字节,所以引用数组类型的实例数据区占用了 8 个字节。

 

根据字段重排列规则 2,在引用数组类型对象头与实例数据区中间需要填充 4 个字节以保证内存对齐的目的。

 

总结

 

本文笔者详细介绍了 Java 普通对象以及数组类型对象的内存布局,以及相关对象占用内存大小的计算方法。

 

以及在对象内存布局中的实例数据区字段重排列的三个重要规则。以及后边由字节的对齐填充引出来的 false sharding 问题,还有 Java8 为了解决 false sharding 而引入的 @Contented 注解的原理及使用方式。

 

为了讲清楚内存对齐的底层原理,笔者还花了大量的篇幅讲解了内存的物理结构以及 CPU 读写内存的完整过程。

 

最后又由内存对齐引出了压缩指针的工作原理。由此我们知道进行内存对齐的四个原因:

 

  • CPU 访问性能:当 CPU 访问内存对齐的地址时,可以通过一个 read transaction 读取一个字长(word size)大小的数据出来。否则就需要两个 read transaction;

  • 原子性:CPU 可以原子地操作一个对齐的 word size memory;

  • 尽可能利用 CPU 缓存:内存对齐可以使对象或者字段尽可能的被分配到一个缓存行中,避免跨缓存行存储,导致 CPU 执行效率减半;

  • 提升压缩指针的内存寻址空间: 对象与对象之间的内存对齐,可以使我们在 64 位系统中利用 32 位对象引用将内存寻址空间提升至 32G。既降低了对象引用的内存占用,又提升了内存寻址空间。

 

在本文中我们顺带还介绍了和内存布局相关的几个 JVM 参数:-XX:+UseCompressedOops, -XX +CompactFields ,-XX:-RestrictContended ,-XX:ContendedPaddingWidth, -XX:ObjectAlignmentInBytes。

 

作者:bin的技术小屋

 

 

文章来源:微信公众号

原文链接:https://mp.weixin.qq.com/s/v4ZdNaICHud0SFy8R1_7kw

点赞收藏
大绿植
请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
3
0