详解Java对象内存结构以及指针压缩原理

详解Java对象内存结构以及指针压缩原理

HotSpot中的Java对象布局

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

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

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

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

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

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

对象头

  • Mark Word :32bit机 4B;64bit机 8B 是固定的。用于存储对象自身的运行时数据,如哈希码(Hash Code)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
  • 类型指针:klass pointer,引用类型在方法区的地址,是对象指向她的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 开启指针压缩占4字节,不开启占8个字节

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

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

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

JOL工具简介

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

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

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

public class JavaObject {

    public static void main(String[] args) {
        L l = new L();
        // 输出l对象的部分
        System.out.println(ClassLayout.parseInstance(l).toPrintable());
    }
}

// 实体类
public class L {

    private boolean myboolean = true;
}

在这里插入图片描述

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

当前对象共占用13字节,8字节的对象头+1字节的boolean类型变量,因为要满足8字节的整数倍,所以需要额外的3字节来对其进行扩充,所以实际存储的时候仍然按照16字节存储。

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

对象头

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

img

在对象头中mark word 占8字节,默认开启指针压缩的情况下klass pointer 占4字节,数组对象的数组长度占4字节,如果开启指针压缩,数组长度会放到类型指针的后半段。

哪些信息会被压缩

1.对象的全局静态变量(即类属性)

2.对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节

3.对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节

4.对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节

哪些信息不会被压缩

1.指向非Heap的对象指针

2.局部变量、传参、返回值、NULL指针

Mark Word标记字

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

在这里插入图片描述

  • 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
  • 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
  • epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。

由上图可知,根据锁状态的不同,Mark Word中的数据表示不同的含义。

Klass Pointer类型指针

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

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

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

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

简单引申一下对象的访问方式,我们创建对象的目的就是为了使用它。所以我们的Java程序在运行时会通过虚拟机栈中本地变量表的reference数据来操作堆上对象。但是reference只是JVM中规范的一个指向对象的引用,那这个引用如何去定位到具体的对象呢?因此,不同的虚拟机可以实现不同的定位方式。主要有两种:句柄池和直接指针。

  • List item使用句柄访问 会在堆中开辟一块内存作为句柄池,句柄中储存了对象实例数据(属性值结构体)的内存地址,访问类型数据的内存地址(类信息,方法类型信息),对象实例数据一般也在heap中开辟,类型数据一般储存在方法区中。
  • 使用指针访问方式指reference中直接储存对象在heap中的内存地址,但对应的类型数据访问地址需要在实例中存储。

在这里插入图片描述
在这里插入图片描述

为什么要在对象头中引入锁

通过上面的介绍我们知道,每个Java对象头都包含了锁标志位,并根据不同锁的程度,存储的指针/数据也不同。那么,为什么Java在设计的时候要把锁的信息存储在对象中呢,这样设计的好处或者作用是什么呢?接下来,针对这个问题,进行一些分析。

首先,根据上面关于不同锁的分析我们可以大致知道,从偏向锁->轻量级锁->重量级锁的升级,是由于线程间对资源的竞争导致的,资源竞争越激励,则锁的级别越高。因此,锁的作用是为了解决多线程对资源竞争,也就是线程间的同步问题的。

那么为什么锁要加在对象头上呢?

让我们从JVM的角度来看待这个问题。在Java程序运行时环境中,JVM需要对两类线程共享的数据进行协调

  1. 保存在堆中的实例变量
  2. 保存在方法区的类变量

这两类数据是被所有线程共享的。每个线程在创建时,JVM都会为其创建一个工作内存,工作内存是每个线程的私有数据区域。而JAVA内存模型中规定,所有变量都存储在主内存中(堆中),主内存是共享内存区域,所有线程都可以访问。

因此,在不考虑线程同步时,每个线程都会自己的一块工作内存,保存了一份主内存中变量的副本进行操作,操作完成后,再将变量写回到主内存。不同的线程之间无法直接访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

