Java对象在内存中的布局 没有你想的那么神秘

写在前面

Java是用C++写的,所以java对象最终会映射到c++中的某个对象,用这个对象可以描述所有Java对象。而我们所熟知的synchronized锁的优化就是基于这个对象来实现的。

对象在内存中的布局

Java对象在被创建的时候,在内存分配完成后,虚拟机需要对对象进行必要设置, 例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

这些信息存放在对象的对象头(Object Header)中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等对象头会有不同的设置方式。

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

对象头

HotSpot虚拟机对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。

这部分数据的长度在32位和64位的虚拟机中分别是32bit和64bit,官方称为"Mark Word"。 考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息。

对象头另一部分是类型指针,即指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。

我们可以在JVM源码(hotspot/share/oops/markOop.hpp)中看到对象头中存储内容的定义

class markOopDesc: public oopDesc {
 public:
  enum { age_bits             = 4,
         lock_bits            = 2,
         biased_lock_bits     = 1,
         max_hash_bits        = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits            = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits             = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits           = 2
  };
}
对象头
  • hash: 对象的哈希码
  • age: 对象的分代年龄
  • biased_lock : 偏向锁标识位
  • lock: 锁状态标识位
  • JavaThread* : 持有偏向锁的线程ID
  • epoch: 偏向时间戳

例如在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间的25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0
而在其他状态(轻量级锁,重量级锁,GC标记,可偏向)下对象的存储内容如下表所示

32位系统

实例数据

实例数据部分是对象真正存储的有效信息,也是在程序代码中说定义的各种类型的字段内容。

对其填充

第三部分对其填充并不是必然存在的,也没有特别的含义,仅是占位符的作用,因为HotSpot VM的内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数,因此当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

查看对象头

我们可以通过openjdk的jol工具来查看对象头存储的内容,首先代码如下

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

public class GetRange {
  // -XX:-UseCompressedOops
  public static void main(String[] args) {

    // 1字节=8位(1byte = 8bit)
    System.out.println(VM.current().details());

    MyClass myClass = new MyClass();

    ClassLayout classLayout = ClassLayout.parseInstance(myClass);

    System.out.println("****New Object****");

    System.out.println(classLayout.toPrintable());

    int hashCode = myClass.hashCode();

    System.out.println("MyClass hashCode : " + hashCode) ;
    System.out.println("MyClass hashCode 二进制 " + Integer.toBinaryString(hashCode));
    System.out.println("MyClass hashCode 二进制长度 " + Integer.toBinaryString(hashCode).length());

    System.out.println();

    System.out.println("****After invoke hashCode()****");

    System.out.println(classLayout.toPrintable(myClass));

    // 获取系统字节序
    System.out.println("系统当前字节序是:" + ByteOrder.nativeOrder());

  }
}

class MyClass {

  String name = "think123";

  int[] other;

  boolean status;
}

输出内容如下

jol-object-header.png

可以知道对象头占了12个字节,存在3个字节的对其填充。

jvm中使用oopDesc来描述一个对象

class oopDesc {
 private:
  volatile markOop _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;

}

我们可以看到对象头被有两部分,mark 部分,官方称为 mark word,存储的是哈希码,对象分代年龄,偏向锁标志等信息。mark word长度是一个系统子宽,在64bit系统上是8个字节。

第二部分是klass的类型指针,指向这个对象是哪个类的实例,这里使用的是union这个联合体,表示变量_klass_compressed_klass共享同一段内存。未开启指针压缩时使用_klass,开启指针压缩时使用_compressed_klass。narrowKlass实际上是一个32bit的unsigned int类型,因此占用4个字节,所以开启指针压缩后对象的头部长度整体为12字节。

narrowKlass定义在hotspot/src/share/vm/oops/oopsHierarchy.hpp,它是juint类型,实际上是32bit的unsigned int

对象体:MyClass中定义了三个字段,int[],String都占用4个字节长度,boolean类型的占用了1个字节长度

填充部分:上面对象头和对象体长度和为21字节,因为要8字节对齐,因此需要填充3字节,这样就刚好等于24字节。

当我们关闭指针压缩后(-XX:-UseCompressedOops),mark word占16个字节,同时也采用8字节进行对其。name和other两个数据域分别占据8字节。而开启指针压缩之后,这两个字节分别占用4个字节。
所以开启这个选项是可以节约内存的,从jdk8之后已经默认开启此选项。

