在介绍对象在内存中的组成结构前,我们先简要回顾一个对象的创建过程:
1、jvm将对象所在的class
文件加载到方法区中
2、jvm读取main
方法入口,将main
方法入栈,执行创建对象代码
3、在main
方法的栈内存中分配对象的引用,在堆中分配内存放入创建的对象,并将栈中的引用指向堆中的对象
所以当对象在实例化完成之后,是被存放在堆内存中的,这里的对象由3部分组成,如下图所示:
对各个组成部分的功能简要进行说明:
对象头:
对象头存储的是对象在运行时状态的相关信息、指向该对象所属类的元数据的指针,如果对象是数组对象那么还会额外存储对象的数组长度
实例数据:实例数据存储的是对象的真正有效数据,也就是各个属性字段的值,如果在拥有父类的情况下,还会包含父类的字段。字段的存储顺序会受到数据类型长度、以及虚拟机的分配策略的影响
对齐填充字节:在java对象中,需要对齐填充字节的原因是,64位的jvm中对象的大小被要求向8字节对齐,因此当对象的长度不足8字节的整数倍时,需要在对象中进行填充操作。注意图中对齐填充部分使用了虚线,这是因为填充字节并不是固定存在的部分,这点在后面计算对象大小时具体进行说明
在具体开始研究对象的内存结构之前,先介绍一下我们要用到的工具,openjdk
官网提供了查看对象内存布局的工具jol (java object layout)
,可在maven
中引入坐标:
org.openjdk.jol jol-core 0.14
在代码中使用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
一共有64个bit,用于存储对象自身的运行时数据,标记对象处于以下5种状态中的某一种:
由上图可知,根据锁状态的不同,Mark Word中的数据表示不同的含义。
Klass Pointer
是一个指向方法区中Class
信息的指针,虚拟机通过这个指针确定该对象属于哪个类的实例。在64位的JVM中,支持指针压缩功能,根据是否开启指针压缩,Klass Pointer
占用的大小将会不同:
在jdk6
之后的版本中,指针压缩是被默认开启的,可通过启动参数开启或关闭该功能:
#开启指针压缩:
-XX:+UseCompressedOops
#关闭指针压缩:
-XX:-UseCompressedOops
简单引申一下对象的访问方式,我们创建对象的目的就是为了使用它。所以我们的Java程序在运行时会通过虚拟机栈中本地变量表的reference数据来操作堆上对象。但是reference只是JVM中规范的一个指向对象的引用,那这个引用如何去定位到具体的对象呢?因此,不同的虚拟机可以实现不同的定位方式。主要有两种:句柄池和直接指针。
通过上面的介绍我们知道,每个Java对象头都包含了锁标志位,并根据不同锁的程度,存储的指针/数据也不同。那么,为什么Java在设计的时候要把锁的信息存储在对象中呢,这样设计的好处或者作用是什么呢?接下来,针对这个问题,进行一些分析。
首先,根据上面关于不同锁的分析我们可以大致知道,从偏向锁->轻量级锁->重量级锁的升级,是由于线程间对资源的竞争导致的,资源竞争越激励,则锁的级别越高。因此,锁的作用是为了解决多线程对资源竞争,也就是线程间的同步问题的。
那么为什么锁要加在对象头上呢?
让我们从JVM的角度来看待这个问题。在Java程序运行时环境中,JVM需要对两类线程共享的数据进行协调:
这两类数据是被所有线程共享的。每个线程在创建时,JVM都会为其创建一个工作内存,工作内存是每个线程的私有数据区域。而JAVA内存模型中规定,所有变量都存储在主内存中(堆中),主内存是共享内存区域,所有线程都可以访问。
因此,在不考虑线程同步时,每个线程都会自己的一块工作内存,保存了一份主内存中变量的副本进行操作,操作完成后,再将变量写回到主内存。不同的线程之间无法直接访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
而JVM堆中存的是我们new出来的实例对象本身,当不考虑线程间同步问题时,这些实例对象是被不同线程共享的。但是当考虑线程间同步问题时,就需要对上述两类线程间共享的数据进行协调,而需要协调的目标正是这些实例对象本身。
也就是说,为了让线程间同步的获取这些实例对象,需要让线程在获取对象时互斥,这就是锁机制。同时,由于竞争的资源对象就是这些实例对象本身,就可以将锁加在这些实例对象上。这样,无论有多少个线程在竞争资源,由于在该对象中标识了所属的线程,那么得到该对象锁的线程就运行,其他线程则等待,从而实现了线程同步的机制。
其实总结起来就是说,因为线程同步时,线程间竞争的是JVM堆中的实例对象资源,所以自然这个锁就加在了对象上。
启用指针压缩:XX:+UseCompressedOops(默认开启),禁止指针压缩:XX:UseCompressedOops
在JVM堆中,32位的对象引用(指针)占4个字节,而64位的对象引用占8个字节。也就是说,64位的对象引用大小是32位的2倍。64位JVM在支持更大堆的同时,由于对象引用变大却带来了性能问题:
同时,在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时,指针压缩会失效。
总结