第五章 对象内存布局:从new到堆空间的奥秘

90 阅读13分钟

第五章 对象内存布局:从new到堆空间的奥秘

1. 对象的实例化

1.1 你有几种方式创建对象?

  • new

    • 最常见的方式
    • 变形1:Xxx的静态方法
    • 变形2:XxxBuilder/XxxFactory的静态方法
  • Class的newInstance():反射的方式,只能调用空参的构造器,权限必须是public

  • Constructor的newInstance(Xxx):反射的方式,可以调用空参、带参的构造器,权限没有要求,实用性更广

  • 使用clone():不调用任何构造器,当前类需要实现Cloneable接口,实现clone(),默认浅拷贝

  • 使用反序列化:从文件中、数据库中、网络中获取一个对象的二进制流,反序列化为内存中的对象

  • 第三方库Objenesis:利用了asm字节码技术,动态生成Constructor对象

1.2 创建对象的步骤

1.2.1 从字节码角度看待对象创建过程

(1)下面从最简单的Object ref = new Object(); 代码进行分析,利用javap -verbose -p 命令查看对象创建的字节码如下:

sequenceDiagram
    participant JVM
    JVM->>栈: NEW java/lang/Object (创建对象)
    JVM->>栈: DUP (复制引用)
    JVM->>对象: INVOKESPECIAL <init>()V (调用构造方法)
    JVM->>局部变量表: ASTORE_1 (存储引用到Slot 0)
    note right of 局部变量表: LocalVariableTable:<br>Start=8, Length=1<br>Slot=0, Name=ref<br>Signature=Ljava/lang/Object;

字节码指令解析

  • NEW:如果找不到Class对象,则进行类加载。加载成功后,则在堆中分配内存,从Object开始到本类路径上的所有属性值都要分配内存。分配完毕之后,进行零值初始化。在分配过程中,注意引用是占据存储空间的,它是一个变量,占用4个字节。这个指令完毕后,将指向实例对象的引用变量压入虚拟机栈顶。
  • DUP:在栈顶复制该引用变量,这时的栈顶有两个指向堆内实例对象的引用变量。如果方法有参数,还需要把参数压入操作栈中。两个引用变量的目的不同,其中压至底下的引用用于赋值,或者保存到局部变量表,另一个栈顶的引用变量作为句柄调用相关方法。
  • INVOKESPECIAL:调用对象实例方法,通过栈顶的引用变量调用<init>方法。

补充: 是类初始化时执行的方法,而是对象初始化时执行的方法。

1.2.2 从执行步骤角度分析

对象创建过程可以分为三个主要阶段:类加载阶段对象分配阶段对象初始化阶段

flowchart TD
    A[new指令] --> B[类加载阶段]
    B --> C[对象分配阶段]
    C --> D[对象初始化阶段]
    
    B --> B1[步骤1: 类加载检查]
    B1 --> B2[类加载过程]
    B2 --> B3[clinit执行]
    
    C --> C1[步骤2: 内存分配]
    C1 --> C2[步骤3: 并发安全处理]
    C2 --> C3[步骤4: 零值初始化]
    C3 --> C4[步骤5: 对象头设置]
    
    D --> D1[步骤6: 成员变量初始化]
    D1 --> D2[步骤7: 实例代码块执行]
    D2 --> D3[步骤8: init构造方法调用]
    
    style B fill:#e1f5fe
    style C fill:#f3e5f5
    style D fill:#e8f5e8
🔵 类加载阶段
步骤1:判断对象对应的类是否加载、链接、初始化

虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。

flowchart TD
    A[new指令] --> B{类是否已加载?}
    B -->|是| C[继续对象创建]
    B -->|否| D[类加载过程]
    D --> E[加载Loading]
    E --> F[链接Linking]
    F --> G[初始化Initialization]
    G --> H[clinit执行]
    H --> C
    
    F --> F1[验证Verification]
    F1 --> F2[准备Preparation]
    F2 --> F3[解析Resolution]

类加载详细过程

  • 加载(Loading):通过类加载器将.class文件加载到内存
  • 链接(Linking)
    • 验证:确保字节码安全性
    • 准备:为静态变量分配内存并设置默认值
    • 解析:将符号引用转换为直接引用
  • 初始化(Initialization):执行<clinit>方法

<clinit>方法执行内容

  • 静态变量的显式初始化
  • 静态代码块的执行
  • 按照在源文件中出现的顺序执行
// 示例:<clinit>执行顺序
public class Example {
    private static int a = 10;        // 1. 静态变量初始化
    static {
        System.out.println("静态块1");  // 2. 静态代码块1
        b = 20;
    }
    private static int b;             // 3. 静态变量声明
    static {
        System.out.println("静态块2");  // 4. 静态代码块2
    }
}

