64位JVM的Java对象头详解

原创 吴就业 149 1 2020-02-22

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/737eba2c2fc34200938a201bffc35d2f

作者:吴就业
链接:https://wujiuye.com/article/737eba2c2fc34200938a201bffc35d2f
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

本篇文章写于2020年02月22日,从公众号同步过来(博客搬家),本篇为原创文章。

我们编写一个Java类,编译后会生成.class文件,当类加载器将class文件加载到jvm时,会生成一个Klass类型的对象(c++),称为类描述元数据,存储在方法区中,即jdk1.8之后的元数据区。当使用new创建对象时,就是根据类描述元数据Klass创建的对象oop,存储在堆中。每个java对象都有相同的组成部分,称为对象头。

在学习并发编程知识synchronized时,我们总是难以理解其实现原理,因为偏向锁、轻量级锁、重量级锁都涉及到对象头,所以了解java对象头是我们深入了解synchronized的前提条件。

查看对象头的神器

介绍一款可以在代码中计算java对象的大小以及查看java对象内存布局的工具包:jol-corejoljava object layout的缩写,即java对象布局。使用只需要到maven仓库http://mvnrepository.com搜索java object layout,选择想要使用的版本,将依赖添加到项目中即可。

jol-core

使用jol计算对象的大小(单位为字节):

ClassLayout.parseInstance(obj).instanceSize()

使用jol查看对象的内存布局:

ClassLayout.parseInstance(obj).toPrintable()

使用JOL查看对象的内存布局

网络搜索了很多资料,对64jvmJava对象头的布局讲解的都很模糊,很多资料都是讲的32位,而且很多都是从一些书上摘抄下来的,难免会存在错误的地方,所以最好的学习方法就是自己去验证,看jvm源码。本篇将详细介绍64jvmJava对象头。

User类为例

public class User {
    private String name;
    private Integer age;
    private boolean sex;
}

通过jol查看User对象的内存布局

User user = new User()
System.out.println(ClassLayout.parseInstance(user).toPrintable());

输出内容如下:

通过jol查看User对象的内存布局

从图中可以看到,对象头所占用的内存大小为16*8bit=128bit。如果大家自己动手去打印输出,可能得到的结果是96bit,这是因为我关闭了指针压缩。jdk8版本是默认开启指针压缩的,可以通过配置vm参数关闭指针压缩。

-XX:-UseCompressedOops

现在取消关闭指针压缩的配置,开启指针压缩之后,再看User对象的内存布局。

开启指针压缩后User对象的内存布局

开启指针压缩可以减少对象的内存使用。从两次打印的User对象布局信息来看,关闭指针压缩时,name字段和age字段由于是引用类型,因此分别占8个字节,而开启指针压缩之后,这两个字段只分别占用4个字节。因此,开启指针压缩,理论上来讲,大约能节省百分之五十的内存。jdk8及以后版本已经默认开启指针压缩,无需配置。

从两次打印的User对象的内存布局,还可以看出,bool类型的age字段只占用1个字节,但后面会跟随几个字节的浪费,即内存对齐。开启指针压缩情况下,age字段的内存对齐需要3个字节,而关闭指针压缩情况下,则需要7个字节。

以默认开启指针压缩情况下的User对象的内存布局来看,对象头占用12个字节,那么这12个字节存储的是什么信息,我们不看网上的资料,而是看jdk的源码。

openjdk源码下载或在线查看

官网:http://openjdk.java.net/

我当前使用的jdk版本是jdk1.8,可通过命令行java -version查看,也可通过下面方式查看,这个大家应该都很熟悉了。

System.out.println(System.getProperties());

打开官网后点击左侧菜单栏的Groups找到HotSpot,在打开的页面的Source code选择Browsable souce,之后会跳转到http://hg.openjdk.java.net/,选择`jdk8u`,跳转后的页面中继续选择`jdk8u`下面的`hotspot`。

传送链接:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/。

openjdk源码下载

Java对象头详解

在开启指针压缩的情况下,User对象的对象头占用12个字节,本节我们通过源码了解对象头都存储了哪些信息。

Java程序运行的过程中,每创建一个新的对象,JVM就会相应地创建一个对应类型的oop对象,存储在堆中。如new User(),则会创建一个instanceOopDesc,基类为oopDesc

[instanceOop.hpp文件:hotspot/src/share/vm/oops/instanceOop.hpp]
class instanceOopDesc : public oopDesc {
}

instanceOopDesc只提供了几个静态方法,如获取对象头大小。因此重点看其父类oopDesc

