Java的对象头

作者: 程序员札记 | 来源:发表于2022-01-29 21:36 被阅读0次

    要看对象的二进制代码, 可以加入下面依赖。

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

    可以调用ClassLayout.parseInstance(l).toPrintable 输出二进制代码。

    public  class  Main{
        public  static  void  main(String[] args) throws InterruptedException {
             L l = new L(); //new 一个对象 
            System.out.println(ClassLayout.parseInstance(l).toPrintable());//输出 l对象 的布局
        }
    }
    
    //对象类
    
    class  L{
        private  boolean myboolean = true;
    }
    
    image.png

    对象头所占用的内存大小为12*8bit=96bit (JDK8默认开启指针压缩)

    普通的对象获取到的对象头结构为:

    |-------------------------------------------------------------------- |
    
    |                     Object Header (128 bits)                      |
    
    |------------------------------------|------------------------------- |
    
    |        Mark Word (64 bits)   | Klass pointer (64 bits)  |
    
    |------------------------------------|-------------------------------|
    

    普通对象压缩后获取结构:

    |-------------------------------------------------------------------------|
    
    |                     Object Header (96 bits)                             |
    
    |------------------------------------|------------------------------------|
    
    |        Mark Word (64 bits)         | Klass pointer (32 bits)  |
    
    |------------------------------------|------------------------------------|
    

    数组对象获取到的对象头结构为:

    |-------------------------------------------------------------------------------------------------- -|
    
    |                                 Object Header (128 bits)                                               |
    
    |--------------------------------|-----------------------|-----------------------------------------  |
    
    |        Mark Word(64bits)       | Klass pointer(32bits) |  array length(32bits)    |
    
    |--------------------------------|-----------------------|------------------------ -----------------|
    

    对象头的组成

    我们先了解一下,一个JAVA对象的存储结构。在Hotspot虚拟机中,对象在内存中的存储布局分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)


    image.png

    Mark Word

    这部分主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。mark word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word为32位,64位JVM为64位。
    为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word示意如下:


    image.png

    其中各部分的含义如下:
    lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。
    通过倒数三位数 我们可以判断出锁的类型

    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 偏向锁
      };
    

    锁状态

    重量级锁Markword

    package com.conrrentcy.thread;
    
    import org.openjdk.jol.info.ClassLayout;
    
    public class LockObjectHeader {
    
        public static void main(String[] args) throws InterruptedException {
            LL l = new LL();
            Runnable RUNNABLE = () -> {
                while (!Thread.interrupted()) {
                    synchronized (l) {
                        String SPLITE_STR = "===========================================";
                        System.out.println(SPLITE_STR);
                        System.out.println(ClassLayout.parseInstance(l)
                                .toPrintable());
                        System.out.println(SPLITE_STR);
                    }
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };
            for (int i = 0; i < 3; i++) {
                new Thread(RUNNABLE).start();
            }
        }
    }
    
    class LL {
        private boolean myboolean = true;
    }
    
    image.png

    Mark Word为0X00007FA92080DCEA( 根据前64位的value倒序排列拼成的串就是mark word), 我们可以看到在第一行object header中 value=ea 对应的2进制为11101010 倒数第三位 为0表示不是偏量锁,后两位为10表示为重量锁。

    轻量级锁Markword

    package com.conrrentcy.thread;
    
    import org.openjdk.jol.info.ClassLayout;
    
    public class LockLightObjectHeader {
    
        public static void main(String[] args) throws InterruptedException {
            LLight l = new LLight();
            synchronized (l) {
                Thread.sleep(1000);
                System.out.println(ClassLayout.parseInstance(l).toPrintable());
                Thread.sleep(1000);
            } // 轻量锁
        }
    }
    
    class LLight {
        private boolean myboolean = true;
    }
    
    image.png

    Markwod为0X000070000468f8, f8对应的二进制倒数第三位为0 ,表示不是偏向锁,最后两位为00表示是轻量级锁。

    讲到这里发现很多同事对java对象模型并不理解,所以我们先把对象头放一放, 先讲讲 java对象的引用方式

    java对象的引用方式

    Klass Pointer

    即对象指向它的元数据的指针,虚拟机通过这个指针来确定是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针(通过句柄池访问)。
    简单引申一下对象的访问方式,我们创建对象的目的就是为了使用它。所以我们的Java程序在运行时会通过虚拟机栈中本地变量表的reference数据来操作堆上对象。但是reference只是JVM中规范的一个指向对象的引用,那这个引用如何去定位到具体的对象呢?因此,不同的虚拟机可以实现不同的定位方式。主要有两种:句柄池和直接指针。
    使用句柄访问
    会在堆中开辟一块内存作为句柄池,句柄中储存了对象实例数据(属性值结构体)的内存地址,访问类型数据的内存地址(类信息,方法类型信息),对象实例数据一般也在heap中开辟,类型数据一般储存在方法区中。

    • 优点:reference存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变。
    • 缺点:增加了一次指针定位的时间开销。


      image.png

    使用指针访问

    指针访问方式指reference中直接储存对象在heap中的内存地址,但对应的类型数据访问地址需要在实例中存储。
    优点:节省了一次指针定位的开销。
    缺点:在对象被移动时(如进行GC后的内存重新排列),reference本身需要被修改。

    对齐填充字节

    因为JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数,就不特别介绍了。
    思考: 方法区的class 是怎么存储的,一个class 被不同的classloader 加载,是如何存储的,一个程序中 new object 是如何找到自己的class 定义的?

    方法区内部结构

    Java代码被编译成字节码文件之后,通过类加载器被加载到运行时数据区。其中,方法区主要存储的是类型的相关信息以及运行时常量池。对于字符串常量,根据JDK版本的不同,有的放到了方法区,有的没有。


    image.png

    方法区中存放的是类型信息、常量、静态变量、即时编译器编译后的代码缓存、域信息、方法信息等。随着JDK的发展,方法区中存放的内容也在发生变化。并不绝对。通常情况下放的是这些内容。

    类型信息

    包括以下几点:

    类的完整名称(比如,java.long.String)
    类的直接父类的完整名称
    类的直接实现接口的有序列表(因为一个类直接实现的接口可能不止一个,因此放到一个有序表中)
    类的修饰符
    可以看做是,对一个类进行登记,这个类的名字叫啥,他粑粑是谁、有没有实现接口, 权限是啥;

    类型的常量池 (即运行时常量池)

    每一个Class文件中,都维护着一个常量池(这个保存在类文件里面,不要与方法区的运行时常量池搞混),里面存放着编译时期生成的各种字面值和符号引用;这个常量池的内容,在类加载的时候,被复制到方法区的运行时常量池 ;

    字面值:就是像string, 基本数据类型,以及它们的包装类的值,以及final修饰的变量,简单说就是在编译期间,就可以确定下来的值;

    符号引用:不同于我们常说的引用,它们是对类型,域和方法的引用,类似于面向过程语言使用的前期绑定,对方法调用产生的引用;

    存在这里面的数据,类似于保存在数组中,外部根据索引来获得它们 ;

    字段信息

    • 声明的顺序
    • 修饰符
    • 类型
    • 名字

    方法信息

    • 声明的顺序
    • 修饰符
    • 返回值类型
    • 名字
    • 参数列表(有序保存)
    • 异常表(方法抛出的异常)
    • 方法字节码(native、abstract方法除外,)
    • 操作数栈和局部变量表大小

    例子:下面是一个Java程序的字节码文件通过javap反编译之后得到的输出。
    class文件中的类型信息、域信息、方法信息都会被类加载器加载到方法区中。


    image.png

    类变量(即static变量)

    • 非final类变量
      在java虚拟机使用一个类之前,它必须在方法区中为每个非final类变量分配空间。非final类变量存储在定义它的类中;

    • final类变量(不存储在这里)
      由于final的不可改变性,因此,final类变量的值在编译期间,就被确定了,因此被保存在类的常量池里面,然后在加载类的时候,复制进方法区的运行时常量池里面 ;final类变量存储在运行时常量池里面,每一个使用它的类保存着一个对其的引用;

    初始化的区别:初始化的时间不同,non-final的类变量在类加载的第二个阶段(链接阶段)的准备阶段被赋默认的初始值,然后再类加载的第三个阶段(初始化阶段)被显示初始化(也就是赋值为代码中写的值)。它的直接调用会引起类加载。

    比如: 定义一个成员变量a, public static int a = 7; a在链接阶段的准备阶段被赋默认值0;然后再初始化阶段被显示初始化为7。
    final的类变量是在编译阶段就被显示初始化了。static final 变量不属于定义类, 在编译期被赋值给NonInitlization了类,他的调用不会引起类的加载。
    比如:定义一个成员变量, public static final int a = 7; ,a在代码被编译成字节码文件的时候就被赋值为7了。

    顺便提一下 ,静态类是在编译期编译的, 多态无法判定,只能由基类来实现。

    对类加载器的引用

    jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。

    对Class类的引用

    jvm为每个加载的类都创建一个java.lang.Class的实例(存储在堆上)。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个Class对象的引用

    全局字符串池

    字符串常量池在 Java 内存区域的哪个位置

    • 在 JDK6.0 及之前版本,字符串常量池是放在 Perm Gen 区(也就是方法区)中,此时常量池中存储的是对象。

    • 在 JDK7.0 版本,字符串常量池被移到了堆中了。此时常量池存储的就是引用了。在 JDK8.0 中,永久代(方法区)被元空间取代了。

    字符串常量池是什么?

    在 HotSpot VM 里实现的 string pool 功能的是一个 StringTable 类,它是一个 Hash 表,默认值大小长度是1009;里面存的是驻留字符串的引用(而不是驻留字符串实例自身)。也就是说某些普通的字符串实例被这个 StringTable 引用之后就等同被赋予了“驻留字符串”的身份。这个 StringTable 在每个 HotSpot VM 的实例里只有一份,被所有的类共享。

    StringTable 本质上就是个 HashSet<String>。这是个纯运行时的结构,而且是惰性(lazy)维护的。注意它只存储对java.lang.String 实例的引用,而不存储 String 对象的内容。 注意,它只存了引用,根据这个引用可以得到具体的 String 对象。

    在 JDK6.0 中,StringTable 的长度是固定的,长度就是 1009,因此如果放入 String Pool 中的 String 非常多,就会造成 hash 冲突,导致链表过长,当调用 String#intern() 时会需要到链表上一个一个找,从而导致性能大幅度下降;

    在 JDK7.0 中,StringTable 的长度可以通过参数指定:

    -XX:StringTableSize=66666

    class 文件常量池(class constant pool)

    我们都知道,class 文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。 字面量比较接近 Java 语言层面常量的概念,如文本字符串、被声明为 final 的常量值等。 符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:

    • 类和接口的全限定名

    • 字段的名称和描述符

    • 方法的名称和描述符

    常量池的每一项常量都是一个表,一共有如下表所示的11种各不相同的表结构数据,这每个表开始的第一位都是一个字节的标志位(取值1-12),代表当前这个常量属于哪种常量类型。

    [图片上传失败...(image-cb36c9-1643462379593)]

    每种不同类型的常量类型具有不同的结构,具体的结构本文就先不叙述了,本文着重区分这三个常量池的概念(读者若想深入了解每种常量类型的数据结构可以查看《深入理解java虚拟机》第六章的内容,其实是自己还没弄明白,后续回来填坑 )。

    运行时常量池(runtime constant pool)

    运行时常量池是方法区的一部分。

    当 Java 文件被编译成 class 文件之后,也就是会生成上面所说的 class 常量池,那么运行时常量池又是什么时候产生的呢?

    JVM 在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析(resolve)三个阶段。而当类加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面也说了,class 常量池中存的是字面量和符号引用,也就是说它们存的并不是对象的实例,而是对象的符号引用值。而经过resolve 之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是上面所说的 StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

    三种常量池之间的关联

    关于 JVM 执行的时候,还涉及到了字符串常量池。

    在类加载阶段, JVM 会在堆中创建对应这些 class 文件常量池中的字符串对象实例,并在字符串常量池中驻留其引用。具体在 resolve 阶段执行。这些常量全局共享。

    这里说的比较笼统,没错,是 resolve 阶段,但是并不是大家想的那样,立即就创建对象并且在字符串常量池中驻留了引用。 JVM 规范里明确指定 resolve 阶段可以是 lazy 的。

    关于 lazy resolution 需要在这里了解一下 ldc 指令

    简单地说,它用于将 String 型常量值从常量池中推送至栈顶。

    以下面代码为例:

    public static void main(String[] args) {

        String s = "abc";
    
    }
    

    比如说该代码文件为 Test.java,首先在文件目录下打开 Dos 窗口,执行 javac Test.java 进行编译,然后输入 javap -verbose Test 查看其编译后的 class 文件如下:

    image.png

    使用 ldc 指令将"abc"加载到操作数栈顶,然后用 astore_1 把它赋值给我们定义的局部变量 s,然后 return。执行 ldc 指令就是触发 lazy resolution 动作的条件

    ldc 字节码在这里的执行语义是:到当前类的运行时常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找该 index 对应的项,如果该项尚未 resolve 则 resolve 之,并返回 resolve 后的内容。 在遇到 String 类型常量时,resolve 的过程如果发现 StringTable 已经有了内容匹配的 java.lang.String 的引用,则直接返回这个引用;反之,如果 StringTable 里尚未有内容匹配的 String 实例的引用,则会在 Java 堆里创建一个对应内容的 String 对象,然后在 StringTable 记录下这个引用,并返回这个引用。

    可见,ldc 指令是否需要创建新的 String 实例,全看在第一次执行这一条 ldc 指令时,StringTable 是否已经记录了一个对应内容的 String 的引用。

    Java 各版本中String.intern()

    1.1 常量池

    Class文件中除了有关的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用。其中字符串池(又名字符串规范化)是一个用一个共享的String替换几个具有相同值但不同身份的对象。你可以通过Map<String, String>来自己实现此目标(根据要求可能有软或弱引用),或者可以使用String.intern()由JDK提供的方法。

    1.2 Java 6中的String.intern()

    Java 6以及6之前中常量池存放在方法区(Perm 区)中,过多的使用intern()会直接产生java.lang.OutOfMemoryError: PermGen space错误的。因为方法区具有固定大小,不能在运行时扩展。虽然可以使用-XX:MaxPermSize=N选项进行设置,根据平台的不同,默认的PermGen大小在32M到96M之间变化。你可以增加它的大小,但它的大小仍然是固定的,这种限制使得不能不受控制的使用String.intern()。这就是Java 6时代的字符串池主要在手动管理的Map中实现的原因。

    1.3 Java 7中的String.intern()

    Oracle对Java 7中的常量池做了一个非常重要的改变 — 常量池被重新定位到堆中。这意味着你不再受限于单独的固定大小内存区域。所有字符串现在都位于堆中,与大多数其他普通对象一样,这使你可以在调整应用程序时仅管理堆大小。从技术上讲,这仅仅是一个使用String.intern()的理由。但还有其他原因。

    常量池中的GC,如果常量不再被引用,那么JVM是可以回收它们来节省内存,因此常量池放在堆区可以更方便和堆区的其他对象一起被JVM进行垃圾收集管理。

    2. String的创建及拼接

    2.1 String的创建

    字符串不属于基本类型,但是可以像基本类型一样,直接通过字面量赋值,当然也可以通过new来生成一个字符串对象。不过通过字面量赋值的方式和new的方式生成字符串有本质的区别:

    image.png

    通过字面量赋值创建字符串时,会先在常量池中查找是否已经存在相同的字符串,倘若已经存在,栈中的引用直接指向该字符串;倘若不存在,则在常量池中生成一个字符串,再将栈中的引用指向该字符串。而通过new的方式创建字符串时,就直接在堆中生成一个字符串的对象,栈中的引用指向该对象。

    https://zhuanlan.zhihu.com/p/107776367

    https://www.jianshu.com/p/75c539eaab5a

    https://blog.csdn.net/u011069294/article/details/107415210?utm_medium=distribute.pc_relevant.none-task-blog-searchFromBaidu-2.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-searchFromBaidu-2.control

    Java class 的类加载器和命名空间

    https://blog.csdn.net/zhousenshan/article/details/108065821

    class只是个metadata,由那个classloader 加载都可以发挥作用。但是,有不同的classloader加载,就有了class的作用域即命名空间。不同的命名空间可以加载同样的类,也可以不允许加载同样的类,策略的不同,有不同的实现和用处。

    1. class 的代码会用当前类的classloader去 加载类, 只要入口类被classloader 加载,入口类所有的功能都会被同一个classloader加载
    • 创建类的实例
    • 访问类的静态变量(注意:当访问类的静态并且final修饰的变量时,不会触发类的初始化。),或者为静态变量赋值。
    • 调用类的静态方法(注意:调用静态且final的成员方法时,会触发类的初始化!一定要和静态且final修饰的变量区分开!!)
    • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。如:Class.forName("bacejava.Langx");
    • 注意通过类名.class得到Class文件对象并不会触发类的加载。
    • 初始化某个类的子类
    • 直接使用java.exe命令来运行某个主类(java.exe运行,本质上就是调用main方法,所以必须要有main方法才行)
    1. 类的命名空间存在于 classloader 之间

    2. 类的classloader findClass 的 方法实现不同,可以有不同的流派。

    • 全局class只有一份
    image.png
    • 每个bundle自满足
    image.png

    相关文章

      网友评论

        本文标题:Java的对象头

        本文链接:https://www.haomeiwen.com/subject/gcvxkrtx.html