而JVM堆中存的是我们new出来的实例对象本身,当不考虑线程间同步问题时,这些实例对象是被不同线程共享的。但是当考虑线程间同步问题时,就需要对上述两类线程间共享的数据进行协调,而需要协调的目标正是这些实例对象本身。

也就是说,为了让线程间同步的获取这些实例对象,需要让线程在获取对象时互斥,这就是锁机制。同时,由于竞争的资源对象就是这些实例对象本身,就可以将锁加在这些实例对象上。这样,无论有多少个线程在竞争资源,由于在该对象中标识了所属的线程,那么得到该对象锁的线程就运行,其他线程则等待,从而实现了线程同步的机制。

其实总结起来就是说,因为线程同步时,线程间竞争的是JVM堆中的实例对象资源,所以自然这个锁就加在了对象上。

指针压缩

启用指针压缩:­XX:+UseCompressedOops(默认开启),禁止指针压缩:­XX:­UseCompressedOops
原理

在JVM堆中,32位的对象引用(指针)占4个字节,而64位的对象引用占8个字节。也就是说,64位的对象引用大小是32位的2倍。64位JVM在支持更大堆的同时,由于对象引用变大却带来了性能问题:

  • 增加了GC开销:64位对象引用需要占用更多的堆空间,留给其他数据的空间将会减少,从而加快了GC的发生,更频繁的进行GC。
  • 降低CPU缓存命中率:64位对象引用增大了,CPU能缓存的oop将会更少,从而降低了CPU缓存的效率。

同时,在64位操作系统中,寻址空间是2的64次方,这个空间近似于无限大了,目前主流GC 处理32G已经是极限了。 CPU吞吐有限。因此,实际上如果指针用64位来寻址的话,很多高位其实是用不上的,导致占用了很多无效空间,加剧了上述的性能问题。因此,在64位操作系统中,也希望保留32位寻址的性能。

但是32位指针只能表示2^32个内存地址,由于CPU寻址的最小单位是Byte,所以能寻址的大小就是4G,对于64位操作系统来说是远远不够的。因此,需要考虑的问题就是,如果在保留32位寻址性能的基础上,提升寻址的大小。

解决方法

上面我们在讲对象头时说过,当对象的长度不足8字节的整数倍时,需要在对象中进行填充操作。也就是说,堆中存储的每个对象大小都是8字节的整数倍。假设,现在我们有三个对象,大小分别是

object1: 8byte
object2: 16byte
object3: 24byte

假设现在用64位来寻址,从地址0000 0000开始存储(为了书写说明方便,这里只保留了低8位)。由于内存的最小可寻址单位通常都是字节,所以每个地址都对应了一个字节的数据,所以对象Object1对应了地址0000 0000 ~ 0000 1000

以此类推,那么每个对象存储的起始地址为:

object1: 0000 0000
object2: 0000 1000
object3: 0001 1000
object4: 0011 0000
....

可以发现,由于每个对象大小都是8字节的整数倍对齐,所以地址的低3位一定会是0 (低4位是:8 4 2 1)。有了这个规律后,那么压缩的原理就显而易见了:

压缩的过程中,可以将64位地址 右移3位 (/8),去掉最后的3位,同时将最高位的29位去掉,保留剩下的32位。
解压的过程中,再左移3位,把最后的3位0补充回去进行寻址

**利用这样的压缩方式,实际的寻址位数变成了35位,所能表示的空间大小就是2^35,就是2^32 *8=32G。**相比原先的32位下的寻址变大了8倍,在保留32位寻址性能的情况下,增加了寻址空间,实现了我们最开始的需求。

这也就解释了为什么当设置的堆内存大于32G时,指针压缩会失效。

总结

  1. 当堆内存小于4G,不需要启动指针压缩,jvm会直接去除高32位地址,避免了编解码过程。
  2. 如果堆内存大小在4G~32G,则会开启指针压缩
  3. 如果堆内存大小大于32G,压缩指针会失效,使用原来的64位来进行寻址。
  • 5
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JermeryBesian

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值