第08章_对象实例化和直接内存

8.1. 对象实例化

面试题

  • 美团

    • 对象在JVM中是怎么存储的?
    • 对象头信息里面有哪些东西?
  • 蚂蚁金服

    • Java对象头有什么?

image-20230605090955721

8.1.1. 创建对象的方式

  • new:最常见的方式、Xxx的静态方法,XxxBuilder/XxxFactory的静态方法

  • Class的newInstance方法:反射的方式,只能调用空参的构造器,权限必须是public

  • Constructor的newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求

  • 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口,实现clone()

  • 使用序列化:从文件中、从网络中获取一个对象的二进制流

  • 第三方库 Objenesis

8.1.2. 创建对象的步骤

从执行步骤的角度来分析:

image-20230605091717581

1. 类加载检查

当JVM试图加载一个类时,它会首先检查这个类是否已经被加载。如果类还没有被加载,JVM会通过类加载器来加载这个类。类加载器会根据类的全限定名来查找并加载类的字节码文件,如果这个类在类路径当中没有找到,那么就会抛出ClassNotFoundException

2. 为对象分配内存

首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小

如果内存规整虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。

  • 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial ,ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带Compact(整理)过程的收集器时,使用指针碰撞。

如果内存不规整虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。

  • 已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。

选择哪种分配方式由Java堆是否规整所决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

3. 处理内存分配并发问题

由于对象实例的创建在JVM中非常频繁,因此在并发环境下多个线程从堆区中划分内存空间是线程不安全的,可以采用以下方式进行解决:

  • 每个线程预先分配一块TLAB(线程分配缓冲区),它存在于Eden区当中,在为对象分配内存时,首先会在TLAB中进行分配,当对象所需内存大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用 CAS+失败重试 进行内存分配:通过设置 -XX:+UseTLAB参数来设定。
  • 采用CAS + 失败重试的方式保证更新操作的原子性。

4. 初始化分配到的内存

对象创建完毕后,虚拟机必须将分配到的内存空间都初始化为零值。这样可以保证实例字段在Java代码中不实例化就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

5. 设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现

6. 执行init方法进行初始化

在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

