图文详解Java对象内存布局

作为一名Java程序员,我们在日常工作中使用这款面向对象的编程语言时,做的最频繁的操作大概就是去创建一个个的对象了。对象的创建方式虽然有很多,可以通过new、反射、clone、反序列化等不同方式来创建,但最终使用时对象都要被放到内存中,那么你知道在内存中的java对象是由哪些部分组成、又是怎么存储的吗?

本文将基于代码进行实例测试,详细探讨对象在内存中的组成结构。全文目录结构如下:

  • 1、对象内存结构概述

  • 2、JOL 工具简介

  • 3、对象头

  • 4、实例数据

  • 5、对齐填充字节

  • 6、总结

文中代码基于 JDK 1.8.0_261,64-Bit HotSpot 运行

1、对象内存结构概述

在介绍对象在内存中的组成结构前,我们先简要回顾一个对象的创建过程:

1、jvm将对象所在的class文件加载到方法区中

2、jvm读取main方法入口,将main方法入栈,执行创建对象代码

3、在main方法的栈内存中分配对象的引用,在堆中分配内存放入创建的对象,并将栈中的引用指向堆中的对象

所以当对象在实例化完成之后,是被存放在堆内存中的,这里的对象由3部分组成,如下图所示:

对各个组成部分的功能简要进行说明:

  • 对象头:对象头存储的是对象在运行时状态的相关信息、指向该对象所属类的元数据的指针,如果对象是数组对象那么还会额外存储对象的数组长度

  • 实例数据:实例数据存储的是对象的真正有效数据,也就是各个属性字段的值,如果在拥有父类的情况下,还会包含父类的字段。字段的存储顺序会受到数据类型长度、以及虚拟机的分配策略的影响

  • 对齐填充字节:在java对象中,需要对齐填充字节的原因是,64位的jvm中对象的大小被要求向8字节对齐,因此当对象的长度不足8字节的整数倍时,需要在对象中进行填充操作。注意图中对齐填充部分使用了虚线,这是因为填充字节并不是固定存在的部分,这点在后面计算对象大小时具体进行说明

2、JOL 工具简介

在具体开始研究对象的内存结构之前,先介绍一下我们要用到的工具,openjdk官网提供了查看对象内存布局的工具jol (java object layout),可在maven中引入坐标:

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

在代码中使用jol提供的方法查看jvm信息:

System.out.println(VM.current().details());

通过打印出来的信息,可以看到我们使用的是64位 jvm,并开启了指针压缩,对象默认使用8字节对齐方式。通过jol查看对象内存布局的方法,将在后面的例子中具体展示,下面开始对象内存布局的正式学习。

3、对象头

首先看一下对象头(Object header)的组成部分,根据普通对象和数组对象的不同,结构将会有所不同。只有当对象是数组对象才会有数组长度部分,普通对象没有该部分,如下图所示:

在对象头中mark word占8字节,默认开启指针压缩的情况下klass pointer占4字节,数组对象的数组长度占4字节。在了解了对象头的基础结构后,现在以一个不包含任何属性的空对象为例,查看一下它的内存布局,创建User类:

public class User {
}

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

