关于java:图文详解Java对象内存布局

37次阅读

共计 9263 个字符,预计需要花费 24 分钟才能阅读完成。

作为一名 Java 程序员,咱们在日常工作中应用这款面向对象的编程语言时,做的最频繁的操作大略就是去创立一个个的对象了。对象的创立形式尽管有很多,能够通过new、反射、clone、反序列化等不同形式来创立,但最终应用时对象都要被放到内存中,那么你晓得在内存中的 java 对象是由哪些局部组成、又是怎么存储的吗?

本文将基于代码进行实例测试,具体探讨对象在内存中的组成构造。

文中代码基于 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:占用内存大小,单位为字节
  • TYPEClass中定义的类型
  • 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 锁降级

在 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 时,启动时会报错:

3.2 Klass Pointer 类型指针

Klass Pointer是一个指向办法区中 Class 信息的指针,虚拟机通过这个指针确定该对象属于哪个类的实例。在 64 位的 JVM 中,反对指针压缩性能,依据是否开启指针压缩,Klass Pointer占用的大小将会不同:

  • 未开启指针压缩时,类型指针占用 8B (64bit)
  • 开启指针压缩状况下,类型指针占用 4B (32bit)

jdk6 之后的版本中,指针压缩是被默认开启的,可通过启动参数开启或敞开该性能:

# 开启指针压缩:-XX:+UseCompressedOops
#敞开指针压缩:-XX:-UseCompressedOops

还是以方才的 User 类为例,敞开指针压缩后再次查看对象的内存布局:

对象大小尽管还是 16 字节,然而组成产生了扭转,8 字节标记字加 8 字节类型指针,曾经能满足对齐条件,因而不须要填充。

8B (mark word) + 8B (klass pointer) + 0B (instance data) + 0B (padding)
3.2.1 指针压缩原理

在理解了指针压缩的作用后,咱们来看一下指针压缩是如何实现的。首先在不开启指针压缩的状况下,一个对象的内存地址应用 64 位示意,这时能形容的内存地址范畴是:

0 ~ 2^64-1

在开启指针压缩后,应用 4 个字节也就是 32 位,能够示意2^32 个内存地址,如果这个地址是实在地址的话,因为 CPU 寻址的最小单位是Byte,那么就是 4GB 内存。这对于咱们来说是远远不够的,然而之前咱们说过,java 中对象默认应用了 8 字节对齐,也就是说 1 个对象占用的空间必须是 8 字节的整数倍,这样就发明了一个条件,使 jvm 在定位一个对象时不须要应用真正的内存地址,而是定位到由 java 进行了 8 字节映射后的地址(能够说是一个映射地址的编号)。

映射过程也非常简单,因为应用了 8 字节对齐后每个对象的地址偏移量后 3 位必然为 0,所以在存储的时候能够将后 3 位 0 抹除(转化为 bit 是抹除了最初 24 位),在此基础上再去掉最高位,就实现了指针从 8 字节到 4 字节的压缩。而在理论应用时,在压缩后的指针后加 3 位 0,就可能实现向实在地址的映射。

实现压缩后,当初指针的 32 位中的每一个 bit,都能够代表 8 个字节,这样就相当于使原有的内存地址失去了 8 倍的扩容。所以在 8 字节对齐的状况下,32 位最大能示意2^32*8=32GB 内存,内存地址范畴是:

0 ~ (2^32-1)*8

因为可能示意的最大内存是 32GB,所以如果配置的最大的堆内存超过这个数值时,那么指针压缩将会生效。配置 jvm 启动参数:

-Xmx32g

查看对象内存布局:

此时,指针压缩生效,指针长度复原到 8 字节。那么如果业务场景内存超过 32GB 怎么办呢,能够通过批改默认对齐长度进行再次扩大,咱们将对齐长度批改为 16 字节:

-XX:ObjectAlignmentInBytes=16 -Xmx32g

能够看到指针压缩后占 4 字节,同时对象向 16 字节进行了填充对齐,依照下面的计算,这时配置最大堆内存为 64GB 时指针压缩才会生效。

对指针压缩做一下简略总结:

  • 通过指针压缩,利用对齐填充的个性,通过映射形式达到了内存地址扩大的成果
  • 指针压缩可能节俭内存空间,同时进步了程序的寻址效率
  • 堆内存设置时最好不要超过 32GB,这时指针压缩将会生效,造成空间的节约
  • 此外,指针压缩不仅能够作用于对象头的类型指针,还能够作用于援用类型的字段指针,以及援用类型数组指针

3.3 数组长度

如果当对象是一个数组对象时,那么在对象头中有一个保留数组长度的空间,占用 4 字节(32bit)空间。通过上面代码进行测试:

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

运行代码,后果如下:

内存构造从上到下别离为:

  • 8 字节mark word
  • 4 字节klass pointer
  • 4 字节数组长度,值为 2,示意数组中有两个元素
  • 开启指针压缩后每个援用类型占 4 字节,数组中两个元素共占 8 字节

须要留神的是,在未开启指针压缩的状况下,在数组长度后会有一段对齐填充字节:

通过计算:

8B (mark word) + 8B (klass pointer) + 4B (array length) + 16B (instance data)=36B

须要向 8 字节进行对齐,这里抉择将对齐的 4 字节增加在了数组长度和实例数据之间。

4、实例数据

实例数据(Instance Data)保留的是对象真正存储的无效信息,保留了代码中定义的各种数据类型的字段内容,并且如果有继承关系存在,子类还会蕴含从父类继承过去的字段。

  • 根本数据类型:
Type Bytes
byte,boolean 1
char,short 2
int,float 4
long,double 8
  • 援用数据类型:

开启指针压缩状况下占 8 字节,开启指针压缩后占 4 字节。

