言简意赅
本文探讨 java 对象在 hotspot 虚拟机下的内存使用细节。
- 每个 java 对象包含 12B 或 16B 的头部信息,用于支持对象锁、多态、反射等特性
- 头部之后的内存区域,存储对象成员变量的值
- 内存对齐机制改变变量地址起点,提高内存使用效率,但形成内存浪费
- hotspot 通过变量重排等手段减少内存浪费
文章结构
对象组成
hotspot 虚拟机主体用 C/C++ 语言实现,运行其中的每个 java 对象会映射到一个 C++ 对象,称为 oop。java 对象和 oop 对象是对同一块内存区域的不同表达方式,因此对 java 对象内存开销的讨论,来源于 oop 对象的内存开销。
C++ 对象有自己的内存布局方式,通常是按照成员变量声明顺序依次分配内存空间,存在虚函数的情况下,对象头是虚表指针,用于实现多态调用。
java 对象虽然根植于 C++,但并没有继承 C++ 内存布局风格,我认为有以下原因:
- 为实现反射,java 对象需要关联到类信息
- 用 C++ 的标准看,java 所有函数都是虚函数,但 java 不需要每个对象都包含一个字的虚表指针
- java语言不面向底层,内存布局可以具有更高的灵活性
oop 对象在 openJdk13 中定义如下:
class
_mark 是一个字长的对象元信息,存着 hash、锁升级信息、分代年龄等。
_metadata 是用 union 定义的指向对象所属类的指针,一个字长的 _klass 或 固定 4Bytes 的压缩指针,用于实现反射、多态等语言特性。
_mark 和 _metadata 构成了 java 对象头,内存中随后的部分是 java 对象实体(非静态成员变量),整个 java 对象在内存中的布局如下图所示:
内存对齐
进一步了解内存布局前,先要清楚内存对齐的概念。
内存对齐是指在两个变量之间插入空白区域,使得变量地址起点满足内存对齐要求。例如:
class
A对象的实际内存占用为:
x和y之间插入了 3Bytes 的空数据,目的是使得y变量满足内存对齐要求。
不同编译器的内存对齐规则存在差异,但其中一个共同点是要求变量地址起点是变量长度的整数倍,内存对齐机制使得对象实际内存占用比实际需要的更多。
内存对齐牺牲了内存利用率,但提高了内存访问效率。这是因为 cpu 是以固定步长从主存中加载数据,一次可以加载包含多个字节的数据区块。内存对齐可以避免一个变量横跨两个数据区块,从而保证数据操作能在一个指令周期中完成(实际时间开销取决于 cpu 数据获取行为,很多 cpu 以 cacheline 粒度和主存交换数据)。另外内存对齐也能使得一个数据不会占用两个 cacheline(以上内容限定为基础类型数据)。
变量重排
变量重排能提高内存利用率,其核心思想是在内存空白区插入长度较小的变量。例如对上例A类结构做改造,交换y和z的位置:
class
A对象的实际内存占用为:
内存开销从 12B 下降到 8B。
hotspot 自动重排成员变量,策略很简单,相同长度的变量连续存储,且按照字段长度有序排列。
C++ 通常不具有这种能力,我认为这受限于 C++ 语言特性和面对的问题场景。换而言之,C++程序可能存在对变量偏移地址的依赖,而java没有。例如 C++ 里使用指针能跳过对象直接修改成员变量内容;又或者部分 C++ 对象或结构体需要映射到具体硬件单元上,变量的顺序关系到硬件驱动方式。这些场景要求不同 C++ 编译器的编译风格要固定且统一。
内存布局范例
类 A,B,C 依次形成父子依赖。
public
输出子类 C 的变量顺序如下(变量偏移获取方式见文末):
16:
从数据结果能够看出:
- 变量偏移从 16Bytes 开始,前 16B 是对象头
- 同一类内,相同类型的成员变量连续存储,double 在前,short 在后
- 父类成员变量位置靠前,子类成员变量位置靠后
- 默认启用指针压缩,引用变量 d1 占用 4B 空间
- 依然存在内存浪费的情况,short1 和 d1 之间有 4B,而 short1 只使用 2B
压缩指针
有一些参数能影响java对象内存布局,首先是压缩指针。
启动参数增加 -XX:-UseCompressedOops 关闭指针压缩,看到引用的内存占用变成 8B。
...
hotspot 压缩指针是在 64bits 运行环境下使用 32bits 存储指针,并能寻址超过 4GB 的空间。压缩指针只对以下三种指针有影响
- the klass field of every object
- every oop instance field
- every element of an oop array (objArray)
开启压缩指针后,每个对象的头信息能节省 4B 空间,对象之间的相互引用也由原来的 8B 变成 4B,在java对象数量很多的场景下,节省的内存空间非常可观。
hotspot 使用 32bits 压缩指针后,依然能提升内存访问范围的方式,是在使用指针时左移三位,此时地址位提升到35bits,寻址空间也放大到32GB。
为什么压缩指针偏移地址是 3,因为压缩指针在JVM内部只影响 klass 对象和 oop 对象的引用,C++在 64bits 环境中分配对象的起始地址基于内存对齐的原则会是8的倍数(Why is dynamically allocated memory always 16 bytes aligned?),因此所有对象地址的低三位一定是0,hotspot在指针存储时就把0省去了。
hotspot在64bits环境下,堆内存小于 32GB 时默认开启压缩指针。
内存布局样式
上文提到,java 对变量重排的实现是将相同类型变量连续存储,而 FieldsAllocationStyle 属性决定不同类型变量间的存储顺序,取值有三种:
- 0:Fields order: oops, longs/doubles, ints, shorts/chars, bytes, padded fields
- 1:Fields order: longs/doubles, ints, shorts/chars, bytes, oops, padded fields
- 2:Fields allocation: oops fields in super and sub classes are together
其中 oops 是引用类型的变量。
style = 0 和 1 只是 oops 位置不同,容易理解。style = 2 时父类 oops 在本类的队尾,子类的 oops 在本类的队头,父子类 oops 在内存空间上连续,减少了 oopMap 里的记录数量。存在多层继承时,相邻两层 oops 连续,在不交错父子内存空间的前提下,尽量减少 oopMap记录数。
上文测试用例启动参数增加 -XX:FieldsAllocationStyle=2,输出为:
16:
看到 A 类引用变量和 B 类引用变量首尾相连,内存连续,C 类引用变量还在 C 类内存区域的尾部,如果还有其他类继承 C 类,会和 C 类再形成首尾相连的关系。
变量压缩
CompactFields 能够通过调整参数顺序,尽量填补 8 字节对齐造成的空白区域。
例如开启压缩指针后,klass 只占用 4 Bytes,FieldsAllocationStyle = 1且包含 double long 成员变量时,klass 和变量之间存在 4Bytes 的空白区域。此时优先插入非 oop 类型的数值类变量,没有数值变量时才插入 oop 变量,这样做是为了避免拆散多个 oop 而增加oopmap 表项。另外在 FieldsAllocationStyle = 0 时,oop 变量会被提前,4 Bytes oop和double变量之间可能产生 4 Bytes空白区,也能用其他短变量来填充。
伪共享
伪共享是指在一个缓存行内的不同变量,频繁的被两个线程同时修改,造成双方缓存行频繁失效,最终影响数据处理效率。处于伪共享状态的两个变量,操作开销与竞争处理同一个变量相同。
下表是MESI在伪共享状态下工作举例,所有操作引用同一缓存行 (例如: "R2" 值处理器2的读操作)
本地 请求 | P1 | P2 | 产生的总线请求 | 数据提供者 | |
0 | 最初 | - | - | - | - |
1 | R1 | E | - | BusRd | Mem |
2 | R2 | S | S | BusRd | P1's Cache |
3 | W1 | M | I | BusUpgr | - |
4 | W2 | I | M | BusRdX | P1's Cache |
5 | W1 | M | I | BusRdX | P2's Cache |
6 | W2 | I | M | BusRdX | P1's Cache |
hotspot 在 jdk8 里提供 @Contended 注解,被该注解标记的变量会在变量前后保持一个ContendedPaddingWidth 的空白,使得该变量不会和非同一竞争组的变量共享一个缓存行。
只是 @Contended 注解默认只给 jdk 内部使用,使用 -XX:-RestrictContended 去除限制。
public
因为标记了 @Contended 的变量会被排在本类的末尾,为了体现末尾的 padding,查询子类 B 的内存布局:
12:
看到char1和char2变量前后有超过128Bytes的空缺(受内存对齐影响)
如果标记char1和char2在同一个竞争组:
public
此时,char1和char2可以共享缓存行
12:
变量布局获取示例
也可以用 jol-core。
import
参考
CompressedOops - CompressedOops - OpenJDK
Wikihttp://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/classfile/classFileParser.cpp