前言

最近复习的时候,对创建对象的内部不禁感到疑惑,常规创建对象大家new一下就ok了,一提到他,每个java程序员都能很快地想到:堆内存分配空间;但是仅仅只是这样吗?它还能干什么?它到底是怎么分配空间的?这不禁引发了我对jvm的深度探索……


以下文章均以下面的简单的代码为例
下面的举例均加粗处理,希望能用简单的例子让大家理解复杂的原理的实际意义!!

public class StudentTest {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.setAge(11);
        s1.setName("Jack");
        System.out.println("姓名:" + s1.getName() + "年龄:" + s1.getAge());
    }
}

一、new的触发

我们用jdk自带的javap命令查看new执行时的字节码指令

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: new           #7                  // class domain/Student
         3: dup
         4: invokespecial #9                  // Method domain/Student."<init>":()V
         7: astore_1
         //后面省略

此处包含两个重要的指令:new和invokespecial
每当new关键字执行时,对应的字节码文件就会执行这两个指令,分别用于创建对象和调用构造方法

二、new指令执行的核心流程

由以上发现我们进一步拆解Student s1 = new Student();,new指令的执行可分为类加载检查、内存分配、零值初始化、对象头设置、构造方法调用五大步骤,环环相扣,一步步将Student对象“组装”成一个完整且可用的对象

1. 类加载检查

我们常说,对象是类的实例,而类是对象的“模板”,创建对象,首先应明确这一“模板”,因此,new执行的第一件事是检查:Student 这个类的 “模板” 有没有被加载到 JVM 里?
如果Student.class还没加载到方法区,就会触发我们大名鼎鼎的“类加载”流程
,分为以下五步:

  1. 加载: (周志明先生亲切地提醒我们不要和“类加载”弄混”)先总结一下核心流程:通过类的全限定名获取二进制字节流,之后将字节流转为方法区的运行时数据结构,在堆中生成java.lang.Class对象(此处为生成Class<Student>)。此后,如果想要通过反射等方法操作Student类,就依靠此时创建的这个对象。

    这里的二进制字节流的来源很灵活,zip/jar/war,网络中,运行时生成,数据库文件等等都可以,也就是因为这一空隙,各种充满创造力的开发人员创建了许多举足轻重的Java技术!

  2. 验证: 验证是连接阶段的第一步,目的是确保 Class 文件字节流符合《Java 虚拟机规范》,避免恶意或错误字节流危害 JVM,验证分为四步,文件格式验证(校验字节流格式),元数据验证(语义分析类的元数据),字节码验证(通过数据流 / 控制流分析确保方法运行安全),符号引用验证(校验类依赖的外部资源)。这里也就是检查一下Student.class 是不是合法文件

    这一过程相当重要,这里是否严谨直接决定了jvm虚拟机能否承受恶意代码的攻击。

  3. 准备: 准备阶段为类的静态变量(也就是static 修饰) 分配内存并设置初始值,实例变量不参与。初始值默认是 “零值”,但若静态变量被final修饰,编译时会生成ConstantValue属性,准备阶段直接将value初始化为设定的值。比如如果 Student 里有 static int age = 10;,这一步会先把 age设为 0,真正赋值 10 要等后面的 “初始化” 阶段,但如果有final修饰,此时直接赋值为10。

  4. 解析:解析阶段将常量池中的符号引用替换为直接引用,直接引用与 JVM 内存布局相关。JVM 可在类加载时解析,或在执行特定字节码指令前延迟解析,主要解析类、接口、字段、类方法、接口方法。比如 Student 里有 String name,String 就是一个符号引用,这一步会找到 String 类在方法区的实际位置。

  5. 初始化:初始化是类加载的最后一步,JVM 开始执行类中编写的 Java 代码,核心是执行编译器生成的<clinit>()(由类中静态变量赋值动作和静态语句块(static{})按代码顺序合并生成)。也就是说,会执行 Student 的静态代码块和静态变量赋值,比如上面所说的static修饰的age= 10会在这一步执行

2. 为对象分配内存

