文章目录

  • 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,对齐填充的存在就是为了满足规范要求

java 内存事务 对象 java对象的内存布局_jvm

1.1 对象头

对象头的数据总共包含了 3 个部分,以下是各个部分的用途:

  1. Mark Word 包含一系列的标识,例如锁的标记、对象年龄等。在32位系统占4字节,在64位系统中占8字节
  2. Class Pointer 指向对象所属的 Class 在方法区的内存指针,通常在32位系统占4字节,在64位系统中占8字节,64位 JVM 在 1.6 版本后默认开启了压缩指针,那就占用4个字节
  3. Length 如果对象是数组,还需要一个保存数组长度的空间,占 4 个字节

其中 Mark Word 是对象头中非常关键的一部分,其在 JVM 中结构如下图所示,读者如感兴趣可以参考 JVM 的定义文件 markOop.hpp

java 内存事务 对象 java对象的内存布局_jvm_02

java 内存事务 对象 java对象的内存布局_jvm_03

1.2 实例数据

实例数据里面主要是对象的字段数据信息,JVM 为了内存对齐在这部分会执行一个字段重排列的动作:

  • 字段重排列
    JVM 在分配内存时不一定是完全按照类定义的字段顺序去分配,而是根据 JVM 选项 -XX:FieldsAllocationStyle 来进行排序,排序遵守以下规则:
  1. 如果一个字段的大小为 N 字节,则从对象开始的内存位置到该字段的位置偏移量一定满足:字段的位置 - 对象开始位置 = mN (m >=1), 即是 N 的整数倍
  2. 子类继承的父类字段的偏移量必须和父类保持一致

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 位系统上默认开启压缩指针时,对象中各个部分内存占用如下:

  1. 对象头
    对象头中 Mark Word 占用 8 字节,类对象指针占用 4 字节,总共占用 12 字节
  2. 实例数据
    People 对象的属性 People.id 占用 4 字节,People.name 占用 4 字节,总共占用 8 字节
  3. 对齐填充
    以上两个部分总共占用 20 字节, 不满足 8N 要求,需要填充补齐 4 字节使对象总内存占用达到 24 字节,满足要求

java 内存事务 对象 java对象的内存布局_开发语言_04

2.2.2 关闭指针压缩

依然使用以上示例代码,调整 JVM 的启动参数,添加 -XX:-UseCompressedOops 关闭压缩指针,可以看到对象内存占用有以下变化:

  1. 对象头
    对象头中 Mark Word 占用 8 字节,类对象指针占用 8 字节,总共占用 16 字节
  2. 实例数据
    People 对象的属性 People.id 占用 8 字节,People.name 占用 8 字节,总共占用 16 字节
  3. 对齐填充
    以上两个部分总共占用 32 字节, 满足 8N 要求,不需要填充补齐,则不存在对齐填充数据

java 内存事务 对象 java对象的内存布局_jvm_05

2.2.3 字段重排列

默认开启压缩指针,将 People.id 字段类型进行修改Long->long,会看到People.id 字段在对象内存中的位置已经发生了变化,People.id 字段为了满足偏移量要求被排在People.name 字段之后,显然字段内存位置并不是完全按照类定义的字段顺序去分配的

java 内存事务 对象 java对象的内存布局_java 内存事务 对象_06

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 数组对象结构

默认开启压缩指针,运行以下示例代码,可以看到数组对象的内存结构如下:

  1. 对象头
    对象头中 Mark Word 占用 8 字节,类对象指针占用 4 字节,总共占用 12 字节
  2. 数组长度 & 对齐填充数据
    数组长度为 1,与填充数据的偏移量及大小一致,显然共用了内存,占用 4 字节
  3. 实例数据
    数组只有一个 People 对象,引用指针占用 4 字节,至此数组对象总大小 20 字节,不满足 8N,再在数组对象间填充 4 字节,使数组对象总大小达到 24 字节,最终满足 8N 要求

java 内存事务 对象 java对象的内存布局_jvm_07

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 示例代码