[oop.hpp文件:hotspot/src/share/vm/oops/oop.hpp]
class oopDesc {
   friend class VMStructs;
 private:
  volatile markOop  _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;
  ........
}

我们只关心对象头,普通对象(如User对象,本篇不讲数组类型)的对象头由一个markOop和一个联合体组成,markOop就是MarkWord。这个联合体是指向类的元数据指针,未开启指针压缩时使用_klass,开启指针压缩时使用_compressed_klass

markOopnarrowKlass的类型定义在/hotspot/src/share/vm/oops/oopsHierarchy.hpp头文件中:

[oopsHierarchy.hpp头文件:/hotspot/src/share/vm/oops/oopsHierarchy.hpp]
typedef juint  narrowKlass;
typedef class markOopDesc* markOop;

因此,narrowKlass是一个juintjunit是在globalDefinitions_visCPP.hpp头文件中定义的,这是一个无符号整数,即4个字节。所以开启指针压缩之后,指向Klass对象的指针大小为4字节。

[/hotspot/src/share/vm/utilties/globalDefinitions_visCPP.hpp]
typedef unsigned int juint;

markOop则是markOopDesc类型指针,markOopDesc就是MarkWord。不知道你们有没有感觉到奇怪,在64jvm中,markOopDesc指针是8字节,即64bit,确实刚好是MarkWord的大小,但是指针指向的不是一个对象吗?我们先看markOopDesc类。

[markOop.hpp文件:hotspot/src/share/vm/oops/markOop.hpp]
//  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)
class markOopDesc: public oopDesc {
 ......
}

markOop.hpp头文件中给出了64bitMarkWord存储的信息说明。markOopDesc类也继承oopDesc。如果单纯的看markOopDesc类的源码,根本找不出来,markOopDesc是用那个字段存储MarkWord的。而且,根据从各种来源的资料中,我们所知道的是,对象头的前8个字节存储的就是是否偏向锁、轻量级锁等等信息(全文都是以64位为例),所以不应该是个指针啊。

为了解答这个疑惑,我是先从markOopDesc类的源码中,找一个方法,比如,获取gc对象年龄的方法,看下jvm是从哪里获取的数据。

class markOopDesc: public oopDesc {
public:
 // 获取对象年龄
  uint  age() const {
    return mask_bits(value() >> age_shift, age_mask);
  }
  // 更新对象年龄
  markOop set_age(uint v) const {
    return markOop((value() & ~age_mask_in_place) | (((uintptr_t)v & age_mask) << age_shift));
  }
  // 自增对象年龄
  markOop incr_age() const {
    return age() == max_age ? markOop(this) : set_age(age() + 1);
  }
}

那么,value()这个方法返回的就是64bitMarkWord了。

class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }
}

value方法返回的是一个指针,就是this。从set_ageincr_age方法中也可以看出,只要修改MarkWord,就会返回一个新的markOopmarkOopDesc*)。难怪会将markOopDesc*定义为markOop,就是将markOopDesc*当成一个8字节的整数来使用。想要理解这个,我们需要先补充点c++知识,因此我写了个demo

自定义一个类叫oopDesc,并且除构造函数和析构函数之外,只提供一个Show方法。

[.hpp文件]
#ifndef oopDesc_hpp
#define oopDesc_hpp

#include <stdio.h>

#include <iostream>
using namespace std;

// 将oopDesc* 定义为 oop
typedef class oopDesc* oop;

class oopDesc{
public:
    void Show();
};
#endif /* oopDesc_hpp */

[.cpp文件]
#include "oopDesc.hpp"

void oopDesc::Show(){
    cout << "oopDesc by wujiuye" <<endl;
}

使用oop(指针)创建一个oopDesc*,并调用show方法。

#include <iostream>
#include "oopDesc.hpp"
using namespace std;
int main(int argc, const char * argv[]) {
    oopDesc* o = oop(0x200);
    cout << o << endl;
    o->Show();
    return 0;
}

测试输出

0x200
oopDesc by wujiuye
Program ended with exit code: 0

因此,通过类名(value)可以创建一个野指针对象,将指针赋值为value,这样就可以使用this作为MarkWord了。如果在oopDesc中添加一个字段,并提供一个方法访问,程序运行就会报错,因此,这样创建的对象只能调用方法,不能访问字段。

MarkWord

MarkWord

通过倒数三位判断当前MarkWord的状态,就可以判断出其余位存储的是什么。