如果类未加载:

  • 在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件
  • 如果没有找到文件,则抛出ClassNotFoundException异常
  • 如果找到,则进行类加载,并生成对应的Class类对象
🟣 对象分配阶段
步骤2:为对象分配内存

首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。 如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小(开启指针压缩)或8个字节(未开启指针压缩)。

flowchart TD
    A[计算对象大小] --> B{堆内存是否规整?}
    B -->|规整| C[指针碰撞法]
    B -->|不规整| D[空闲列表法]
    
    C --> E[移动指针分配内存]
    D --> F[查找合适空闲块]
    
    E --> G[内存分配完成]
    F --> G

说明:选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

指针碰撞法(Bump The Pointer)

适用于内存规整的情况,如Serial、ParNew等带压缩功能的收集器。

flowchart LR
    subgraph 分配前
        A[已使用内存] --> B[指针]
        B --> C[空闲内存]
    end
    subgraph 分配后
        D[已使用内存] --> E[新对象]
        E --> F[新指针位置]
        F --> G[剩余空闲内存]
    end
    
    style E fill:#ffeb3b
    style F fill:#f44336,color:#fff

空闲列表法(Free List)

适用于内存不规整的情况,如CMS等收集器。

flowchart TD
    A[空闲列表] --> B[块1: 100B]
    A --> C[块2: 200B]
    A --> D[块3: 50B]
    
    E[需要分配: 80B] --> F{查找合适块}
    F --> G[选择块2: 200B]
    G --> H[分配80B]
    H --> I[剩余120B返回列表]
    
    style G fill:#4caf50,color:#fff
    style H fill:#ffeb3b
步骤3:处理并发安全问题

在分配内存空间时,需要保证new对象时的线程安全性。虚拟机采用两种方式解决并发问题:

flowchart TD
    A[并发安全问题] --> B[CAS + 失败重试]
    A --> C[TLAB机制]
    
    B --> B1[原子性操作]
    B --> B2[失败时重试]
    
    C --> C1[线程本地分配缓冲]
    C --> C2[减少锁竞争]
    C --> C3[提高分配效率]
    
    style B fill:#ff9800,color:#fff
    style C fill:#2196f3,color:#fff
  • CAS (Compare And Swap) + 失败重试:保证指针更新操作的原子性
  • TLAB(Thread Local Allocation Buffer):每个线程在Java堆中预先分配一小块内存,可通过-XX:+/-UseTLAB参数设定
步骤4:零值初始化

内存分配结束,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)。

flowchart LR
    A[分配的内存空间] --> B[零值初始化]
    B --> C[boolean: false]
    B --> D[int: 0]
    B --> E[long: 0L]
    B --> F[float: 0.0f]
    B --> G[double: 0.0d]
    B --> H[引用: null]
    
    style B fill:#9c27b0,color:#fff

这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

步骤5:设置对象头

将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。

flowchart TD
    A[对象头设置] --> B[Mark Word]
    A --> C[类型指针]
    A --> D[数组长度]
    
    B --> B1[哈希码]
    B --> B2[GC分代年龄]
    B --> B3[锁状态标志]
    B --> B4[线程持有锁]
    B --> B5[偏向线程ID]
    
    C --> C1[指向方法区类元数据]
    
    D --> D1[仅数组对象需要]
    
    style A fill:#607d8b,color:#fff
🟢 对象初始化阶段
步骤6:成员变量初始化

按照在源代码中定义的顺序,对实例变量进行显式初始化。

// 示例:成员变量初始化顺序
public class Student {
    private String name = "默认姓名";    // 1. 实例变量初始化
    private int age = 18;              // 2. 实例变量初始化
    private static String school = "默认学校"; // 静态变量(类加载时已初始化)
}
步骤7:实例代码块执行

执行实例代码块(非静态代码块),按照在源文件中出现的顺序执行。

public class Student {
    private String name = "默认姓名";
    
    {   // 实例代码块1
        System.out.println("实例代码块1执行");
        name = "修改后的姓名";
    }
    
    private int age = 18;
    
    {   // 实例代码块2
        System.out.println("实例代码块2执行");
        age = 20;
    }
}
步骤8:执行<init>构造方法

在Java程序的视角看来,初始化才正式开始。执行构造方法,完成程序员自定义的初始化逻辑,并把堆内对象的首地址赋值给引用变量。

sequenceDiagram
    participant 栈帧
    participant 堆对象
    participant 构造方法
    
    栈帧->>堆对象: 1. 成员变量初始化
    栈帧->>堆对象: 2. 实例代码块执行
    栈帧->>构造方法: 3. 调用<init>方法
    构造方法->>堆对象: 4. 执行构造方法体
    堆对象->>栈帧: 5. 返回对象引用

<init>方法包含内容

  • 实例变量的初始化
  • 实例代码块的执行
  • 构造方法体的执行

