先去看看这篇博客了解下运行时JVM数据区域,然后再回来看下面内容,🔥🔥🔥记得先赞后看效果翻倍👍👍👍 ~
引言
在Java开发中,new
关键字是我们创建对象最常用的方式。然而,在这简单的操作背后,JVM进行了一系列复杂而精妙的操作。许多开发者虽然每天都在创建对象,但对于对象在JVM中是如何被创建、如何在内存中布局以及如何被访问的底层细节知之甚少。理解这些底层机制不仅有助于我们编写更高效的代码,还能在性能调优和故障排查时提供关键洞察,帮助我们更好地理解Java程序的运行原理。
本文将深入JVM层面,全面解析Java对象的完整生命周期:从创建过程、内存布局到访问定位方式。通过本文,您将获得对Java对象在JVM中表现的全面认识。
一、对象的创建过程
1.1 类加载检查
当JVM遇到一条new
指令时(例如new MyClass()
),它首先检查这个指令的参数是否能在运行时常量池中定位到一个类的符号引用。
- 检查内容 :检查这个符号引用代表的类是否已被加载、解析和初始化过
- 如果未加载 :如果JVM发现这个类还没有被加载,它会立即先执行类的加载过程(Loading → Linking → Initialization)。这是一个相对耗时的操作,包括读取类文件、验证、准备解析等步骤
- 如果已加载:则直接进入下一步,为新生对象分配内存
这一步确保了对象一定是基于一个已被JVM完全知晓和验证的类创建的。
1.2 内存分配
在类加载检查通过后,JVM将为新生对象分配内存 。所谓分配内存,就是从Java堆中划出一块确定大小的内存空间给这个新对象。分配方式取决于Java堆是否规整,而堆是否规整又由所采用的垃圾收集器 是否带有空间压缩整理的能力决定。
主要有两种分配方式:
a) 指针碰撞
- 条件 :假设Java堆的内存是绝对规整的,所有用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器
- 操作 :分配内存仅仅就是把那个指针向空闲空间方向挪动一段与对象大小相等的距离
- 收集器:Serial, ParNew等带有压缩整理功能的收集器
b) 空闲列表
- 条件:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错
- 操作:JVM必须维护一个列表,记录哪些内存块是可用的。在分配时,从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
- 收集器:CMS这种基于标记-清除算法的收集器
内存分配中的并发问题
对象创建在JVM中非常频繁,即使在单线程环境下,仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。
JVM采用了两种方案来解决这个问题:
- CAS + 失败重试 :对分配内存空间的动作进行同步处理 ,采用比较并交换(Compare And Swap)算法保证原子性。这是现代虚拟机普遍采用的方式
- TLAB :本地线程分配缓冲 。每个线程在Java堆中预先分配一小块私有内存。哪个线程要分配内存,就在自己的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。可以通过
-XX:+/-UseTLAB
参数来设定是否启用
1.3 内存空间初始化(零值初始化)
内存分配完成后,JVM会将分配到的内存空间(不包括对象头)都初始化为零值(0, null, false等)。
- 目的 :这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值
- 例如:
int
会初始化为0,boolean
初始化为false,所有引用类型会初始化为null
注意:此时对象还没有开始执行Java代码中定义的构造方法。
1.4 设置对象头
Java对象在内存中的存储布局可以分为三部分:对象头、实例数据和对齐填充。
在零值初始化之后,JVM需要对这个新生对象的对象头进行设置。对象头包含两类信息:
-
Mark Word:用于存储对象自身的运行时数据
- 哈希码
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
- 等等
- 这部分数据在32位和64位的虚拟机中分别为32bit和64bit,它的结构是非固定的,会根据对象的状态复用自己的存储空间,以节省空间
-
类型指针 :即对象指向它的类元数据的指针。JVM通过这个指针来确定这个对象是哪个类的实例
- 并不是所有的虚拟机实现都必须在对象数据上保留类型指针(即通过指针访问对象类型),这取决于对象的访问定位方式
-
数组长度:如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据
1.5 执行构造方法
从JVM的视角看,执行构造方法<init>
是对象创建的最后一步。
<init>
方法 :Java编译器会将实例变量初始化器 (例如int a = 123;
)和构造方法块 ({}
)的代码,收集合并到一个名为<init>
的特殊方法中- 执行过程 :JVM执行
<init>
方法,按照开发者的意图对对象进行初始化,也就是为对象的字段赋予程序员真正想要的初始值 - 与
<clinit>
的区别 :<init>
是实例构造器,用于初始化对象。而<clinit>
是类构造器,用于初始化类变量(static变量)
直到这一步,一个真正可用的、符合开发者预期的对象才被完全构造出来。
flowchart TD A["Java代码: new MyClass()"] --> B["JVM字节码: new指令"] B --> C{类加载检查} C -- 已加载? --> E[内存分配] C -- 未加载 --> D["执行类加载过程<br>Loading → Linking → Initialization"] --> E subgraph 内存分配策略 direction LR E1[指针碰撞<br>(堆规整)] E2[空闲列表<br>(堆不规整)] end E --> E1 E --> E2 E1 --> F[内存空间零值初始化] E2 --> F[内存空间零值初始化] F --> G[设置对象头<br>Mark Word 和 类型指针] G --> H[执行构造方法<init>] H --> I[对象创建完成]
二、对象的内存布局
Java对象在堆内存中的存储布局可以分为三个连续的区域:
- 对象头
- 实例数据
- 对齐填充
2.1 对象头
对象头包含了JVM用于管理对象所必需的运行时数据。它本身又由三部分组成:Mark Word 、类型指针 ,以及数组长度(如果是数组对象)。
a) Mark Word
这是对象头中最重要的部分,用于存储对象自身的运行时数据。它的长度在32位JVM上是32位,在64位JVM上是64位。
为了实现空间高效利用,Mark Word的设计是非固定的,会根据对象的状态在不同时刻存储不同的内容,以复用自己的存储空间。下表展示了在64位JVM下,Mark Word在不同状态下的存储内容:
锁状态 (Lock State) | 25 bits (64位JVM) | 31 bits (64位JVM) | 1 bit (cms_free) | 4 bits (分代年龄) | 1 bit (偏向锁标志) | 2 bits (锁标志位) |
---|---|---|---|---|---|---|
无锁 (Unlocked) | unused | identity_hashcode | 分代年龄 (age) | 0 | 01 | |
偏向锁 (Biased) | thread_id (54 bits) | epoch (2 bits) | 分代年龄 (age) | 1 | 01 | |
轻量级锁 (Lightweight Locked) | 指向栈中锁记录的指针 (ptr_to_lock_record) | 00 | ||||
重量级锁 (Heavyweight Locked) | 指向监视器(管程/互斥量)的指针 (ptr_to_heavyweight_monitor) | 10 | ||||
GC标记 (Marked for GC) | unused | 11 |
- 身份哈希码 :调用
Object.hashCode()
或System.identityHashCode()
后计算并存储的结果。一旦存入,该值会一直伴随该对象 - 分代年龄:对象在Survivor区每熬过一次Minor GC,年龄就增加1。此值有4位,最大为15,这就是为什么-XX:MaxTenuringThreshold的默认最大值是15
- 锁信息:synchronized锁的等级(偏向锁、轻量级锁、重量级锁)相关的线程ID、锁记录指针、重量级锁监视器指针等
- GC状态:标记该对象是否被垃圾回收器标记为可回收状态
b) 类型指针
- 即对象指向它的类型元数据的指针
- JVM通过这个指针来确定这个对象是哪个类的实例
- 并不是所有JVM实现都必须在对象数据上保留类型指针(这取决于对象的访问定位方式,例如使用句柄池就不需要),但通过直接指针(HotSpot默认方式)访问则必须存在
- 在64位JVM上,如果开启了压缩指针(-XX:+UseCompressedOops,默认开启),该指针的长度为4字节(32位),否则为8字节(64位)
c) 数组长度
- 只有数组对象才有
- 用于记录数组的长度,占4字节(32位)
- 有了这个字段,JVM就可以从数组对象的元数据中确定数组的大小,而不需要必须通过类型元数据
对象头大小总结:
- 在64位JVM(开启压缩指针)下 :
- 普通对象:Mark Word(8字节) + 类型指针(4字节) = 12字节
- 数组对象:Mark Word(8字节) + 类型指针(4字节) + 数组长度(4字节) = 16字节
- 在64位JVM(关闭压缩指针)下 :
- 普通对象:Mark Word(8字节) + 类型指针(8字节) = 16字节
- 数组对象:Mark Word(8字节) + 类型指针(8字节) + 数组长度(4字节) + 对齐填充(4字节) = 24字节
2.2 实例数据
- 这是对象真正存储的有效信息,即我们在程序代码里所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的,都必须记录起来
- 存储顺序 :JVM默认会按照以下规则对字段进行排序:
- 父类定义的变量会出现在子类之前
- 较宽的变量(如double/long)通常会被分配在更靠前的位置(但HotSpot VM较新版本的一些优化策略可能会有所调整)
- 如果开启了
-XX:CompactFields
参数(默认开启),JVM允许子类中较窄的变量插入到父类变量的空隙中,以节省空间
- 这部分的大小完全由类的字段定义决定
2.3 对齐填充
- 这部分不是必须存在的 ,仅仅起着占位符的作用
- 目的 :HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍 。换句话说,就是任何对象的大小都必须是8字节的整数倍
- 对象头和实例数据部分结束后,如果整个对象的大小还不是8字节的整数倍,那么对齐填充就会发挥作用,将剩余的空间补足
- 这完全是出于性能考虑,让CPU读取数据更加高效(例如,一次总线事务可以读取完整的数据)
对象内存布局可视化:
graph TD A["对象头 (Header)"] --> B["实例数据 (Instance Data)"] B --> C["对齐填充 (Padding)"]
2.4 实战:使用JOL分析对象布局
OpenJDK提供了Java Object Layout (JOL) 工具包,可以让我们直观地查看对象的内存布局。
示例代码:
首先引入JOL库(如果使用Maven):
xml
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
<scope>provided</scope>
</dependency>
然后编写分析代码:
java
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
public class ObjectLayoutDemo {
public static void main(String[] args) {
// 打印JVM详情(例如是否开启压缩指针)
System.out.println(VM.current().details());
// 分析一个简单的对象
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 分析一个自定义对象
class MyClass {
private int id;
private String name;
private boolean flag;
// 未使用的填充空间可能会被JVM优化
}
MyClass myObj = new MyClass();
System.out.println(ClassLayout.parseInstance(myObj).toPrintable());
}
}
可能的输出(在64位JVM,开启压缩指针下):
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 # (Mark Word: 无锁状态,identity_hashcode未计算)
4 4 (object header) 00 00 00 00 # (Mark Word 继续)
8 4 (object header) e5 01 00 f8 # (类型指针,压缩后4字节)
12 4 (loss due to the next object alignment) <-- 对齐填充
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
ObjectLayoutDemo$1MyClass object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00
4 4 (object header) 00 00 00 00
8 4 (object header) 77 1b 01 f8 # 类型指针
12 4 int MyClass.id 0
16 1 boolean MyClass.flag false
17 3 (alignment/padding gap) # 为了将后面的引用对齐到8字节而做的填充
20 4 java.lang.String MyClass.name null
24 4 (loss due to the next object alignment) # 对象总大小24字节,需要填充到8的倍数(24已是8的倍数,这里可能无填充或显示0)
Instance size: 24 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total
从输出中可以清晰地看到:
Object
对象:12字节对象头 + 4字节填充 = 16字节MyClass
对象:12字节对象头 + 4字节(int) + 1字节(boolean) + 3字节(对齐间隙) + 4字节(压缩后的引用) = 24字节。内部的3字节填充是为了让name
引用字段从20字节开始(不是8的倍数),跳到24字节(是8的倍数),这样CPU访问效率更高
三、对象的访问定位
对象的访问定位探讨的是这样一个核心问题:栈上的reference
类型数据(即我们通常所说的"对象引用"或"指针")如何准确地定位到堆中对象实例的具体位置?
JVM规范只规定了reference
类型是一个指向对象的引用,但并未定义这个引用应该通过何种方式去定位、访问堆中对象的具体位置。主流的JVM实现主要有两种方式:
- 使用句柄访问
- 使用直接指针访问
HotSpot VM主要采用第二种方式,但理解第一种方式对于对比和深入理解至关重要。
3.1 使用句柄访问
如果使用句柄方式,Java堆将会划分出一块内存来作为句柄池。
reference
中存储的是对象的句柄地址- 句柄本身包含了对象实例数据的指针和对象类型数据的指针
其内存结构和访问过程如下图所示:
flowchart TD A[栈 Stack<br>reference变量] --> B["存储: 句柄地址"] B --> C[访问句柄池] subgraph C[Java堆 Heap: 句柄池] direction LR D[句柄] end subgraph D direction TB E[实例数据指针] --> F[Java堆 Heap: 实例池<br>对象实例数据<br>(如: instance_var=100)] H[类型数据指针] --> I[方法区 Method Area<br>对象类型数据<br>(如: 类信息、常量、静态变量等)] end
优点:
- 引用稳定 :
reference
本身存储的是稳定的句柄地址。当对象被垃圾收集器移动时(例如在标记-压缩或复制算法中),只会改变句柄中的实例数据指针 ,而reference
本身不需要做任何修改。这对于那些需要频繁进行GC优化(如压缩堆)的场景非常友好
缺点:
- 访问速度相对较慢 :访问对象实例数据需要两次指针定位(先定位到句柄,再通过句柄定位到实例数据),这比直接指针访问多了一次内存寻址开销。而对象访问在Java程序中是非常频繁的操作,因此这种开销会被放大
3.2 使用直接指针访问(HotSpot采用的方式)
如果使用直接指针方式,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息。
reference
中存储的就是对象的直接地址- 对象实例数据中需要包含一个指向方法区中对象类型数据的指针(即对象头中的类型指针)
其内存结构和访问过程如下图所示:
flowchart TD A[栈 Stack<br>reference变量] --> B["存储: 对象直接地址"] B --> C subgraph C[Java堆 Heap: 实例数据] direction TB D[对象头<br>类型指针] --> E[方法区 Method Area<br>对象类型数据] F[对象实例数据<br>(如: instance_var=100)] end
优点:
- 访问速度更快 :相比于句柄方式,它节省了一次指针定位的时间开销。由于对象访问是Java程序中最频繁的操作之一,因此这类开销的减少对性能的提升是非常可观的
缺点:
- 引用不稳定 :当对象被移动时(例如GC后内存整理),
reference
本身存储的地址需要被直接更新。如果有很多地方都引用了这个对象,更新这些引用的开销会比较大(不过现代GC算法如Shenandoah、ZGC等,通过读屏障等技术极大地优化了这个问题)
3.3 HotSpot VM的实现与优化
HotSpot VM主要使用直接指针方式进行对象访问。
你可能会问,它如何解决直接指针的"引用不稳定"这个缺点呢?答案是:通过复杂的GC算法和精巧的实现来协同解决。
-
针对移动对象的处理:
- 在发生垃圾回收,特别是需要压缩堆(如使用Serial, ParNew, G1等收集器的老年代回收)时,HotSpot确实需要更新所有指向被移动对象的引用
- 这个过程是由GC器通过记忆集 和卡表等技术来跟踪哪些引用需要更新,并在STW阶段高效地完成所有引用的更新操作。虽然这增加了GC的复杂性,但换取的是运行时更高的访问性能
-
压缩指针:
- 在64位JVM上,直接指针的大小是64位(8字节),这相比32位指针会带来更大的内存占用和带宽消耗
- HotSpot引入了压缩指针技术(-XX:+UseCompressedOops,默认开启)
- 原理:并非真的用64位地址去寻址,而是通过一定的偏移和缩放,将64位的地址用32位的数据来存储和表示。JVM在运行时会将这32位的"压缩指针"左移3位(相当于乘以8)再加上一个基地址,来得到真正的64位地址
- 效果:这让引用变量的大小从8字节降到了4字节,节省了大量内存(尤其是大量小对象时),同时因为CPU缓存能容纳更多引用,也间接提升了访问速度
3.4 对比总结
特性 | 句柄访问 | 直接指针访问 (HotSpot) |
---|---|---|
reference存储内容 | 句柄的地址 | 对象的直接地址 |
访问开销 | 两次指针寻址(速度慢) | 一次指针寻址(速度快) |
GC时引用更新 | 对象移动时,只需更新句柄,reference不变 | 对象移动时,必须更新reference |
内存占用 | 需要额外句柄池空间 | 对象头中需要类型指针,但总体更节省 |
优点 | 引用稳定,利于GC | 性能极高,访问速度快 |
缺点 | 访问速度慢,占用额外内存 | GC时更新引用的开销更大 |
四、总结
Java对象的创建、内存布局和访问定位是JVM的核心机制。通过本文的详细解析,我们可以看到,一个简单的new
操作背后,JVM进行了类加载检查、内存分配、初始化、设置对象头和执行构造方法等一系列复杂操作。
对象在内存中的布局分为对象头、实例数据和对齐填充三部分,其中对象头包含了Mark Word和类型指针等关键信息,用于JVM管理对象的生命周期、同步状态和类型识别。
HotSpot VM为了追求极致的执行性能,选择了直接指针作为对象访问定位的方式。它通过投入巨大的工程复杂度在垃圾收集器上(如精确式GC、卡表、读屏障等技术)来克服直接指针在对象移动时的缺点,从而最终赢得了性能上的优势。
理解这些底层机制,不仅能够帮助我们编写更高效的Java代码,还能在进行性能调优、内存分析和故障排查时提供重要的理论依据和实践指导。
附录
相关JVM参数
-XX:+UseTLAB
:启用线程本地分配缓冲(默认开启)-XX:+UseCompressedOops
:启用压缩指针(64位JVM默认开启)-XX:CompactFields
:允许子类窄变量插入父类变量空隙(默认开启)-XX:MaxTenuringThreshold
:设置对象晋升老年代的年龄阈值(默认15)
推荐工具
- JOL(Java Object Layout):分析对象内存布局
- HSDB(HotSpot Debugger):深入分析JVM运行时状态
- JMC(Java Mission Control):监控和分析JVM运行性能
参考资料
- 《深入理解Java虚拟机》 - 周志明
- OpenJDK官方文档
- Java语言规范(JLS)
- JVM规范(JVMS)
- Oracle官方Java性能调优指南