浅谈Java对象的今生今世

 

前言

    如果你想深入Java语言,那么基础一定要牢靠。有句话怎么说的,基础不牢,地动山摇,假如有许多门通向了基础的深处,那么对象头一定是在找到门之前的"须知",它没什么高深莫测的要点,但是请你务必要知道,或者请你背下来。在日常开发中,我们经常去创建一个对象,但是你知道一个对象占用多大的内存空间吗?你知道它们在内存的存储布局吗?synchronized锁的对象的标志在哪?一个对象年龄达到了需要晋升,这个年龄标志在哪?下面篇幅中会为你解惑,但我们首先需要知道的是,在HotSpot虚拟机中,对象在内存中的布局可以分为三个区域,分别为:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

 

 

1.对象头(Header)

    在HotSpot JVM中,对象头一般包括2个部分,为什么说是一般呢?因为它有可能也包含第三个部分,这些包含的内容分别如下。

        1: 第一部分包含了存储对象本身的运行时的数据,例如hashCode、GC分代年龄、锁状态、是否偏向、偏向锁的线程id、等等,这部分内容在64位的虚拟机中占有8个字节,这部分内容官方称之为'MarkWord'

        2: 第二部分是类型指针,它是对象指向它的类的元数据的指针,JVM通过这个指针来确定这个对象的实例,它在64位的虚拟机中占用8个字节(不开启压缩指针的前提下,下面会扩展),官方称之为'KlassWord'

        3: 第三部分只有当对象是一个数组的时候,对象头中才会包含这部分内容,它是记录数组的长度的数据,因为当确定一个普通对象的大小时,其通过元数据就能确定大小了,而数组不能,所以就要额外的标识一个数组的大小,它在64位的虚拟机中占用8个字节(不开启压缩指针的前提下,下面会扩展)

   http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/tip/src/share/vm/oops/oop.hpp#l59

//HotSpot 的文档中,对于对象头是这样定义的/** *下文大概的意思在 Java 对象头中,储存了堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息 * 这些信息都是储存在一个 Mark Word 里面的,那什么是 Mark Word 呢? */Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object's layout, type, GC state, synchronization state, and identity hash code. Consists of two words.In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.

  1.1:MarkWord

    前面有讲到它是存储对象本身运行时的数据,我们来看一下这些数据都有什么?

|             Mark Word (64 bits)                          |      State      |
| [unused:25] [identity_hashcode:31] [unused:1] [age:4] [biased_lock:1] [lock:2] | Normal           |
| [thread:54] [epoch:2] [unused:1] [age:4] [biased_lock:1]  [lock:2]        | Biased           |
| [ptr_to_lock_record:62]  [lock:2]                               | Lightweight Locked  |
| [ptr_to_heavyweight_monitor:62] [lock:2]                             | Heavyweight Locked  |
| [lock:2]                                                   | Marked for GC     |
identity_hashcode 31位的对象标识Hash码,采用延迟加载技术。调用方法`System.identityHashCode()`计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到Monitor中。
thread持有偏向锁的线程ID
epoch偏向时间戳
ptr_to_lock_record指向栈中锁记录的指针
ptr_to_heavyweight_monitor指向Monitor的指针
age4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
biased_lock对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
lock2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。
biased_locklock 描述
001无锁
101偏向锁 
000轻量级锁 
010重量级锁 
011  GC标记 

(更直观的一张图)

    

(占用大小)

数据类型32位JVM(bit)64位JVM(bit)开启指针压缩的64位JVM(bit)
Mark Word326464
Klass Pointer326432
Array Length323232

  1.2:Class pointer

    这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。它在64位虚拟机上占用8个字节,同样你如果开启了压缩指针,它同样会被压缩成4个字节也就是32位。

http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/tip/src/share/vm/oops/instanceKlass.hpp#l43

  1.3:Array Length

        如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,它在64位虚拟机上占用8个字节,同样你如果开启了压缩指针,它同样会被压缩成4个字节也就是32位。

