1. Java 对象的内存布局

Java 虚拟机规范定义了对象类型的数据在内存中的存储格式,一个对象由 对象头 + 实例数据 + 对齐填充数据 三个部分共同组成

  • 对象头
    包括了堆对象的类型、GC状态、锁状态和哈希码等基本信息,Java 对象和 JVM 内部对象的对象头格式一致
  • 实例数据
    主要是存放对象的类自身属性信息以及父类的属性信息,如果一个类没有字段属性,就不需要实例数据域
  • 对齐填充数据
    虚拟机规范要求每个对象所占内存字节数必须是 8N,对齐填充的存在就是为了满足规范要求

在这里插入图片描述

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

在这里插入图片描述

在这里插入图片描述

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 字节,满足要求

在这里插入图片描述

2.2.2 关闭指针压缩

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

  1. 对象头
    对象头中 Mark Word 占用 8 字节,类对象指针占用 8 字节,总共占用 16 字节
  2. 实例数据
    People 对象的属性 People.id 占用 8 字节,People.name 占用 8 字节,总共占用 16 字节
  3. 对齐填充
    以上两个部分总共占用 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 数组对象结构

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

  1. 对象头
    对象头中 Mark Word 占用 8 字节,类对象指针占用 4 字节,总共占用 12 字节
  2. 数组长度 & 对齐填充数据
    数组长度为 1,与填充数据的偏移量及大小一致,显然共用了内存,占用 4 字节
  3. 实例数据
    数组只有一个 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 示例代码

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

  1. 对象头
    对象头中 Mark Word 占用 8 字节,类对象指针占用 4 字节,总共占用 12 字节
  2. 实例数据
    父类 People 的属性 People.id 占用 4 字节,People.name 占用 4 字节,子类 Chinese 自身的属性Chinese.nation 占有 4 字节,总共占用 12 字节
  3. 对齐填充
    以上两个部分总共占用 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 子类对象的结构分析

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

  1. 子类是否会继承父类的私有成员变量?
    从子类对象的内存布局来看,父类的私有成员变量显然被子类继承了。实际上类私有变量的访问控制符 private 只是编译器层面的限制,在计算机内存中不论是私有的还是公开的变量,都按规则存放在一起,对虚拟机来说没有区别
  2. 子类创建对象时是否会创建父类对象?
    子类继承了父类的成员变量,需要访问父类属性的时候可以通过 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 信息并关闭逃逸分析,运行结果如下所示:

  1. 单个Chinese 对象占用内存 24 字节,1 亿个对象占用大约 2G 内存,打印了 10 次 GC 信息,说明对象都分配在堆内,堆内存不够进行了垃圾回收
  2. 创建 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 信息,结果如下图显示:

  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 后才能正确工作,存在延后性。也就是说,在逃逸分析得出结果并开始标量替换的优化之前,热点代码通常已经执行多次,已经有部分对象在堆中生成。除此之外,当栈上空间不足时,创建对象一定会往堆内分配

在这里插入图片描述

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

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

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

在这里插入图片描述

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