JVM 对象

知识框架:


  1. 对象创建 ~ 常量池 ~ 符号应用 ~ 类加载 ~ 直接引用
  2. 内存分配 ~ 指针碰撞(规整)~ 空闲列表(不规整)~ 并发分配(CAS / TLAB)
  3. 内存布局 ~ 对象头(运行时数据、类型引用)~ 实例数据 ~ 对齐填充
  4. 访问定位 ~ 句柄 ~ 直接指针

1. 对象的创建

当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的 符号引用(Symbolic References),并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的 类加载过程

符号应用和直接引用:


  • 符号引用(Symbolic References)

    是以一组符号来描述所引用的目标,可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可

    与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容
    各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的

  • 直接引用(Direct References)

    是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄

    和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在

2. 对象内存分配

类加载完毕后,虚拟机将在 为新的对象分配内存(该内存大小在类加载完成后就可以完全确定了),内存的分配方式按照堆内存是否规整分为两种:

  1. **堆内存规整:**要求垃圾收集器带有 **空间压缩整理(Compact)**能力(如 Serial、ParNew 等)

    指所有被使用过的内存放在一边,空闲的放在另一边。中间有一个指针作为分界点的指示,在执行内存分配时,只需要将指针往空闲的方向挪动一段与对象大小相等的距离即可,这种分配方式称为 指针碰撞(Bump the Pointer)

  2. **堆内存不规整:**垃圾收集器基于标记-清除算法时会导致堆内存不规整(如 CMS)

    指已使用的内存和空闲的内存混合在一起,这时候就不能简单地使用指针碰撞了,需要维护一个列表,记录哪些内存时可用的,分配的时候找到一块足够大的空间划分给对象,这种分配方式称为 空闲列表(Free List)

对象内存分配的并发问题:


对象在分配内存空间时需要对同一个指针进行操作,容易出现并发问题,解决方案又两个:

  1. 对内存分配操作进行同步处理,实际上虚拟机采用的是 CAS 配上失败重试保证操作的原子性
  2. 使用 TLAB,将内存分配动作按照线程划分在不同的区域,不需要进行同步处理,只有这个本地缓存用完了才需要使用同步处理

内存分配完毕后,虚拟机必须将分配到的内存空间(但不包括 对象头)都初始化为零值(如果使用 TLAB 的话,可以提前至分配 TLAB 时进行)
这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的 零值

然后需要对对象进行必要的设置(也就是对 对象头 的设置),有如下

  1. 对象是哪个类的实例
  2. 如何才能找到该类的元数据信息
  3. 对象的哈希码(实际上会延后到真正调用 Object::hashCode() 方法时才计算
  4. 对象的 GC 分代年龄

根据虚拟机当前运行状态的不同(如是否启用偏向锁),对象头会有不同的设置方式

3. 对象内存布局

在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:
对象头(Object Header)、**实例数据(Instance Data)**和 对齐填充(Padding)

3.1. 对象头(Object Header)

:包括了两类信息:运行时数据、类型指针

  1. 运行时数据

    如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等

    这部分数据的长度在 32 位和 64 位虚拟机分别位 32 bit 和 64 bit(未开启压缩指针),官方称为 Mark Word。但对象需要存储的运行时数据很多,32 或 64 位的 bitmap 可能装不下,所以 Mark Word 被设计成有着动态定义的数据结构

    对象未被同步锁锁定的状态下,32 比特分配如下:
    哈希码(25 bit)、GC 分代年龄(4 bit)、锁标志位(2 bit)、固定为 0(1 bit)

    在其他状态下(按锁标志位确定状态)对象存储的内容如下:

    锁标志位 状态 存储内容
    01 未锁定 对象哈希码、GC 分代年龄
    00 轻量级锁定 指向锁记录的指针
    10 膨胀(重量级锁定) 指向重量级锁的指针
    11 GC 标记 空(不需要记录信息)
    01 可偏向 偏向线程 ID、偏向时间戳、对象分代年龄

    32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)

  2. 类型指针,即对象指向它的类型元数据的指针

    Java 虚拟机通过这个指针来确定该对象是哪个类的实例

    但并不是所有的虚拟机实现都必须在对象数据上保留类型指针(即:查找对象元数据信息并不一定要经过对象本身)

3.2. 实例数据(Instance Data)

是对象真正存储的有效信息(父类继承下来的 + 子类中定义的)

其存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响,HotSpot 虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),可以看到相同宽度的字段总是被分配到一起存放

在满足上面条件的情况下,在父类中定义的变量会出现在子类之前
如果 HotSpot 虚拟机的+XX:CompactFields参数值为 true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间

3.3. 对齐填充(Padding)

并不是必然存在的,仅仅起到占位符的作用,这是由于 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,所以对象头部分已经被精心设计成正好是 8 字节的倍数

如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

4. 对象的访问定位

Java 程序会通过栈上的 reference 数据来操作堆上的具体对象
具体应如何定位访问到队中对象的具体位置是由虚拟机来实现的,主要有使用句柄和直接指针两种

4.1. 句柄访问

Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址
句柄中包含了对象实例数据与类型数据各自具体的地址信息

**优势:**reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改

图示:

4.2. 直接指针访问

Java 堆中对象的内存布局需要考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址

**优势:**节省了一次指针定位的时间开销(若只是访问对象本身的话,就不需要多一次间接访问的开销)

由于对象访问在 Java 中非常频繁,所以这类开销积少成多是很可观的,HotSpot 虚拟机采用的就是第二种方式进行对象访问