第五章 对象内存布局:从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 Word | 8字节 | 哈希码、GC分代年龄、锁状态等 | 运行时元数据 |
| 类型指针 | 8字节 | 指向方法区中的Student类元数据 | 确定对象类型 |
| int id | 4字节 | 整型字段值 | 实例数据 |
| boolean active | 1字节 | 布尔字段值 | 实例数据 |
| 字段对齐填充 | 3字节 | 无意义填充 | 保证double 8字节对齐 |
| double score | 8字节 | 双精度浮点值 | 实例数据 |
| 对象对齐填充 | 0字节 | 无需填充 | 总大小32字节,已是8的倍数 |
总内存占用:32字节
字段排列规则:
- 相同宽度字段聚集:相同大小的字段会被分配在一起
- 字段对齐:double、long等8字节字段需要8字节对齐
- 父类字段优先:父类字段出现在子类字段之前
- 对象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):
| 锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit | 2bit |
|---|---|---|---|---|---|---|
| 无锁 | unused | hashcode | unused | age | biased_lock | lock |
| 偏向锁 | thread ID | epoch | unused | age | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 0 | 00 | |||
| 重量级锁 | 指向互斥量(重量级锁)的指针 | 0 | 10 | |||
| GC标记 | 空 | 0 | 11 |
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 最佳实践建议
- 合理设计对象结构:避免过多小对象,减少对象头开销
- 字段排序优化:将相同类型字段放在一起,减少内存碎片
- 避免频繁创建大对象:使用对象池或缓存机制
- 监控内存分配:定期分析堆转储,识别内存热点
- 调优JVM参数:根据应用特点调整TLAB和对齐参数
本章总结:通过深入分析对象从创建到访问的完整生命周期,我们掌握了JVM对象内存布局的核心机制。从字节码层面的NEW指令到堆内存的精确布局,从指针碰撞到TLAB优化,从句柄访问到直接指针定位,每个环节都体现了JVM在性能和安全性之间的精妙平衡。理解这些底层原理,有助于我们编写更高效的Java代码,并在遇到内存问题时能够快速定位和解决。