0%

对象在JVM中的生活

对象在 JVM 中的生命周期:从创建到访问的完整历程

在 Java 中,“万物皆对象”,对象是程序运行的核心载体。从对象的创建、内存分配,到在 JVM 中的存储布局,再到如何被访问,每一步都遵循 JVM 的严格规范。本文将详细解析对象在 JVM 中的 “一生”,包括创建方式、JVM 处理流程、内存布局及访问定位机制。

对象的创建方式

Java 中创建对象的方式多样,不同方式对应不同的底层实现,核心区别在于是否依赖构造器、权限要求等:

创建方式 核心原理 特点与限制
new 关键字 直接调用类的构造器 最常用,可调用任意权限的构造器(public/protected/private)。
Class.newInstance() 反射调用无参构造器 仅支持无参构造器,且构造器必须为 public(JDK 9+ 后放宽限制,但仍需可访问)。
Constructor.newInstance() 反射调用指定构造器 支持任意参数的构造器,无权限限制(即使构造器为 private,也可通过反射访问)。
clone() 方法 复制已有对象的内存数据 需实现 Cloneable 接口(否则抛 CloneNotSupportedException),不调用构造器。
反序列化 从字节流恢复对象 需类实现 Serializable 接口,不调用构造器,通过输入流重建对象。

JVM 中对象的创建步骤

以最常见的 new TestObject() 为例,其在 JVM 中的创建过程可分为 6 个核心步骤,对应字节码指令如下:

1
2
3
4
0: new           #2                  // 步骤1:检查类并分配内存
3: dup // 步骤2:复制对象引用(为构造器准备)
4: invokespecial #3 // 步骤3:调用构造器<init>
7: astore_1 // 步骤4:将引用存入局部变量表

步骤 1:类加载检查(new 指令)

new 指令首先触发类加载检查:

  • 从常量池查找 TestObject 的符号引用,检查该类是否已完成加载、链接、初始化
  • 若未加载,则通过类加载器(双亲委派机制)加载类文件,生成 Class 对象(存储在方法区);
  • 若类加载失败(如类不存在),抛出 NoClassDefFoundError

步骤 2:为对象分配内存

类加载完成后,JVM 需为对象分配内存(大小在类加载时已确定)。内存分配有两种算法,取决于堆内存是否规整:

算法 适用场景 核心逻辑
指针碰撞 堆内存规整(如 Serial、ParNew 收集器) 用一个指针划分已用 / 空闲内存,分配时指针向空闲区移动对象大小的距离。
空闲列表 堆内存不规整(如 CMS 收集器) 维护一张空闲内存块列表,分配时从列表中找到足够大的块划分给对象。
解决并发分配问题

对象创建频繁,可能出现多线程同时分配内存的冲突,JVM 采用两种解决方案:

  • CAS 同步:对内存分配操作加锁(Compare-And-Swap),保证原子性;
  • TLAB(本地线程分配缓冲):为每个线程预分配一小块 Eden 区内存(默认占 Eden 的 1%),线程优先在自己的 TLAB 中分配,用完后再竞争公共内存(减少锁竞争)。可通过 -XX:+UseTLAB 开启(默认开启)。

步骤 3:初始化默认值

内存分配后,JVM 会将对象的实例变量设为默认值(零值):

  • 基本类型:如 int 设为 0,boolean 设为 false
  • 引用类型:设为 null

这一步确保对象在未显式初始化时,实例变量也有合法值(对应类加载的 “准备阶段” 逻辑)。

步骤 4:设置对象头(mark word

对象头是对象内存的核心元数据,包含两部分:

  1. Mark Word(标记字段):存储对象运行时状态,如:
    • 哈希值(hashCode);
    • GC 分代年龄(经历 Minor GC 的次数);
    • 锁状态标志(无锁、偏向锁、轻量级锁、重量级锁);
    • 偏向线程 ID(偏向锁时)。
  2. 类型指针:指向对象所属类的 Class 对象(在方法区),JVM 通过该指针确定对象的类型(如 TestObject.class)。

步骤 5:执行 <init> 方法(invokespecial 指令)

invokespecial 指令调用类的构造器 <init> 方法,完成显式初始化

  • 执行实例变量的显式赋值(如 private int a = 10);
  • 执行实例代码块({ ... });
  • 执行构造器中的逻辑。

这一步将对象从 “默认初始化状态” 转为 “用户期望的初始化状态”。

步骤 6:赋值给引用变量

最后,将对象的引用(内存地址)存入虚拟机栈的局部变量表(如 astore_1 指令将引用存入 Slot 1),至此对象创建完成。

对象的内存布局

对象在堆中的内存布局分为三部分:对象头、实例数据、对齐填充,以 64 位 HotSpot 为例:

1. 对象头(Header)

  • Mark Word:8 字节(64 位),存储运行时状态(如锁信息、GC 年龄);
  • 类型指针:8 字节(64 位,开启指针压缩后为 4 字节),指向 Class 对象;
  • 数组长度(仅数组对象):4 字节,记录数组元素个数(非数组对象无此字段)。

可以使用该方式来查看对象头

1
2
3
4
5
6
<!--查看对象头工具-->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
1
ClassLayout.parseClass(TestDup.class).toPrintable()

2. 实例数据(Instance Data)

对象的核心数据,存储实例变量(非静态变量),包括:

  • 从父类继承的实例变量;
  • 自身定义的实例变量。

存储顺序由 JVM 优化决定,默认按 “longs/doubles → ints/shorts/chars → bytes/booleans → references” 排序,以减少内存对齐开销。

3. 对齐填充(Padding)

JVM 要求对象总长度必须是 8 字节的整数倍(内存对齐),若对象头 + 实例数据的长度不足,则用对齐填充补全(无实际意义,仅满足对齐要求)。

示例:一个简单对象的内存布局(64 位,指针压缩开启)

1
2
3
4
TestObject 对象(总大小 16 字节):
- 对象头:12 字节(Mark Word 8 字节 + 类型指针 4 字节)
- 实例数据:2 字节(假设含一个 short 变量)
- 对齐填充:2 字节(12 + 2 = 14 → 补 2 字节至 16)

对象的访问定位

创建对象后,Java 程序通过栈中的引用(reference) 访问堆中的对象,JVM 有两种访问方式:

1. 句柄访问

  • 原理:堆中划分一块 “句柄池”,引用存储句柄地址;句柄包含两部分指针:
    • 实例数据指针:指向对象的实例数据(堆中);
    • 类型数据指针:指向对象的类元数据(方法区中)。
  • 优点:对象被 GC 移动时(如压缩整理),只需修改句柄的实例数据指针,引用本身不变,稳定且安全。
  • 缺点:多一次指针跳转,访问效率略低。

2. 直接指针访问(HotSpot 采用)

  • 原理:引用直接存储对象在堆中的内存地址,对象头的类型指针直接指向类元数据。
  • 优点:少一次指针跳转,访问速度更快(对象访问频繁,可节省大量开销)。
  • 缺点:对象移动时,需同时修改所有引用的地址(依赖 GC 过程中的指针更新)。

欢迎关注我的其它发布渠道

表情 | 预览
快来做第一个评论的人吧~
Powered By Valine
v1.3.10