未开启指针压缩

上面我把当前系统的字节序打印了出来,可以看到当前是小字节序。

举例来说,数值0x2211使用两个字节储存:高位字节是0x22,低位字节是0x11。
大端字节序:高位字节在前,低位字节在后,这是人类读写数值的方法。
小端字节序:低位字节在前,高位字节在后,即以0x1122形式储存。

因此我们查看myClass的hashCode的时候就要倒着看了。我们查看object header中的hashcode要从第9位到39位(64位系统中,用31字节保存哈希)开始查看。


myClass的hashcode(长度为30,最高位补2个0) :

myClass HashCode :    00100001 01011000 10001000 00001001

Mark Word中HashCode : 00001001 10001000 01011000 0 0100001

可以发现myClass的hashCode和mark word中的存储刚好是反着来的。

不是说存储hash的只有31位吗?为什么这里用32位来比较呢?我用32位来比较是为了更加易于观察。 实际上MarkWord中保存哈希码最后8位的第一位0是从未使用的25位中借来的(需要结合小字节序)

接下来,我们使用JOL工具查看处于不同锁状态下,mark word中的标志位是怎样的。

偏向锁

首先我们来看偏向锁,由于JVM默认会在启动后4秒才会启动偏向锁,所以测试时需要设置马上启动偏向锁(-XX:BiasedLockingStartupDelay=0)

// -XX:BiasedLockingStartupDelay=0
public static void main(String[] args) throws InterruptedException {

    Layouter layouter = new HotSpotLayouter(new X86_32_DataModel());

    MyClass myClass = new MyClass();

    ClassLayout layout = ClassLayout.parseInstance(myClass);

    System.out.println("进入同步代码块之前:");
    System.out.println(layout.toPrintable());


    synchronized (myClass) {
      System.out.println("同步代码块中:");
      System.out.println(layout.toPrintable());
    }

    System.out.println("退出同步代码块后:");
    System.out.println(layout.toPrintable());

}

偏向锁
  1. 可以看到在进入同步代码块之前低8位是00000101,表示处于偏向锁(后三位是101),但是现在还没有偏向任何一个线程,因此没有数据
  2. 处于同步代码块中以及退出同步代码块时,mark word一致,低8位都是00000101,处于偏向锁,且存储了偏向线程id以及时间戳等信息。说明退出同步块后,依然保留偏向锁的信息

轻量级锁和重量级锁

我们使用下面的代码来演示轻量级锁以及重量级时状态位的变化情况

 //不设置立刻启动偏向锁
public static void main(String[] args) throws InterruptedException {

    Layouter layouter = new HotSpotLayouter(new X86_32_DataModel());

    MyClass myClass = new MyClass();

    ClassLayout layout = ClassLayout.parseInstance(myClass);

    System.out.println("创建t1线程之前:");
    System.out.println(layout.toPrintable());

    Thread t1 = new Thread(() -> {
       synchronized ((myClass)) {
         try {
             TimeUnit.SECONDS.sleep(5);
         } catch (InterruptedException e) {
             e.printStackTrace();
         }
       }
    });

    t1.start();

    System.out.println("持有锁之前:");
    System.out.println(layout.toPrintable());


    synchronized (myClass) {
        System.out.println("持有锁中:");
        System.out.println(layout.toPrintable());
    }

    System.out.println("释放锁:");
    System.out.println(layout.toPrintable());

    System.out.println("System.gc() 后");
    System.gc();
    System.out.println(layout.toPrintable());

}

运行结果如下:

轻量级锁和重量级锁

mark word的状态变更过程如下:

  1. 创建t1线程前阶段,锁标志位为01,偏向锁标志位为0,处于无锁状态
  2. main线程持有锁之前阶段,标志位为00,属于轻量级锁,此时t1线程已经持有锁且main线程未请求锁,所以此时无竞争
  3. main线程持有锁阶段,标志位为10,膨胀为重量级锁,此时t线程已经已经释放了锁,main线程获取锁成功,即此时存在竞争
  4. main线程释放锁阶段,标志位为10,仍为重量级锁,不会自动降级
  5. System.gc后阶段,恢复为无锁状态,GC年龄变为1

至此,我们知道了处于不同锁情况下,状态位的变化情况。

写到最后

创作不易,请大家点个赞呀。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343