public static void main(String[] args) {
    User user=new User();
    //查看对象的内存布局     System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

执行代码,查看打印信息:

  • OFFSET:偏移地址,单位为字节
  • SIZE:占用内存大小,单位为字节
  • TYPE:Class中定义的类型
  • DESCRIPTION:类型描述,Obejct header表示对象头,alignment表示对齐填充
  • VALUE:对应内存中存储的值

当前对象共占用16字节,因为8字节标记字加4字节的类型指针,不满足向8字节对齐,因此需要填充4个字节:

8B (mark word) + 4B (klass pointer) + 0B (instance data) + 4B (padding)

这样我们就通过直观的方式,了解了一个不包含属性的最简单的空对象,在内存中的基本组成是怎样的。在此基础上,我们来深入学习对象头中各个组成部分。

3.1 Mark Word 标记字

在对象头中,mark word一共有64个bit,用于存储对象自身的运行时数据,标记对象处于以下5种状态中的某一种:


3.1.1 基于mark word的锁升级

在jdk6 之前,通过synchronized关键字加锁时使用无差别的的重量级锁,重量级锁会造成线程的串行执行,并且使cpu在用户态和核心态之间频繁切换。随着对synchronized的不断优化,提出了锁升级的概念,并引入了偏向锁、轻量级锁、重量级锁。在mark word中,锁(lock)标志位占用2个bit,结合1个bit偏向锁(biased_lock)标志位,这样通过倒数的3位,就能用来标识当前对象持有的锁的状态,并判断出其余位存储的是什么信息。

基于mark word的锁升级的流程如下:

1、锁对象刚创建时,没有任何线程竞争,对象处于无锁状态。在上面打印的空对象的内存布局中,根据大小端,得到最后8位是00000001,表示处于无锁态,并且处于不可偏向状态。这是因为在jdk中偏向锁存在延迟4秒启动,也就是说在jvm启动后4秒后创建的对象才会开启偏向锁,我们通过jvm参数取消这个延迟时间:

-XX:BiasedLockingStartupDelay=0


这时最后3位为101,表示当前对象的锁没有被持有,并且处于可被偏向状态。

2、在没有线程竞争的条件下,第一个获取锁的线程通过CAS将自己的threadId写入到该对象的mark word中,若后续该线程再次获取锁,需要比较当前线程threadId和对象mark word中的threadId是否一致,如果一致那么可以直接获取,并且锁对象始终保持对该线程的偏向,也就是说偏向锁不会主动释放。

使用代码进行测试同一个线程重复获取锁的过程:

public static void main(String[] args) {
    User user=new User();
    synchronized (user){
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
    synchronized (user){
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
}

执行结果:

可以看到一个线程对一个对象加锁、解锁、重新获取对象的锁时,mark word都没有发生变化,偏向锁中的当前线程指针始终指向同一个线程。

3、当两个或以上线程交替获取锁,但并没有在对象上并发的获取锁时,偏向锁升级为轻量级锁。在此阶段,线程采取CAS的自旋方式尝试获取锁,避免阻塞线程造成的cpu在用户态和内核态间转换的消耗。测试代码如下:

public static void main(String[] args) throws InterruptedException {
    User user=new User();
    synchronized (user){
        System.out.println("--MAIN--:"+ClassLayout.parseInstance(user).toPrintable());
    }
    Thread thread = new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD--:"+ClassLayout.parseInstance(user).toPrintable());
        }
    });
    thread.start();
    thread.join();
    System.out.println("--END--:"+ClassLayout.parseInstance(user).toPrintable());
}

先直接看一下结果:

整个加锁状态的变化流程如下:

  • 主线程首先对user对象加锁,首次加锁为101偏向锁
  • 子线程等待主线程释放锁后,对user对象加锁,这时将偏向锁升级为00轻量级锁
  • 轻量级锁解锁后,user对象无线程竞争,恢复为001无锁态,并且处于不可偏向状态。如果之后有线程再尝试获取user对象的锁,会直接加轻量级锁,而不是偏向锁

4、当两个或以上线程并发的在同一个对象上进行同步时,为了避免无用自旋消耗cpu,轻量级锁会升级成重量级锁。这时mark word中的指针指向的是monitor对象(也被称为管程或监视器锁)的起始地址。测试代码如下:

public static void main(String[] args) {
    User user = new User();
    new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD1--:" + ClassLayout.parseInstance(user).toPrintable());
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();
    new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD2--:" + ClassLayout.parseInstance(user).toPrintable());
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();
}

查看结果:


可以看到,在两个线程同时竞争user对象的锁时,会升级为10重量级锁。

3.1.2 其他信息

对mark word中其他重要信息进行说明:

  • hashcode:无锁态下的hashcode采用了延迟加载技术,在第一次调用hashCode()方法时才会计算写入。对这一过程进行验证:
public static void main(String[] args) {
    User user=new User();
    //打印内存布局     System.out.println(ClassLayout.parseInstance(user).toPrintable());
    //计算hashCode     System.out.println(user.hashCode());
    //再次打印内存布局     System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

可以看到,在没有调用hashCode()方法前,31位的哈希值不存在,全部填充为0。在调用方法后,根据大小端,被填充的数据为:

1011001001101100011010010101101

将2进制转换为10进制,对应哈希值1496724653。需要注意,只有在调用没有被重写的Object.hashCode()方法或System.identityHashCode(Object)方法才会写入mark word,执行用户自定义的hashCode()方法不会被写入。

大家可能会注意到,当对象被加锁后,mark word中就没有足够空间来保存hashCode了,这时hashcode会被移动到重量级锁的Object Monitor中。

  • epoch:偏向锁的时间戳

  • 分代年龄(age):在jvm的垃圾回收过程中,每当对象经过一次Young GC,年龄都会加1,这里4位来表示分代年龄最大值为15,这也就是为什么对象的年龄超过15后会被移到老年代的原因。在启动时可以通过添加参数来改变年龄阈值:

-XX:MaxTenuringThreshold

当设置的阈值超过15时,启动时会报错:


....博主太懒了字数太多了,不想写了....



#java##后端开发##面试##大厂##Java找工作#
全部评论
内存是很重要很难的啊,感谢分享
点赞 回复 分享
发布于 2022-09-25 22:16 陕西

相关推荐

我也曾抱有希望:说的好直白
点赞 评论 收藏
分享
孤寡孤寡的牛牛很热情:为什么我2本9硕投了很多,都是简历或者挂,难道那个恶心人的测评真的得认真做吗
点赞 评论 收藏
分享
点赞 2 评论
分享
牛客网
牛客企业服务