完整初始化顺序示例

public class InitOrder {
    private String field1 = "字段1初始化";     // 步骤6-1
    
    {   // 步骤7-1
        System.out.println("实例代码块1");
    }
    
    private String field2 = "字段2初始化";     // 步骤6-2
    
    {   // 步骤7-2
        System.out.println("实例代码块2");
    }
    
    public InitOrder() {                    // 步骤8
        System.out.println("构造方法执行");
    }
}

因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

补充说明:<clinit> vs <init>

方法执行时机执行内容执行次数
<clinit>类加载时静态变量初始化、静态代码块每个类只执行一次
<init>对象创建时实例变量初始化、实例代码块、构造方法每次创建对象都执行

2. 对象的内存布局

2.1 对象内存布局概述

以一个简单的Student对象为例,展示对象在堆内存中的完整布局:

public class Student {
    private int id;           // 4字节
    private boolean active;   // 1字节
    private double score;     // 8字节
}
flowchart TB
    subgraph 堆内存中的Student对象
        A["对象头 (16字节)"]
        A --> A1["Mark Word (8字节)<br/>哈希码、GC年龄、锁状态"]
        A --> A2["类型指针 (8字节)<br/>指向Student.class"]
        
        B["实例数据 (16字节)"]
        B --> B1["int id (4字节)<br/>值: 1001"]
        B --> B2["boolean active (1字节)<br/>值: true"]
        B --> B3["填充 (3字节)<br/>字段对齐"]
        B --> B4["double score (8字节)<br/>值: 95.5"]
        
        C["对齐填充 (0字节)"]
        C --> C1["总大小32字节<br/>已是8的倍数"]
    end
    
    style A fill:#e3f2fd
    style B fill:#f3e5f5
    style C fill:#e8f5e8
    style A1 fill:#bbdefb
    style A2 fill:#bbdefb
    style B1 fill:#f8bbd9
    style B2 fill:#f8bbd9
    style B3 fill:#ffcdd2
    style B4 fill:#f8bbd9

内存布局详细说明

内存区域大小内容说明
Mark Word8字节哈希码、GC分代年龄、锁状态等运行时元数据
类型指针8字节指向方法区中的Student类元数据确定对象类型
int id4字节整型字段值实例数据
boolean active1字节布尔字段值实例数据
字段对齐填充3字节无意义填充保证double 8字节对齐
double score8字节双精度浮点值实例数据
对象对齐填充0字节无需填充总大小32字节,已是8的倍数

总内存占用:32字节

字段排列规则

  1. 相同宽度字段聚集:相同大小的字段会被分配在一起
  2. 字段对齐:double、long等8字节字段需要8字节对齐
  3. 父类字段优先:父类字段出现在子类字段之前
  4. 对象8字节对齐:整个对象大小必须是8的倍数

2.2 对象头(Header)

对象头:它主要包括两部分。

  • 一个是对象自身的运行时元数据(mark word)。

    • 哈希值(hashcode):对象在堆空间中都有一个首地址值,栈空间的引用根据这个地址指向堆中的对象,这就是哈希值起的作用
    • GC分代年龄:对象首先是在Eden中创建的,在经过多次GC后,如果没有被进行回收,就会在survivor中来回移动,其对应的年龄计数器会发生变化,达到阈值后会进入养老区
    • 锁状态标志:在同步中判断该对象是否是锁
    • 线程持有的锁
    • 线程偏向ID
    • 偏向时间戳
  • 另一个是类型指针,指向元数据区的类元数据InstanceKlass,确定该对象所属的类型

此外,如果对象是一个数组,对象头中还必须有一块用于记录数组的长度的数据。因为正常对象元数据就知道对象的确切大小。所以数组必须得知道长度。

2.3 实例数据(Instance Data)

作用

它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)。

这里需要遵循的一些规则

  • 相同宽度的字段总是被分配在一起
  • 父类中定义的变量会出现在子类之前(因为父类的加载是优先于子类加载的)
  • 如果CompactFields参数为true(默认为true):子类的窄变量可能插入到父类变量的空隙

2.4 对齐填充(Padding)

对齐填充:不是必须的,也没特别含义,仅仅起到占位符的作用

2.5 对象内存布局详细结构

┌─────────────────┐
│   Mark Word    │ (8B)
├─────────────────┤
│  Klass Pointer │ (4B/8B)
├─────────────────┤
│   实例数据       │
├─────────────────┤
│   对齐填充       │
└─────────────────┘

Mark Word结构(64位JVM):

锁状态25bit31bit1bit4bit1bit2bit
无锁unusedhashcodeunusedagebiased_locklock
偏向锁thread IDepochunusedage101
轻量级锁指向栈中锁记录的指针000
重量级锁指向互斥量(重量级锁)的指针010
GC标记011

