主要讨论虚拟机在Java堆中对象的分配、布局和访问全过程
对象的创建
虚拟机收到一条new指令时, 首先将会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用, 并检查这个引用代表的类是否已经被加载、解析和初始化过。如果没有, 必须先执行类加载的过程。
类加载通过后, 虚拟机会为新生对象分配内存, 对象所需的内存大小在类加载完成后就可以完全确定下来, 为对象分配空间等同于从JAVA堆中划分一块确认大小的内存, 分配的方式有两种:
-
指针碰撞(Bump the Pointer)
假设JAVA堆中的内存是规整的, 所有用过的内存放在一边, 空闲可用的内存在另一边, 中间放一个指针作为分界线的指示器, 那么分配内存就是把指针往空闲区域挪动一段和新生对象大小相等的距离
Serial、ParNew采用的指针碰撞
-
空闲列表(Free List)
如果JAVA堆中的内存是不规整的, 虚拟机就必须要维护一个列表, 用来记录哪一块内存是可用的, 在分配内存的时候再从列表中找一块足够大的空间分配给新生对象, 并更新列表上的记录。
CMS 收集器采用的空闲列表
并发情况下, 可能出现在给A分配内存, 指针还没来得及修改, 对象B又使用了原来的指针来分配内存的情况, 解决这问题, 有两种方案:
一是对分配内存空间的动作进行同步处理——实际上虚拟机才用的CAS加上失败重试的方式保证原子性;
二是把内存分配的动作按照线程划分不同的空间之中进行, 即每个线程在JAVA堆中预先分配了一块小的空间——本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。只有当TLAB空间用完需要分配新的TLAB的时候, 才需要同步锁定。虚拟机是否使用TLAB, 可以通过参数 -XX:+/-UseTLAB来设置。
内存分配完成后, 虚拟机会将内存空间都初始为0值(不包括对象头)。
接下来, 虚拟机要对对象进行必要的设置, 比如这个对象是哪个类的实例、如何才能找到元数据信息、对象的哈希码、对象的GC分代年龄信息等——这些信息都是存放在对象的对象头(Object Header)中。
从虚拟机的角度看, 上面的工作完成, 一个新的对象就产生了。
但是从程序员的角度看, 对象的创建才刚刚开始——
对象的内存布局
对象在内存中存储的布局可以分为三个部分: 对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
-
对象头
对象头又分为两个部分: “Mark word” 和 “类型指针”
第一部分用于存储对象本身的运行时数据, 比如哈希码(Hash Code)、GC分代年龄、锁状态标志、线程持有的锁、偏向锁ID、偏向锁时间戳等, 这部分的数据在32位和64位虚拟机中分别是32位和64位的Bitmap, 官方称它为”Mark Word”。
第二部分类型指针, 即对象指向它的类元数据的指针, 虚拟机就是通过这个指针来确定这个对象是哪个类的实例的。并不是所有的虚拟机都在对象头上面保存类型指针。
如果对象是一个数组, 那么对象头中必须还有一块可以记录数组长度的数据。
-
实例数据
实例数据是对象真正存储的有效信息, 也就是代码中定义的各种类型的字段。无论是从父类继承下来的, 还是子类定义的, 多需要记录下来。
-
对齐填充
这部分并不是必须存在的, 仅起到占位符的作用。
由于HotSpot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍, 通俗点说就是需要对象的大小必须是8字节的整数倍。
而对象头部分正好是8字节的倍数, 因此, 对象实例部分不是8的整数倍时, 就需要对齐填充来补全了。
对象的访问定位
创建对象后要使用对象, 我们JAVA程序需要通过栈上的reference 数据来操作JAVA堆上的具体对象, 但是JAVA虚拟机规范只规定了一个指向对象的引用, 并没有说明这个引用应该通过何种方式去访问JAVA堆中的对象, 所以这取决于虚拟机的具体实现。
目前主流的有使用句柄和直接指针两种方式:
-
使用句柄访问
使用句柄的话, JAVA堆中会分配出一块内存来作为句柄池,**reference中存储的就是对象的句柄地址**,句柄中就包含了到对象具体实例数据地址指针和到对象类型数据地址指针(在方法区中)。
-
使用直接指针访问
如果是直接访问,reference中存储的就是对象地址
句柄访问的最大优势就是reference中存储的是句柄地址,在对象被移动后只会改变句柄中的实例数据指针,reference本身不需要修改。
直接访问的优势就是速度更快,它节省了一次指针定位的开销。对象访问频繁,开销极少成多也是一项不小的成本。Sum HotSpot 使用的是直接访问。