文章目录
- 1. Java 对象的内存布局
- 1.1 对象头
- 1.2 实例数据
- 1.3 对齐填充
- 2. 对象内存布局的查看
- 2.1 依赖引入
- 2.2 示例代码
- 2.2.1 默认开启压缩指针
- 2.2.2 关闭指针压缩
- 2.2.3 字段重排列
- 2.2.4 数组对象结构
- 3. 子类对象的内存结构
- 3.1 示例代码
- 3.2 子类对象的结构分析
- 4. 对象的栈上分配
- 4.1 关闭逃逸分析
- 4.2 默认开启逃逸分析与标量替换
- 4.2 默认开启逃逸分析,关闭标量替换
1. Java 对象的内存布局
Java 虚拟机规范定义了对象类型的数据在内存中的存储格式,一个对象由 对象头 + 实例数据 + 对齐填充数据
对象头
包括了堆对象的类型、GC状态、锁状态和哈希码等基本信息,Java 对象和 JVM 内部对象的对象头格式一致实例数据
主要是存放对象的类自身属性信息以及父类的属性信息,如果一个类没有字段属性,就不需要实例数据域对齐填充数据
虚拟机规范要求每个对象所占内存字节数必须是 8N,对齐填充的存在就是为了满足规范要求
1.1 对象头
对象头
的数据总共包含了 3 个部分,以下是各个部分的用途:
Mark Word
包含一系列的标识,例如锁的标记、对象年龄等。在32位系统占4字节,在64位系统中占8字节Class Pointer
指向对象所属的 Class 在方法区的内存指针,通常在32位系统占4字节,在64位系统中占8字节,64位 JVM 在 1.6 版本后默认开启了压缩指针,那就占用4个字节Length
如果对象是数组,还需要一个保存数组长度的空间,占 4 个字节
其中 Mark Word
是对象头中非常关键的一部分,其在 JVM 中结构如下图所示,读者如感兴趣可以参考 JVM 的定义文件 markOop.hpp
1.2 实例数据
实例数据里面主要是对象的字段数据信息,JVM 为了内存对齐在这部分会执行一个字段重排列
的动作:
- 字段重排列
JVM 在分配内存时不一定是完全按照类定义的字段顺序去分配,而是根据 JVM 选项-XX:FieldsAllocationStyle
来进行排序,排序遵守以下规则:
- 如果一个字段的大小为 N 字节,则从对象开始的内存位置到该字段的位置偏移量一定满足:字段的位置 - 对象开始位置 = mN (m >=1), 即是 N 的整数倍
- 子类继承的父类字段的偏移量必须和父类保持一致
JVM 选项
-XX:FieldsAllocationStyle
的候选值如下:
- 0:先放入oops(普通对象引用指针),再放入基本变量类型(顺序:long/double、int、short/char、byte/boolean)
- 1:默认值,先放入基本变量类型(顺序:long/double、int、short/char、byte/boolean),然后放入oops(普通对象引用指针)
- 2:oops和基本变量类型交叉存储
1.3 对齐填充
对齐填充数据不是必须的,另外填充数据可能在实例数据末尾,也可能穿插在实例数据各个属性之间。JVM 堆中所有对象分配的内存字节总数必须是 8N,如果对象头和实例数据占用的总大小不满足要求,则需要通过对齐数据来填满
计算机访问内存的方式
计算机处理器读取内存数据时不是逐个字节去访问,而是以内存访问粒度(2、4、8、16 甚至 32 字节的块)
为单位访问内存。这样做的目的是减少内存访问次数,加快执行速度JVM 要求内存对齐的原因
基于以上内存访问方式,对于未对齐地址的内存数据,处理器一次访问非常可能在这块内存中取到不需要的数据,必须额外做一些移除不需要的数据的工作,再将其放置在寄存器中,这会严重影响内存访问效率,具体分析读者可参考 IBM 开发者网站文章 。总而言之,JVM 要求内存对齐是为了计算机高效寻址,快速读取对象数据
2. 对象内存布局的查看
2.1 依赖引入
在项目中添加以下依赖,即可使用相关 API 查看对象的内存布局
Maven 依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
Gradle 依赖:
implementation('org.openjdk.jol:jol-core:0.16')
2.2 示例代码
2.2.1 默认开启压缩指针
压缩指针
我们知道,数据保存在计算机内存中实际都有一个起始内存地址,而对象数据映射到操作系统层面其实就可以看作是一段内存。在 64 位操作系统中,如果每一个对象都使用实际的 64 位地址指针来引用,那指针本身所占用的内存就是一个开销。 所谓压缩指针实际上是一个相对于 64 位 Java 堆内存基址的 32 位对象偏移量,借助这个 32 位偏移量而不是 64 位实际内存地址,虚拟机就可以用更小的内存耗费引用 64 位系统内存中的对象,给其他数据留下更多内存空间。另外因为它是对象偏移量而不是字节偏移量,所以可用于处理多达 40 亿个对象。具体信息读者可参考 官方文档
public class ObjectMemoryLayout {
static class People {
private Long id;
private String name;
}
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new People()).toPrintable());
}
}
可以看到在 64 位系统上默认开启压缩指针时,对象中各个部分内存占用如下:
- 对象头
对象头中Mark Word
占用 8 字节,类对象指针
占用 4 字节,总共占用 12 字节- 实例数据
People 对象的属性People.id
占用 4 字节,People.name
占用 4 字节,总共占用 8 字节- 对齐填充
以上两个部分总共占用 20 字节, 不满足 8N 要求,需要填充补齐 4 字节使对象总内存占用达到 24 字节,满足要求
2.2.2 关闭指针压缩
依然使用以上示例代码,调整 JVM 的启动参数,添加 -XX:-UseCompressedOops
关闭压缩指针,可以看到对象内存占用有以下变化:
- 对象头
对象头中Mark Word
占用 8 字节,类对象指针
占用 8 字节,总共占用 16 字节- 实例数据
People 对象的属性People.id
占用 8 字节,People.name
占用 8 字节,总共占用 16 字节- 对齐填充
以上两个部分总共占用 32 字节, 满足 8N 要求,不需要填充补齐,则不存在对齐填充数据
2.2.3 字段重排列
默认开启压缩指针,将 People.id
字段类型进行修改Long->long
,会看到People.id
字段在对象内存中的位置已经发生了变化,People.id
字段为了满足偏移量要求被排在People.name
字段之后,显然字段内存位置并不是完全按照类定义的字段顺序去分配的
public class ObjectMemoryLayout {
static class People {
private long id;
private String name;
}
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new People()).toPrintable());
}
}
2.2.4 数组对象结构
默认开启压缩指针,运行以下示例代码,可以看到数组对象的内存结构如下:
- 对象头
对象头中Mark Word
占用 8 字节,类对象指针
占用 4 字节,总共占用 12 字节- 数组长度 & 对齐填充数据
数组长度为 1,与填充数据的偏移量及大小一致,显然共用了内存,占用 4 字节- 实例数据
数组只有一个People
对象,引用指针占用 4 字节,至此数组对象总大小 20 字节,不满足 8N,再在数组对象间填充 4 字节,使数组对象总大小达到 24 字节,最终满足 8N 要求
public class ObjectMemoryLayout {
static class People {
private Long id;
private String name;
}
public static void main(String[] args) {
People[] peoples = {new People()};
System.out.println(ClassLayout.parseInstance(peoples).toPrintable());
}
}
3. 子类对象的内存结构
3.1 示例代码
默认开启压缩指针,运行以下示例代码,可以看到子类对象的内存布局如下:
- 对象头
对象头中Mark Word
占用 8 字节,类对象指针
占用 4 字节,总共占用 12 字节- 实例数据
父类 People 的属性People.id
占用 4 字节,People.name
占用 4 字节,子类 Chinese 自身的属性Chinese.nation
占有 4 字节,总共占用 12 字节- 对齐填充
以上两个部分总共占用 24 字节, 满足 8N 要求,不需要填充补齐,则不存在对齐填充数据
public class ObjectMemoryLayout {
static class People {
private Long id;
private String name;
}
static class Chinese extends People {
private String nation;
}
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new Chinese()).toPrintable());
}
}
3.2 子类对象的结构分析
从上一节可以看到子类对象的内存布局如下图所示,这个图例能够回答以下问题:
- 子类是否会继承父类的私有成员变量?
从子类对象的内存布局来看,父类的私有成员变量显然被子类继承了。实际上类私有变量的访问控制符private
只是编译器层面的限制,在计算机内存中不论是私有的还是公开的变量,都按规则存放在一起,对虚拟机来说没有区别- 子类创建对象时是否会创建父类对象?
子类继承了父类的成员变量,需要访问父类属性的时候可以通过super
从自身内存中读取,并不需要持有父类对象,显然不会创建父类对象。不过继承自父类的私有成员变量也需要初始化,子类对象创建的时候会调用父类构造方法来完成这部分工作,但不会创建出父类对象
4. 对象的栈上分配
在JVM 1.8 版本后,虚拟机默认开启 逃逸分析
特性,如果对象不会被当前线程以外的线程访问到,则优化为在线程栈上分配,从而减轻堆内 GC 压力。有关于这点可以通过以下示例验证
4.1 关闭逃逸分析
public class ObjectMemoryLayout {
static class People {
private Long id;
private String name;
}
static class Chinese extends People {
private String nation;
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
System.out.println("====== start create");
for (int i = 0; i < 100000000; i++) {
new Chinese();
}
System.out.println("====== cost: " + (System.currentTimeMillis() - start));
}
}
示例代码在 for 循环中创建1亿个 Chinese
对象,并且笔者添加了 JVM 运行配置 -XX:+PrintGC
-XX:-DoEscapeAnalysis
打印 GC 信息并关闭逃逸分析,运行结果如下所示:
- 单个
Chinese
对象占用内存 24 字节,1 亿个对象占用大约 2G 内存,打印了 10 次 GC 信息,说明对象都分配在堆内,堆内存不够进行了垃圾回收- 创建 1 亿 个对象总共耗时为 467 ms
4.2 默认开启逃逸分析与标量替换
public class ObjectMemoryLayout {
static class People {
private Long id;
private String name;
}
static class Chinese extends People {
private String nation;
}
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
System.out.println("====== start create");
for (int i = 0; i < 100000000; i++) {
new Chinese();
}
System.out.println("====== cost: " + (System.currentTimeMillis() - start));
// 线程延时 100s 结束
Thread.sleep(100000);
}
}
复用之前的示例代码,笔者只添加了 JVM 运行配置 -XX:+PrintGC
开启打印 GC 信息,结果如下图显示:
- 单个
Chinese
对象占用内存 24 字节,1 亿个对象占用大约 2G 内存,但是没有任何 GC 信息打印,说明没有发生 GC- 打印结果显示创建 1 亿 个对象总共耗时为 11 ms,但在线程延时 100s 结束之前使用
jps
命令找到当前 java 进程 pid,再使用命令sudo jmap -histo -F pid | head -n 30
查看堆内实例数据,能看到com.nathan.ex.ObjectMemoryLayout$Chinese
对象数量远远低于 1 亿- 在没有发生 GC 的情况下,堆内实际对象数量却远低于代码创建数,之所以会有这种情况,是因为默认开启逃逸分析后,JVM 会将未逃逸出当前线程的对象在线程栈上分配,分配的方式是
标量替换
标量替换
首先需了解数据分为聚合量
和标量
,聚合量是能够继续分解的,而标量是不可在再分的。JVM 的标量替换是指,如果逃逸分析证明一个对象不会被其他线程访问到,并且这个对象可以再分,那么创建对象的时候,实际上会优化为使用若干个基本类型的标量数据来替换它。这样将对象拆开后在栈上分配,就可以大幅减少在堆中开辟空间创建对象的操作,降低堆内锁竞争和内存占用。另外需要注意的是,逃逸分析是基于 JIT(即时编译) 的一种应用,只有在热点代码执行达到一定条件触发 JIT 后才能正确工作,存在延后性。也就是说,在逃逸分析得出结果并开始标量替换的优化之前,热点代码通常已经执行多次,已经有部分对象在堆中生成。除此之外,当栈上空间不足时,创建对象一定会往堆内分配
4.2 默认开启逃逸分析,关闭标量替换
复用之前的示例代码,笔者添加了 JVM 运行配置 -XX:+PrintGC -XX:-EliminateAllocations
开启打印 GC 信息并关闭标量替换,结果如下图显示:
- 单个
Chinese
对象占用内存 24 字节,1 亿个对象占用大约 2G 内存,打印了 10 次 GC 信息,说明虽然开启了逃逸分析,但是关闭标量替换后,未逃逸出线程的对象并没有拆分为标量在栈上分配,而是被原原本本地创建出来在堆内分配- 创建 1 亿 个对象总共耗时为 444 ms,与关闭逃逸分析时相差无几