2:实例数据(Instance Data)

    

    实例数据是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,这里的字段内容不仅仅包括当前类的字段,也包括他的父类中所定义的字段,这部分的存储规则遵循虚拟机分配策略参数和字段在Java源码中的定义顺序,HotSpot JVM默认的分配策略是long/double, int,short/char,byte/boolean,oops(普通对象指针,Ordinary Object Pointers)也可以理解为Reference。

    注意:在父类中定义的变量会出现在子类前,但是我们可以通过将CompactFileds参数设置为true,将子类中较小的变量插入到父类大变量的空隙中。

3:对齐填充(Padding)    

    这部分内容并不是必须存在的,因为Hot Spot JVM中规定了对象的大小必须是8字节的整数倍,在C/C++中类似的功能被称之为内存对齐,内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

内存对齐遵循两个规则:

  • 假设第一个成员的起始地址为0,每个成员的起始地址(startpos)必须是其数据类型所占空间大小的整数倍。

  • 结构体的最终大小必须是其成员(基础数据类型成员)里最大成员所占大小的整数倍。

这里也就不难理解为什么JVM规定对象的大小必须是8字节的整数倍了,因为在64位虚拟机中中(不开启指针压缩),对象中存在很多占用8byte的数据类型。但是同时也存在一些4byte的数据类型,这时我们的Padding就起到了作用,去补充不满8byte的部分,凑齐8的整数倍。所以你可以简单理解为:JVM规定对象的起始内存地址必须是8字节的整数倍,如果不够的话就用占位符来填充,此部分占位符就是对齐填充;