3. 对象的访问定位

创建对象的目的是为了使用它。定位,通过栈上reference访问。

JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?

flowchart LR
    栈帧[栈帧 reference] --> 堆[堆 InstanceOopDesc]
    堆 --> 方法区[方法区 InstanceKlass]
    方法区 -.->|元数据指针| 堆

《java虚拟机规范》没有说明,所以对象访问方式由虚拟机实现而定。主流有两种方式:

  • 使用句柄访问
  • 使用直接指针访问

3.1 方式1:句柄访问

flowchart TD
    栈[Java栈] --> 变量表[局部变量表 reference]
    变量表 --> 句柄池[句柄池]
    句柄池 --> 实例指针[实例数据指针]
    句柄池 --> 类型指针[类型数据指针]
    类型指针 --> 方法区[方法区对象类型数据]
  • 实现:堆需要划分出一块内存来做句柄池,reference中存储对象的句柄池地址,句柄中包含对象实例与类型数据各自具体的地址信息。
  • 好处:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针,reference本身不需要被修改。

3.2 方式2:直接使用指针访问

flowchart LR
    栈[Java栈] --> 变量表[局部变量表 reference]
    变量表 --> 实例数据[堆对象实例数据]
    实例数据 --> 类型指针[方法区类型数据]
  • 实现:reference中存储的就是对象的地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
  • 好处:速度更快,java中对象访问频繁,每次访问都节省了一次指针定位的时间开销。

3.3 HotSpot使用哪种方式的呢?

HotSpot这里主要使用第2种方式:直接指针访问

JVM可以通过对象引用准确定位到Java堆区中的instanceOopDesc对象,这样既可成功访问到对象的实例信息,当需要访问目标对象的具体类型时,JVM则会通过存储在instanceOopDesc中的元数据指针定位到存储在方法区中的instanceKlass对象上。

4. 对象内存布局核心要点总结

4.1 对象创建流程总览

flowchart TD
    A[new指令] --> B[类加载检查]
    B --> C[内存分配]
    C --> D[并发安全处理]
    D --> E[零值初始化]
    E --> F[对象头设置]
    F --> G[init方法调用]
    G --> H[对象创建完成]
    
    C --> I[指针碰撞]
    C --> J[空闲列表]
    
    D --> K[CAS+重试]
    D --> L[TLAB]

4.2 内存布局三大组成

组成部分作用大小说明
对象头存储运行时元数据8-16字节Mark Word + 类型指针
实例数据存储字段值变长按字段类型对齐
对齐填充内存对齐0-7字节保证8字节倍数

4.3 访问定位方式对比

访问方式优点缺点使用场景
句柄访问引用稳定,GC时无需修改引用多一次间接访问开销频繁GC的场景
直接指针访问速度快,节省一次定位GC时需要修改引用HotSpot默认方式

4.4 性能优化要点

4.4.1 对象头优化
  • 指针压缩-XX:+UseCompressedOops(默认开启)
  • 类指针压缩-XX:+UseCompressedClassPointers
4.4.2 内存分配优化
  • TLAB优化-XX:+UseTLAB(默认开启)
  • TLAB大小-XX:TLABSize=512k
  • TLAB浪费阈值-XX:TLABWasteTargetPercent=1
4.4.3 对齐填充优化
  • 对象对齐-XX:ObjectAlignmentInBytes=8(默认8字节对齐)
  • 字段重排序-XX:+CompactFields(默认开启)

4.5 常见问题诊断

4.5.1 内存泄漏排查
# 生成堆转储
jmap -dump:format=b,file=heap.hprof <pid>

# 分析对象大小
jmap -histo <pid>

# 查看对象详情
jhat heap.hprof
4.5.2 对象分配监控
# 监控TLAB分配
-XX:+PrintTLAB

# 监控GC详情
-XX:+PrintGCDetails

# 监控对象年龄分布
-XX:+PrintTenuringDistribution

4.6 最佳实践建议

  1. 合理设计对象结构:避免过多小对象,减少对象头开销
  2. 字段排序优化:将相同类型字段放在一起,减少内存碎片
  3. 避免频繁创建大对象:使用对象池或缓存机制
  4. 监控内存分配:定期分析堆转储,识别内存热点
  5. 调优JVM参数:根据应用特点调整TLAB和对齐参数

本章总结:通过深入分析对象从创建到访问的完整生命周期,我们掌握了JVM对象内存布局的核心机制。从字节码层面的NEW指令到堆内存的精确布局,从指针碰撞到TLAB优化,从句柄访问到直接指针定位,每个环节都体现了JVM在性能和安全性之间的精妙平衡。理解这些底层原理,有助于我们编写更高效的Java代码,并在遇到内存问题时能够快速定位和解决。