Java的对象头MarkWord


在Java中任意对象都可以用作锁,因此必定要有一个映射关系,存储该对象以及其对应的锁信息(比如当前哪个线程持有锁,哪些线程在等待)。一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题:需要对map做线程安全保障,不同的synchronized之间会相互影响,性能差;另外当同步对象较多时,该map可能会占用比较多的内存。所以最好的办法是将这个映射关系存储在对象头中,因为对象头本身也有一些hashcode、GC相关的数据,所以如果能将锁信息与这些信息共存在对象头中就好了。

在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。在Java中对象包含三个部分,概括起来分为对象头、对象体和对齐字节。如果是数组,还包括数组长度。markword的结构,定义在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)

  - the two lock bits are used to describe three states: locked/unlocked and monitor.
  [ptr             | 00]  locked             ptr points to real header on stack
  [header      | 0 | 01]  unlocked           regular object header
  [ptr             | 10]  monitor            inflated lock (header is wapped out)
  [ptr             | 11]  marked             used by markSweep to mark an object

在markOop.hpp文件中定义了Mark Word部分,对象头还包括Klass Word。用图表示如下

Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例

对象头Mark Word

下图是Java对象处于5种不同状态时,Mark Word中64个bit位的表现形式

我们可以看出Java的对象头在对象的不同的状态下会有不同的表现形式,主要有三种状态,无锁状态,加锁状态,GC标记状态。那么就可以理解Java当中的上锁其实可以理解给对象上锁。也就是改变对象头的状态,如果上锁成功则进入同步代码块。其中各部分的含义如下:

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

    偏向锁标识位 锁标识位 锁状态
    0 01 未锁定
    1 01 偏向锁
    00 轻量级锁
    10 重量级锁
    11 GC标记
  • biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。(JVM的做法是将偏向锁和无锁的状态表示为同一个状态,都为01,然后根据偏向锁的标识再去标识是无锁还是偏向锁状态)

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

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

  • thread:持有偏向锁的线程ID

  • epoch:偏向锁的时间戳

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

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

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

查看对象头信息

借助JOL工具可以看到对象头在加锁状态下的信息,首先需要使用依赖

<dependency>
     <groupId>org.openjdk.jol</groupId>
     <artifactId>jol-core</artifactId>
     <version>0.10</version>
</dependency>

创建Person.java

public class Person {

}

然后创建Demo类

public class MarkTest {

    public static void main(String[] args) {
        Person person = new Person();
        //打印JVM的详细信息
        System.out.println("---JVM的详细信息---");
        System.out.println(VM.current().details());
        //打印对应的对象头信息
        System.out.println("---对象头信息---");
        System.out.println(ClassLayout.parseInstance(person).toPrintable());
    }

}

运行的结果如下

不是说Klass是64bits(8个字节)但是这儿只有4个字节,因为默认情况下是开启压缩指针的,我们可以通过JVM运行参数-XX:-UseCompressedOops来关闭压缩指针,这样Klass就是8个字节了。我们可以看到整个对象是16个字节,其中对象头(object header)12B(12个字节),还有4B是对齐的字节(因为在64位虚拟机上对象的大小必须是8的倍数),由于这个对象里面没有任何字段,故而对象的实例数据为0B。现在的锁状态是01,表示无锁状态。注意因为是小端存储,所以你看的值是倒过来的。前25bit没有使用所以都是0,后面31bit存的hashcode,所以第一个字节中八位存储的分别就是分代年龄、偏向锁信息、对象状态

插一个题外话,我们可以证明一下对象的大小必须是8的倍数。加一个int(4个字节)数据,如下

public class Person {

    //占四个字节的int字段
    private int age;
}

结果如下

这个对象的大小还是没有改变还是16B,那是因为剩余了4个对齐字节,刚好填上了int属性,我们再在Person中加一根boolean属性看一下

public class Person {

    //占一个字节的boolean字段
    private boolean flag;
    //占四个字节的int字段
    private int age;
}

结果如下

尽管boolean属性只占用了一个字节,但是对象大小是24字节

偏向锁

我们可以通过代码模拟触发偏向锁的情况,然后查看对象头的信息。代码如下

public class MarkTest {

    private static final Person person = new Person();

    public static void main(String[] args) {
        //打印对应的对象头信息
        System.out.println("---before lock---");
        System.out.println(ClassLayout.parseInstance(person).toPrintable());

        sync();

        System.out.println("---after lock---");
        System.out.println(ClassLayout.parseInstance(person).toPrintable());
    }

    private static void sync() {
        synchronized (person) {
            System.out.println("---thread id---"+Thread.currentThread().getId());
            System.out.println("---获取到锁---");
            System.out.println(ClassLayout.parseInstance(person).toPrintable());
        }
    }
}

结果如下

上面这个程序只有一个线程去调用sync方法,应该是偏向锁,但是你会发现输出的结果(第一个字节)依然是00000001和无锁的时候一模一样,其实这是因为虚拟机在启动的时候对于偏向锁有延迟,即偏向锁功能要在JVM启动几秒后才会激活,所以要关闭偏向锁的延迟,在JVM启动参数添加 -XX:BiasedLockingStartupDelay=0

再运行结果如下

偏向锁

这时候大家会有疑问了,为什么在没有加锁之前是偏向锁,准确的说,应该是叫可偏向的状态,因为它后面没有存线程的ID,当lock ing的时候,后面存储的就是线程的ID(50276357)。这里我打印了当前线程的id,发现和对象头中存储的id不一样,实际上对象头中存储的是os的线程id,而不是Thread.currentThread.getId()得到的线程id,它是JVM分配的id,不是os的线程id。

最后我们再画个图总结下各种锁的对象头(只画出了最重要的部分,其他的省略)


Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
Synchronized之偏向锁 Synchronized之偏向锁
synchronized 是我们日常使用频率很高的关键字,它是控制线程安全的同步锁。虽然开发中一直使用,但对于它的实现原理一直理解不够透彻,在面试中也是经常被问,对于一些高级资深的岗位来说,不仅仅只是简单的问一下synchronized 的
2021-04-30
Next 
LeetCode-八皇后问题 LeetCode-八皇后问题
八皇后问题,是一个古老而著名的问题,是回溯算法的典型案例。该问题是国际西洋棋棋手马克斯·贝瑟尔于1848年提出:在8×8格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。 高斯
2021-04-17
  TOC