一、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值。
-
对于基本数据类型(如
int
、double
、char
等),如果没有显式初始化,它们的默认值如下:-
int
类型的变量默认值为0
。 -
double
类型的变量默认值为0.0
。 -
char
类型的变量默认值为'\u0000'
(即空字符)。 -
public class Person {int age; // 没有初始化,默认为0String 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 Pointerspublic static class A {//8B mark word//4B Klass Pointer 如果关闭压缩‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,则占用8Bint id; //4BString name; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8Bbyte b; //1BObject o; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B}
}
在64位操作系统上的执行结果(默认开启指针压缩):
关闭指针压缩后,引用类型占用的空间变成8字节:
根据提供的 JOL(Java Object Layout)输出,以下是 com.example.demo.JOLSample$A
对象的内存布局分析:
-
对象头(Object Header)
- Mark Word(标记字):
- 偏移量:
0
,大小:8
字节 - 值:
0x0000000000000005
- 含义:表示对象处于 可偏向状态(
biasable
),分代年龄为0
(age: 0
),存储锁、GC 状态等信息。
- 偏移量:
- Klass Word(类指针):
- 偏移量:
8
,大小:4
字节 - 值:
0xf800cf18
- 含义:指向类元数据的指针(JVM 开启指针压缩后为 4 字节)。
- 偏移量:
- Mark Word(标记字):
-
实例字段(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
(引用类型)。
- 偏移量:
-
对象对齐填充(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
字节。
- 内部(Internal):
内存布局图示
偏移量 | 大小(字节) | 内容 | 说明 |
---|---|---|---|
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)