JVM中对象的构造过程
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 | public class test { |
用反汇编工具javap对其分析得到字节码的可读格式
1 | public class test |
这里可以看出在不指定一个类的构造函数时,编译器会为我们自动补全一个零参构造器——由于这个对象我们什么也没有写,因此分析出来的字节码也只包含了常量池和零参构造器。
下面我们来逐行分析这个零参构造器在JVM创建一个新对象时到底做了什么事:
1 | public test(); |
这里声明了下列代码块是test对象的零参构造器
1 | descriptor: ()V |
描述符指出了这个方法的参数和返回值,由于是零参构造器所以括号内是没有任何参数的,V说明这是一个返回Void类型的方法,而这里描述符的字段(即“()V”)实际上是被存储在常量池中的
1 | flags: (0x0001) ACC_PUBLIC |
这里的访问权限修饰符指出了这是一个public方法,即公有构造器,可以被外部调用
1 | Code: |
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 | LineNumberTable: |
LineNumberTable标记了Java源文件中行号与class文件中对应字节码位置的偏移关系
在JVM抛出异常报错时根据这个偏移关系找到源文件的错误行号
1 | LocalVariableTable: |
LocalVariableTable就是分配给该方法的局部变量表了,可以看出这个方法的局部变量表只包含this引用
Ltest表示这是一个test类型的对象引用
数据类型描述符
在字节码中,我们使用一系列大写字母作为描述符来表示数据类型
描述符 | 数据类型 |
---|---|
B | byte |
C | char |
D | double |
F | float |
I | int |
J | long |
S | short |
V | void |
Z | boolean |
L* | Object |
[* | 数组类型 |