默认开启压缩指针,运行以下示例代码,可以看到子类对象的内存布局如下:

  1. 对象头
    对象头中 Mark Word 占用 8 字节,类对象指针占用 4 字节,总共占用 12 字节
  2. 实例数据
    父类 People 的属性 People.id 占用 4 字节,People.name 占用 4 字节,子类 Chinese 自身的属性Chinese.nation 占有 4 字节,总共占用 12 字节
  3. 对齐填充
    以上两个部分总共占用 24 字节, 满足 8N 要求,不需要填充补齐,则不存在对齐填充数据

java 内存事务 对象 java对象的内存布局_数据_08

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 子类对象的结构分析

从上一节可以看到子类对象的内存布局如下图所示,这个图例能够回答以下问题:

  1. 子类是否会继承父类的私有成员变量?
    从子类对象的内存布局来看,父类的私有成员变量显然被子类继承了。实际上类私有变量的访问控制符 private 只是编译器层面的限制,在计算机内存中不论是私有的还是公开的变量,都按规则存放在一起,对虚拟机来说没有区别
  2. 子类创建对象时是否会创建父类对象?
    子类继承了父类的成员变量,需要访问父类属性的时候可以通过 super 从自身内存中读取,并不需要持有父类对象,显然不会创建父类对象。不过继承自父类的私有成员变量也需要初始化,子类对象创建的时候会调用父类构造方法来完成这部分工作,但不会创建出父类对象

java 内存事务 对象 java对象的内存布局_java 内存事务 对象_09

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 信息并关闭逃逸分析,运行结果如下所示:

  1. 单个Chinese 对象占用内存 24 字节,1 亿个对象占用大约 2G 内存,打印了 10 次 GC 信息,说明对象都分配在堆内,堆内存不够进行了垃圾回收
  2. 创建 1 亿 个对象总共耗时为 467 ms

java 内存事务 对象 java对象的内存布局_java_10

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 信息,结果如下图显示:

  1. 单个Chinese 对象占用内存 24 字节,1 亿个对象占用大约 2G 内存,但是没有任何 GC 信息打印,说明没有发生 GC
  2. 打印结果显示创建 1 亿 个对象总共耗时为 11 ms,但在线程延时 100s 结束之前使用 jps 命令找到当前 java 进程 pid,再使用命令 sudo jmap -histo -F pid | head -n 30 查看堆内实例数据,能看到com.nathan.ex.ObjectMemoryLayout$Chinese 对象数量远远低于 1 亿
  3. 在没有发生 GC 的情况下,堆内实际对象数量却远低于代码创建数,之所以会有这种情况,是因为默认开启逃逸分析后,JVM 会将未逃逸出当前线程的对象在线程栈上分配,分配的方式是标量替换
  • 标量替换 首先需了解数据分为聚合量标量聚合量是能够继续分解的,而标量是不可在再分的。JVM 的标量替换是指,如果逃逸分析证明一个对象不会被其他线程访问到,并且这个对象可以再分,那么创建对象的时候,实际上会优化为使用若干个基本类型的标量数据来替换它。这样将对象拆开后在栈上分配,就可以大幅减少在堆中开辟空间创建对象的操作,降低堆内锁竞争和内存占用。另外需要注意的是,逃逸分析是基于 JIT(即时编译) 的一种应用,只有在热点代码执行达到一定条件触发 JIT 后才能正确工作,存在延后性。也就是说,在逃逸分析得出结果并开始标量替换的优化之前,热点代码通常已经执行多次,已经有部分对象在堆中生成。除此之外,当栈上空间不足时,创建对象一定会往堆内分配

java 内存事务 对象 java对象的内存布局_开发语言_11

4.2 默认开启逃逸分析,关闭标量替换

复用之前的示例代码,笔者添加了 JVM 运行配置 -XX:+PrintGC -XX:-EliminateAllocations 开启打印 GC 信息并关闭标量替换,结果如下图显示:

  1. 单个Chinese 对象占用内存 24 字节,1 亿个对象占用大约 2G 内存,打印了 10 次 GC 信息,说明虽然开启了逃逸分析,但是关闭标量替换后,未逃逸出线程的对象并没有拆分为标量在栈上分配,而是被原原本本地创建出来在堆内分配
  2. 创建 1 亿 个对象总共耗时为 444 ms,与关闭逃逸分析时相差无几

java 内存事务 对象 java对象的内存布局_数据_12