类加载完成!那么这个时候JVM就可以计算出一个Student对象需要多大内存(关于内存布局我们最后补充),此时从 Java 堆划分一块确定大小的空间,具体方式分两种,取决于 Java 堆内存是否规整。

  1. 指针碰撞
    Java 堆内存绝对规整时使用该方式(所谓规整,是指用内存和空闲内存各在一侧,中间有分界指针),此时将分界指针向空闲空间方向挪动,挪动距离等于对象大小,简单高效。
    比如 Student 对象要占 32 字节,就把指针往后挪 32 字节,挪出来的这块空间就是给新 Student 的。 类似一根绳子,从左到右用,用完的在左,空闲的在右,指针往右挪就是分配新空间
  2. 空闲列表
    Java 堆内存不规整(也就是已用内存和空闲内存交错),虚拟机维护 “可用内存块列表”,分配时从列表中找足够大的块分配给对象,同时更新列表,流程相对复杂。
    此处会将列表里找一块 “够大” 的内存块分给 Student 对象,再把列表里的这块内存标记为 “已使用”。

Java 堆是否规整,由所使用的垃圾收集器是否具备 “空间压缩整理能力” 决定,对此,周志明在书中例举了一些垃圾收集器类型,我的总结如下:

垃圾收集器类型 是否具备 Compact 能力 对应内存分配方式 备注
Serial、ParNew 等 指针碰撞 Serial 是单线程 GC,ParNew 是 Serial 的多线程版
CMS(基于清除算法) 空闲列表 CMS 是 “标记 - 清除” 算法,会产生内存碎片

但是这里会出现一个重要的问题 如果多个线程同时 new Student(),会不会抢同一块内存? 由此引出并发分配的线程安全问题的两种解决方案:

  • 同步处理(CAS + 失败重试) 对内存分配动作加同步,通过 CAS(Compare and
    Swap)机制保证更新操作的原子性,失败则重试,直至成功。
  • 本地线程分配缓冲(TLAB) 为每个线程在 Java 堆预先分配一小块内存(TLAB),线程分配内存时优先使用自身 TLAB。仅当线程的TLAB 用完、需要分配新 TLAB 时,才进行同步锁定。可通过 JVM 参数-XX:+/-UseTLAB开启或关闭TLAB功能。
    说白了,就是给每个线程在堆里预先划一块 “专属内存”,线程创建 Student 对象时,先在自己的 TLAB里分配。用完了再用 CAS 申请新的 TLAB。 这个是默认方式,可以减少很多冲突。(HotSpot 虚拟机默认开启 TLAB,可通过-XX:-UseTLAB关闭)

3. 零值初始化

内存分配好了,虚拟机现在要把除对象头外的内存空间设为“零值”,也就是赋上我们熟知的默认初始化值,Student 的 age 字段会被设为 0,name 字段会被设为 null。
补充一下“零值”的规则:

数据类型 明细 默认值
整数型 byte,short,int,long 0
浮点型 float,double 0.0
字符型 char “\u0000”
布尔型 boolean false
引用数据类型 String,Object,数组,自定义类对象 null

4. 设置对象头(Object Header)

内存零值初始化后,虚拟机需为对象设置对象头信息,这些信息与对象的类关联、GC 状态等相关,且设置方式会受虚拟机运行状态影响。相当于给s1对象贴上两组关键标签,让jvm知道s1是什么。 这两组关键标签也就是对象头的两个组成部分,Mark Word和类型指针。

  • Mark Word
    也可以说是运行时数据,用以记录对象的实时状态。例如,这个对象的哈希码(唯一标识),GC 分代年龄(经历过几次垃圾回收),锁状态(比如有没有被加锁,是无锁、偏向锁还是重量级锁)。这部分数据是会动态变化的。
  • 类型指针
    记录对象属于哪个类的实例,以及如何找到该类的元数据。JVM 靠这个指针知道:“哦,这个对象是 Student 类的实例,不是 Object 也不是 String^_^”。

