详解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
:占用内存大小,单位为字节TYPE
:Class
中定义的类型DESCRIPTION
:类型描述,Obejct header
表示对象头,alignment
表示对齐填充VALUE
:对应内存中存储的值
当前对象共占用13字节,8字节的对象头+1字节的boolean类型变量,因为要满足8字节的整数倍,所以需要额外的3字节来对其进行扩充,所以实际存储的时候仍然按照16字节存储。
通过打印出来的信息,可以看到我们使用的是64位 jvm,并开启了指针压缩,对象默认使用8字节对齐方式。通过jol
查看对象内存布局的方法,将在后面的例子中具体展示,下面开始对象内存布局的正式学习。
对象头
首先看一下对象头(Object header
)的组成部分,根据普通对象和数组对象的不同,结构将会有所不同。只有当对象是数组对象才会有数组长度部分,普通对象没有该部分,如下图所示:
在对象头中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需要对两类线程共享的数据进行协调:
- 保存在堆中的实例变量
- 保存在方法区的类变量
这两类数据是被所有线程共享的。每个线程在创建时,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时,指针压缩会失效。
总结
- 当堆内存小于4G,不需要启动指针压缩,jvm会直接去除高32位地址,避免了编解码过程。
- 如果堆内存大小在4G~32G,则会开启指针压缩
- 如果堆内存大小大于32G,压缩指针会失效,使用原来的64位来进行寻址。