new 一个对象的过程中,JVM 内部发生了什么?
本文详解了new一个对象时JVM的内部过程:首先进行类加载检查,然后在堆中分配内存并初始化(赋零值、设对象头),最后执行构造函数
()完成对象创建。
这是一个非常经典且核心的 JVM 问题。当我们在 Java 代码中写下 new Person(); 这样一行简单的代码时,JVM 内部经历了一个严谨而复杂的过程。
下面我将这个过程分解为几个关键步骤,并结合字节码和内存区域进行详细解释。
宏观流程概览
new一个对象的过程可以概括为以下三大步:
- 类加载检查与加载:确保要创建的对象的类已经被加载到内存中。
- 对象内存分配:在堆内存中为新对象划分一块空间。
- 对象初始化:对分配的内存进行初始化,包括设置对象头、为实例变量赋零值,并执行构造函数
<init>()。
详细步骤分解
让我们以 Person p = new Person(); 为例,深入探讨每一步的细节。
第 1 步:类加载检查 (Class Loading Check)
当 JVM 遇到 new 指令时,它首先会执行以下检查:
- 查找类的符号引用:JVM 会去运行时常量池中查找是否存在
Person类的符号引用。 - 检查类是否已加载:检查这个符号引用代表的类是否已经被加载、解析和初始化过。
- 如果否 (类未加载):JVM 必须立即启动类加载过程。这个过程本身也很复杂,包括:
- 加载 (Loading):通过类的全限定名找到对应的
.class文件,读取其二进制字节流,并将其转换为方法区中的运行时数据结构,同时在堆中生成一个代表这个类的java.lang.Class对象。 - 链接 (Linking):
- 验证 (Verification):确保
.class文件的字节流符合 JVM 规范,没有安全问题。 - 准备 (Preparation):为类的静态变量 (static fields) 分配内存,并设置其初始零值(如
int为 0,boolean为false, 引用类型为null)。 - 解析 (Resolution):将常量池中的符号引用替换为直接引用(内存地址)。
- 验证 (Verification):确保
- 初始化 (Initialization):执行类的构造器方法
<clinit>()。这个方法由编译器收集所有类变量的赋值动作和静态代码块 (static{}) 中的语句合并而成。只有在这一步,静态变量才会被赋予代码中指定的初始值。
- 加载 (Loading):通过类的全限定名找到对应的
- 如果是 (类已加载):直接进行下一步。
- 如果否 (类未加载):JVM 必须立即启动类加载过程。这个过程本身也很复杂,包括:
第 2 步:为对象分配内存 (Memory Allocation)
类加载完成后,JVM 就知道这个对象需要多大的内存空间了(实例变量大小 + 对象头大小)。接下来,JVM 会在Java 堆 (Heap) 中为这个新对象分配内存。
分配内存的方式主要有两种,具体选择哪种取决于所使用的垃圾收集器和堆内存是否规整:
指针碰撞 (Bump the Pointer)
- 适用场景:如果 Java 堆是规整的(即所有已用内存和未用内存分列两侧),比如使用 Serial、ParNew 等带压缩整理过程的收集器。
- 工作方式:JVM 维护一个指针,指向空闲内存的起始位置。当需要分配内存时,只需将该指针向空闲方向移动对象大小的距离即可。
- 并发问题:在多线程环境下,多个线程可能同时申请内存,导致指针位置更新冲突。解决方法通常是:
- CAS (Compare-And-Swap):通过 CAS 机制配合失败重试来保证指针更新的原子性。
- TLAB (Thread Local Allocation Buffer):为每个线程在 Eden 区预先分配一小块私有内存。线程优先在自己的 TLAB 中分配,只有当 TLAB 用完或不足时,才需要通过加锁方式在堆中分配新的 TLAB。这是 HotSpot JVM 默认使用的方式,能极大地提升分配效率。
空闲列表 (Free List)
- 适用场景:如果 Java 堆是不规整的(已用和未用内存交错),比如使用 CMS 这种基于标记-清除算法的收集器。
- 工作方式:JVM 维护一个列表,记录了哪些内存块是可用的。当需要分配时,从列表中找到一块足够大的内存块划分给对象实例,并更新列表记录。
第 3 步:内存空间零值初始化 (Zero Initialization)
内存分配完成后,JVM 会将分配到的内存空间(不包括对象头)都初始化为零值。
- 目的:这是 Java 语言安全性的一个重要保障。它确保了对象的实例字段在不赋初始值的情况下也能有一个可预期的默认值(如
int为 0,Object为null),而不是一个随机的内存值。
第 4 步:设置对象头 (Object Header)
接下来,JVM 需要对对象的对象头 (Header) 进行必要的设置。对象头包含了两部分信息:
- Mark Word (标记字):用于存储对象自身的运行时数据,如哈希码 (HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等。这部分数据是动态的,会随着对象的运行状态而改变。
- Klass Pointer (类型指针):这是一个指向方法区中该对象对应类元数据 (Class Metadata) 的指针。通过这个指针,JVM 才能确定这个对象是哪个类的实例,从而能够访问到类的方法、字段等信息。
- 数组长度 (如果对象是数组):如果对象是一个数组,对象头中还会有一块区域用于记录数组的长度。
第 5 步:执行 <init>() 方法 (Instance Initialization)
上述步骤都是由 JVM 内部完成的。从开发者的视角来看,真正的“初始化”才刚刚开始。
- 执行构造函数:JVM 会调用对象的构造函数,即
<init>()方法。 - 初始化顺序:在
<init>()方法内部,会按照以下顺序进行初始化:- 调用父类的构造函数 (
super())。 - 为实例变量进行显示赋值(如
private int age = 10;)。 - 执行构造代码块 (
{ ... })。 - 执行构造函数中剩余的代码。
- 调用父类的构造函数 (
这个 <init>() 方法执行完毕后,一个真正意义上完整、可用的对象才算创建完成。
字节码视角
让我们看一下 Person p = new Person(); 对应的字节码,这能更清晰地揭示整个过程:
0: new #2 // class Person (对应步骤1, 2, 3, 4:检查类加载,分配内存,零值初始化,设置对象头,并将对象引用压入操作数栈顶)
3: dup // 复制栈顶的对象引用 (一份用于调用构造函数,另一份用于赋值给局部变量 p)
4: invokespecial #3 // method Person.<init>:()V (对应步骤5:使用刚刚复制的引用调用构造函数 <init>())
7: astore_1 // 将初始化完成的对象引用从操作数栈中弹出,并存入局部变量表索引为1的位置 (即变量p)
new:完成类加载检查、内存分配、零值初始化和对象头设置,并将新创建对象的引用压入操作数栈。此时对象还只是一个“半成品”。dup:复制栈顶的引用。因为invokespecial会消耗一个引用,而astore_1还需要一个引用来赋值。invokespecial:调用<init>()方法,对这个“半成品”对象进行真正的初始化。astore_1:将栈顶的、已经完全初始化好的对象引用存储到局部变量p中。
内存布局图示
+---------------------+ +------------------------------------------------+
| Java Stack | | Java Heap |
|---------------------| |------------------------------------------------|
| main() Stack Frame | | |
| +---------------+ | | +----------------------------------------+ |
| | Local Var Tbl | | | | Person Object | |
| | +---------+ | | | |----------------------------------------| |
| | p | reference |-----(指向)---->| Object Header (Mark Word, Klass Ptr) | |
| | +---------+ | | | |----------------------------------------| |
| +---------------+ | | | Instance Data (age, name, etc.) | |
| | Operand Stack | | | | (此时已完成<init>(), 具有程序员设定的值)| |
| +---------------+ | | +----------------------------------------+ |
+---------------------+ +------------------------------------------------+
总结
new一个对象的过程远比一行代码看起来复杂,它是一个涉及 JVM 指令、类加载子系统、运行时数据区(堆、栈、方法区)和执行引擎 协同工作的完整流程。
简要回顾一下关键路径:
- 检查加载:JVM 检查
Person类是否已加载,若无则触发类加载。 - 分配内存:在堆上通过指针碰撞或空闲列表为
Person对象分配内存。 - 零值初始化:JVM 将分配的内存区域清零。
- 设置头信息:填充对象头,包括 Mark Word 和指向方法区类信息的 Klass Pointer。
- 执行构造:调用
<init>()方法,完成实例变量赋值和构造代码的执行。 - 引用赋值:将堆中对象的内存地址(引用)赋值给栈上的
p变量。
至此,一个对象才被完整地创建出来。