JVM 对象内存布局

用户头像
Alex🐒
关注
发布于: 2020 年 07 月 22 日



Openjdk 提供 jol 工具可查看对象内存布局,以下内容适用 HotSpot



HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。



  • Mark Word:对象的 Mark Word 部分占 8 个字节(64-bits 系统),包含一些的标记位,比如轻量级锁、偏向锁标记等等

  • Klass Pointer:Class 对象指针占是 8 个字节(64-bits 系统),指向的位置是对象对应的 Class 对象的内存地址

  • 数组长度,如果是数组对象,此处记录数组的长度

  • 实例数据:这里面包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte 和 boolean 占 1 个字节,short 和 char 占 2 个字节,int 和 float 占 4 个字节,long 和 double 占 8 个字节,reference 占 4 个字节

  • 对齐填充:最后一部分是对齐填充的字节,按 8 个字节填充。



对象头(Object Header)

官方描述:Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object's layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.



每个 GC 管理的堆对象的位于头部的公共结构,每个 OOP 都指向一个对象头。对象头主要包括:对象布局、对象类型、GC 状态、synchronization 状态、hash code。由两个 Word (Mark Word + Klass Pointer)组成,如果是数组对象,还包括一个长度字段。Java 对象和 VM 内部对象都有一个通用的对象头格式。

Mark Word

官方描述:The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.



包括 synchronization 状态和 HashCode(也可以持有偏向锁的线程指针、或 synchronization 轻量级/重量级锁记录的指针),以及 GC 信息(GC 分代年龄)



Mark Word 结构(32-bits 和 64-bits 有差异,主要了解 64-bits 环境,../hotspot/src/share/vm/oops/markOop.hpp



// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)



对象处于不同状态时,Mark Word 的表现形式不同。根据锁状态位确认其他位存储的内容

  • lock: 01 - 未锁定;

  • biased_lock: 0 - 无偏向锁;存储对象 hashCode、分代年龄

[ ------------------------------------- | 0 | 01]
[unused:25 | hash:31 | unused:1 | age:4 | 0 | 01]



  • biased_lock: 1 - 可偏向锁;存储偏向线程ID、时间戳、分代年龄

[ JavaThread:54 | epoch:2 | age:4 | 1 | 01] 锁偏向于给定的线程
[ ------------------- | epoch:2 | age:4 | 1 | 01] 锁是匿名偏向的



  • lock: 00 - 轻量级锁

[ ptr_to_lock_record | 00] 轻量级锁,ptr 指向栈中的 lock record



  • lock: 10 - 重量级锁

[ ptr_to_monitor | 10] 膨胀锁,ptr 指向锁对象的 Monitor



  • lock: 11 - GC 标记

[ ----- | 11] 用于 GC 标记无效的对象,不需要存储信息

扩展

问题0. 为什么有 3 种锁状态?

轻量级锁和偏向锁是 Java 6 对 synchronized 锁进行优化后新增加的,为了优化 synchronized 锁性能,参考 synchronized 锁膨胀原理。



在打印一个普通的对象布局时,看到的内容的前 8 个字节是 Mark Word:

OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)



问题1. 按照 Mark Word 的布局,前面的 25 位应该是 0,为什么第 8 位是 1

跟大小端模式有关,大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中;小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。



所以这种情况是当前计算机采用的小端模式,所以要从后向前读,实际的排列是

00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001



问题2. 为什么 hashCode 也全都是 0

调用 hashCode() 方法后才会有 hashCode 记录

OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 63 5c 06 (00000001 01100011 01011100 00000110) (106717953)
4 4 (object header) 0b 00 00 00 (00001011 00000000 00000000 00000000) (11)



转换为实际排列

00000000 00000000 00000000 00001011 00000110 01011100 01100011 00000001