[markOop.hpp文件]
enum {  locked_value             = 0, // 0 00 轻量级锁
         unlocked_value           = 1,// 0 01 无锁
         monitor_value            = 2,// 0 10 重量级锁
         marked_value             = 3,// 0 11 gc标志
         biased_lock_pattern      = 5 // 1 01 偏向锁
  };

现在,我们再看下User对象打印的内存布局。

User对象内存布局

通过前面的讲解,我们已经知道,对象头的前64位是MarkWord,后32位是类的元数据指针(开启指针压缩)。

从图中可以看出,在无锁状态下,该User对象的hashcode0x7a46a697。由于MarkWord其实是一个指针,在64jvm下占8字节。因此MarkWordk0x0000007a46a69701,跟你从图中看到的正好相反。这里涉及到一个知识点“大端存储与小端存储”。

学过汇编语言的朋友,这个知识点应该都还记得。本篇不详细介绍,不是很明白的朋友可以网上找下资料看。

写一个synchronized加锁的demo分析锁状态

接着,我们再看一下,使用synchronized加锁情况下的User对象的内存信息,通过对象头分析锁状态。

案例1

public class MarkwordMain {

    private static final String SPLITE_STR = "===========================================";
    private static User USER = new User();

    private static void printf() {
        System.out.println(SPLITE_STR);
        System.out.println(ClassLayout.parseInstance(USER).toPrintable());
        System.out.println(SPLITE_STR);
    }

    private static Runnable RUNNABLE = () -> {
        while (!Thread.interrupted()) {
            synchronized (USER) {
                printf();
            }
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    };

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 3; i++) {
            new Thread(RUNNABLE).start();
            Thread.sleep(1000);
        }
        Thread.sleep(Integer.MAX_VALUE);
    }
}

轻量级锁状态下的对象头

从该对象头中分析加锁信息,MarkWordk0x0000700009b96910,二进制为0xb00000000 00000000 01110000 00000000 00001001 10111001 01101001 00010000

倒数第三位为"0",说明不是偏向锁状态,倒数两位为"00",因此,是轻量级锁状态,那么前面62位就是指向栈中锁记录的指针。

案例2

public class MarkwordMain {

    private static final String SPLITE_STR = "===========================================";
    private static User USER = new User();

    private static void printf() {
        System.out.println(SPLITE_STR);
        System.out.println(ClassLayout.parseInstance(USER).toPrintable());
        System.out.println(SPLITE_STR);
    }

    private static Runnable RUNNABLE = () -> {
        while (!Thread.interrupted()) {
            synchronized (USER) {
                printf();
            }
        }
    };

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 3; i++) {
            new Thread(RUNNABLE).start();
        }
        Thread.sleep(Integer.MAX_VALUE);
    }
}

重量级锁状态下的对象头

从该对象头中分析加锁信息,MarkWordk0x0000 7ff0 c800 53ea,二进制为0xb00000000 00000000 01111111 11110000 11001000 00000000 01010011 11101010

倒数第三位为"0",说明不是偏向锁状态,倒数两位为"10",因此,是重量级锁状态,那么前面62位就是指向互斥量的指针。

#后端

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

使用Docker部署用于学习的ElasticSearch集群

在Linux服务器上使用 Docker安装ElasticSearch集群。

使用Mybatis-Plus提高开发效率

使用`mybatis-plus`可以少写很多常用的`SQL`,通过继承`BaseMapper`使用,还可以动态拼接`SQL`。第一眼看到我还以为是`JPA`。

ElasticSearch高版本API的使用姿势

如何在`Java`项目中使用`elasticsearch-rest-high-level-client`。

Java锁事之Unsafe、CAS、AQS知识点总结

Unsafe、CAS、AQS是我们了解Java中除synchronized之外的锁必须要掌握的重要知识点。CAS是一个比较和替换的原子操作,AQS的实现强依赖CAS,而在Java中,CAS操作需通过使用Unsafe提供的方法实现。

Dubbo路由功能实现灰度发布及源码分析

灰度发布是实现新旧版本平滑过渡的一种发布方式,即让一部分服务更新到新版本,如果这部分服务没有什么问题,再将其它旧版本的服务更新。而实现简单的灰度发布我们可以使用版本号控制,每次发布都更新版本号,新更新的服务就不会调用旧的服务提供者。

JVM垃圾回收GC Root与安全点Safepoint

我看很多资料在介绍`GC Root`时,并没有说栈帧的操作数栈上引用的对象也是`GC Root`,包括我去翻阅《深入理解Java虚拟机》这本书也是一样。所以我才好奇。