HotSpot VM对象探秘
下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局、访问的全过程。
1. 对象的创建
下面讲述的只是普通的Java对象,不包括数组、Class对象。
先看一张概括过程的图:
Java对象创建过程图示VM遇到new指令时:
-
检查指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析、初始化过。如果没有,先执行相应的类加载过程。类加载过程中,准备阶段给类变量赋默认值,初始化阶段执行<clinit>(),按程序员意图初始化类变量的值。
-
为新生对象分配内存,对象所需的内存大小在类加载完成后就可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
- 指针碰撞(Bump the Pointer):内存是规整的,用过的内存放在一边,空闲的内存放在另一边。分配时,指针向空闲内存空间移动一段与对象大小相等的距离。
- 空闲列表(Free List):内存是不规整的,维护一个列表,记录哪些内存块可用,分配时,在列表中找到一块足够大的空间分给对象实例,并更新列表记录。
选用哪种分配方式由Java堆是否规整决定,Java堆是否规整由所采用的垃圾收集器是否具有压缩整理功能决定:"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
在使用Serial、ParNew等带有Compact过程的垃圾收集器时,采用的分配算法是指针碰撞;使用CMS基于Mark-Sweep算法(标记清除算法)的收集器时,分配算法通常是空闲列表。
并发下给对象分配内存时保证线程安全的方式(同时创建两个对象会出现对对象指针的竞争条件):
-
CAS+失败重试:
对分配内存空间的动作进行同步处理,实际是采用CAS配上失败重试的方式保证更新操作的原子性。即:每次不加锁而是假设没有冲突去完成某项操作,如果因为冲突失败就重试,直到成功为止。
-
TLAB:
每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),通过 -XX:+/-UseTLAB 设定。
哪个线程要分配内存,就在哪个线程的TLAB上分配。只有TLAB用完,并分配新的TLAB时,才需要同步锁定。
-
虚拟机将对象分配到的内存空间都初始化为零值(不包括对象头),也就是VM对非静态字段的默认初始化。如果使用TLAB,这一工作可以提前到TLAB分配时进行。
-
虚拟机对对象头进行必要的设置。如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。至此,虚拟机视角的new操作完成。
-
如果字节码中跟随invokespecial指令,执行<init>方法,按照程序员意愿初始化。首先执行非静态成员变量显式初始化赋值(即private int i = 2;)和初始化代码块按顺序组合成的代码,再执行构造器。(先初始化块,再构造方法)
如果有继承关系,创建子类对象时,父类的成员变量显式初始化,初始化代码块,构造器都要完成后,再执行子类的成员变量显式初始化,初始化代码块,构造器。
public class Demo {
public static void main(String[] args) {
new Father();
}
}
class Father {
private int i = 1;
Father() {
i = 2;
}
{
i = 3;
}
}
上面的代码编译成字节码,如下图:
字节码图1 字节码图2public class Demo {
public static void main(String[] args) {
Object obj = new Object();
}
}
上面的代码编译成字节码:
字节码图3https://github.com/iMustang/l-jdk/raw/master/src/main/java/basic/constructor/Father.java
public class Father {
public Father() {
System.out.println("父类构造器执行");
i = 3;
}
{
System.out.println("父类初始化块");
i = 2;
}
public int initI() {
System.out.println("父类成员变量定义时赋值(显式初始化)");
return 1;
}
private int i = initI();
}
public class Son extends Father {
public Son() {
System.out.println("子类构造器执行");
j = 3;
}
{
System.out.println("子类初始化块");
j = 2;
}
private int j = initJ();
public int initJ() {
System.out.println("子类成员变量定义时赋值(显式初始化)");
return 1;
}
public static void main(String[] args) {
new Son();
}
}
// 执行结果:
父类初始化块
父类成员变量定义时赋值(显式初始化)
父类构造器执行
子类初始化块
子类成员变量定义时赋值(显式初始化)
子类构造器执行
2 对象的内存布局
对象在内存中的布局分为3块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
-
对象头
对象头包含两部分信息:
-
存储对象自身的运行时数据(官方称为Mark Word),如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
该部分数据长度为:32位VM为32bit,64位VM(未开启压缩指针)为64bit。
-
类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有VM实现都在对象数据上保留类型指针。
另外,如果对象是一个数组,在对象头中还有一块用于记录数组长度。
-
-
实例数据
记录代码中定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的。存储顺序受到虚拟机分配策略(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。
HotSpot VM默认的分配策略是longs/doubles、ints、shorts/chars、bytes/boolean、oops(Ordinary Object Pointers),在满足这个前提条件下,父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认为true),那么子类中较窄的变量也可能会插入到父类变量的空隙中。
-
对齐填充
并不是必然存在的。HotSpot VM自动内存管理要求对象起始地址是8字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
3 对象的访问定位
对象访问方式取决于虚拟机实现,目前主流的访问方式有两种:
- 使用句柄。Java堆中划分一块内存区域作为句柄池,reference中存储的是对象的句柄地址。句柄中包含了对象的实例数据和类型数据的各自的具体地址信息。
- 直接指针。reference中存储的直接是对象的地址。HotSpot使用的是这种方式。
- 使用句柄访问对象最大好处是reference中存储的是稳定的句柄地址,在对象被移动时,只会改变句柄中的实例数据指针,reference本身不需要修改。
- 使用直接对象指针最大好处是速度更快,节省了一次指针定位的时间开销。
网友评论