问题3. 当 Java 处在锁状态时,hashCode 值存储在哪

  1. 当一个对象已经计算过 identity hash code,就无法进入偏向锁状态

  2. 当一个对象当前正处于偏向锁状态,并且需要计算其 identity hash code 的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁

  3. 重量锁的实现中,ObjectMonitor 类里有字段可以记录非加锁状态下的 Mark Word,其中可以存储 identity hash code 的值。



hashCode 只针对 identity hash code。用户自定义的 hashCode() 方法所返回的值不存在 Mark Word 中。Identity hash code 是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。



作者:RednaxelaFX链接:https://www.zhihu.com/question/52116998/answer/133400077来源:知乎

Klass Pointer

官方描述:The second word of every object header. Points to another object (a metaobject) which describes the layout and behavior of the original object. For Java objects, the "klass" contains a C++ style "vtable".



指向另一个对象(元数据 class 对象),描述当前对象所属的类型。通过这个指针确定对象是哪个类的实例。



指针的位长度为 JVM 的一个字大小(即 32 位使用 32 位,64位的使用 64 位来存储),如果应用的对象过多,使用 64 位的指针将浪费大量内存(64 位的 JVM 可能会比 32 位的 JVM 多耗费 50% 的内存)。为了节约内存可以使用选项 +UseCompressedOops 开启指针压缩,开启后,下列指针将压缩至 32 位:

  • Klass Pointer

  • 每个对象的属性指针(即对象变量)

  • 普通对象数组的每个元素指针

数组长度

如果对象是一个数组,对象头使用额外的空间来存储数组的长度,64 位环境如果开启 +UseCompressedOops 选项,该区域长度也将由 64 位压缩至 32 位。

实例数据

实例数据部分是对象存储的有效信息,即程序代码里面所定义的各种类型的字段内容,包括从父类继承的。 这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 代码中定义顺序的影响。



HotSpot 虚拟机默认的分配策略为 long/double、int、short/char、byte/boolean、oop,从分配策略中可以看出,相同宽度的字段总是被分配到一起



在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields 开启(-XX:+CompactFields, 默认开启),子类之中较窄的变量可能会插入到父类变量的空隙之中。

对齐填充

对齐填充没有特别的含义,仅仅起着占位符的作用。是因为 HotSpot VM 的内存管理系统要求对象起始地址必须是8字节的整数倍,也就是对象的大小必须是 8 字节的整数倍。因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。



对象头如果不是 8 字节的整数倍,同样会被填充。

为什么 Boolean 需要占用一个字节

Boolean 的存储实际只需要 1 位就可以表示,但是 JVM 为 Boolean 分配了一个字节,原因很简单,更容易实现对齐和访问效率。如果我们必须处理某些字段的子字节(位)偏移量,在每次访问布尔值时,都需要额外的逻辑来读取/写入给定位置的单个位,而不仅仅是整个字节。同时只需要按照字节为单独填充对齐即可。

相关概念

OOP

Java 对象的表示模型叫做“OOP-Klass”模型,包括两部分:



  1. OOP,即 Ordinary Object Point,普通对象指针,用来描述对象实例信息

  2. Klass,用来描述 Java 类,包含了元数据和方法信息等



一个 Java 对象就包括两部分:数据和方法,分别对应到 OOP 和 Klass。JVM 运行时加载一个 Class 时,会在 JVM 内部创建一个 instanceKlass 对象,表示这个类的运行时元数据。创建一个这个 Class 的 Java 对象时,会在 JVM 内部相应的创建一个 instanceOop 来表示这个 Java 对象。instanceKlass 对象放在了方法区,instanceOop 放在了堆,instanceOop 的引用在 JVM 栈。



Lock record

当没有资源抢占时,sychronized 锁是偏向锁,当此时发生资源抢占时,升级为轻量级锁(参考 sychronized 锁膨胀)。



Lock record 存在于持有锁对象的线程的栈中,内容为锁对象的 Mark Word,锁对象的 Mark Word 记录了 Lock record 的地址,后三位是锁状态标记。

Monitor