3:验证

    OpenJDK,提供了JOL包,可以帮我们在运行时计算某个对象的大小。(官网:http://openjdk.java.net/projects/code-tools/jol/)

 

 3.1:依赖

 

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

 

 3.2:测试对象

    

 /**  * @author Duansg * @date 2020-04-08 10:32:43 */public class Person {    /** @desc 年龄 */    private int age;}

 

对象大小验证

/** * @author Duansg * @date 2020-04-08 10:32:43 */public class Test {    public static void main(String[] args) {        Person person = new Person();        //查看对象内部信息        System.out.println(ClassLayout.parseInstance(person).toPrintable());    } }

 

解析:可以看到控制台打印的Object Header有12Byte,age实例数据4Byte。实例数据比较容易理解,因为int类型占用4个字节。我们使用的是普通对象,对象头的数据应该是MarkWord + Class pointer,因为笔者使用的是64位的JVM,按理说应该是8Byte+8Byte,但是因为JDK1.8是默认开启压缩指针的,所以Class pointer应该是4Byte,所以最后是12Byte(对象头)+4(实例数据)=16Byte,也就是上图中的Instance size: 16 bytes。

 

 

对齐填充

你可能会疑惑,上图也没有看出来对齐填充的内容啊?它到底存不存在?我们来看一下。

/** * @author Duansg * @date 2020-04-08 10:32:43 */public class Person {
    /** @desc 年龄 */    private int age;
    /** @desc 身高cm */    private int height;}

你觉得这个对象我增加了身高属性以后会占用多大?20还是24?验证一下。

 

解析:通过上图我们可以看到对齐填充的内容,最后20Byte对齐填充到24Byte

 

hashCode验证

    你看到这里,一定会有奇怪的问题,比如:说好的hashcode呢?锁标识呢?这一堆0和1怎么解读?别着急,我们先来分析一下这个JOL打印的数据。

VALUE01 00 00 00 (00000001 00000000 00000000 00000000) (1)00 00 00 00 (00000000 00000000 00000000 00000000) (0)43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)

看到这里首先需要引入一个概念,大端存储与小端存储模式,笔者使用的是win10,这里采用的就是小端存储。如果是mac上文中的顺序会不一致。

    它主要指的是数据在计算机中存储的两种字节优先顺序。小端存储指从内存的低地址开始,先存储数据的低序字节再存高序字节;相反,大端存储指从内存的低地址开始,先存储数据的高序字节再存储数据的低序字节。(它们都有各自的优点,具体的可以度娘一下,因为笔者不是计算机专业出身,也仅仅是知道这个概念。但是没关系,你知道这里如何解读就可以了。)

在解读之前你或许还有一个疑问,为啥对象头前64位几乎都是0?hashCode去哪了?

    [1]:当一个对象的hashCode()未被重写时,调用这个方法会返回一个由随机数算法生成的值。因为一个对象的hashCode不可变,所以需要存到对象头中。当再次调用该方法时,会直接返回对象头中的hashcode。

    [2]:identify_hashcode 采用延迟加载的方式生成。只有调用hashcode()时,才会写入对象头。若一个类的hashCode()方法被重写,对象头中将不存储hashcode信息,我们自己实现的hashcode()也并未将生成的值写入对象头。

/** * @author Duansg * @date 2020-04-08 10:32:43 */public class Test {
    public static void main(String[] args) {        Person person = new Person();        //调用hashcode()        System.out.println(person.hashCode());        //查看对象内部信息        System.out.println(ClassLayout.parseInstance(person).toPrintable());    }}

既然说采用了小端存储,我们先来换算一下。

//hash_code十进制:1229416514二进制:01001001 01000111 01101000 01000010------小端存储指从内存的低地址开始,先存储数据的低序字节再存高序字节--小端存储:01000010 01101000 01000111 01001001

你会发现其实就是JOL输出的信息反过来看。就能跟hashCode可以对上号。知道这一点以后,我们从jol输出的信息来解读一下。

原值:00000001 01000010 01101000 01000111 01001001 00000000 00000000 00000000大端存储:00000000 00000000 00000000 01001001 01000111 01101000 01000010 00000001//注明一下,新创建的对象无状态(无锁),跟据第一张图代入一下。00000000000000000000000001001001010001110110100001000010    0          0000        0         01|----------25bit--------||-------------31bit-----------||-unused-||-分代年龄-||-是否偏向-||-锁状态-| 

 

解析:通过上面分析的,把中间31位拿出来,换算成十进制就是hashcode,也进一步验证了。

 

锁验证

    想必细心的童鞋一定发现了,在上面的解析中,同时了验证了新创建的对象是无状态(无锁)的,但什么时候是偏向锁状态呢?其实JVM默认开启偏向锁,默认的偏向锁启动时间为4-5秒后,所以先让主线程睡5秒再加锁能保证对象处于偏向锁的状态。

/** * @author Duansg * @date 2020-04-08 10:32:43 */public class Test {
    public static void main(String[] args) {        try {            Thread.sleep(5000);        } catch (InterruptedException e) {            e.printStackTrace();        }        Person person = new Person();        //查看对象内部信息        System.out.println(ClassLayout.parseInstance(person).toPrintable());    }}

 

匿名偏向与偏向锁验证

    细心的童靴会发现上图中仅仅只有锁标识,线程标识呢?在支持偏向之前创建的对象都是无状态的,但是在支持偏向后,创建的对象就自动带有偏向标识,但是此时是没有偏向任何线程的,属于一个匿名偏向(anonymously biased)状态,此时对象可以偏向任何一个线程,详情可以看下我推荐阅读的博文<<死磕Synchronized底层实现--偏向锁>>地址:https://github.com/farmerjohngit/myblog/issues/13

/** * @author Duansg * @date 2020-04-08 10:32:43 */public class Test {
    public static void main(String[] args) {        try {            Thread.sleep(5000);        } catch (InterruptedException e) {            e.printStackTrace();        }        Person person = new Person();        //加锁        synchronized (person){            //查看对象内部信息            System.out.println(ClassLayout.parseInstance(person).toPrintable());        }    }}

实际上关于偏向锁的内容有很多,比如重新偏向、偏向撤销、批量重偏向等。但这不是本篇重点,笔者其实想把这部分内容拆解出来,最后在讲到锁升级。但这里只需记住它的概念就行,偏向锁是jdk1.6引入的一项锁优化。它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。

 

 

轻量级锁验证

首先按照惯例,先来一段概念。

    1:当是轻量级锁/重量级锁时,jvm会将对象的 mark word 复制一份到栈帧的Lock Record中。等线程释放该对象时,再重新复制给对象。

     2:如果一个对象头中存在hashcode,则无法使用偏向锁。

/** * @author Duansg * @date 2020-04-08 10:32:43 * @desc 直接用synchronized给对象加锁,此时触发的就是轻量锁。 */public class Test {
    public static void main(String[] args) {        Person person = new Person();        synchronized (person){            //查看对象内部信息            System.out.println(ClassLayout.parseInstance(person).toPrintable());        }    }}
/** * @author Duansg * @date 2020-04-08 10:32:43 */public class Test {
    public static void main(String[] args) {        Person person = new Person();        synchronized (person){            //查看对象内部信息            System.out.println(ClassLayout.parseInstance(person).toPrintable());        }        new Thread(() -> {            synchronized (person) {                // 轻量级锁                System.out.println(ClassLayout.parseInstance(person).toPrintable());            }        }).start();    }}

 

取消偏向

    如果通过Object对象的本地hashCode方法来获取对象的hashCode值,会使对象取消偏向锁状态。

/** * @author Duansg * @date 2020-04-08 10:32:43 */public class Test {
    public static void main(String[] args) {        try {            TimeUnit.SECONDS.sleep(5);        } catch (InterruptedException e) {            e.printStackTrace();        }        Person person = new Person();        System.out.println(ClassLayout.parseInstance(person).toPrintable());        System.out.println(person.hashCode());        System.out.println(ClassLayout.parseInstance(person).toPrintable());    }}

 

解析:可以看到,计算完对象的hashCode之后,该对象立即从偏向锁状态变为了无锁状态,即使后续给对象加锁,该对象也只会进入轻量级或者重量级锁状态,不会再进入偏向状态了。因为该对象一旦进行Object的hashCode计算,那么对象头中会保存这个hashCode,此时再也无法存放偏向线程的id了(对象头的长度也无法同时存放hashCode和偏向线程id),所以此后该对象无法再进入偏向锁状态。

“重量级锁验证”

    如果在偏向锁还没有退出同步代码块的时候,另一个线程来竞争这个锁资源,会直接升级为重量级锁。

/** * @author Duansg * @date 2020-04-08 10:32:43 */public class Test {
    public static void main(String[] args) {        Person person = new Person();        new Thread(() -> {            synchronized (person){                try {                    TimeUnit.SECONDS.sleep(5);                } catch (InterruptedException e) {                    e.printStackTrace();                }            }        }).start();        Thread thread = new Thread(() -> {            synchronized (person){                try {                    TimeUnit.SECONDS.sleep(2);                } catch (InterruptedException e) {                    e.printStackTrace();                }            }        });        thread.start();        System.out.println(ClassLayout.parseInstance(person).toPrintable());    }}

压缩指针

 

    前面提到了压缩指针,从字面意思上可以看出来'要对指针进行压缩(瘦身)嘛',为什么要对指针进行压缩呢?大家可以考虑一下,如果同样多的对象,如果使用64位的虚拟机要比32位的虚拟机多耗费1/2的内存(32位占用4B,64位占用8B),这样的话使用64位的指针太浪费内存了,所以为了节省内存,从JDK 1.6 update14开始,64 bit JVM正式支持了-XX:+UseCompressedOops 这个压缩指针,它可以起到节约内存占用的新参数,它的实现方式是在机器码中植入压缩与解压指令,可能会给JVM增加额外的开销。我们可以使用选项-XX:+UseCompressedOops开启压缩指针,开启该选项后,指针将压缩至32位。但是也仅仅只有普通对象指针才会被压缩(1: 每个Class的属性指针(即静态变量)、2: 每个对象的属性指针(即对象变量)、3: 普通对象数组的每个元素指针),也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。关于这部分内容你可以在这找到

            https://wiki.openjdk.java.net/display/HotSpot/CompressedOops

 

 


 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值