Skip to content

Java对象创建的过程介绍

1. 类加载检查

当JVM遇到一条new指令时,它首先会进行类加载检查。

  • 检查过程:JVM会拿着new指令后面的类名,去运行时常量池中查找,看是否能找到这个类的符号引用。
  • 判断与动作:
    • 如果找不到,说明这个类还没有被加载。JVM必须立即执行完整的类加载过程,包括加载、验证、准备、解析和初始化这几个阶段。
    • 如果能找到,并且这个类已经被加载、解析和初始化过了,那么就直接进入下一步。

2. 分配内存

类加载检查通过后,JVM就要开始在Java堆中为这个新对象分配内存了。对象所需内存的大小,在类加载完成后就已经完全确定了。

分配内存的方式主要有两种,具体采用哪一种取决于Java堆是否规整:

  • 指针碰撞 (Bump the Pointer):

    • 适用场景:如果Java堆的内存是绝对规整的,即所有用过的内存都放在一边,所有空闲的内存放在另一边,中间有一个明确的分界点指示器。
    • 分配方式:此时,分配内存就非常简单,只需要把那个分界点指针向空闲空间那边挪动一段与对象大小相等的距离即可。
    • 使用这种方式的垃圾收集器:Serial、ParNew等带有压缩整理过程的收集器。
  • 空闲列表 (Free List):

    • 适用场景:如果Java堆的内存不是规整的,已使用的内存和空闲的内存相互交错。
    • 分配方式:此时,JVM必须维护一个列表,记录上哪些内存块是可用的。在分配时,就从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
    • 使用这种方式的垃圾收集器:CMS这种基于标记-清除算法的收集器。

并发安全问题

在多线程环境下,多个线程可能同时申请内存,为了保证内存分配的原子性,JVM采用以下两种方式来解决线程安全问题: * CAS + 失败重试:JVM采用乐观锁的方式,通过CAS(Compare-And-Swap)操作来保证更新指针的原子性。如果失败,就不断重试。 * TLAB (Thread Local Allocation Buffer):这是更常用的方法。JVM会为每个新创建的线程,在堆的Eden区预先分配一小块私有的内存区域,称为本地线程分配缓冲。当线程需要分配内存时,首先在自己的TLAB中进行分配。只有当TLAB用完并需要分配新的TLAB时,才需要加同步锁。这极大地提升了内存分配的吞吐量。

3. 初始化零值

内存分配完成后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值(比如int为0, booleanfalse, 引用类型为null)。

这一步操作保证了对象的实例字段,即使在Java代码中没有被显式地赋初始值,也可以直接使用,程序会访问到这些字段对应数据类型的零值。

4. 设置对象头

接下来,JVM要对对象进行必要的设置,这些信息都存储在对象的头部(Object Header)中。对象头包含了关于对象自身的运行时数据,比如:

  • 这个对象是哪个类的实例。
  • 对象的哈希码。
  • 对象的GC分代年龄。
  • 锁状态标志。
  • 指向类元数据的指针(通过这个指针,JVM可以确定这个对象是哪个类的实例)。

5. 执行init方法

从JVM的视角看,到上一步为止,一个新对象已经产生了。但从Java程序的视角看,对象的创建才刚刚开始。接下来,会执行对象的<init>()方法,也就是我们通常所说的构造方法。

这个<init>()方法是一个由编译器生成的方法,它会按照程序代码的意图对对象进行初始化。这个过程包括: 1. 成员变量的显式初始化:执行你在类中为成员变量定义的初始值,比如 private int count = 10;。 2. 实例初始化块:执行类中定义的实例初始化块({...}代码块)。 3. 构造方法体:最后,执行构造方法中的代码。

这几个步骤的执行顺序是固定的:父类的<init>方法会先于子类的<init>方法执行,而在同一个类的<init>方法内部,成员变量初始化和实例初始化块的执行顺序,取决于它们在源代码中的书写顺序,并且它们都在构造方法体之前执行。

<init>()方法执行完毕后,一个真正可用的、被完整初始化的对象才算完全创建出来。此时,执行new指令的线程会把这个对象的引用压入操作数栈顶,赋值给对应的变量。

总结一下,一个Java对象的诞生,是一个从JVM底层到Java应用层,从分配裸内存到填充业务数据的,严谨而有序的过程。