每个对象都存在着一个 Monitor 与之关联,对象与其 Monitor 之间的关系有存在多种实现方式,如:Monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,当一个 Monitor 被某个线程持有后,便处于锁定状态。在 HotSpot 中,Monitor 是由 ObjectMonitor 实现的,其主要数据结构如下:

ObjectMonitor() {
_header = NULL;
_count = 0; // 计数器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 持有当前 Monitor 的对象线程
_WaitSet = NULL; // 处于 wait 状态的线程,会被加入到 _WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁 block 状态的线程,会被加入到 _EntryList
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}



ObjectMonitor 中有两个队列,_WaitSet_EntryList,用来保存 ObjectWaiter 对象列表(每个等待锁的线程都会被封装成 ObjectWaiter 对象),_owner 指向持有 ObjectMonitor 对象的线程。



当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 Monitor 后进入把 Monitor 中的 _owner 设置为当前线程,同时 Monitor 中的计数器 _count 加 1。



若线程调用 wait() 方法,将释放当前持有的 Monitor,_owner 变量恢复为 NULL,计数器减 1,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 Monitor 并复位变量的值,以便其他线程进入获取 Monitor。如下图所示:



synchronized 膨胀为重量级锁时,对象头中存储的就是持有的对象的 Monitor 的地址。

指针压缩

32 位系统,4 字节,有效位数 32 位,最大内存大小 2^32 = 4G

64 位系统,8 字节,有效位数 48 位(16 位保留),最大内存大小 2^48 = 256T



在 32 位系统下,存放 Class 指针的空间大小是 4 字节,MarkWord 是 4 字节,对象头为 8 字节。

在 64 位系统下,存放 Class 指针的空间大小是 8 字节,MarkWord 是 8 字节,对象头为 16 字节。



64 位开启指针压缩的情况下,存放 Class 指针的空间大小是 4 字节,MarkWord 还是 8 字节,对象头为 12 字节。



指针压缩命令(默认开启)

-XX:+UseCompressedOops # 开启
-XX:-UseCompressedOops # 关闭

实现原理

64 位地址划分为堆的基地址+偏移量,当堆内存小于 32G 时候,在压缩过程中,把偏移量/8后保存到 32 位地址。使用时再把 32 位地址放大 8 倍,所以启用 CompressedOops 的条件是堆内存要在 32G 以内。



压缩指针之所以能改善性能(节省空间),是因为它通过对齐,还有偏移量将 64 位指针压缩成 32 位,而不是完整长度的 64 位指针,CPU 缓存使用率得到改善,应用程序也能执行得更快。

零基压缩优化(Zero Based Compressd Oops)

零基压缩是针对压缩/解压动作的进一步优化。 通过改变正常指针的随机地址分配特性,强制堆地址从零开始分配(需要 OS 支持),进一步提高了压解压效率。要启用零基压缩,分配给 JVM 的内存大小必须控制在 4G 以上,32G 以下。



如果堆大小大于 32G,压缩指针式小,使用原来的 64 位,指针地址更加浪费空间,所以一般的 JVM 分配的内存不会超过 31G。

扩展

问题1. Oop 能表示的最大范围是多少?

32位再扩大八倍即 2^35 这么大



问题2. 开启指针压缩的情况下,Oop 地址如何扩容?

缩小 16 倍后保存到 32 位地址,范围即可扩大到 2^36

如果计算对象大小

空对象(未开启指针压缩),16 字节(8 + 8 + 0 + 0 + 0)