这里已经能理解即可,对于对象头内容详情,我们在后面补充。

5. 执行构造方法(<init>())

此时,对于虚拟机来说,我们的对象已经创建完成,但是,new关键字的使命还没有结束!对于java程序而言,我们的对象创建才刚刚开始!必须得等执行完<init>(),完成初始化之后,对象才真正可用。
<init>() 是 Class 文件中对应 Java 构造函数的字节码方法,是编译器自动生成的,负责按程序员的意愿初始化对象(比如给字段赋值、初始化资源)。通常情况下,字节码中 new 指令(创建对象)后会跟着 invokespecial 指令(也就是我们第一部分看到的内容),触发<init>() 方法执行;不过若通过其他特殊方式创建对象,可能不会按这个顺序。
<init>()会把这些内容按顺序合并:
1. Student 里的实例变量赋值(比如如果写了 private int age = 5;,这一步会执行);
2. Student 里的实例代码块(比如 { System.out.println(“初始化”); });
3. 我们自己写的构造函数(比如 public Student() {})。

而且有个强制规则:Student 的 <init>() 会先调用父类(Object)的 <init>(),保证父类的字段先初始化。这就是为什么我们没写super(),编译器也会默认加的原因

至此,我们的Student对象才算真正的“可用”。
最后,第一部分中最后字节码里的 astore_1 指令会把对象的堆内存地址存到局部变量表的 s1 里 —— 这时候 s1 = new Student() 才算彻底完成,后面才能调用 s1.setAge(11)。

三、补充:对象的内存布局

下面补充一下对象在堆内存中到底是存的什么,可以了解一下。

1. 对象头

包含两类核心信息,若对象是数组则会额外增加字段,因此总结以下三部分:

  • Mark Word:存储对象运行时数据,是动态结构。
    核心存储内容:哈希码、GC 分代年龄、锁状态标志、偏向线程 ID 等。
    长度差异:32 位虚拟机占 32 比特,64 位(未开压缩指针)占 64 比特。
    状态关联:不同锁状态下,存储内容会变化(如无锁时用 25 比特存哈希码)。
  • 类型指针:指向对象的类型元数据,虚拟机通过它确定对象所属类。
    注意,并非所有虚拟机都保留该指针,查找元数据可通过其他方式。
  • 数组额外字段:若对象是 Java 数组,需额外存储数组长度。

2. 实例数据

这是对象真正存储业务数据的部分,即代码中定义的字段:

  • 存储顺序影响因素:虚拟机参数:受-XX:FieldsAllocationStyle(分配策略)和-XX:CompactFields(默认 true)影响;源码定义顺序:父类字段默认在子类字段之前。
  • 默认分配规则:相同宽度字段存一起,顺序为longs/doubles → ints → shorts/chars → bytes/booleans → oops。
  • 空间优化:CompactFields=true时,子类窄字段可插入父类字段空隙,节省空间。

3. 对齐填充

非必需部分,仅为满足虚拟机内存管理规则而存在。
因为HotSpot 要求对象大小为 8 字节倍数,对象头已设计为 8 字节倍数(1 倍或 2 倍),若实例数据长度未对齐,需通过填充补全。


总结

从 new Student() 到 s1 可用,new关键字总体做了下面几步,整个过程我觉得用“盖房子”来比喻很形象:

  1. 类加载检查:确认好Student类的 “建筑图纸”
  2. 内存分配:在堆里“划一块地”,给Student这一“房子”
  3. 零值初始化:把地里的杂物清空,保证基础干净;
  4. 对象头设置:给房子贴上门牌号(类型指针)和状态标签(Mark Word);
  5. 构造方法调用:按我们的需求装修房子;
  6. 引用赋值:把房子的钥匙(s1)交给我们,接下来我们就可用s1这个钥匙使用Student这个房子了。

注:本文参考了周志明《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》的第二章,第七章以及少部分第六章内容以及部分自身实践和理解进行整合与总结,希望能帮到同样想深入了解java底层的朋友,如果有误或者不全面的地方,感谢诸位大佬指出。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