以最常用的虚拟机
HotSpot为例,探讨一下对象在Java堆中分配、布局和访问的过程。
Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常仅仅是一个
new关键字而已,
根据某个类来声明一个引用变量指向被创建的对象,并使用此引用变量操作该对象。
在实例化对象的过程中, JVM 中发生了什么化学反应呢?
当JVM接收到字节码new 指令
时,首先在
metaspace
内检查需要创建的类元信息是否存在。若不存在,那么在
双亲委派
模式下,使用当前类加载器以ClassLoader+包名+类名为Key 进行查找对应的.class 文件。如果没有找到文件,则抛出
ClassNotFoundException 异常,如果找到,则进行
①类加载。
在类加载检查通过后,首先计算对象占用空间大小,接着在
②
堆中划分一块内存给新对象。假设Java堆中内存是规整的,被使用过的内存都放在一边,空闲的内存放在另一边,中间放一个指针作为分界点的指示器,那分配内存就仅仅是把指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为
“指针碰撞”(Bump The Pointer)。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为
“空闲列表”(Free List)。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用
Serial、
ParNew等带压缩整理过程的收集器时,系统采用的分配算法是
指针碰撞,既简单又高效;而当使用
CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的
空闲列表来分配内存
对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并
不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:
-
同步处理:在分配内存空间时,需要进行同步操作,比如用
CAS( Compare And Swap)失败重试、
区域加锁等方式保证分配操作的原子性。
-
TLAB:每个线程在Java堆中预先分配一小块内存,称为
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定,
-XX:+/-UseTLAB参数设定
内存分配完成之后,虚拟机将分配到的内存空间(但不包括对象头)都
③
初始化为零值,这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用。
Java虚拟机接下来需要对
④对
象头进行设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的
对象头(Object Header)之中。
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——
构造函数,即Class文件中的
<init>()方法还没有执行,new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行
⑤初始
化,这样一个真正可用的对象才算完全被构造出来。
// Object ref = new Object()对应的字节码
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
new:如果找不到Class 对象,则进行类加载。加载成功后,在堆中分配内存,从Object 开始到本类路径上的所有属性值都要分配内存。分配完毕之后,进行零值初始化。在分配过程中,注意引用是占据存储空间的,它是一个变量,占用4个字节。这个指令完毕后,将指向实例对象的引用变量压入虚拟机栈顶。
dup:在栈顶复制该引用变量,这时的栈顶有两个指向堆内实例对象的引用变量。如果<init> 方法有参数,还需要把参数压入操作栈中。两个引用变量的目的不同,其中压至底下的引用用于赋值,或者保存到局部变量表,另一个栈顶的引用变量作为句柄调用相关方法。
invokespecial :调用对象初始化方法,通过栈顶的引用变量调用<init> 方法。<clinit> 是类初始化时执行的方法, 而<init> 是对象初始化时执行的方法。
astore_1:将栈顶的引用型变量存入局部变量表的第二个位置
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头一是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;二是存储类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
对齐填充仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是
任何对象的大小都必须是8字节的整数倍。
对象头由2个部分组成:
_mark + oop指针。
_mark在32系统占4字节,64位系统占8字节;64位系统中,
oop指针和
引用对象在开启压缩指针(-XX:+UseCompressedOops)时大小为4字节,关闭压缩指针(-XX:-UseCompressedOops)时为8字节。
对象头在32位系统,占用 8 字节;64位系统,开启指针压缩时,占用 12 字节(注意对齐),否则是16字节
public class Persion {
int id;
}
Oracle JDK从6 update 23开始在64位系统上会默认开启压缩指针(-XX:+UseCompressedOops)