详解JVM中的Java对象

对象的创建

虚拟机遇到new时先检查此指令的参数是否能在常量池中找到类的符号引用,并检查符号引用代表的类是否被加载、解析、初始化,若没有则先进行类加载。如果没有加载,需要先进行类加载。

第一步:对象内存的分配

类加载检查通过后,虚拟机为新生对象分配内存,对象所需内存大小在类加载完成后便可完全确定。分配内存的任务等同于从堆中分出一块确定大小的内存。根据Java堆是否规整,分配内存的方式分为如下两种:

指针碰撞(Bump the Pointer)

如果Java堆是规整的,用一个指针隔开用过的内存和没用的内存,分配内存时只要移动下这个指针到内存大小的位置就行了,这种分配方式成为指针碰撞。
指针碰撞

空闲列表(Free List)

如果Java堆是不规整的,需要用一个列表记录那些内存被用了、哪些没有用过,分配内存时需要更新列表,这种分配方式称为空闲列表。
![空闲列表](https://img-blog.csdnimg.cn/20201123161336869.png#pic_center
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact(将用过的,没用的内存标记整理到两边)过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep(标记清除,标记要回收的对象,然后将其回收)算法的收集器时,通常采用空闲列表。

内存分配的线程安全问题

并发情况下分配内存时会有线程安全问题,解决方式有两种:

  • 第一种就是同步,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
  • 第二种是为每个线程在堆上都预先分配一小块内存空间,成为本地线程缓存(Thread Local Allocation Buffer,及TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

第二步:对象属性的初始化

内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作额可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的字段的数据类型所对应的零值。
接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才会计算)、对象的GC分代年龄等信息。

第三步:对象构造函数的执行

在上面工作都完成之后,从虚拟机的视角来看,一个新对象已经产生了。但是从Java代码执行角度来看,对象的创建才刚刚开始——构造函数(Class文件中的init()方法)还没有执行,所有字段都为默认的零值,对象并没有按业务需求对字段填充。真正执行了这一步之后,整个对象的创建才算真正的完成。
在这里插入图片描述

对象的内存布局

对象头

MarkWord

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(也称为"MarkWord"),如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit。对象需要存储的运行时数据很多,已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自己定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据集结构,以便在极小的空间内存储尽量多的数据,根据对象的状态服用自己的存储空间。MarkWord被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的Hotspot虚拟机中,如果对象处于未被锁定的状态下,那么MarkWord的32bit空间中的25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下所示:
对象的存储内容

类型指针

对象头的另外一部分是类型指针(Class Pointer),即对象指向它的类元信息(方法区)的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。在32位系统占4字节,在64位系统中占8字节。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,这部分占用4个字节。

实例数据

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java代码中的定义顺序有关。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
在这里插入图片描述

对齐填充

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。对齐填充存在的意义就是为了提高CPU访问数据的效率,这是一种以空间换时间的做法。
我们知道64位系统CPU每次能处理8字节的数且只能以
0x00000000 - 0x00000007,0x00000008-0x0000000f这样访问内存地址,不能0x00000002 - 0x00000009这样访问(为什么?因为硬件不允许,否则就不需要对齐填充了)。
那么如果没有对齐填充就可能会存在数据跨内存地址区域存储的情况。

举个栗子:

比如现在有4个数据,boolean,int,char,long,内存起始地址为0x00(简写)。

在没有对齐填充的情况下,内存地址存放情况如下:

在这里插入图片描述

因为处理器只能0x00-0x07,0x08-0x0F这样读取数据,所以当我们想获取这个long型的数据时,处理器必须要读两次内存,第一次(0x00-0x07),第二次(0x08-0x0F),然后将两次的结果才能获得真正的数值。

那么在有对齐填充的情况下,内存地址存放情况是这样的:
在这里插入图片描述

现在处理器只需要直接一次读取(0x08-0x0F)的内存地址就可以获得我们想要的数据了。

对齐填充存在的意义就是为了提高CPU访问数据的效率,这是一种以空间换时间的做法;正如我们所见,虽然访问效率提高了(减少了内存访问次数),但是在0x07处产生了1bit的空间浪费。

那么如何才能不浪费掉这1字节的地址空间呢?

试想一下,如果此时又有一个1字节的数据存到内存中,是不是可以把这1字节的数据直接插到0x07的地方,这样就不需要填充也能实现8字节对齐了呢?没错,JVM就是这样做的,因此JVM在为对象分配内存时,对象中的实例数据的排序规则并不是完全按照我们在类中的所定义的顺序来排序的(首先按照大小排序)。

压缩指针

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

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

  • 为了能够保持32位的性能,oop必须保留32位。那么,如何用32位oop来引用更大的堆内存呢?答案是——压缩指针(CompressedOops)。
    32位内最多可以表示4GB,64位地址分为堆的基地址+偏移量,当堆内存<32GB时候,在压缩过程中,把偏移量/8后保存到32位地址。在解压再把32位地址放大8倍,所以启用CompressedOops的条件是堆内存要在4GB*8=32GB以内。

  • 所以压缩指针之所以能改善性能,是因为它通过对齐(Alignment),还有偏移量(Offset)将64位指针压缩成32位。换言之,性能提高是因为使用了更小更节省空间的压缩指针而不是完整长度的64位指针,CPU缓存使用率得到改善,应用程序也能执行得更快。

  • 为了解决64位JVM占用内存过多的情况,从JDK 1.6 update14开始,64位的JVM正式支持了 -XX:+UseCompressedOops 这个可以压缩指针、节约内存占用的新参数。64位JVM才支持压缩指针,压缩指针对应虚拟机选项 -XX:+UseCompressedOops,在JDK1.7、JDK1.8中都是默认开启的。64位JVM开启指针压缩的情况下,存放类型指针(Class Pointer)的空间大小是4字节,MarkWord是8字节,对象头为12字节(前提是堆内存不大于32G),当堆内存大于32G时对象头占16个字节。

对象的访问定位

Java程序会通过栈上的reference(栈帧的局部变量表)数据来操作堆上的及具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个对象的引用,并没有定义这个引用应该通过什么方式定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流有使用句柄和直接指针两种方式:

  • 使用句柄访问的话,Java堆中划出一块内存存储句柄池,引用中存储的是句柄的地址,而句柄中存储了到对象实例数据和到对象类型数据的指针。其好处是稳定,因为reference中存储的是句柄的地址,所以当对象移动(因为垃圾回收等)时只需要修改句柄中到对象实例的指针即可,reference并不需要变动,缺点是需要多一次寻址。
    在这里插入图片描述
  • 使用直接指针的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据相关信息,reference中存储的就是对象的地址,此时对象中就要存储到对象类型数据的指针了。这种方式的好处就是访问对象比较快。
    在这里插入图片描述

文章参考:
《深入理解Java虚拟机第三版》、Java对象的创建、内存布局及访问定位JVM中的对象探秘(三)- 对象的实例数据与对齐填充

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值