作为一名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
:占用内存大小,单位为字节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 锁降级
在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
类型的字段,再查看内存布局:
char
和byte
类型的变量被提到后面进行前置补位,并在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
如果文章对您有所帮忙,欢送关注公众号 码农参上