从前谈到 Java 的对象时,笔者只是粗略知道对象存储在堆空间中。而对应的类文件何时加载,内存如何进行安全地分配(为什么会有不安全的情况),如何分配等细节一无所知。实际上,虚拟机在真正调用一个对象的构造函数之前还做了许多额外工作。本章的内容来自《深入理解虚拟机 第3版》,主要围绕 HotSpot 虚拟机展开,主要围绕 HotSpot 虚拟机展开,并介绍它创建一个新对象的详细过程。
不仅仅是一个构造函数
遇到 new 指令时,虚拟机会先尝试去常量池(位于方法区内)中搜索到该类的符号引用,并检查它引用的类是否被加载,解析过(这里假定类已经被解析过,类解析的细节在后续的章节再提及)。在类加载完成之后,创建对应对象所需的内存便完全确定了,虚拟机会将已经确定好的内存空间划分出去。
如何划分堆空间?
如果 Java 堆是规整的:已分配的地址在指针一侧,未分配的地址在指针另一侧,那么分配内存时仅仅需要将指针向着未分配地址的方向挪动即可。这种分配方式称之为 "指针碰撞" ( Bump the Pointer) 。
如果 Java 堆并非规整的,则虚拟机就必须再维护一个 空间列表( Free List) ,每一个元组相当于记录着一个可用内存块。当需要分配内存时,虚拟机在列表中找到足够大的内存块划分出去,并更新记录表的内容。
除此之外,垃圾收集器是否具备 *空间压缩能力 ( Compact ) * 也影响 Java 堆是否规整。虚拟机使用 Serial, ParNew 等具备压缩能力的收集器时,通常使用指针碰撞方式,该方法简单而高效。
如何避免分配重复的内存?
引发这个现象的原因是:创建对象是非常频繁的行为,在并发条件下,该指针的修改并不是线程安全的。比如说线程 1 请求为对象 A 分配内存,而线程 2 在指针还未及时更改时又请求为对象 B 分配内存。解决此问题有两个方式:
- CAS ( Compare and Swap ) :它是乐观锁的一种实现方式,涉及到三个操作数:V (内存),A (预期原值),B (新值),过程大致可描述为冲突检测 + 数据更新。即:当要给 A 分配内存时进行检查,如果发现指针已经变化,则向该线程返回一个错误,但允许在某个时间段之后重试。
- 本地线程分配缓冲 ( Thread Local Allocation Buffer,TLAB ) :每个线程提前在堆空间内分配到一小块内存作为缓冲,本线程在需要为对象分配空间时,优先使用线程内的缓冲内存。当内存空间不足时,线程再通过同步锁定机制获取新的内存。可以通过
-XX:+/-UseTLAB参数设定虚拟机是否使用 TLAB 。
初始化对象
无论用上述哪种方式,当内存空间分配完毕之后,虚拟机会将这块内存全部初始化为零值。因此,在 Java 中可以直接访问没有被赋初始值的实例对象。随后,Java 会对此对象进行设置,比如它属于哪个类型,它的 hashcode (这个数据是延迟计算的),这些都属于 Java 对象头的内容。
至此,虚拟机已经创建好了一个新的对象。随后,new 关键字后面的构造函数会被调用,实际上会执行 Class 文件中的 <init>() 方法,并按照开发人员的设定对对象内部的成员进行赋值。
综上,创建对象的过程可以用一个流程图来概述:
对象的内存布局
在对象内部,所有的元信息也是 "规整排放" 的。每个对象的内部数据包含三个部分:对象头 ( Header ),实例数据 ( Instance Data ) 和对齐填充 ( Padding )。
对象头 Header
对象头内存储两类信息。
第一类:存储对象的运行时数据,比如保存着 Header 的哈希码,GC 分代年龄,锁状态,偏向线程 ID 等内容,统称为 “Mark Word” 。它们属于一个对象的额外存储成本 (相当于 HTTP 报文的头部内容,与数据本身无关),因此虚拟机总是希望尽可能压缩对象头部信息。在 32 位和 64 位虚拟机下,Mark Word 的长度分别对应 32 位和 64 位,或者说,它的长度总是 8 字节的 1 倍或 2 倍(它和对齐填充部分有关)。
随着对象锁状态的不同,Mark Word 内部的数据也会随之变化,具体的内容在后续章节中再详谈。
第二类:类型指针,虚拟机可以通过该指针来判断此对象是属于哪个类的实例。并不是所有的虚拟机都依托类型指针来判断对象的类型,换句话说,要知道某个对象的类型,不一定要经过对象本身。 (详见下文:对象的访问定位部分)如果该对象属于数组,且长度已知,则对象头内还会额外开辟小部分空间来记录它的长度。
实例数据 Instance Data
该部分存储着对象真正的数据,比如 String name = "Tim" ,或者是 int age = 12 ...... 无论是从父类继承过来的数据,还是子类中定义的内容都会受到虚拟机分配参数 ( -XX:FieldsAllocationStyle ) 和字段在 .java 源代码中定义顺序的影响。
HotSpot 虚拟机默认分配空间的顺序是:longs/doubles,ints,shorts,chars,byte/boolean,oops(Ordinary Object Pointers,普通对象指针,用于访问类型数据),相同长度的数据会被集中存储,以此为条件,父类定义的变量在子类之前。
HotSpot 虚拟机也允许设置 +XX:CompactFields 让子类长度较短的数据插入到父类变量的空隙之间来节省长度。
那么,一个对象的静态数据去哪了?它们在类第一次被虚拟机加载时就已经随之被存放在方法区内了。而类何时会被首次加载呢?第一种情况,首次创建了该类的对象,但是虚拟机没有找到对应的符号引用;第二种情况,没有创建任何对象,但是调用了对象的静态数据,这也会导致类被加载。
下面可以用一段简单的代码来验证:
public class Circle {
static {
System.out.println("调用了 Circle 的静态变量,导致 Circle 类被加载。");
}
public static double PI = 3.14;
}
class Run {
public static void main(String[] args) {
System.out.println(Circle.PI);
}
}
虽然没有在主函数内声明任何对 Circle 实例的构造,但是从访问 Circle 的静态变量 PI,因而触发 static{} 语句块副作用的运行结果来看, Circle 类型已经被加载到了方法区内。
对齐填充 Padding
虚拟机要求对象的起始地址必须是 8 字节的整数倍,或者说对象的大小也必须为 8 的整数倍。因此,当实例数据的长度不整齐时,就使用该部分来补齐。该部分的数据没有任何含义,也不是必须存在的。
对象的访问定位
栈帧内的本地变量表有一个 reference 数据来指向堆内存中的具体对象。《Java 虚拟机规范》 并没有严格规定 reference 应具体以什么样的方式去定位,因此衍生出了两种方式:
句柄访问:Java 堆会划分句柄池和实例池。一个 reference 变量指向一个句柄,它存储了两个指针:一个指向实例池当中的对象实例数据,一个指向了方法区内的对象类型数据。访问逻辑是:reference -> 句柄 -> 指针 ->地址 。该方法的优势是:当对象频繁地被移动时,改变的只有 句柄 -> 指针 ,而 reference -> 句柄 不会变化,因此 reference 的指向可以一直保持不变。
直接指针访问:此方式要在对象头内部设定类型指针。当访问对象本身时,直接使用 reference 变量就可以访问 Java 堆内的对象实例数据,逻辑是:reference -> 对象实例数据 。显然,这种访问速度要更加高效,因为它节省了一次指针定位的时间。
对于 HotSpot 虚拟机而言,它采取第二种方式进行对象访问。
参考资料
-
《深入理解 Java 虚拟机 第 3 版》