JVM(八)对象的实例化内存布局与访问定位
1 对象创建的方式
-
new
- 变形1:
-
Class的
newInstance()
,即反射Class的
newInstance
反射的使用较为苛刻,要求只能调用空参的构造器,而且权限必须是public这种方式再jdk9中被标记为过时了
-
Constructor的
newInstance()
,也属于是反射可以调用空参或者无参的构造器,权限没有要求
-
使用clone()方法:不调用任何构造器,但要求当前类实现Cloneable接口
-
使用反序列化:从文件或者网络中获取对象的二进制流,然后序列化为java对象
-
使用第三方库,如
Objenesis
,可以利用一些字节码技术动态生成对象
2 对象创建的步骤
2.1 从字节码的角度看对象的创建
对下面代码编译后的字节码文件进行反编译:
- 首先调用new操作符指令创建运行时常量池索引为2的类的对象并进行零值初始化,在此之前也需要检查这个类是否被加载
- 这一步主要有两个工作:1 将类加载到方法区 2 在堆中开辟创建对象的空间
- dup指令在栈帧的操作数栈中将指向创建对象的引用复制一份,这样就有两个引用指向堆空间的对象实体,栈底部的引用负责对对象进行赋值操作;栈顶的引用则是作为一个句柄调用相关方法;也可以看到操作数栈的深度为2
- invokespecial #1指令调用运行时常量池索引为1的方法引用,即是
java/lang/Object."<init>":()V
,Object的空参构造器和代码块的赋值,对对象实例完成赋值操作 - astore_1指令将
操作数栈
结果放入局部变量表
下标为1的位置(0是String[] args形参)
public class ObjectTest {
public static void main(String[] args) {
Object obj = new Object();
}
}
...
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: return
LineNumberTable:
line 5: 0
line 6: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
8 1 1 obj Ljava/lang/Object;
...
2.1 从执行的步骤看对象的创建
-
判断对象对应的类是否被加载、链接和初始化,虚拟机在遇到一条new指令的时候,首先去检查这个指令的参数能否在
MetaSpace
的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载、链接和初始化(即判断类元信息是否存在)。如果没有则需要在双亲委派模式下,使用对应的类加载器以ClassLoader+包名+类名
为key查找对应的class文件,如果没有找到则抛出ClassNotFoundException
异常,否则将类元信息加载到方法区,并生成对应的Class类对象 -
为对象分配内存,首先计算对象占用空间大小(int、引用等占4字节,double、long占8)
- 如果内存规整,即有一段连续的内存来存储对象,虚拟机采用
指针碰撞
来为对象分配内存,即将所有用过的内存放在一遍,没有用过的放在另一边,指针作为分界点的指示器,分配内存的时候就把指针向空闲的一侧移动与对象大小相等的距离。一般使用带有Compact(整理)过程的收集器、基于压缩算法的如Serial、ParNew使用指针碰撞的方式为对象分配内存 - 如果内存不规整,则采用空闲列表法为对象分配内存。即虚拟机维护一个列表,用于记录哪些内存块是可用的,再分配的时候从列表中找一块足够大的空间进行分配并更新列表即可
- 如果内存规整,即有一段连续的内存来存储对象,虚拟机采用
-
处理并发安全问题:
- 首先为每个线程都在堆上创建一个TLAB,线程会优先在自己这块内存区域创建对象
- 如果TLAB不足以分配对象,则采用CAS配上失败重试保证更新的原子性
-
初始化分配到的空间,为所有的属性设置零值,保证对象实例字段在不被赋值的情况下也能直接使用
-
设置对象头
-
执行
<init>
方法进行初始化<init>
方法包括成员变量的赋值语句、实例化代码块以及类的构造方法的整合,并把堆内对象的首地址赋值给引用变量
3 对象的内存布局
- 对象头(Header),包括两部分:
- 运行时元数据,包括:
- 哈希值(
HashCode
):表示对象在内存中的首地址,方便变量进行引用 - GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
- 哈希值(
- 类型指针:指向类元数据Instance Klass,确定对象所属的类型(对象.getClass()就是获取的类型指针)
- 运行时元数据,包括:
- 实例数据(Instance Data):对象真正存储的有效信息,包括程序代码定义和从父类继承来的各种类型的字段;并且具有以下规则:
- 相同宽度的字段总是被分配在一起(4字节、8字节变量)
- 分类中定义的变量出现在子类之前
- 如果
CompactFields
参数为true(默认true),子类的窄变量可以插入到父类变量的空隙中以节省空间
- 对齐补充(Padding):保证对象占有空间是8字节的倍数,不是必须也没有特别的含义
public class CustomerTest {
public static void main(String[] args) {
Customer cust = new Customer();
}
}
class Customer {
Integer id;
String name;
Account acct;
}
class Account {
...
}
4 对象访问定位
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?
句柄访问
- 句柄访问是指在java堆中开辟一块
句柄池
,包括堆中到对象实例数据的指针
和到方法区中到对象类型数据的指针
- 每个对象会对应一个句柄池
直接指针访问
- 直接指针访问是指栈帧局部变量表中的
对象引用
直接指向对象实例数据
,然后在对象实体中有一个指向方法区中对象类型数据的指针
两种方式的优缺点?
- 句柄访问的方式需要在堆空间中开辟额外空间,并且在进行对象访问定位的时候需要先定位到句柄才能定位到对象实体,效率较低
- 如果发生对象的移动,即对象的存储地址发生变化,则直接指针的访问方式需要去修改每个指向对象实体的对象引用,而句柄访问的方式只需要改一次句柄的指针即可