0. 前言

在 Java 程序中,执行 User user = new User(); 这样的代码时,JVM 究竟做了哪些工作?本文将深入探讨 Java 类对象在底层的创建过程。

1. Java 类对象的创建过程

Java 对象保存在内存中时,主要由三部分组成:对象头(Object Header)实例数据(Instance Data)对齐填充(Padding)。因此,Java 对象的创建过程实际上是对这三部分进行配置、补充和初始化的过程。

注:对齐填充
在 JVM 中,要求对象占用内存的大小应该是 8 字节的倍数。对齐填充字段是用来补齐内存至 8 字节倍数的,无其他实际作用。

1.1 类加载检查阶段

虚拟机遇到一条 new 指令时,首先会检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用所代表的类是否已被加载、连接和初始化。如果没有,则必须先执行相应的类加载过程。

1.2 分配内存

类加载检查通过后,JVM 将为新创建的对象分配内存。对象所需的内存大小在类加载完成后便可以确定。因此,为对象分配内存空间相当于把确定大小的内存从 Java 堆中划分出来。

分配方式主要有指针碰撞(Bump the Pointer)空闲列表(Free List)两种。选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否具有压缩整理功能决定(标记 - 清除算法不规整,标记 - 整理和复制算法都是规整的)。

  1. 指针碰撞

    • 适用场合:堆内存规整(没有内存碎片)。
    • 原理:用过的内存放一边,没用过的内存放一边,中间有一个分界值指针。分配内存时只需要向着没用过的内存方向将该指针移动对象确定内存大小位置即可。
    • GC 收集器:Serial、ParNew。
  2. 空闲列表

    • 适用场合:堆内存不规整。
    • 原理:JVM 会维护一个列表,该列表会记录哪些内存块是可用的。分配内存时找一块足够大的内存区域划分给对象实例,最后更新内存列表。
    • GC 收集器:CMS。

内存分配的并发问题

堆内存是线程共享的,所以在创建对象分配内存的时候,一个重要的问题就是线程安全。JVM 采用以下两种方式保证线程安全:

  1. CAS + 失败重试:CAS 是乐观锁的一种实现方式。乐观锁假设没有冲突,不加锁地去执行操作。JVM 采用 CAS + 失败重试的方式保证更新操作的原子性。
  2. 线程本地分配缓存(TLAB):JVM 为每个线程预先在 Eden 区分配一小块区域,即线程本地分配缓存(Thread Local Allocation Buffer, TLAB)。JVM 在给线程中的对象分配内存时,首先在 TLAB 中划分内存。当对象大于 TLAB 剩余内存或者 TLAB 用尽时,JVM 会再采用 CAS + 失败重试的方式分配内存。

1.3 初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。这一过程保证了 Java 的实例对象在 JVM 中可以不赋初始值就直接使用,程序能访问这些字段的数据类型所对应的零值。

1.4 设置对象头

初始化零值后,JVM 要对对象信息进行必要的设置(与类的关联关系、关联类的元数据信息、对象的哈希码、对象的 GC 分代年龄),这些信息存在对象的对象头中。

1.4.1 对象头的形式

JVM 中对象头的布局有以下两种(以 32 位 JVM 为例):

1. 普通对象

Object Header (64 bits)
Mark Word (32 bits) \Klass Word (32 bits)

2. 数组对象

Object Header (96 bits)
Mark Word (32 bits) \Klass Word (32 bits) \Array Length (32 bits)

1.4.2 对象头组成

对象头主要包含以下部分:

  1. Mark Word
  2. 指向类的指针(Klass Pointer)
  3. 数组长度(只有数组对象才有)
1.4.2.1 Mark Word

Mark Word 记录了对象和锁有关的信息。当这个对象被 synchronized 关键字当成同步锁时,围绕这个锁的一系列操作都和 Mark Word 有关。

Mark Word 在 32 位 JVM 中的长度是 32bit,在 64 位 JVM 中长度是 64bit。JVM 一般是这样使用锁和 Mark Word 的:

  1. 无锁状态:当没有被当成锁时,这是一个普通的对象。Mark Word 记录对象的 HashCode,锁标志位是 01,是否偏向锁那一位是 0
  2. 偏向锁(获取):当对象被当做同步锁并有一个线程 A 抢到了锁时,锁标志位还是 01,但是否偏向锁那一位改成 1,前 23bit 记录抢到锁的线程 ID,表示进入偏向锁状态。
  3. 偏向锁(重入):当线程 A 再次试图来获得锁时,JVM 发现同步锁对象的标志位是 01,是否偏向锁是 1,也就是偏向状态。Mark Word 中记录的线程 ID 就是线程 A 自己的 ID,表示线程 A 已经获得了这个偏向锁,可以执行同步锁的代码。
  4. 偏向锁(竞争):当线程 B 试图获得这个锁时,JVM 发现同步锁处于偏向状态,但是 Mark Word 中的线程 ID 记录的不是 B。那么线程 B 会先用 CAS 操作试图获得锁。这里的获得锁操作是有可能成功的,因为线程 A 一般不会自动释放偏向锁。

    • 如果抢锁成功,就把 Mark Word 里的线程 ID 改为线程 B 的 ID,代表线程 B 获得了这个偏向锁,可以执行同步锁代码。
    • 如果抢锁失败,则继续执行步骤 5。
  5. 轻量级锁:偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM 会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁 Mark Word 的指针,同时在对象锁 Mark Word 中保存指向这片空间的指针。上述两个保存操作都是 CAS 操作。

    • 如果保存成功,代表线程抢到了同步锁,就把 Mark Word 中的锁标志位改成 00,可以执行同步锁代码。
    • 如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤 6。
  6. 自旋锁:轻量级锁抢锁失败,JVM 会使用自旋锁。自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从 JDK 1.7 开始,自旋锁默认启用,自旋次数由 JVM 决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤 7。
  7. 重量级锁:自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会被阻塞。

32 位 JVM 中,Mark Word 存储示意:

其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1bit 区分了这是无锁状态还是偏向锁状态。

JDK 1.6 以后的版本在处理同步锁时存在锁升级的概念,JVM 对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。

1.4.2.2 指向类的指针

该指针在 32 位 JVM 中的长度是 32bit,在 64 位 JVM 中长度是 64bit。Java 对象的类数据保存在方法区。

1.4.2.3 数组长度

只有数组对象保存了这部分数据。该数据在 32 位和 64 位 JVM 中长度都是 32bit。

1.5 执行 方法

上述过程执行完,对象实例便已经创建出来了,但是所有的成员变量(属性字段)还都是零值。所以在 new 命令执行完之后,还需要执行 <init> 方法对类的成员变量进行初始化,至此一个类的实例对象就创建完成了。

说明

  • 适用版本:本文关于对象头结构及锁升级(偏向锁、轻量级锁、重量级锁)的描述主要基于 JDK 1.6 至 JDK 14 的经典 JVM 实现。
  • 时效性注记:从 JDK 15 开始,偏向锁(Biased Locking)被标记为废弃(Deprecated),并在 JDK 16 中默认禁用。在新版本 JVM 中,锁机制的具体实现可能有所调整,但对象创建的基本流程(加载、分配、初始化等)保持一致。