1.对象的创建
首先声明这一篇博客是在HotSpot虚拟机的前提之下记录的。主要参考书籍来源于周志明老师的《深入理解JVM虚拟机》。
在语言层面,创建对象仅仅是使用一个new
关键字。但是从虚拟机的角度来看,创建一个对象一共有5个步骤:类加载检查、分配内存、初始化零值、设置对象头、执行<init>方法。
类加载检查
当虚拟机遇到一条new
指令的时候,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析、初始化过。如果没有就需要执行相应的类加载流程。
分配内存
类加载检查通过以后,虚拟机将对新生对象分配内存,对象所需要的内存大小在类加载完成以后即可完全确定。为对象分配内存实际上等同于将一块确定大小的内存块从Java堆中划分出来。
指针碰撞和空闲列表
分配内存的方式有两种,分别是指针碰撞、空闲列表。
指针碰撞:假设Java堆中内存是绝对规整的,所有使用过的内存都放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空闲方向挪动一段与对象大小相等的距离。
空闲列表:如果Java堆内存是不规整的,已经被使用的内存与空闲的内存交错在一起,无法进行指针碰撞,虚拟机会会维护一个列表,记录哪块内存可用,哪块内存不可用,分配的时候找到一块足够大的空间划分给对象实例,并更新列表。
选择哪种分配方式由内存是否规整决定,内存是否规整又由所采用的垃圾收集器是否具有空间压缩整理的能力来决定。
如果我们使用Serial、ParNew收集器(这两种垃圾收集器采用了标记-整理算法),意味着具有空间压缩整理的能力,系统自然会选择简单高效的指针碰撞方法,而如果我们选择CMS这种基于标记清除的收集器,理论上就只能选择空闲列表方法来分配对象的内存。
理论上这么说是因为,在CMS垃圾收集器的内部,为了能在大多数情况下分配的更快,设计了一个叫做Allocation Buffer的分配缓冲区,在我们通过空闲列表拿到一大块可用内存以后,实际上对于这一大块可用内存,在它里面我们仍可以使用指针碰撞的方式来分配。
内存分配中的线程安全性
在JVM中,分配内存是一个非常频繁的行为,在并发的情况下是存在线程安全性问题的。
比如:正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针分配内存。
对此有两种解决方案:
- 对分配内存空间的操作进行同步处理。实际上虚拟机是采用CAS+失败重试的方式保证更新操作的原子性。
- 使用本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。为每个线程预先在堆内存中分配一小块内存,也就是TLAB,哪个线程需要分配线程就在哪个线程的TLAB中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
可以通过虚拟机参数
-XX: +/-UseTLAB
选择是否使用TLAB。默认情况下是开启的。
初始化零值
在HotSpot虚拟机中,对象在堆内存中的存储布局可以分为:对象头、实例数据、对其填充。这里只是提一下,后面还会说。
内存分配完成以后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值。如果使用了TLAB,那么这份工作在TLAB分配期间会顺便进行。
这样可以保证在Java代码中,即使不赋初始值也可以使用。例如某个类的成员变量(int)即使不赋值,它的值就默认是0。不过要注意如果是某个局部变量(int)不赋值,那么他就没有默认值。
设置对象头
这个步骤中,虚拟机会对对象进行必要的设置。例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上只有真正调用对象的hashCode方法才会生成哈希码)、GC分代年龄等信息。
执行<init>()方法
从虚拟机的角度来看这个时候,一个新的对象已经产生了。但是从Java程序的角度来看这个时候,对象的创建才刚刚开始。
<init>()方法实际上对应的就是类的构造方法,只有执行构造方法之后,一个真正按照我们意愿的对象才算创建出来。
2.对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头
对象头主要保存了两类信息:
-
第一类是用于存储对象的运行时数据
- 第一类是用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,官方称它为 “Mark Word”。
-
第二类是类型指针,也就是指向该对象是哪个类的实例
- 如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
实例数据
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
对齐填充
对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
3.对象的访问定位
Java程序会通过栈上的reference数据来操作堆上的具体对象。reference类型访问对象的方式是由虚拟机决定的,主要有句柄和直接指针两种。
句柄访问
如果使用句柄访问的话,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。
直接指针访问
如果使用直接指针访问的话,reference中存储的就是对象地址。
两种访问定位的方式各有各的好处:
-
使用句柄,那么本地遍历表中的reference保存的是稳定的句柄地址,在对象被移动(垃圾收集器时移动对象是非常普遍的行为)时只会改变句柄中的地址信息,而reference本身并不需要被修改。
-
使用句柄,那么本地遍历表中的reference保存的是稳定的句柄地址,在对象被移动(垃圾收集器时移动对象是非常普遍的行为)时只会改变句柄中的地址信息,而reference本身并不需要被修改。
-
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。