HotSpot虚拟机介绍

HotSpot 是 Oracle/Sun JDK、OpenJDK 默认的虚拟机,也是目前应用最广泛的 Java 虚拟机

HotSpot 有个东西比较牛逼,过计数器识别 “热点方法” 和 “热点循环”,对热点代码进行即时编译(JIT),将字节码编译为本地机器码执行,大幅提升性能,这个还真和纯解释执行不太一样,因为它是解释器(Interpreter)+ 即时编译器(C1/C2)结合,解释器保证启动速度,JIT 保证运行性能。

对象的创建

v-8

那么大概就是这些步骤,这些步骤要都了解的

那么这环环相扣的五步可以简单来说成:校验→分配→初始化→元信息设置→构造执行

Step1:类加载检查

这步的核心是确认“能创建”

JVM 接收到 new XXX() 指令时,首先去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程

类加载的最终产物是 Class 对象,类加载会直到生成 XXXClass 对象并存入方法区,它存储了类的元信息(字段、方法、父类、接口等),是后续对象创建的 “模板”。

Step2:分配内存

这步的核心是给对象 “划空间”

类加载检查通过后,接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来

对象所需的内存大小在类加载完成后便可确定,因为基于第一步的 Class 对象,完全可用计算出一个 XXX 对象所需的内存大小,包括对象头、实例数据、对齐填充的总大小,然后从 Java 堆 中为新对象分配对应大小的内存块。

分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

  • 指针碰撞:
    • 堆内存规整(用过的和空闲的分开)时
    • 用一个指针指向空闲内存起始位置,分配时只需将指针向后移动对象大小的距离
    • 使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表:
    • 堆内存碎片化时
    • JVM 维护 “空闲内存块列表”,分配时从列表中找足够大的块分配,并更新列表
    • 如 CMS 收集器

这样都能保证快速的分配连续大小的指定内存块

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果出现了冲突失败就重试,总有一次会成功的。虚拟机采用 CAS 分配上失败重试的方式保证更新操作的原子性。

  • TLAB: 为每一个线程预先在堆的 Eden 区分配一块儿内存,这个线程创建对象时,优先在自己这块儿内存里分配,不用跟其他线程抢,避免了多线程争用堆内存的锁开销。

    • 为什么需要 TLAB?

      创建对象是高频操作,如果所有线程都直接去 Eden 区的公共区域分配内存,每次都要加锁,效率很低;给每个线程分一块 “私人空间”,线程在自己的空间里创建对象不用加锁,速度更快。

内存分配并发问题是线程安全的保障,它遵循的原则是 TLAB 优先,若 TLAB 用完 / 不足,才去堆的公共区域分配,也就是CAS + 失败重试加锁

因为堆是线程共享区域,为避免多线程竞争,HotSpot 为每个线程分配独立的 TLAB(本地线程分配缓冲),线程创建对象时优先在自己的 TLAB 中分配(无锁)

可通过 -XX:+UseTLAB(默认开启)控制是否启用 TLAB。

Step3:初始化零值

这步也叫内存初始化,这步的核心是给空间 “填默认值”

将第二步分配的内存块(除对象头外的实例数据区)全部初始化为零值(如 int→0、boolean→false、引用→null)。

这一步是 “默认值填充”,保证对象的实例变量在构造方法执行前,都有符合 Java 规范的默认值;对象头的初始化在下一步,不在这处理

这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。也是为什么未赋值的成员变量能直接使用(比如 int id; 直接打印会输出 0)。

Step4:设置对象头

给对象 “贴标签”

为分配好的内存块设置对象头(Header) 信息,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。

详细来说就是

  • Mark Word(标记字段,8 字节):存储对象的运行时状态(哈希值、GC 分代年龄、锁状态、偏向锁线程 ID 等),默认是 “无锁状态”,分代年龄为 0;
  • 类型指针(Klass Pointer,4 字节,64 位开启压缩指针):指向方法区中 XXXClass 对象,让 JVM 知道这个对象属于哪个类。

Step5:执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,因为<init> 方法还没有执行,所有的字段都还为零。

