跳至主要內容

对象是如何创建出来的

AruNi_LuJavaJVM约 1859 字大约 6 分钟

本文内容

前言

在编写 Java 程序时,经常需要 new 对象,你知道 new 关键字背后的秘密吗?JVM 是如何执行 new 指令的呢?本文就带你一探究竟。

0. 概览

首先从全局的角度来看看 new 一个对象的整体过程,会经历如下几个主要步骤:

  1. 类加载检查
  2. 内存分配
  3. 初始化零值
  4. 填充对象头信息
  5. 执行构造函数<init>() 方法)。

1. 类加载检查

在方法区中的 常量池 里,存储了类的信息,还有一个常量池表,用来存放 编译时生成的各种字面量和符号引用

想要创建一个对象,首先肯定得拿到该对象对应的类,那么就需要先将该类加载到内存。

所以在类加载检查时,首先就会去 常量池中定位到这个类的符号引用,然后 检查该符号引用代表的类是否已经被加载、解析和初始化过了。如果没有,则会先执行类加载过程。

2. 内存分配

类加载检查通过后,就会给对象 分配内存空间,这个过程其实就是 在堆中划分出一块确定大小的内存

那么怎么划分呢?根据垃圾收集器是否带有 空间压缩整理 的能力,在 JVM 中有两种内存分配的划分方式:

  • 指针碰撞
  • 空闲列表

2.1 指针碰撞

如果 Java 堆内存是绝对规整的,所有 已使用的内存放在一边,未使用的内存放在另一边,那么就可以在这两段内存的 中间放一个指针作为分界点,这样在分配内存的时候,就只用简单的 把该指针往空闲内存的方向移动一段与对象大小相等的距离即可,这种内存分配方式就叫 指针碰撞

而堆内存是否规整,就取决于采用的垃圾收集器是否带有空间压缩整理(Compact)的能力。

比如 Serial、ParNew 等带有压缩整理的收集器,采用的内存分配算法就是指针碰撞,既简单又高效。

2.2 空闲列表

如果 Java 堆中的内存不是规整的两部分,而是 已使用和未使用的内存相互交错在一起,那 JVM 就会维护一个列表,该列表记录哪些内存块是可用的,这样在分配内存时,就可以在这个 空闲列表中找出一块足够的空间划分给对象实例,这种方式就叫 空闲列表

像 CMS 这种基于清除(Sweep)算法的收集器,就只能使用这种空闲列表的方式分配内存了。

2.3 并发修改问题

创建对象是一个非常频繁的行为,在有 大量对象同时创建时,如果只是简单的修改指针的位置、或者在空闲列表上找一块空闲空间,然后再把这块空间在列表上更新为已使用。这都是一种更新行为,在并发情况下都 不是线程安全的

在 JVM 中有两种解决并发问题的方案:

  • 对分配内存的空间进行同步处理,JVM 使用的是 CAS 来保证更新的原子性;

  • 把内存分配按线程的不同划分到不同的空间上进行,即 每个线程会在堆中预先分配一块小内存,称之为 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。

    也就是说,在线程分配内存时,首先会在自己的 TLAB 中进行,每个线程的 TLAB 互不相同,自然也不存在线程安全问题了。

    只有在自己的 TLAB 使用完时,向堆中分配新的缓冲区才需要进行同步处理

是否让 JVM 使用 TLAB,可以用参数 -XX:+/-UseTLAB 来设定。

3. 初始化零值

给对象分配完内存后,JVM 必须要 将分配到的内存空间都初始化为零值(如果使用了 TLAB,那初始化零值的工作也可以提前到 TLAB 分配时顺便进行)。

为什么要事先初始化为零值呢?这样可以让对象的实例字段在代码中可以 不赋初值就能直接使用,访问到的就是这些字段类型对应的零值。

4. 填充对象头信息

接下来,JVM 就需要 在对象的对象头(Object Header)中填充该对象的一些信息,包括:

  • 该对象对应的是哪个类的实例
  • 如何找到类的元数据信息
  • 对象的哈希码(实际上会延迟到真正调用 Object::hashCode() 时才计算);
  • 对象的 GC 分代年龄
  • 是否启用偏向锁
  • 其他信息

补充:对象在堆内存中的内存布局是怎样的?

在 JVM 中,对象在堆内存中可以划分为三个部分:

  • 对象头(Header);
  • 实例数据(Instance Data);
  • 对齐填充(Padding)。

对象头 部分包含两类信息:

  • 对象自身的运行时数据,比如哈希码、GC 分代年龄、锁状态标志、偏向线程 ID 等。这部分信息称为 Mark Word
  • 类型指针,即对象指向它类型元数据的指针,通过该指针来确定该对象是哪个类的实例

如果对象是一个数组,那么对象头中还会包含该数组的长度信息。

实例数据 就是对象真正存储的有效信息了,即 对象的字段信息(包括从父类继承下来的)。

对齐填充 不是必须的,只是用来占位,因为 JVM 的自动内存管理系统要求对象的起始地址必须是 8 字节的整数倍,即 对象的大小必须是 8 字节的整数倍

对象头部分已经被精心设计成 8 字节的整数倍了,所以对齐填充主要是填充实例数据部分。

内存对齐可以让处理器在读取数据时直接按照规定的大小一次性读取,而不用做额外的处理,提高了效率。

5. 执行构造函数

经过了上面的步骤,从 JVM 的视角看,一个新对象已经创建好了。但从我们程序员的视角来看,对象的创建才刚刚开始,因为 此时所有的字段都是零值,还没有执行我们的构造函数(对应 Class 文件中的 <init>() 方法)。

所以接下来就会 执行 <init>() 方法,按照程序员的意愿来对对象进行初始化,这样一个真正可用的对象就被完全构造出来了。

6. 总结

最后用一张流程图来概括一个对象被创建的过程:

image-20230820114138786

上次编辑于: