参考文档:Java对象结构与锁实现原理及MarkWord详解

1. 简介

我们都知道,Java 对象存储在堆(Heap)内存。那么一个 Java 对象到底包含什么呢?概括起来分为对象头、对象体和对齐字节。如下图所示:

img

对象的几个部分的作用:

  1. 对象头中的 Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合 GC、存放该对象的 hashCode;
  2. Klass Word 是一个指向方法区中 Class 信息的指针,意味着该对象可随时知道自己是哪个 Class 的实例;
  3. 数组长度也是占用 64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;
  4. 对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;
  5. 对齐字是为了减少堆内存的碎片空间。

2. Mark Word(标记字)

img

以上是 Java 对象处于 5 种不同状态时,Mark Word 中 64 个位的表现形式,上面每一行代表对象处于某种状态时的样子。其中各部分的含义如下:

2.1 lock

2 位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了 lock 标记。该标记的值不同,整个 Mark Word 表示的含义不同。 biased_lock 和 lock 一起,表达的锁状态含义如下:
img

2.2 biased_lock

对象是否启用偏向锁标记,只占 1 个二进制位。为 1 时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock 和 biased_loc k共同表示对象处于什么锁状态。

2.3 age

4 位的Java对象年龄。在 GC 中,如果对象在 Survivor 区复制一次,年龄增加 1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行 GC 的年龄阈值为 15,并发 GC 的年龄阈值为 6。由于 age 只有 4 位,所以最大值为 15,这就是 -XX:MaxTenuringThreshold 选项最大值为 15 的原因。

2.4 identity_hashcode

31 位的对象标识 hashCode,采用延迟加载技术。调用方法 System.identityHashCode() 计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord 的字节没有足够的空间保存 hashCode,因此该值会移动到管程 Monitor 中。

2.5 thread

持有偏向锁的线程ID。

2.6 epoch

偏向锁的时间戳。

2.7 ptr_to_lock_record

轻量级锁状态下,指向栈中锁记录的指针。

2.8 ptr_to_heavyweight_monitor

重量级锁状态下,指向对象监视器Monitor的指针。

2.9 锁升级

参考:java 锁

我们通常说的通过 synchronized 实现的同步锁,真实名称叫做重量级锁。但是重量级锁会造成线程排队(串行执行),且会使 CPU 在用户态和核心态之间频繁切换,所以代价高、效率低。为了提高效率,不会一开始就使用重量级锁,JVM 在内部会根据需要,按如下步骤进行锁的升级:

  1. 初期锁对象刚创建时,还没有任何线程来竞争,对象的 Mark Word 是下图的第一种情形,这偏向锁标识位是 0,锁状态 01,说明该对象处于无锁状态(无线程竞争它)。
  2. 当有一个线程来竞争锁时,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。这时 Mark Word 会记录自己偏爱的线程的 ID,把该线程当做自己的熟人。如下图第二种情形。
  3. 当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的 Mark Word 就执行哪个线程的栈帧中的锁记录。如下图第三种情形。
  4. 如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,这个锁对象 Mark Word 再次发生变化,会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程。如下图第四种情形。

img

2.10 Monitor 对象

在 HotSpot 虚拟机中,monitor 是由 C++ 中 ObjectMonitor 实现。

synchronized 的运行机制,就是当 JVM 监测到对象在不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。

那么三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁。当一个 Monitor 被某个线程持有后,它便处于锁定状态。

Monitor 主要数据结构如下

// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
    _header       = NULL;
    _count        = 0;       // 记录个数
    _waiters      = 0,
    _recursions   = 0;       // 线程重入次数
    _object       = NULL;    // 存储 Monitor 对象
    _owner        = NULL;    // 持有当前线程的 owner
    _WaitSet      = NULL;    // 处于wait状态的线程,会被加入到 _WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;   // 单向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;   // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}
  • ObjectMonitor,有两个队列:_WaitSet _EntryList,用来保存 ObjectWaiter 对象列表。
  • _owner,获取 Monitor 对象的线程进入 _owner 区时, _count + 1。如果线程调用了 wait() 方法,此时会释放 Monitor 对象, _owner 恢复为空, _count - 1。同时该等待线程进入 _WaitSet 中,等待被唤醒。

锁执行效果如下

图 15-06,锁🔒执行效果

每个 Java 对象头中都包括 Monitor 对象(存储的指针的指向),synchronized 也就是通过这一种方式获取锁,也就解释了为什么 synchronized() 括号里放任何对象都能获得锁!

3. Klass Word(类指针)

这一部分用于存储对象的类型指针,该指针指向它的类元数据(Class),JVM 通过这个指针确定对象是哪个类的实例。该指针的位长度为 JVM 的一个字大小,即 32 位的 JVM 为 32 位,64 位的 JVM 为 64 位。

如果应用的对象过多,使用 64 位的指针将浪费大量内存,统计而言,64 位的 JVM 将会比 32 位的JVM多耗费 50% 的内存。为了节约内存可以使用选项 +UseCompressedOops 开启指针压缩(默认开启),其中,oop 即 ordinary object pointer 普通对象指针。开启该选项后,类指针将压缩至 32 位。

元数据
虚拟机在加载类的时候会将类的信息,常量,静态变量和即时编译器编译后的代码等数据存储在方法区(Method Area).

类的元数据,即类的数据描述也被存在方法区.

klass word 其实指向的是方法区的某个存有类的信息地址

4. 数组长度

若是是非数组对象就没这部分

如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着 JVM 架构的不同而不同:32 位的 JVM 上,长度为 32 位;64 位 JVM 则为 64 位。64 位 JVM 如果开启 +UseCompressedOops 选项,该区域长度也将由 64 位压缩至 32 位。

5. 对象体

即为 java 成员变量的大小。

6. 对齐字节

关于对齐填充,Java 对象的大小默认是按照 8 字节对齐,也就是说 Java 对象的大小必须是 8 字节的倍数。若是算到最后不够 8 字节的话,那么就会进行对齐填充。

那么为何非要进行 8 字节对齐呢?这样岂不是浪费了空间资源?

其实不然,由于 CPU 进行内存访问时,一次寻址的指针大小是 8 字节,正好也是 L1 缓存行的大小。如果不进行内存对齐,则可能出现跨缓存行的情况,这叫做 缓存行污染

img

由于当 obj1 对象的字段被修改后,那么 CPU 在访问 obj2 对象时,必须将其重新加载到缓存行,因此影响了程序执行效率

也就说,8字节对齐,是为了效率的提高,以空间换时间的一种方案。固然你还能够 16 字节对齐,可是 8 字节是最优选择。

7. 指针压缩

jdk8 是默认开启的。

引用类型在 64 位系统上占用 8 个字节,虽然一个并不大,但是耐不住多。

所以为了解决这个问题,JDK 1.6 开始 64 bit JVM 正式支持了 -XX:+UseCompressedOops (需要jdk1.6.0_14) ,这个参数可以压缩指针。

启用 CompressOops 后,会压缩的对象包括:

  • 对象的全局静态变量(即类属性);
  • 对象头信息:64 位系统下,原生对象头大小为 16 字节,压缩后为 12 字节;
  • 对象的引用类型:64 位系统下,引用类型本身大小为 8 字节,压缩后为 4 字节;
  • 对象数组类型:64 位平台下,数组类型本身大小为 24 字节,压缩后 16 字节。

当然压缩也不是万能的,针对一些特殊类型的指针 JVM是不会优化的。 比如:

  • 指向非 Heap 的对象指针
  • 局部变量、传参、返回值、NULL指针。

8. 验证

我们可以通过 JOL 依赖验证。

参考:深入理解Java的对象头mark word