JVM对象模型
# 对象的创建
# 概述
在Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上.创建对象通常仅仅是一个new关键字而已。而在虚拟机中,对象(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?
虚拟机遇到一条new指令时,首先将去检査这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检査这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程 。
我们来看一段很简单的创建对象代码:
public Person genPerson(){
Person person = new Person();
return person;
}
2
3
4
我们分析一下这段代码的指令,从字节码指令角度来看一下对象创建过程:
| 字节码行号 | 字节码指令 | 说明 |
|---|---|---|
| 0 | new #2 | 在Java堆上为Person对象分配内存空间,并将地址压入操作数栈顶 |
| 3 | dup | 复制操作数栈顶值,并将其压入栈顶,也就是说此时操作数栈上有连续两个相同的Person对象地址 |
| 4 | invokespecial #3 | 从操作数栈顶弹出一个Person对象的引用,调用实例初始化方法init方法 |
| 7 | astore_1 | 从操作数栈顶弹出Person对象的引用并存到局部变量表中索引为1的slot中 |
| 8 | aload_1 | 把局部变量表中索引为1的sloat中存储的数据压栈 |
| 9 | areturn | 栈顶元素出栈,返回结果 |
大部分指令还是很好理解的,但是new 指令后,为什么一定要dup操作呢?
因为java代码的new操作编译为虚拟机指令后,虚拟机指令new在堆上分配了内存并在栈顶压入了指向这段内存的地址供任何下面的操作来调用,但是在这个操作数被程序员能访问的操作之前,虚拟机自己肯定要调用对象的 init 方法,也就是如果程序员做一个 Type a = new Type(); 其实要连续两次对栈顶的操作数进行操作。其中一次是虚拟机内部自动调用的,另一次才是程序员的访问,例如给变量赋值,抛出异常等。
我们接下来详细解析一下new指令具体做了哪些事。
# 堆内存分配
在类加载检査通过后,接下来虚拟机将为新生对象分配内存,而对象所需要的内存空间大小在类加载完成时便可以确认,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
虚拟机划分堆内存区域的方法主要有指针碰撞法、空闲列表法。
- 指针碰撞法
带有压缩整理功能的垃圾收集器Serial、ParNew等带有Compact过程 的收集器,内存规整,使用过的内存与未使用的内存分开,中间放着一个指针作为分界点指示器。

对于这种形态的内存空间,那分配内存就仅仅是把那个指针向空闲空间那边移动一段与对象大小相等的距离。这种分配方式称为“指针碰撞法”(Bump the Pointer)。
指针碰撞法主要适用于内存绝对规整的情况,也就是将使用过的内存与未使用的内存严格分隔开。
- 空闲列表法
CMS这种基于Mark-Sweep算法的收集器,内存都是不规整的,已使用内存与未使用内存交错存放。分配内存就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表法”(Free List)。通常使用的都是这种方式。

# 堆内存分配并发处理
除如何划分可用空间之外,还有另外一个要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种方案:
- 一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
- 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer),简称TLAB

哪个线程要分配内存,就在哪个线程的TLAB上分配,只有在线程的TLAB用完,才会在堆中分配,这时候需要分配新的TLAB的时候才需要同步控制。
# TLAB相关参数
-XX:+UseTLAB:允许在年轻代空间中使用线程局部分配块(TLAB)。默认情况下启用此选项。要禁用TLAB,请指定-XX:-UseTLAB
-XX:TLABSize=size:设置线程局部分配缓冲区(TLAB)的初始大小(以字节为单位)。附加字母k或K表示千字节,m或M指示兆字节,g或G指示千兆字节。如果此选项设置为0,则JVM会自动选择初始大小。示例:-XXTLABSize=512K
-XX:TLABRefillWasteFraction:表示设置进入TLAB空间单个对象大小,是一个比例值,默认为64;如果对象小于整个空间的1/64,则放在TLAB区。如果对象大于整个空间的1/64,则放在堆区
-XX:+PrintTLAB:表示查看TLAB信息
-XX:ResizeTLAB:表示自动调整TLABRefillWasteFraction阈值
# 对象的内存布局
# OOP-Klass Model
内存分配完成后,虚拟机将分配到的内存初始化为零值(除对象头外),接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,,对象创建才刚刚开始——init方法还没有执行,所有的字段都还为零。所以,一般来说执行new指令之后会接着执行init方法,把对象按照程序员的意愿进往初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局也不是jvm规范的一部分,属于实现的细节。Hotspot设计了一个OOP-Klass Model,这里的 OOP 指的是普通对象指针(Ordinary Object Pointer ),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而Klass则包含元数据和方法信息,用来描述Java类。
那Klass是什么时候创建的呢?一般jvm在加载class文件时,会在方法区创建instanceKlass,表示其元数据,包括常量池、字段、方法等。
OOP则是在Java程序运行过程中new对象时创建的,它包含以下几个组成部分:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。
# 对象头(Header)
HotSpot 虚拟机的对象头包括两部分:Mark Word和类型指针。如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。如下图所示:

- 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳、对象分代年龄。这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“Mark Word”;
- 第二部分是类型指针,义即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;
- 如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小
# 实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。
HotSpot的默认分配策略为longs/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary Object Pointer,一般对象指针),相同宽度的字段分配到一起。
# 对齐填充(Padding)
对齐填充不是必然存在的,没有特别的含义,它仅起到占位符的作用。
由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,也就是说对象的大小必须是 8 字节的整数倍。对象头部分是 8 字节的倍数,所以当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
# 对象内存布局实例解析
我们来看这样一段代码:
public class Model {
public static int a = 1;
public int b;
public Model(int b) {
this.b = b;
}
public static void main(String[] args) {
int c = 10;
Model modelA = new Model(2);
Model modelB = new Model(3);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在执行main方法时,对象的内存布局结构如下图:

# 对象的访问定位
建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。
由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有句柄访问和直接指针访问两种。
# 句柄访问
如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

优点:在垃圾回收的时候对象要经常移动,这时候只需要改变句柄中指向对象实例数据的指针即可。而不用修改reference。
# 直接指针访问
如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

优点:减少了一次指针定位的时间开销,JAVA对象的访问十分频繁,效率的提升积少成多,十分可观。
就HotSpot而言,它是使用直接指针访问方式进行对象访问的,但 从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。