深入理解JVM第三版笔记(5)-HotSpot虚拟机对象揭秘
HotSpot虚拟机对象揭秘
对象的创建
类是否加载
当Java虚拟机遇到一条new指令时,会先检查这个指令的参数是否能在常量池中定位到一个类的符号应用,并检查这个符号引用代表的类是否已经被加载,解析和初始化过.如果没有则会进行响应的类加载过程.
分配内存空间
虚拟机接下里为新生对象分配内存,所需内存的大小在类加载完成之后是完全确定的,为对象分配空间其实就是从内存中划分一块区域,有指针碰撞(Bump the Pointer)和空闲列表(Free List)两种方式.指针碰撞的实质是在一边为已用内存另一边为空闲内存的区域移动边界线,移动的距离就是对象所需的内存大小,它需要维护有序的两块内存.而当已用内存和空闲内存交错分布时,需要新的方式来维护内存分布信息,一般来说会采用空闲列表来记录内存中尚未使用的空闲内存.
使用Serial,ParNew等带压缩整理过程的收集器时会采用指针碰撞方法,既简单又高效;而当采用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用空闲列表的方式.(在具体实现上,为了分配的更快,会使用Linear Allocation Buffer技术来分配缓冲区,使得局部有序,整体无序,通过空闲列表拿到一块分配缓冲之后,其中仍然采用指针碰撞的方式来分配.)
为对象分配内存是非常频繁的操作,仅仅修改指针指向的位置,在并发情况下是不安全的,解决这个问题有两种方案.
- 对内存分配空间动作采取同步处理,虚拟机使用CAS操作来保证原子性更新操作的原子性
- 另外一种是把内存分配的动作按线程划分在不同空间中进行,即每个线程在堆内预先分配一小块内存,称为本地线程分配缓冲(Thead Local Allocation Buffer,TLAB,预划分空间,增加分配操作的速度,但会降低内存空间的利用率),哪个线程需要分配内存,就在哪个线程的本地缓冲区分配,只有本地缓冲区用完了,分配新的缓冲区(真香,再来一块)时才需要同步锁定.虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB
初始化零值
内存分配完之后,虚拟机必须将分配到的空间(不包括对象头)初始化为零,如果使用了TLAB的话,这一项工作也可提前至TLAB分配前进行.这步操作保证了对象的实例字段在Java代码中不赋初值就可以直接使用,程序访问到对应类型的默认零值.
填充对象头
接下来,JVM还要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据,对象的哈希码(会延迟到调用Object::hashCode()方法时计算),对象的GC分代年龄等信息.这些信息放在对象的对象头(Object Header)中,根据虚拟机的不同,例如是否启用偏向锁等,还会有不同的设置方式.
执行<init>()方法
上述工作完成后,从虚拟机的角度看,一个新的对象已经产生了,但是从Java程序的角度看,对象创建才刚刚开始--构造函数,即Class文件中的<init>()方法还没有执行,字段还都是默认的零值,对象需要的其他资源和状态信息也没有按照预定的意图构造好.一般来说new指令之后会执行<init>()方法(由字节码流中new指令后是否跟随invokespecial指令决定,JVM在遇到new关键字后会产生这两个指令,但直接通过其他方式生成的则不一定如此),按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才被构造出来.
对象的内存布局
在HotSpot虚拟机中,对象在堆内存的存储布局可以划分为三个部分.
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
对象头
对象头部分包含两类信息,第一类是用于存储对象自身运行时数据,包括哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向进程ID,偏向时间戳等.
这部分数据在32位和64位的虚拟机中分别为32和64个bit,官方称它为"Mark Word",对象需要存储很多运行时数据,其大小超出了32位,64位BitMap结构所能记录的最大限度,但是对象头里的信息是与对象自身定义的数据无关的额外存储成本(存储对象的额外开销),处于空间效率的考虑,Mark Word被设计成一个有着动态定义的数据结构(不同类型的Mark Word有着不同的空间结构),以便在极小的空间内存储尽量多的数据.
另一类信息是类型指针,即对象指向它的类型元数据的指针,JVN通过这个指针来确定该对象是哪个类的实例.并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身.此外,如果对象是一个Java数组,那在对象头中还必须有一块用于几率数组长度的数据.因为JVM可以通过普通Java对象的元数据信息确定Java对象的大小,但如果数组长度是不确定的,将无法通过元数据的信息推断出数组的大小.
实例数据
实例数据是对象真正存储的有效信息,这部分的存储顺序会受虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义的顺序的影响.HotSpot默认的分配顺序是longs/doubles,ints,shorts/chars,bytes/booleans,oops(Ordinary Object Pointers),在满足这个前提条件的情况下,父类定义的变量会在子类定义的变量之前.如果HotSpot虚拟机的-XX:CompactFields参数值(默认为true),那么子类中较窄的变量也允许插入父类变量的空隙之中,以节省一点空间.
对齐填充
这不是必然存在的,也没有特别的意义,它仅仅起着占位符的作用,因为HopSpot虚拟机的自动内存关系系统要求对象的起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍.对象头部分已被精心设计成8字节的整数倍,因此实例数据部分没有对齐的话,就需要通过对齐填充来补全.
对象的访问定位
句柄(Handle)方式
句柄(Handle),使用句柄的话,堆内存中会划分出一块来作为句柄池,Java栈本地变量表中reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据(指针)和类型数据(指针)各自的地址信息.使用句柄方式时,对象头中可以不用包含类型信息指针.
直接指针
使用直接指针的话,Java堆中的内存布局就必须考虑如何访问类型的相关信息,reference中存储的直接就是对象地址,如果是访问对象本身的话,就不需要多一次间接访问的开销.
使用句柄方式的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时很常见)时只会改变句柄中的实例数据指针,reference本省不需要被修改.
使用直接指针来访问的最大好处就是速度快,节省了一次指针定位的时间开销,积少成多地也是一项极为可观的开销节省.
HotSpot虚拟机主要使用直接指针的方式(Shenandoah收集器会有一次额外的转发).