JVM 调用对象的构造方法,包括父类的构造方法,执行 <init> 方法(这是编译器自动生成的方法,整合了实例变量赋值、构造方法代码),将对象初始化到程序员期望的状态。

  • 说一嘴 包括父类的构造方法 这部分:它会先调用父类的 <init> 方法,直到 Object 类,再执行实例变量的显式赋值,最后执行构造方法中的自定义代码。

所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来

那么这五步,大概就是

flowchart TD
    A[第一步:类加载检查] -->|确认Class对象存在| B[第二步:分配内存]
    B -->|从堆中划内存(TLAB优先)| C[第三步:内存初始化]
    C -->|填充零值| D[第四步:设置对象头]
    D -->|Mark Word+类型指针| E[第五步:执行<init>方法]
    E -->|构造方法执行完成| F[对象创建完成,引用入栈]
    
    A -->|Class未加载| A1[触发类加载流程]
    A1 --> A

对象的内存布局

HotSpot 中所有对象的总大小必须是 8 字节的整数倍,这是整个内存布局的 “底层约束”,也是操作系统中的约束,对象头、实例数据、对齐填充的设计都围绕这个规则展开。

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:

  • 对象头(Header):“对象的管理信息区,是 JVM 实现 GC、锁、哈希计算的关键。

    • 标记字段(Mark Word)
      • 8字节64位的字段,存储对象运行时的动态状态数据,包括哈希值,GC分代年龄,锁标志位,是否偏向锁
    • 类型指针(Klass pointer)
      • 指向方法区中该对象所属类的 Class 对象的指针,64 位 JVM 开启压缩指针时占 4 字节,未开启时占 8 字节。
  • 实例数据(Instance Data):存储代码中定义的所有实例变量,包括从父类继承的变量

    • 这里存在一个内存优化,HotSpot 会按 “相同宽度字段合并” 的规则排列,目的是减少内存碎片,顺序为:

      1
      long/double(8字节) → int/float(4字节) → short/char(2字节) → byte/boolean(1字节) → 引用类型(4字节,压缩指针)

      父类的变量会排在子类变量前面(比如 User 继承 PersonPersonage 会排在 Userid 前面)。

  • 对齐填充(Padding):对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 当“对象头 + 实例数据” 的总大小不是 8 字节整数倍时,用来补全

对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。

  • reference:栈上的 “对象引用”,本质是一个内存地址值

毕竟,堆中存储对象的实例数据,方法区存储对象的类型数据

那么,对象访问的本质:通过栈上的 reference,找到堆中的实例数据 + 方法区的类型数据

对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

两种访问方式的核心区别就是 “reference 存什么地址”

这两种方式也对应了操作系统中两种地址的访问形式(二级地址和一级地址)

句柄

如果使用句柄的话

  • 堆中专门划一块区域叫句柄池,每个对象对应一个句柄;
  • 栈上的 reference 存的是 “句柄的地址”
  • 句柄里存了两个关键地址
    • 实例数据地址,指向堆中对象的具体数据
    • 类型数据地址,指向方法区的 Class 对象
flowchart LR
    A[栈:reference] -->|存储句柄地址| B[堆:句柄池-某句柄]
    B -->|实例数据指针| C[堆:对象实例数据(id/name等)]
    B -->|类型数据指针| D[方法区:类型数据(User.class)]

我雇了一个导航叫句柄,它带我找到对象

image-20260123103853044

直接指针

如果使用直接指针访问,reference 中存储的直接就是堆中对象实例的地址

这是 HotSpot 默认

而对象实例的对象头里,自带一个 类型指针 Klass Pointer,它会指向方法区的类型数据

flowchart LR
    A[栈:reference] -->|存储对象实例地址| B[堆:对象实例数据(id/name等)]
    B -->|对象头的类型指针| C[方法区:类型数据(User.class)]

直接指针少一次指针跳转,访问速度更快,但是若对象被 GC 移动,需要修改栈中 reference 存储的地址,但是又但是了,HotSpot 有牛逼的内存管理机制规避这个问题,但是 reference 存储的地址会随对象移动变化,逻辑上不如句柄稳定,这个问题没法规避

image-20260123104427323