4.1 字段重排序

给 User 类增加根本数据类型的属性字段:

public class User {
    int id,age,weight;
    byte sex;
    long phone;
    char local;
}

查看内存布局:

能够看到,在内存中,属性的排列程序与在类中定义的程序不同,这是因为 jvm 会采纳 字段重排序 技术,对原始类型进行从新排序,以达到内存对齐的目标。具体规定遵循如下:

  • 依照数据类型的长度大小,从大到小排列
  • 具备雷同长度的字段,会被调配在相邻地位
  • 如果一个字段的长度是 L 个字节,那么这个字段的偏移量(OFFSET)须要对齐至nL(n 为整数)

下面的前两条规定绝对容易了解,这里通过举例对第 3 条进行解释:

因为 long 类型占 8 字节,所以它的偏移量必然是 8n,再加上后面对象头占 12 字节,所以 long 类型变量的最小偏移量是 16。通过打印对象内存布局能够发现,当对象头不是 8 字节的整数倍时(只存在 8n+4 字节状况),会按从大到小的程序,应用 4、2、1 字节长度的属性进行补位。为了和对齐填充进行辨别,能够称其为前置补位,如果在补位后依然不满足 8 字节整数倍,会进行对齐填充。在存在前置补位的状况下,字段的排序会突破下面的第一条规定。

因而在下面的内存布局中,先应用 4 字节的 int 进行前置补位,再按第一条规定从大到小程序进行排列。如果咱们删除 3 个 int 类型的字段,再查看内存布局:

charbyte 类型的变量被提到后面进行前置补位,并在 long 类型前进行了 1 字节的对齐填充。

4.2 领有父类状况

  • 当一个类领有父类时,整体遵循在父类中定义的变量呈现在子类中定义的变量之前的准则
public class A {
    int i1,i2;
    long l1,l2;
    char c1,c2;
}
public class B extends A{
    boolean b1;
    double d1,d2;
}

查看内存构造:

  • 如果父类须要后置补位的状况,可能会将子类中类型长度较短的变量提前,然而整体还是遵循子类在父类之后的准则
public class A {
    int i1,i2;
    long l1;
}
public class B extends A {
    int i1,i2;
    long l1;
}

查看内存构造:

能够看到,子类中较短长度的变量被提前到父类后进行了后置补位。

  • 父类的前置对齐填充会被子类继承
public class A {long l;}
public class B extends A{
    long l2;
    int i1;
}

查看内存构造:

当 B 类没有继承 A 类时,正好满足 8 字节对齐,不须要进行对齐填充。当 B 类继承 A 类后,会继承 A 类的前置补位填充,因而在 B 类的开端也须要对齐填充。

4.3 援用数据类型

在下面的例子中,仅探讨了根本数据类型的排序状况,那么如果存在援用数据类型时,排序状况是怎么的呢?在 User 类中增加援用类型:

public class User {
     int id;
     String firstName;
     String lastName;
     int age;
}

查看内存布局:

能够看到默认状况下,根本数据类型的变量排在援用数据类型前。这个程序能够在 jvm 启动参数中进行批改:

-XX:FieldsAllocationStyle=0

从新运行,能够看到援用数据类型的排列程序被放在了后面:

FieldsAllocationStyle 的不同取值简要阐明:

  • 0:先放入一般对象的援用指针,再放入根本数据类型变量
  • 1:默认状况,示意先放入根本数据类型变量,再放入一般对象的援用指针

4.4 动态变量

在下面的根底上,在类中退出动态变量:

public class User {
     int id;
     static byte local;
}

查看内存布局:

通过后果能够看到,动态变量并不在对象的内存布局中,它的大小是不计算在对象中的,因为动态变量属于类而不是属于某一个对象的。

5、对齐填充字节

Hotspot 的主动内存管理系统中,要求对象的起始地址必须是 8 字节的整数倍,也就是说对象的大小必须满足 8 字节的整数倍。因而如果实例数据没有对齐,那么须要进行对齐补全空缺,补全的 bit 位仅起占位符作用,不具备非凡含意。

在后面的例子中,咱们曾经对对齐填充有了充沛的意识,上面再做一些补充:

  • 在开启指针压缩的状况下,如果类中有 long/double 类型的变量时,会在对象头和实例数据间造成间隙(gap),为了节俭空间,会默认把较短长度的变量放在前边,这一性能能够通过 jvm 参数进行开启或敞开:
# 开启
-XX:+CompactFields
# 敞开
-XX:-CompactFields

测试敞开状况,能够看到较短长度的变量没有前移填充:

  • 在后面指针压缩中,咱们提到了能够扭转对齐宽度,这也是通过批改上面的 jvm 参数配置实现的:
-XX:ObjectAlignmentInBytes

默认状况下对齐宽度为 8,这个值能够批改为 2~256 以内 2 的整数幂,个别状况下都以 8 字节对齐或 16 字节对齐。测试批改为 16 字节对齐:

下面的例子中,在调整为 16 字节对齐的状况下,最初一行的属性字段只占了 6 字节,因而会增加 10 字节进行对齐填充。当然一般状况下不倡议批改对齐长度参数,如果对齐宽度过长,可能会导致内存空间的节约。

6、总结

本文通过应用jol 对 java 对象进行测试,学习了对象内存布局的基本知识。通过学习,可能帮忙咱们:

  • 把握对象内存布局,基于此基础进行 jvm 参数调优
  • 理解对象头在synchronize 的锁降级过程中的作用
  • 相熟 jvm 中对象的寻址过程
  • 通过计算对象大小,能够在评估业务量的根底上在我的项目上线前预估须要应用多少内存,避免服务器频繁 gc

如果文章对您有所帮忙,欢送关注公众号 码农参上

正文完
 0