一、对象的创建
当虚拟机遇到一个new指令时,会发生什么?
1.检查类的符号引用
首先虚拟机会去检查这个指令的参数能否在方法区常量池中定位到一个类的符号引用(在编译的时候一个每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替),并且检查这个符合引用代表的类是否被加载、解析和初始化过,没有的话,则先进行类的加载。
2.分配内存
在类加载检测完成后,虚拟机会给新生对象分配内存(java堆中分配)。其所需要的内存大小在类加载完成后就可以确定了。
分配的方式根据垃圾收集器是否带有压缩整理功能决定可分为:
- 指针碰撞:内存是绝对规整的,所有用过的内存放在一边,空闲的放在另一边,两者之间通过一个指针作为分界点的指示器,每分配一块内存,指针移动一段与对象大小相等的距离。
- 空闲列表:java堆对应的内存区域是不规整的,虚拟机需要维护一个用于记录内存使用情况的表,在给对象分配内存时,从表中找出足够大的一块空间划分给对象实例。
不过在分配的方式实现上还需要考虑并发情况下的线程安全问题。有两种方案:
- 通过CAS配上失败重试的方式保证更新操作的原子性。
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆上预先分配一小块内存,成为本地线程缓冲区(Thread Local Allocation Buffer,TLAB)。线程分别在各自的TLAB上分配对应对象内存。只有线程对应的TLAB用完需要重新分配新的TLAB时,才会进行同步锁定。
3.初始化零值
内存分配完后,会对对象分配到的除了对象头之外的内存空间都初始化为零值(如果使用TLAB方案,那么这个过程也可以在分配TLAB时进行)。初始化零值是为了保证对象的实例字段在java代码中可以不赋初始值就直接使用。
4.设置对象头信息
会对对象的对象头所包含的信息进行设置(比如对象属于哪个类的实例、如何才能找到类的元数据信息、对象哈希码、对象的GC分代年龄等)。到此为止从虚拟机的角度来看一个对象已经产生了。
5.初始化对象
执行完new指令后会接着执行<init>方法进行对象的初始化,把对象按照开发者的意愿进行初始化后,一个真正可用的对象才算完全产生出来。
二、对象的内存布局
在HotSpot虚拟机中,一个对象在内存中的布局包含三块内容:对象头、实例数据、对其填充。
1.对象头
对象头主要包含两部分信息,第一部分是用于存储对象自身的运行时数据(官方称该部分为“Mark Word”),比如哈希码、GC分代年龄、锁状态标志、线程只有的锁、偏向线程ID、偏向时间戳等。该部分的长度由虚拟机决定,在32位和64位的虚拟机中该长度分别32位和64位。不过因为对象需要存储的运行时数据往往很多,其实已经是超过32位、64位Bitmap所能记录的限度,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间存储尽量多的数据(根据对象的状态复用自己的存储空间)。
比如说在32位的HotSpot虚拟机中,无锁状态下,锁标志为01,25bit用于存储对象的hashCode,4bit用于存储GC分代年龄,1bit因为为无所状态所以固定为0。
对象头的另一部分为类型指针,即指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是属于哪个类的实例。不过并不是所有虚拟机都会在对象数据上保留类型指针。另外,如果对象是一个java数组,那么对象头中还必须有一块用于记录数组长度的数据(因为虚拟机可以通过普通java对象的元数据信息确定对象的大小,但从数组的元数据信息中无法获取数组的大小)。
2.实例数据
该部分用于存储对象真正的有效信息,也就是程序代码中所定义的各种类型字段内容。不管是从父类继承下来的,还是子类自身定义的,都会进行记录。具体的存储顺序会受到虚拟机分配策略参数和字段在源码中的定义顺序影响。Hotspot虚拟机默认的分配策略是longs/doubles、ints、shots/chars、bytes/booleans、oops(对象指针),从分配策略中可以看出同一宽度的字段总是被分配在一起。在满足这个前提条件下,在父类的中定义的变量会出现在子类之前。如果CompactFileds参数为true(默认情况),那么子类的宽度较窄的变量也可能会插入到父类的变量空隙之中。
3.对齐填充
这部分主要是做到占位符的作用,没有特别含义。因为HotSpot VM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,那么对象的大小也必须是8字节的整数倍。因此在对象实际大小不满足8字节的整数倍时,就需要这个部分进行占位填充。
二、对象的访问定位
在上一章的内存结构中我们有提到,虚拟机栈会存储方法的执行过程中的局部变量表,如果变量为对象的话,栈中存储的就是该对象的引用,而具体的对象则被创建在堆当中,对象所对应的类信息则存储在方法区。在java程序中需要通过栈上reference数据来操作堆上的具体对象。对象访问的方式则取决于虚拟机实现来决定。目前主要有使用句柄和直接指针两种类型。
1.使用句柄
使用句柄的方式访问,虚拟机会在java堆上划分出一部分区域作为句柄池,reference指向对象的句柄。句柄中存储了对象实例数据地址和类型数据地址(这也就是之前在介绍对象头所说的并不是所有虚拟机都会在对象数据上保留类型指针)。如下图:
2.直接指针
如果使用直接指针访问,那么java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。如下图:
使用句柄方式的最大好处就是存储的是稳定的句柄地址,当对象被移动(垃圾回收有可能会移动对象),reference本身的值不会被修改。而使用直接指针的方式访问会更加快,它节省了一次指针定位的时间开销,不过当对象被移动时,reference值会被改动。