JVM中对象的构造过程

Smile_slime_47

JVM中对象的构造过程

new的过程


当我们首次调用一个类时(调用静态方法也好、实例化对象也好),JVM经过了以下几个步骤

加载

JVM检查该类的Class对象是否已经被加载,如果Class对象已被加载,则在运行时常量池中通过对应的符号引用获取Class对象,若不存在符号引用则说明尚未被加载。若未被加载则调用Class.forName()通知classLoader类加载器寻找对应的class文件并加载入内存创建Class对象;若对应Class对象已被加载则跳过这一步

  • 验证:在classLoader找到类文件后,JVM会对搜索到的类文件进行检查。在通过魔数验证(文件头的0XCAFEBABE)后,JVM还会进行语义和语法正确性检查
  • 准备:此时类内的static成员也会被分配给Class的内存空间,在调用静态成员时直接通过Class对象调用
  • 解析:JVM将Class对象常量池中的符号引用进行解析,获取常量池中的字段、变量、路径等

以上三步又被叫做链接,是将.class文件加载进虚拟机内存并创建为Class对象的步骤

在JDK中就是这样的代码,不过我们是无法像这样子显式声明一个Class对象的,因为Class对象的构造函数是私有的

1
Class cls = new Class(String);

分配内存空间

实例化一个新对象时,new关键字会通知JVM根据对应的Class对象申请一块(heap)内存空间。

  • 如果是给引用变量赋值,那么在这步之前JVM还会给变量分配一块(stack)内存空间

根据GC调度策略的不同,获取一块空闲内存空间通常有两种方式:

  • 指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
  • 空闲列表:如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)

零值初始化

JVM将该内存空间的数值初始化为零值,并初始化设置对象头(Class对象、元数据、哈希值、GC年龄等),此时对于虚拟机而言已经完成了对象的创建操作,该操作保证了对象的字段可以不经过初始化直接使用

构造器初始化

JVM根据参数调用Class对象中的构造函数,对对象进行初始化操作,此时才是真正完成了程序层面上的实例化对象

这一步在字节码中是通过一系列invoke操作码实现的,而invoke操作码本身就是一系列用于方法调用的操作,零参构造器调用实际上也是通过invokespecial调用了Object的<init>默认构造器而已

返回实例地址

JVM返回给new关键字对应实例对象的堆内存地址,此时引用变量正式被链接到对应对象上

对象的内存结构


对于HotSpot虚拟机,在中对象实例的数据分为对象头、实例数据和对齐填充三部分

对象头

对象头分为两部分

  • 第一部分存储了该类实例的元数据,如哈希码、GC 分代年龄、锁状态标志等等
  • 第二部分存储了该类的类型指针,指向对应的Class对象,指示该对象是哪个类的实例化

实例数据

实例数据存储了这个类的各字段属性的值

对齐填充

由于HotSpot规定了对象的起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍,不足的部分需要通过占位符来填充

构造器分析


我们编写一个最简单的对象

1
2
public class test {
}

用反汇编工具javap对其分析得到字节码的可读格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class test
...
Constant pool:
...
{
public test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ltest;
}
SourceFile: "test.java"

这里可以看出在不指定一个类的构造函数时,编译器会为我们自动补全一个零参构造器——由于这个对象我们什么也没有写,因此分析出来的字节码也只包含了常量池零参构造器

下面我们来逐行分析这个零参构造器在JVM创建一个新对象时到底做了什么事:

1
public test();

这里声明了下列代码块是test对象的零参构造器

1
descriptor: ()V

描述符指出了这个方法的参数和返回值,由于是零参构造器所以括号内是没有任何参数的,V说明这是一个返回Void类型的方法,而这里描述符的字段(即“()V”)实际上是被存储在常量池中的

1
flags: (0x0001) ACC_PUBLIC

这里的访问权限修饰符指出了这是一个public方法,即公有构造器,可以被外部调用

1
2
3
4
5
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

Code指出下方为JVM执行的代码块,包含了一系列操作码和操作数

  • stack是分给给该方法的栈深度,也叫做最大操作数栈,由JVM分配
  • locals是局部变量表大小,单位是槽(slot),局部变量表用于存放方法中的参数变量和局部变量等
  • args_size是该方法的参数数量

三者都为1是因为实际上所有方法在JVM中都是以静态方法(static)来实现的,在非静态方法中JVM隐藏了一个参数——this引用,通过this引用来告诉静态方法要操作的是当前对象,由于this引用本身也是一个参数变量,所以是需要栈深度和局部变量表来存储操作的

在局部变量表中,this引用始终占据局部变量表的0槽位

比如说我们调用了一个String的方法

1
s.charAt(0);

实际上在JVM眼里的形式是

1
String.charAt(s,0)

aload_0指的是将局部变量表中的0槽(即this引用)推入操作数栈顶

invokespecial #1是JVM的方法调用,通过#1在常量池中找到要调用的方法(这里是父类Object的init构造器),然后将操作数栈中的参数出栈传递给方法

  • JVM通过一系列invoke操作码来进行方法调用,这里不多解释

return表示方法返回,由于构造函数是一个Void类型的方法,所以没有返回值

1
2
LineNumberTable:
line 1: 0

LineNumberTable标记了Java源文件中行号与class文件中对应字节码位置的偏移关系

在JVM抛出异常报错时根据这个偏移关系找到源文件的错误行号

1
2
3
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ltest;

LocalVariableTable就是分配给该方法的局部变量表了,可以看出这个方法的局部变量表只包含this引用

Ltest表示这是一个test类型的对象引用

数据类型描述符

在字节码中,我们使用一系列大写字母作为描述符来表示数据类型

描述符 数据类型
B byte
C char
D double
F float
I int
J long
S short
V void
Z boolean
L* Object
[* 数组类型
Comments