本文分析下我们开发中经常接触的对象在虚拟机上的形态。本文主要从对象的构成,对象的创建,对象的访问几个方面来分析。
- 对象的内存结构
- 对象的创建
- 对象的访问
少年,是不是还在为找不到对象而烦恼?不要怕,看完这篇文章,你将在虚拟机世界实现对象自由。
1. 对象的内存结构
对象在内存中的结构由三部分组成:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1.1 对象头(Header)
对象头包括两部分信息,一部分是标记字段(Mark Word),一部分是类型指针。
- 标记字段(Mark Word):用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分的数据长度在32位和64位的虚拟机中分别为32bit和64bit。在32位的虚拟机中,如果对象处于未被锁定的状态,Mark Word的32bit空间中有25bit用于存储哈希码,4bit存储分代年龄,1bit固定为0,表示不是偏向锁,2bit表示锁标记状态。Mark Word中锁状态的存储结构如下图:
在Mark Word中不同的锁状态,对应着该对象锁的不同级别,知道了这些锁的类型,对分析JVM锁实现线程安全是非常重要的,后面会专门写一篇关于synchronized锁(JDK1.6之后,synchronized锁等级:偏向锁,轻量级锁,重量级锁)和自旋锁(轻量级锁)的文章,这里不做深入探讨。
- 类型指针(Klass Pointer):即指向对象的类型数据的指针。通过该指针可以确定当前对象属于哪个类的实例,这里特别说明下,在不同的虚拟机实现上有一定差异,该指针指向不一定是对象的类型数据,还有可能是句柄池中的地址,在句柄池中间接的指向类型数据,这一点在第3部分对象访问时再详细说明。
当对象是数组时,在对象头中还必须记录数组的长度,通过类型数据可以确认该对象需要分配的内存大小,但是无法确认该数组有多少个该类型的对象,所以需要记录下数组的长度,用来计算数组所需要的内存大小(类型数据空间x数组长度)。
1.2 实例数据(Instance Data)
实例数据是真正存储的有效信息,即程序代码中定义的各种类型的字段数据。包括从父类继承的和当前子类中定义的,均需要记录。该部分的存储顺序受虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序影响。一般满足以下原则:父类字段一般位于子类字段的前面,相同类型的字段分配在一起。
1.3 对齐填充(Padding)
对齐填充主要是起到占位符的作用,不是必然存在的。虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍,对象头正好是8字节的倍数(1倍或2倍),对象实例会存在数据项的差异,所以需要通过对齐填充来确保,对象是8字节的整数倍。
2. 对象的创建
在Java程序中,对象可以通过克隆、反序列化、直接创建等方式得到,但归根结底均是通过new关键字来实现的,在虚拟机中遇到new关键字需要做哪些操作呢?
在虚拟机中遇到一条new指令时,首先会去检查这个指令的参数是否能在运行时常量池中定位到一个类的符号引用,然后对获取到的符号引用的类进行检查,判断类是否已经被加载、解析和初始化过,如果没有则进行上述操作。
在类的检查通过后,对新生对象分配内存,对象所需大小在类加载完成后即可被确定。然后从Java堆中分配出对应大小的内存空间。
对象内存分配需要考虑的两个问题:内存空间划分方式和内存空间划分安全问题。
1. 内存空间划分的两种方式:指针碰撞和空闲列表
- 1.1 指针碰撞(Bump The Pointer):需要Java堆中内存绝对规整,所有用过的内存在一侧,未使用的在另一侧,中间有一个指针作为分界点,当分配对象内存时,将临界指针移动对象占用大小的距离即可。采用该方式的GC收集算法有:标记-整理算法,复制算法。
- 1.2 空闲列表(Free List):Java堆内存不是规整的,使用的内存和未使用的内存相互交错,虚拟机维护一个列表,记录空闲的内存块,在分配内存时,从列表中寻找合适的内存块分配,然后更新空闲列表。采用该方式的GC收集算法有:标记-清除算法。
2. 内存空间划分安全问题
对象实例是分配到Java堆上的,在JVM虚拟机系列(一)运行时数据区域这篇文章介绍Java堆空间时,该区域属于线程共享区域。所以在多个线程同时创建对象时,如何保证在Java堆上对象内存的安全分配呢? 主要有两种处理方式:CAS加失败重试方式和线程本地缓存区。
- 2.1 CAS加失败重试方式:对分配内存空间的动作进行同步处理,保证更新操作的原子性,如果操作失败,则重新申请内存。
- 2.2 线程本地缓冲区(Thread Local Allocation Buffer,TLAB):每个线程预先分配一小块内存,把内存分配的动作按照线程划分在不同的空间中进行,当TLAB的空间使用完后需要重新分配时,才需要加上同步锁。虚拟机可以使用-XX:+/UseTLAB参数来设定TLAB空间的大小。
内存分配完成后,虚拟机将分配到的内存空间初始化为零值(不包括对象头),这一步也可以称为类的初始化,在Java代码中类似static修饰的部分,然后设置对象头中的相关数据,类的元数据,哈希码,对象分代年龄等信息。 当上述工作完成后,还有最后一步,即进行对象实例的初始化,Java代码的构造方法中初始化的部分。所有工作都完成后,对象的创建结束。
3. 对象的访问
对象创建完成之后,存在于Java堆中,在Java方法中我们如何访问到Java堆中的对象实例呢?在JVM虚拟机系列(一)运行时数据区域这篇文章中介绍的运行时数据区,我们知道在Java线程中都存在着线程私有的虚拟机栈区域,在该区域内每个方法有各自对应的栈帧,栈帧存储的虚拟机数据类型除了和Java类似的8种基本类型外,还有returnAddress和reference类型,其中reference类型表示引用类型,代表一个地址指针,该地址记录便是Java堆中对象相关的地址信息,这个地址信息又分为两种:一种是间接指针(也称句柄指针)地址,另一种是直接指针地址。虚拟机实现采用何种方式,取决于虚拟机自身的选择。
3.1 句柄指针访问
该方式会在Java堆中再划分出一个区域,叫句柄池,句柄池中包含对象实例数据的指针和对象类型数据的指针,reference中存储的是对象的句柄地址。句柄访问如下图所示:
特点:
- reference中存储的是稳定的句柄地址,对象被移动时,只需要改变句柄地址即可,reference中持有的地址无需改动。
- 访问速度相对于直接访问较慢,需要查询两次才可以访问到真实数据。
3.2 直接指针访问
reference类型直接持有Java堆中对象实例数据的地址,直接指针访问如下图所示:
特点:
- 直接指针访问速度较快
- GC操作,需要更改reference保存的对象地址。
3.3 直接指针访问演示
3.3.1 直接访问概览
在HotSpot虚拟机内,采用了直接指针访问的方式,Java对象在JVM数据区中的内存关系,如下图所示:
3.3.2 演示源码及反编译字节码
演示源码JvmTest.java
1 package com.jvm.test;
2
3 public class JvmTest {
4 public static void main(String[] args) {
5 Obj objA = new Obj();
6 objA.a = 10;
7 objA.b = 1.0f;
8 int result = objA.testAB();
9 System.out.println("result = " + result);
10 }
11
12 private static class Obj {
13 private int a;
14 private float b;
15
16 public int testAB() {
17 return (int) (a + b);
18 }
19 }
20 }
JvmTest.class文件通过命令 javap -verbose JvmTest.class 反编译的字节码
public class com.jvm.test.JvmTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#2 = Class #29 // com/jvm/test/JvmTest$Obj
#3 = Methodref #2.#30 // com/jvm/test/JvmTest$Obj."<init>":(Lcom/jvm/test/JvmTest$1;)V
#4 = Methodref #2.#31 // com/jvm/test/JvmTest$Obj.access$102:(Lcom/jvm/test/JvmTest$Obj;I)I
#5 = Methodref #2.#32 // com/jvm/test/JvmTest$Obj.access$202:(Lcom/jvm/test/JvmTest$Obj;F)F
#6 = Methodref #2.#33 // com/jvm/test/JvmTest$Obj.testAB:()I
...
#28 = NameAndType #20:#21 // "<init>":()V
#29 = Utf8 com/jvm/test/JvmTest$Obj
#30 = NameAndType #20:#46 // "<init>":(Lcom/jvm/test/JvmTest$1;)V
#31 = NameAndType #47:#48 // access$102:(Lcom/jvm/test/JvmTest$Obj;I)I
#32 = NameAndType #49:#50 // access$202:(Lcom/jvm/test/JvmTest$Obj;F)F
#33 = NameAndType #51:#52 // testAB:()I
{
public com.jvm.test.JvmTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: new #2 // class com/jvm/test/JvmTest$Obj
3: dup
4: aconst_null
5: invokespecial #3 // Method com/jvm/test/JvmTest$Obj."<init>":(Lcom/jvm/test/JvmTest$1;)V
8: astore_1
9: aload_1
10: bipush 10
12: invokestatic #4 // Method com/jvm/test/JvmTest$Obj.access$102:(Lcom/jvm/test/JvmTest$Obj;I)I
15: pop
16: aload_1
17: fconst_1
18: invokestatic #5 // Method com/jvm/test/JvmTest$Obj.access$202:(Lcom/jvm/test/JvmTest$Obj;F)F
21: pop
22: aload_1
23: invokevirtual #6 // Method com/jvm/test/JvmTest$Obj.testAB:()I
26: istore_2
...
52: return
LineNumberTable: # 源码与方法Code字节码指令的对应关系,主要用于debug
line 5: 0 # 代码里的第5行代表的Code下的标号为0的指令,也有可能是连续几个指令
line 6: 9
line 7: 16
line 8: 22
line 9: 27
line 10: 52
}
3.3.3 字节码执行
JvmTest类中main方法的Code字节码执行过程,程序计数器记录当前执行的字节码指令地址,局部变量表记录着方法内的局部变量,操作数栈是当前指令所操作的变量。各个数据区域的变化过程如下:
-
- 执行偏移地址为0的指令:
-
- 执行偏移地址为3的指令:
-
- 执行偏移地址为5的指令:
-
- 执行偏移地址为8的指令:
-
- 执行偏移地址为9的指令:
-
- 执行偏移地址为10的指令:
-
- 执行偏移地址为12的指令:
-
- 执行偏移地址为15的指令:
结语:本文主要讲解了Java对象在虚拟机中的构成、创建和访问方式,至此,我们对虚拟机对象在内存中的形式有了一个清晰的认识,结合演示示例,虚拟机字节码指令的解析和执行,对于如何访问和使用对象有了更进一步的认识,掌握了相关知识,对我们的开发将会有很大的帮助,希望本篇文章能为您解开了虚拟机世界的神秘面纱。