接下来我们来讨论下对象在虚拟机中分配、布局和访问的过程。因为不同虚拟机的实现不同,下文我们以Hot-Spot虚拟机为例。
对象的创建
对象的创建总体过程可以概括为 关键字 -> 类型检查 -> 内存分配 -> 零值 -> 构造函数。
关键字触发
当JVM遇到一条字节码new指令的时候,将触发对象创建的流程。
类型检查
首先JVM会检查该指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载、解析和初始化。
内存分配
两种分配方法
JVM的内存分配算法与JVM的GC算法息息相关。当JVM的内存时规整的,此时JVM只需要将内存指针向空闲方向挪动即可,这种方法被称为“指针碰撞”。但如果内存是不规整的,那么JVM就需要维护一个列表,来记录哪些部分是可用的,此方法叫做“空闲列表”。
分配的线程安全
仅考虑如何分配内存是不够的,因为在内存分配中也会涉及到线程安全的问题。在并发情况下,当线程A向对象A分配内存,此时A的指针还没来得及修改,线程B直接将对象B创建到了该位置。
有两种办法来保证线程安全:第一种是采用同步机制,利用CAS(compare and swap)操作来保证操作的原子性,这里拓展一下通常CPU支持CAS操作,但是底层也是通过lock指令实现的。另一种是现在TLAB中分配,当TLAB不足时再同步锁定。
赋零值
实例数据
当内存分配完后,虚拟机需要将分配到的值都初始化为零值,这一步操作的位置同内存分配的位置相关,可能在TLAB中进行。这一步的意义在于保证了对象中的实例字段在java代码中可以不赋初始值就可以直接使用。因此,实例代码块总是在实例对象赋值之后执行。
public class MyObject {
int i;
int j = 1;
{
System.out.println("init block start:" +i +" | "+j);
j = 2;
System.out.println("init block end:" +i +" | "+j);
}
public MyObject(int i, int j) {
this.i = i;
this.j = j;
}
}
public class InitTest {
public static void main(String[] args) {
MyObject myObject = new MyObject(10, 20);
System.out.println("Init finished: "+ myObject.i +" | "+ myObject.j);
}
/**
* Output:
* init block start:0 | 1
* init block end:0 | 2
* Init finished: 10 | 20
*/
}
对象头
接下来JVM还需要对此对象进行设置,例如对象是哪个类、如何找到类的元数据、对象的哈希码、对象的GC分代年龄和是否被用作锁等。(Mark Word + Class Pointer)
构造函数
上面的操作都完成后从JVM的角度看一个对象已经创建完成了,但是从java程序的角度来看对象的创建才刚开始——构造函数。在构造函数中,会执行<init>指令,按照程序员的意愿对对象进行初始化。至此一个完成的对象才算是创建完成了。
对象的内存布局
一个对象在内存中通常由三个部分组成:对象头、示例数据和对齐填充。
对象头
Mark Word
Mark Wrod中保存了对象的状态信息等,具体如下表:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 重量级锁定 |
空,不需要记录 | 11 | GC标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
Class Pointer
指向类型元数据的指针。
实例数据
即我们在代码中定义的成员变量,包括父类继承的部分。
对齐填充
Hot-Spot要求对象的大小必须是8字节的整数倍,因此需要对齐填充。
对象的访问定位
Java程序通过栈上的reference数据来访问和操作堆上的具体对象。常见的有下面的两种方式。
句柄
句柄下的访问可以抽象成如下过程:
reference -> 句柄池 -> 实例数据+对象类型数据。
该模式下,reference需要句柄池来做“代理”,因此reference不需要跟踪因GC而引起的数据地址变化,句柄池会做更新。但是因为多了一层,导致访问速度较慢。
直接指针
在这种模式下reference直接标记对象的内存地址,好处是速度快。但是需要对reference进行维护更新。