JVM对象创建全流程解析

发布于:2025-06-19 ⋅ 阅读:(14) ⋅ 点赞:(0)

一、JVM对象创建流程

在这里插入图片描述

Ⅰ、类加载检查——JVM创建对象时先检查类是否加载

在虚拟机遇到new指令时,比如new关键字、对象克隆、对象序列化时,如下字节码

0: new           #2                  // class com/example/demo/Calculate

检查指令的参数(#2)是否能在常量池中定位到一个类的符号引用

常量池:
Constant pool:
   #1 = Methodref          #7.#27         // java/lang/Object."<init>":()V
   #2 = Class              #28            // my/Calculate
   #3 = Methodref          #2.#27         // my/Calculate."<init>":()V
   #4 = Fieldref           #29.#30        // java/lang/System.out:Ljava/io/PrintStream;
   #5 = Methodref          #2.#31         // my/Calculate.compute:()I
   #6 = Methodref          #32.#33        // java/io/PrintStream.println:(I)V
   #7 = Class              #34            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               Lmy/Calculate;
  #15 = Utf8               compute
  #16 = Utf8               ()I
  #17 = Utf8               a
  #18 = Utf8               I
  #19 = Utf8               b
  #20 = Utf8               main
  #21 = Utf8               ([Ljava/lang/String;)V
  #22 = Utf8               args
  #23 = Utf8               [Ljava/lang/String;
  #24 = Utf8               calculate
  #25 = Utf8               SourceFile
  #26 = Utf8               Calculate.java
  #27 = NameAndType        #8:#9          // "<init>":()V
  #28 = Utf8               my/Calculate
  #29 = Class              #35            // java/lang/System
  #30 = NameAndType        #36:#37        // out:Ljava/io/PrintStream;
  #31 = NameAndType        #15:#16        // compute:()I
  #32 = Class              #38            // java/io/PrintStream
  #33 = NameAndType        #39:#40        // println:(I)V
  #34 = Utf8               java/lang/Object
  #35 = Utf8               java/lang/System
  #36 = Utf8               out
  #37 = Utf8               Ljava/io/PrintStream;
  #38 = Utf8               java/io/PrintStream
  #39 = Utf8               println
  #40 = Utf8               (I)V

检查符号引用代表的类是否已经被加载、校验、准备、解析和初始化,如果没有加载,通过类加载机制加载类。

Ⅱ、分配内存——创建对象的一大工作就是分配内存

由于类一旦被加载,就可知该类对象所占内存空间(因为对象头大小、属性-每个类型占用多少字节是固定的)

为对象分配内存,就是从堆或者栈(一般是堆)中为分配一块确定大小的空间

划分内存的方式——通过指针碰撞或者空闲列表的方式分配内存空间:

  • 指针碰撞:默认使用的方式,通过一个指针标识当前已经使用到位置,指针一侧是已分配的空间、另一次是未使用的空闲内存,通过指针移动对象所需空间大小来分配内存。要求java堆内存绝对规整,已用空间分配在一侧。
  • 空闲列表:通过维护一张空闲列表维护空闲空间的初始位置和块大小,通过在空闲列表寻找可用的内存块(对象所需空间>空闲块时,该空闲块不可用),分配并更新空闲列表。

并发分配问题——在分配内存的时必然存在多个线程为对象在堆中分配空间(堆是线程共享的区域),就是存在并发分配内存的问题,解决方法:

  • CAS锁+失败重试:CAS-Compare And Swap

  • TLAB:本地线程分配缓存-Thread Local Allocate Buffer,先为每个线程在java堆中分配一块空间,当为该线程的对象分配内存时,先从预分配内存中进行分配(打破了线程竞争同一块堆空间的问题)

    -XX:+UseTLAB(默认开启)、-XX:TLABSize设定预分配内存空间大小

Ⅲ、初始化——为分配给对象的内存空间赋0值,不包括对象头

如果是TLAB(本地线程分配缓存)的分配方式,则初始化提前到为每个线程在java堆中分配一块空间时进行。

这一过程使java的实例变量和类变量可以在不赋初始值就可使用,只是访问出的是该类型的0值。

  • 对于基本数据类型(如 intdoublechar 等),如果没有显式初始化,它们的默认值如下:

    • int 类型的变量默认值为 0

    • double 类型的变量默认值为 0.0

    • char 类型的变量默认值为 '\u0000'(即空字符)。

    • public class Person {
          int age; // 没有初始化,默认为0
          String name; // 没有初始化,默认为null
      }
      Person person = new Person();
      System.out.println(person.age); // 输出 0
      System.out.println(person.name); // 输出 null
      
  • 对于对象引用类型(如类、接口、数组等),如果没有显式初始化,它们的默认值是 null

  • 局部变量:在Java中,局部变量(在方法内部声明的变量)如果不初始化就直接使用,编译器会报错,因为局部变量在使用前必须显式初始化。

public void test() {
    int x; // 编译错误:局部变量x可能尚未初始化
    System.out.println(x);
}

Ⅳ、设置对象头

对象

  • 对象头
    • 标记字段(Mark Word):占用内存视操作系统,32位的占4字节(32bit),64位的占8字节(64bit),包括锁标志位、对象的hashcode、分代年龄、偏向线程ID、偏向锁时间戳(Epoch)、锁指针。锁标志位内容不同则保存的对象信息不同。
    • 类型指针(Klass Pointer):占用内存视是否开启指针压缩,开启指针压缩占用4字节,不开启占用8字节,默认开启。是指向元空间中类的元数据信息的指针,JVM通过这个指针判断该对象是哪个类的实例。
    • 数组长度(如果对象是数组类型):如果对象是数据类型,存储数组长度,占用4字节。
  • 实例数据
  • 对齐填充

以下表格是32位的操作系统下默认开启指针压缩的对象头:

标记字段的结构 类型指针
25bit 4bit 1bit 2bit 4字节
23bit 2bit 是否偏向锁 锁标志位
对象的哈希码 分代年龄 0 01(无锁)
线程ID:持有偏向锁的线程ID,标识哪个线程偏向该对象 Epoch:偏向锁的时间戳,用于批量撤销偏向锁 分代年龄 1 01(无锁)
指向栈中锁记录的指针 00(轻量级锁)
指向重量级锁指针(操作系统级互斥锁) 10(重量级锁)
11(GC标记,表示对象待回收,由GC算法确定)

Ⅴ、执行方法

执行方法,按照程序员的意愿进行初始化,为属性赋值(赋程序员给的值)和执行构造方法。

二、查看对象大小和指针压缩

1、查看对象的内存布局

引入依赖

        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.17</version>
        </dependency>

示例代码

package com.example.demo;

import org.openjdk.jol.info.ClassLayout;

/**
 * 计算对象大小
 */
public class JOLSample {

    public static void main(String[] args) {
        ClassLayout layout2 = ClassLayout.parseInstance(new A());
        System.out.println(layout2.toPrintable());
    }

    // ‐XX:+UseCompressedOops 默认开启的压缩所有指针
    // ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
    // Oops : Ordinary Object Pointers
    public static class A {
        //8B mark word
        //4B Klass Pointer 如果关闭压缩‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,则占用8B
        int id; //4B
        String name; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
        byte b; //1B
        Object o; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
    }
}

在64位操作系统上的执行结果(默认开启指针压缩):

在这里插入图片描述

关闭指针压缩后,引用类型占用的空间变成8字节:

在这里插入图片描述

根据提供的 JOL(Java Object Layout)输出,以下是 com.example.demo.JOLSample$A 对象的内存布局分析:

  1. 对象头(Object Header)

    • Mark Word(标记字):
      • 偏移量:0,大小:8 字节
      • 值:0x0000000000000005
      • 含义:表示对象处于 可偏向状态biasable),分代年龄为 0age: 0),存储锁、GC 状态等信息。
    • Klass Word(类指针):
      • 偏移量:8,大小:4 字节
      • 值:0xf800cf18
      • 含义:指向类元数据的指针(JVM 开启指针压缩后为 4 字节)。
  2. 实例字段(Instance Fields)

    • int id
      • 偏移量:12,大小:4 字节
      • 值:0(默认初始值)。
    • byte b
      • 偏移量:16,大小:1 字节
      • 值:0(默认初始值)。
    • 对齐填充(Padding Gap)
      • 偏移量:17,大小:3 字节
      • 原因:下一个字段 String name 需对齐到 4 字节边界(20 是 4 的倍数),因此在 byte b 后填充 3 字节。
    • String name
      • 偏移量:20,大小:4 字节
      • 值:null(引用类型,指针压缩后占 4 字节)。
    • Object o
      • 偏移量:24,大小:4 字节
      • 值:null(引用类型)。
  3. 对象对齐填充(Object Alignment Gap)

    • 偏移量:28,大小:4 字节
    • 原因:对象总大小需对齐至 8 字节(64 位 JVM 的默认对齐)。当前已用 28 字节(0~27),需填充至 32 字节(28 + 4 = 32)。

