Java对象的内存布局详解------超市薯片是怎么摆在货架上的?
PS:本文参考的JDK 21源码版本为 ------ jdk-21-ga
从薯片的角度来理解------什么是Java对象内存布局?
薯片与Java对象
如果把堆想象成超市,对象想像成超市里面的一袋袋薯片、商品,每个袋除了条码之外还有商品简介,商品简介里面写明了这包薯片的成分、配料和生产信息之类的东西。
Java对象其实也像商品一样被分门别类的放在堆这个超市里面,就以大家最熟悉的薯片来举例。
-
商品包装 VS 对象头: 每个薯片都有一个精致的包装,上面有图片、商品简介、商品二维码、营养成分含量等,这个商品的包装就对应了Java中的对象头。通过商品包装顾客可以快速了解商品,而通过对象头JVM也可以快速了解对象的一些信息。
-
**商品简介 VS MarkWord:**每袋薯片都有一个商品简介,上面写了商品的成分、厂家信息、卫生许可之类的东西,可以帮我们快速了解薯片的信息。对于Java对象来说也需要一个商品简介来标记这个对象的一些GC信息、锁信息等,方便JVM在使用这个对象的时候获取一些基本信息,这个商品简介或者说标签就是------MarkWord。
-
商品二维码 VS Class Pointer: 每袋薯片都有商品二维码以方便快速找到这袋薯片和结账,Java类也有一个商品二维码 ------ Class Pointer。 这个Class指针指向的是JVM中方法区当中的类的字节码文件,可以快速的通过对象去访问对应类的字段、方法、接口等等。
-
实例数据 VS 薯片: 薯片是包装里面真正可以吃的东西,而Java类的实例数据也是Java对象真正需要被使用到的东西,包括成员变量、成员方法引用等等。
-
**填充字节 VS 氮气:**为了让薯片保质,也为了让薯片一袋袋的更好看一点儿,薯片里面被充满了氮气。Java类也一样,由于例如对于64位操作系统来说,8Bytes的长度更有利于操作系统处理,因此不足8Bytes的对象会被填充满8Bytes的整数倍。
Java对象构成
总的来说,与超市货架上摆放的薯片类似,Java 对象内存布局指的是一个对象在内存中的分配方式和结构。每个 Java 对象都占用一定的内存空间,并且在内存中的布局是由对象头、实例数据以及填充字节(Padding)等组成的。以下是 Java 对象内存布局的主要组成部分:
- 对象头(Object Header): 对象头存储了与对象相关的元数据信息,包括
MarkWord
、Class Pointer
等。MarkWord
通常包含对象的锁状态、垃圾回收信息等。Class Pointer
指向对象的类元数据,用于确定对象的类型信息。 - 实例数据(Instance Data): 实例数据是对象中存储的实际数据,即对象的字段值。这部分数据的大小和类型由对象的类定义决定。
- 填充字节(Padding): 由于硬件对齐的要求,对象在内存中可能需要进行填充,以保证对象的起始地址是对齐的。填充字节的大小取决于硬件架构和虚拟机的具体实现。
对象内存布局的示意图如下:
具体的内存布局可能因为 JVM 实现、垃圾回收策略、对象的大小等因素而有所不同。例如,对象头的大小、对齐规则以及额外的信息(比如数组长度、引用指针等)都可能影响对象的内存布局。理解对象内存布局对于进行性能调优、内存优化以及对 Java 虚拟机的工作原理有重要的帮助。
工欲善其事,必先利其器------JOL工具
Java Object Layout (JOL) 是一个开源的 Java 库,用于深入了解 Java 对象的布局和内存消耗。该工具提供了一种在运行时分析 Java 对象布局的方式,包括对象头、实例数据、对齐等信息。JOL 通常用于性能优化、调试和了解 Java 对象内部结构。下面是 JOL 的一些主要用途和功能:
- 对象布局分析: JOL 允许您查看 Java 对象在内存中的布局,包括对象头、实例数据、填充字节等。这对于了解对象的内存占用和对齐方式很有帮助。
- 性能优化: 通过使用 JOL,您可以深入了解对象在内存中的排列方式,从而有助于优化对象的布局,减少内存占用,提高访问效率。
- 调试和分析: JOL 提供了一种方法来检查对象的内部结构,这对于调试和分析代码中的对象问题非常有用。
JOL依赖和使用
JOL作为一个扩展jar包,要在Java项目中使用 JOL 工具,只需要执行以下步骤:
step 1: 添加 JOL 依赖
首先,在您的项目中添加 JOL 依赖,可以使用 Maven 或 Gradle 来管理依赖关系。以下是 Maven 示例:
xml
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version> <!-- 请检查最新版本 -->
</dependency>
step 2: Java代码中使用 JOL
接下来,在 Java 代码中使用 JOL 进行对象布局分析。以下是一个简单的示例:
java
import org.openjdk.jol.info.ClassLayout;
public class JOLExample {
public static void main(String[] args) {
// 创建一个示例对象
MyClass myObject = new MyClass();
// 使用 JOL 获取对象布局信息并打印
String layout = ClassLayout.parseInstance(myObject).toPrintable();
System.out.println(layout);
}
// 示例类
static class MyClass {
int x;
long y;
}
}
step 3: 运行测试
上述代码的运行结果如下:
auto
com.tsinghualei.memstructure.JOLExample$MyClass object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x01003200
12 4 int MyClass.x 0
16 8 long MyClass.y 0
Instance size: 24 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
这段日志是通过 JOL 工具生成的,它提供了关于Java对象MyClass
类实例的内部结构和内存布局的详细信息,下面是对这个日志的解释:
- 对象头信息(object header: mark):
0 8
:这是对象头的第一行,0
表示相对于对象的起始地址的偏移量,8
表示对象头的大小是8字节。- ```0x0000000000000001
表示对象的标记,其中
1表示对象是非偏向的,
age: 0`表示对象的年龄是0。
- Class指针
(object header: class)
:8 4
:这是第二行Class指针,表示相对于对象的起始地址的偏移量为8字节,4字节的大小。- ``这是对象头的类部分,
0x01003200
是一个标记,用于标识该对象的类。
- 对象字段信息:
12 4
:表示相对于对象的起始地址的偏移量为12字节,4字节的大小。int MyClass.x
:这是MyClass
类的一个int
类型的字段,名为x
,值为0
。16 8
:表示相对于对象的起始地址的偏移量为16字节,8字节的大小。long MyClass.y
:这是MyClass
类的一个long
类型的字段,名为y
,值为0
。
- 对象大小:
Instance size: 24 bytes
:这是该对象的总大小,包括对象头、类信息和实例字段。在这里,该对象的大小是24字节。 - 空间损失:
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
:这表示对象的内部和外部空间损失,因为一般对于64位操作系统来说,对象内存长度需要对齐8 Bytes,在这里没有空间损失,总共是0字节。
综合起来,这份日志提供了关于MyClass
对象在内存中布局的详细信息,包括对象头、类信息、实例字段以及对象的总大小。当前对象共占用24字节,因为8字节标记字节(MarkWord)、4字节的类指针,8字节的成员变量、不满足向8字节对齐这里无需填充。
对象头------薯片包装
Java 对象头是每个 Java 对象在内存中的开头部分,用于存储对象的元数据信息,对象头的结构在不同的 JVM 实现中可能有所不同,但一般包括以下几个重要的部分:
-
MarkWord(标记字段): MarkWord 主要用于存储对象的状态信息,例如是否被锁定、是否可回收、对象的哈希码、年龄等。这个部分是对象头中的一个字段,占用一定的字节
-
Class Pointer(类型指针): 指向对象的类元数据(Class Metadata)的指针,用于确定对象的类型信息。这个指针指向对象所属类的 Class 对象。
-
数组长度(如果是数组对象): 如果对象是数组类型,对象头中还包括一个字段用于存储数组的长度。这个字段仅在数组对象的对象头中存在。
对象头的结构对于 Java 虚拟机的各种功能非常重要,包括垃圾回收、同步锁、线程安全等,不同的 JVM 实现可能会有不同的优化和扩展,但基本的对象头结构通常是类似的。
对象头的MarkWord (标记字段)------薯片包装上的商品简介
MarkWord
是 Java 对象头中的一部分,用于存储对象的状态信息,它占据对象头的前8个字节。MarkWord
包含了多个标志位,用于记录对象的状态,支持垃圾回收、同步锁等功能。具体的标志位含义可能会因 JVM 实现而有所不同,但通常包括以下内容:
- 锁定状态: 用于支持对象的同步操作,包括偏向锁、轻量级锁和重量级锁,锁定状态的标志位表示对象是否被锁定,以及采用何种锁机制。
- 偏向线程 ID: 在偏向锁的情况下,
MarkWord
中可能包含偏向线程的 ID,用于标识哪个线程获取了偏向锁。 - 偏向时间戳: 在偏向锁的情况下,用于记录上次偏向操作的时间戳,帮助判断是否需要撤销偏向锁。
- 分代年龄: 用于支持分代垃圾回收算法,标识对象的存活时间。
总体而言,MarkWord
提供了一些位来记录对象的状态信息,这些信息在 JVM 的运行时中用于优化对象的同步和垃圾回收信息。这部分还有一个比较重要的知识点就是Java的sychonized锁升级和对象头中的MarkWord的关系,可以参考我公众号里面的其它文章。
对象头的Class Pointer(类指针)------薯片包装上的二维码
每个 Java 对象在内存中的对象头中都包含一个指针,指向该对象的类的元数据(Class
对象)。这个指针用于确定对象的类型信息,包括对象所属的类、父类、实现的接口等。具体来说,类指针包含了以下信息:
- 类的类型信息: 指向对象所属类的
Class
对象,该对象包含了关于类的元数据,如类的字段、方法、构造函数等信息。 - 方法表(Method Table): 一些虚拟机使用类指针来访问对象所属类的方法表,这是一张包含了类中所有方法的表格。
- 其他元数据: 类指针可能包含其他用于支持 Java 的特性的元数据,比如类型擦除的信息、泛型信息等。
类指针的存在使得 Java 具有反射和运行时类型信息(RTTI)的能力,允许程序在运行时动态地获取对象的类型信息。这对于实现面向对象编程的特性,如多态,非常重要。
Class指针压缩
指针压缩(Pointer Compression)是一种优化技术,通常应用于64位的 Java 虚拟机。它旨在减小对象头中的一些字段的大小,从而降低对象的内存占用。
指针压缩的一种实现方式是将对象引用的高位空间用于存储对象头信息,因为在64位系统上,实际应用中的堆空间很少会超过32GB,因此对象引用的高位通常是没有用到的。关于 Class Pointer
和指针压缩的关系:
- 未压缩的情况: 在没有指针压缩的情况下,
Class Pointer
通常是一个完整的指针,指向对象所属类的元数据。这个指针的大小通常是 8 字节,具体取决于虚拟机的实现和运行在何种硬件架构上。 - 指针压缩的情况: 在启用指针压缩的情况下,
Class Pointer
可能经过压缩,一般是4字节,这样可以减小对象头的大小,从而降低对象在堆中的内存占用。
指针压缩技术是一种用于减小对象头大小并提高内存利用率的优化手段,但它需要考虑到堆的大小和系统架构,在大多数情况下,这种优化是由虚拟机自动处理的,而不需要程序员干预。
JVM设置指针压缩
在 Java 虚拟机中,可以通过 JVM 启动参数来控制是否启用指针压缩。指针压缩通常用于64位的 JVM,通过减小对象头中的一些字段的大小来降低对象的内存占用。在 HotSpot 虚拟机中,使用 -XX:ObjectAlignmentInBytes
参数来控制指针压缩的开启和关闭。
- **开启指针压缩:**表示对象的对齐方式是 4 字节,这通常是启用指针压缩的标志。在这种情况下,对象引用的高位将用于存储对象头信息,以减小对象头的大小。
bash
-XX:+UseCompressedOops
- 关闭指针压缩:
bash
-XX:-UseCompressedOops
上述参数表示对象的对齐方式是 8 字节,这通常是禁用指针压缩的标志。在这种情况下,对象引用的高位不会用于存储对象头信息,保持对象头的大小较大。
对象头的数组长度 ------ 数组类型独有
在 Java 中,对象头中包含了一个用于表示数组长度的字段。这个字段的存在仅针对数组对象,在普通对象中是不存在的。对于普通对象,对象头主要包含了 MarkWord
和 Class Pointer
,用于标记对象的状态和指向对象的类元数据。而对于数组对象,对象头还包含了一个额外的字段用于存储数组的长度。
存储数组长度的主要目的是为了支持对数组的快速访问和遍历。在没有存储数组长度的情况下,要想获取数组的长度就需要进行遍历整个数组,这会导致性能开销较大,特别是对于大型数组来说。以下是存储数组长度的一些重要原因:
- 快速访问: 存储数组长度使得程序能够在 O(1) 的时间复杂度内获取数组的长度,这对于很多算法和操作来说是非常重要的,因为它允许在不需要遍历整个数组的情况下直接获取数组的大小。
- 循环迭代: 在循环中遍历数组时,知道数组的长度可以控制循环的次数,从而使代码更简洁和高效。
- 边界检查: 存储数组长度也允许进行边界检查,确保在访问数组元素时不会越界,这有助于提高程序的健壮性,防止访问超出数组边界的内存。
- 内存布局: 存储数组长度也有助于虚拟机在内存中布局数组,虚拟机可能会使用数组的长度来进行优化,例如在进行内存回收时,能够知道数组的实际大小,从而更有效地管理内存。
需要注意的是,这种优化不是绝对的,在某些情况下,如果数组的长度是已知的且常量,编译器和虚拟机可能会进行一些优化,而不依赖于实际存储的数组长度字段。
对象实例数据 ------ 薯片
字段重排
如下面的JOL输出可以看出,属性的排列顺序与在类中定义的顺序可能不同,这是因为 JVM 采用字段重排序技术,对原始类型进行重新排序,以满足内存对齐的需求。内存对齐的好处主要体现在提高访问效率、减少内存碎片、提高数据缓存利用率和最终提高系统性能。
- Java代码
auto
public class User {
int id,age,weight;
byte sex;
long phone;
char local;
}
- JOL输出
auto
12 4 int User.id 0
16 8 long User.phone 0
24 4 int User.age 0
28 4 int User.weight 0
32 2 char User.local
34 1 byte User.sex 0
JVM中内存对齐具体规则遵循如下:
- 按照数据类型的长度大小,从大到小排列。
- 具有相同长度的字段会被分配在相邻位置。
- 如果一个字段的长度是 L 个字节,那么这个字段的偏移量(OFFSET)需要对齐至 nL(n 为整数)的位置。
通过按照特定字节大小对齐数据,可以减少 CPU 访问内存的次数,提高访问效率;减少内存碎片,提高内存利用率;避免字段横跨多个缓存行,提高数据缓存利用率。这些优势在大型数据库系统、图形处理等对性能要求较高的应用中尤为重要,有助于系统更有效地利用硬件资源,提升整体性能。
继承父类
在类继承中,从内存布局上来说,父类的变量通常出现在子类的变量之前,但是在一些特殊情况下可能由于内存对其要求而需要实现补位,以下是一个具体的示例的Java代码:
java
public class Parent {
long parentLong;
}
public class Children extends Parent {
long childLong;
int childInt;
}
使用 JOL(Java Object Layout)输出该类的内存布局如下:
auto
Offset Size Type Field Name
0 12 (Object header)
12 4 int Children.childInt 0
16 8 long Parent.parentLong 0
24 8 long Children.childLong 0
可以看到,父类 Parent
中的 parentLong
出现在子类 Children
中的变量之前。这是因为在内存中,通常遵循将父类的字段排在子类字段之前的原则。在一些特殊情况下,可能由于对齐要求而进行前置补位,但整体结构仍然符合子类在父类字段之后的布局规则。
引用数据类型
默认情况下,JVM 在内存中排列变量时会将基本数据类型的变量放在引用数据类型之前,如下Java代码:
auto
public class User {
int int1;
String ref;
int int2;
}
使用 JOL(Java Object Layout)输出该类的内存布局如下:
auto
12 4 int User.int1 0
16 4 int User.int2 0
20 4 java.lang.String User.ref null
如上面JOL输出的内存布局所示,默认情况下的引用类型ref排在了int类型的后面,但是这种默认顺序可以通过 JVM 启动参数进行修改,具体操作如下:
bash
-XX:FieldsAllocationStyle=0
静态变量
在Java中,静态变量是属于类的变量,一般会随着类加载的时候被存放在方法区当中,所以在对象的实例数据中是没有静态变量数据的(关于这部分内容可以参考我的文章《运行时Java类的内存营地------方法区详解》)。例如,在类中加入了一个静态变量,Java代码如下:
java
public class User {
int id;
static byte local;
}
使用 JOL(Java Object Layout)输出该类的内存布局如下:
auto
12 4 int User.id 0
通过观察内存布局的结果,可以明确静态变量并不包含在对象的内存布局中,静态变量属于类而不属于具体的对象,因此其大小并不计算在对象的内存中。
对齐填充字节------氮气
在Hotspot的自动内存管理系统中,要求对象的起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍。因此,如果实例数据没有对齐,就需要进行对齐填充以满足这一要求,填充的位仅充当占位符,不具有特殊含义。
在前述例子中,我们已经对对齐填充有了深入的了解。此外,在启用指针压缩的情况下,如果类中存在long/double类型的变量,将在对象头和实例数据之间形成间隙(gap)。为了节省空间,默认情况下会将较短长度的变量放在前面。这一功能可以通过JVM参数进行开启或关闭:
bash
# 开启
-XX:+CompactFields
# 关闭
-XX:-CompactFields
在关闭情况下,可以观察到较短长度的变量没有前移填充。另外,我们提到了可以修改对齐宽度的参数:
bash
-XX:ObjectAlignmentInBytes
默认情况下对齐宽度为8字节,可以将其修改为2~256之间的2的整数幂。通常情况下,对齐宽度选择为8字节或16字节。在测试中,将对齐宽度修改为16字节,可以看到最后一行的属性字段仅占用6字节,因此会添加10字节进行对齐填充。然而,一般情况下不建议修改对齐长度参数,因为过长的对齐宽度可能导致内存空间的浪费。
总结
在Java中,对象头是每个对象在内存中的开头部分,存储着对象的元数据信息。对象头包括MarkWord(标记字段)和Class Pointer(类型指针)。MarkWord用于记录对象的状态,支持垃圾回收和同步锁等功能,而Class Pointer指向对象所属类的元数据,提供反射和运行时类型信息的支持。
对于数组对象,对象头还包括一个字段用于存储数组的长度,以支持快速访问和遍历。在内存布局中,父类的变量通常出现在子类的变量之前,但可能由于内存对齐的需求而进行补位。引用数据类型的变量通常排在基本数据类型之前。
字段重排和指针压缩是优化技术,字段重排通过内存对齐提高访问效率和数据缓存利用率,指针压缩减小对象头大小,提高内存利用率。静态变量不包含在对象的内存布局中,属于类而不属于具体的对象。
最后,对齐填充字节用于满足对象起始地址必须是8字节整数倍的要求,填充位仅为占位符。了解这些概念有助于理解Java对象在内存中的存储方式和优化策略。
更多优质内容
微信公众号:ByteRaccoon、知乎\稀土掘金\小红书都叫:浣熊say
PS:本文当中有些链接跳转不过去,是因为掘金禁止跳转微信公众号的链接,对连接内容感兴趣的,可以直接看我的微信公众号原文,里面的文章可以正常跳转。