因此一般来说(由字节码中跟随invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。

给对象属性赋值的操作

  • 属性的默认初始化

  • 显式初始化

  • 代码块中初始化

  • 构造器中初始化

8.2. 对象内存布局

**对象在堆空间的内存布局有以下三部分组成**:

  • 对象头
  • 实例数据
  • 对齐填充

image-20230707115724601

8.2.1. 对象头(Header)

对象头包含了两部分,分别是对象标记(Mark Word)和类型指针(Class Pointer)。如果是数组,还需要记录数组的长度。

对象头(Object Header)的大小取决于JVM的架构和对象本身。在32位的JVM中,对象头的大小至少为8字节,包括4字节的对象标记(Mark Word)和4字节的类型指针(Klass Word)。而在64位的JVM中,对象头的大小至少为12字节或16字节,具体取决于是否开启了指针压缩(CompressedOops)。如果开启了指针压缩,对象头的大小为12字节,包括8字节的对象标记和4字节的类型指针;如果没有开启指针压缩,对象头的大小为16字节,包括8字节的对象标记和8字节的类型指针。

此外,如果对象是一个数组,那么对象头还需要额外的4个或8个字节来存储数组长度(Array Length),具体取决于JVM的架构和是否开启了指针压缩。

8.2.1.1对象标记(Mark Word)

对象标记(Mark Word)的大小也取决于JVM的架构。在32位的JVM中,对象标记的大小为32位,也就是4字节。而在64位的JVM中,对象标记的大小为64位,也就是8字节。

  • 哈希值(HashCode):用于确定对象在哈希表中的位置。可以通过调用对象的hashCode()方法来获取对象的哈希值。
  • GC分代年龄:用于确定对象是否需要在垃圾回收时晋升到老年代。
  • 线程持有的锁:用于确定哪个线程持有了对象的锁。
  • 锁状态标志:用于确定对象的锁状态,例如无锁、偏向锁、轻量级锁或重量级锁。
  • 偏向线程ID:用于确定哪个线程持有了对象的偏向锁。
  • 偏向时间戳:用于记录偏向锁的获取时间。

img

8.2.1.2类型指针

类型指针就是一个指向方法区当中该类元数据信息的指针。这样,虚拟机就能够通过这个指针快速地访问到该类的元数据信息,从而实现对对象的操作。
例如,当我们调用一个对象的方法时,虚拟机会根据对象头中的类型指针找到方法区中存储的该类的方法信息,然后根据方法信息中的指令执行相应的操作。

类型指针(Klass Word)的大小取决于JVM的架构。在32位的JVM中,类型指针的大小为32位,也就是4字节。而在64位的JVM中,类型指针的大小为64位,也就是8字节。不过,在64位的JVM中,可以通过开启指针压缩(CompressedOops)来减小类型指针的大小,将其压缩至32位(4字节)。

可以通过添加JVM参数来显式配置指针压缩

-XX:+UseCompressedOops  // 开启指针压缩
-XX:-UseCompressedOops  // 关闭指针压缩

8.2.1.3数组长度

只有数组对象才有。数组长度(Array Length)的大小也取决于JVM的架构。在32位的JVM中,数组长度的大小为32位,也就是4字节。而在64位的JVM中,数组长度的大小为64位,也就是8字节。不过,在64位的JVM中,可以通过开启指针压缩(CompressedOops)来减小数组长度的大小,将其压缩至32位(4字节)。

8.2.2. 实例数据(Instance Data)

实例数据中存储了对象的有效信息,包括从父类继承的信息和子类中定义的信息。实例数据部分的大小取决于对象所属类中定义的字段类型和数量。

  • 相同宽度的字段总是被分配在一起
  • 父类中定义的变量会出现在子类之前

8.2.3. 对齐填充(Padding)

对齐填充(Padding)是对象内存布局中的另一部分。它并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。

由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。如果对象的大小就是8字节的整数倍,那么就不用再进行对齐填充了。

8.2.4对象内存布局图示

对于以下程序:

public class Customer{
    public static void main(String[] args) {
        Customer cust = new Customer();
    }
}

对于以上代码

  1. 虚拟机栈当中存储的是一个个的栈帧,每一个栈帧又代表着一个个方法的调用。
  2. 由于main方法是静态方法,所以this并不存在于局部变量表中。局部标量表中有args和cust。
  3. 局部变量表中的对象引用指向了堆空间当中的对象实例。其中对象实例分为对象头和实例数据。
  4. 对象头当中又分为运行时元数据和类型指针,类型指针指向方法区当中的类元信息。
  5. 运行时元数据又分为哈希值、GC分代年龄、线程所持有的锁、锁状态、偏向线程ID以及偏向时间戳。
  6. 实例数据当中存在的是对象真正有用的信息。

image-20230605114648347

8.2.5对象内存布局示例

8.2.5.1JOL工具介绍

JOL (Java Object Layout)一个分析 JVM 中对象内存布局的工具。可以查看对象的内存布局、内存踪迹和引用。这使得 JOL 的分析比其它工具更精确。通过 OpenJDK 官方提供的 JOL 工具,我们即可很方便分析、了解一个 Java 对象在内存当中的具体布局情况

使用:在Maven中引入JOL依赖

<!-- JOL依赖 -->
<dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.9</version>
</dependency>

8.2.5.2查看对象的内存布局信息

字段 含义
OFFSET 偏移量,也就是到这个字段位置所占用的byte数
SIZE 该类型字节大小
TYPE Class中定义的类型
DESCRIPTION 描述
VALUE 内存中的值
  1. 只有对象头
class User{

}

public class JOLDemo {
    public static void main(String[] args) {
        User user = new User();
//        查看对象内部信息
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
}

image-20230707143144012

由于该对象是一个空对象。查看对象的内存布局发现只有对象头信息。因为对象头在64bit的JVM当中最少为12或者是16bit(12bit就是开启了指针压缩,将类型指针由8bit压缩到了4bit)。则0-4和4-8是就是对象标记(Mark Word),8-12就是被压缩后的类型指针

由于JVM要求对象的起始地址要为8字节的整数倍,也就是对象大小要为8字节的整数倍。当前为12字节,所以采取对齐填充将对象大小由12字节增大到16字节。

  1. 对象添加字段,含有对象头+实例数据
class User{
    private int age;
    private boolean married;
}

public class JOLDemo {
    public static void main(String[] args) {
        User user = new User();
//        查看对象内部信息
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
}

image-20230707150132966

从上图可以看出,对象头占用12字节。实例数据中int类型的字段age占用4字节,boolean类型的字段married占用1字节,所用一共是17字节。

由于JVM要求对象的起始地址要为8字节的整数倍,也就是对象大小要为8字节的整数倍。当前为17字节,所以采取对齐填充将对象大小由17字节增大到24字节。

8.2.5.3分析GC分代年龄

GC分代年龄为什么是15?

因为在对象头当中的对象标识的GC分代年龄就是使用4bit来存储的。4bit表示的最大10进制数就是15(即最大1111)。

8.2.5.4压缩指针

  1. 查看压缩指针参数

首先使用以下命令打印出那些已经被设置过的详细的XX参数的名称和值。

java -XX:+PrintCommandLineFlags -version

image-20230707152604863

  1. 查看压缩指针场景
class User{

}

public class JOLDemo {
    public static void main(String[] args) {
        User user = new User();
//        查看对象内部信息
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
}

image-20230707150132966

从上图可以看出,对象头占用12字节。实例数据中int类型的字段age占用4字节,boolean类型的字段married占用1字节,所用一共是17字节。

由于JVM要求对象的起始地址要为8字节的整数倍,也就是对象大小要为8字节的整数倍。当前为17字节,所以采取对齐填充将对象大小由17字节增大到24字节。

  1. 关闭压缩指针

使用命令:

-XX:+UseCompressedClassPointers

8.3. 对象的访问定位

JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢?

image-20230616100409986

8.3.1. 句柄访问

在堆空间当中创建一个句柄池,reference中存储句柄地址,而句柄当中包含了对象示例和类的元数据的指针。

image-20230616100501766

8.3.2. 直接指针(HotSpot采用)

直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的类的元数据。

image-20230616100527620

8.4. 直接内存(Direct Memory)

8.4.1. 直接内存概述

不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域直接内存是在Java堆外的、直接向系统申请的内存区间。来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存。通常,访问直接内存的速度会优于Java堆,即读写性能高。

  • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。

  • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

  • 分配回收成本较高

  • 不受JVM内存回收管理

直接内存大小可以通过MaxDirectMemorySize设置。如果不指定,默认与堆的最大值-Xmx参数值一致

8.4.2. 非直接缓存区

使用IO读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要两份内存存储重复数据,效率低。

image-20230616101511545

8.4.3. 直接缓存区

使用NIO时,操作系统划出的直接缓存区可以被java代码直接访问,只有一份。NIO适合对大文件的读写操作。

image-20230616101648066


第08章_对象实例化和直接内存
https://xhablog.online/2022/05/16/JVM-第08章_对象实例化和直接内存/
作者
Xu huaiang
发布于
2022年5月16日
许可协议