空对象(开启指针压缩),16 字节(8 + 4 + 0 + 0 + 4

class EmptyObject {
}



对比内存空间

# 未开启
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 b0 b2 09 (00101000 10110000 10110010 00001001) (162705448)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
# 开启,增加了填充的部分
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total



普通对象(未开启指针压缩),32 字节(8 + 8 + 0 + 1 + 4 + 8 + 3

普通对象(开启指针压缩),32 字节(8 + 4 + 0 + 1 + 4 + 8 + 7

class NormalObject {
byte a = 1;
int b = 20;
double c = 30.0;
}



对比内存空间

# 未开启
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 28 80 30 13 (00101000 10000000 00110000 00010011) (321945640)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
16 8 double NormalObject.c 30.0
24 4 int NormalObject.b 20
28 1 byte NormalObject.a 1
29 3 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 3 bytes external = 3 bytes total
# 开启,填充的长度不同
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 int NormalObject.b 20
16 8 double NormalObject.c 30.0
24 1 byte NormalObject.a 1
25 7 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total



数组对象(未开启指针压缩),40 字节(8 + 8 + 4 + 4 + 3x4 + 4

数组对象(开启指针压缩),32 字节(8 + 4 + 4 + 3x4 + 4

int[] array = {1, 2, 3}



对比内存空间

# 未开启,有两部分填充(header 也填充了一次)
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 68 db 4f 0b (01101000 11011011 01001111 00001011) (189782888)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
16 4 (object header) 03 00 00 00 (00000011 00000000 00000000 00000000) (3)
20 4 (alignment/padding gap)
24 12 int [I.<elements> N/A
36 4 (loss due to the next object alignment)
Instance size: 40 bytes
Space losses: 4 bytes internal + 4 bytes external = 8 bytes total
# 开启
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 03 00 00 00 (00000011 00000000 00000000 00000000) (3)
16 12 int [I.<elements> N/A
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

对象创建过程

一个空对象

Object o = new Object();



分析指令码

NEW java/lang/Object # 申请一个空间,成员变量是默认值,是一个半初始化对象
DUP #
INVOKESPECIAL java/lang/Object.<init> ()V # 调用构造方法,成员变量初始化
ASTORE 1 # o 和 new Object() 建立关联
RETURN # 返回



有一个成员变量的对象

public class T {
int m = 8;
}
T t = new T();



指令码

NEW com/alex/T
DUP
INVOKESPECIAL com/alex/T.<init> ()V
ASTORE 1
RETURN



在虚拟机中,对象的创建过程:

  1. JVM 要检查类是否已经被加载到了内存,即类的符号引用是否已经在常量池中,并且检查这个符号引用代表的类是否已被加载、解析和初始化过的。如果还没有,需要先触发类的加载解析初始化

  2. 为新对象申请一个内存空间。对象所需内存的大小在类加载完成后便可完全确定。

  3. 完成实例数据部分的半初始化(初始化为 0 值)

  4. 完成对象头的填充。以上是 NEW 命令完成的动作此时,在虚拟机的视角来看,一个新的对象已经产生了。

  5. 是在 Java 程序的视角来看,初始化正式开始,INVOKESPECIAL 命令调用 <init> 方法完成初始复制和构造函数。

对象的定位

reference 类型在 Java 虚拟机规范里面只规定了是一个指向对象的引用,并没有定义这个引用应该通过什么种方式去定位、访问到堆中的对象的具体位置,对象访问方式也是取决于虚拟机实现而定的。主流的访问方式有使用句柄和直接指针两种。

句柄方式

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



使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾回收)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。

直接指针

使用直接指针访问,Java 堆对象的布局中回存储访问类型数据的地址信息(Hotspot 采用这种方式),reference 中存储的直接就是对象地址:



使用直接指针来访问最大的好处就是访问速度更快,节省了一次指针定位的时间开销。




[1]  https://javamex.com/tutorials/memory/object_memory_usage.shtml 

[2]  http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html 

[3]  https://github.com/unofficial-openjdk/openjdk/blob/jdk8u/jdk8u/hotspot/src/share/vm/oops/markOop.hpp 



发布于: 2020 年 07 月 22 日 阅读数: 5
用户头像

Alex🐒

关注

还未添加个人签名 2020.04.30 加入

还未添加个人简介

评论

发布
暂无评论
JVM 对象内存布局