关键指标

  • 实例总大小(Instance Size)32 字节。
  • 空间损失(Space Losses)
    • 内部(Internal)3 字节(字段间填充)。
    • 外部(External)4 字节(对象末尾填充)。
    • 总计损失7 字节。

内存布局图示

偏移量 大小(字节) 内容 说明
0 8 Mark Word 锁、GC 状态等
8 4 Klass Word 类元数据指针
12 4 int id 整型字段
16 1 byte b 字节字段
17 3 对齐填充 补齐至 4 字节边界
20 4 String name 字符串引用(null
24 4 Object o 对象引用(null
28 4 对象对齐填充 补齐至 8 字节边界

总结

  • 对象头占 12 字节8 + 4),字段数据占 13 字节4 + 1 + 4 + 4),但实际占用 20 字节(含内部填充)。
  • JVM 通过填充确保字段对齐和对象对齐,提高内存访问效率。
  • 优化建议:若需减少空间,可调整字段顺序(如将 byte b 放在末尾),但 JVM 会自动重排,通常无需手动干预。
2、指针压缩的JVM配置参数

‐XX:+UseCompressedOops :开启的压缩所有指针,默认开启

‐XX:+UseCompressedClassPointers :开启的压缩对象头里的类型指针Klass Pointer,默认开启

3、为什么要有指针压缩

1、在64位的平台中节约空间和带宽:在主内存和缓存之间复制较大指针会占用更多带宽;

2、32位地址最大支持4G内存,通过对对象指针的压缩编码、解码以支持更大的内存配置(不超过32G);

3、堆内存小于4G时不需要开启指针压缩,JVM会自动去除高32位地址,使用低虚拟地址空间;

4、堆内存大于32G时,压缩指针失效,强制使用64位对java对象寻址。(所以堆内存不建议大于32G)

ressedOops :开启的压缩所有指针,默认开启

‐XX:+UseCompressedClassPointers :开启的压缩对象头里的类型指针Klass Pointer,默认开启

3、为什么要有指针压缩

1、在64位的平台中节约空间和带宽:在主内存和缓存之间复制较大指针会占用更多带宽;

2、32位地址最大支持4G内存,通过对对象指针的压缩编码、解码以支持更大的内存配置(不超过32G);

3、堆内存小于4G时不需要开启指针压缩,JVM会自动去除高32位地址,使用低虚拟地址空间;

4、堆内存大于32G时,压缩指针失效,强制使用64位对java对象寻址。(所以堆内存不建议大于32G)


网站公告

今日签到

点亮在社区的每一天
去签到