Java对象的创建、内存布局及访问定位

对象的创建

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

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

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

  • 空闲列表(Free List):如果Java堆是不规整的,需要用一个列表记录那些内存被用了、哪些没有用过,分配内存时需要更新列表,这种分配方式称为空闲列表。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact(将用过的,没用的内存标记整理到两边)过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep(标记清除,标记要回收的对象,然后将其回收)算法的收集器时,通常采用空闲列表。

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

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

内存分配完成后,虚拟机要对分配的内存空间都初始化为零值。例如我们在类中定义的成员变量,int,boolean不赋值默认初始化为0和false。然后需要设置对象的头信息等,比如对象是哪个类的实例、如何找到类的元数据信息等。最后会执行init操作,对变量按照程序员的意愿进行初始化。

对象的内存布局

关于对象的内存布局,可以先看下面这张图,图中已经画的很清楚了,可以看到内存中存储的区域可以分为三部分:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(也称为"MarkWord"),如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit。MarkWord被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的Hotspot虚拟机中,如果对象处于未被锁定的状态下,那么MarkWord的32bit空间中的25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下所示:

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

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

原生数据类型(primitive type)的内存占用如下:

reference类型在32位系统上每个占用4bytes, 在64位系统上每个占用8bytes。

64位机器上reference类型占用8个字节,开启指针压缩后b2b占用4个字节。

static class B2 {
    Integer b2b;
}

关于Java对象推荐一篇文章《一个Java对象到底占用多大内存?》

第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。对齐的原因是为了能够更好的利用cpu高速缓存(L1 cache, L2 cache),这些缓存都是64字节一行。

压缩指针

理论上说上 32 位的 JVM 堆内存可以到达 2^32,即 4GB,实际上还受OS允许进程最大内存的限制,比如说swap空间、内核空间占用、内存碎片等等,实际上JVM可利用的内存要远小于4G(一般来说Windows系统下为1.5G-2G,Linux系统下为2G-3G)。如果是64位JVM,寻址空间最大是2^64,那就很大了。

32位JVM下的对象头占8个字节,64位JVM下对象头占16个字节,64位JVM对象头要比32位占用更多的内存,所以64位JVM要比32位性能差一些。

为了解决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个字节。

64位机器上reference类型占用8个字节,开启指针压缩后占用4个字节。

-XX:+UseCompressedOops                默认开启的压缩所有指针
-XX:+UseCompressedClassPointers  默认开启的压缩对象头里的类型指针Klass Pointer

对象的访问定位

通过前面我们对虚拟机栈的栈帧的介绍得知,局部变量表中存储了对象的reference,那通过引用如何找到对象呢?主要有使用句柄和直接指针两种方式:

使用句柄访问的话,Java堆中划出一块内存存储句柄池,引用中存储的是句柄的地址,而句柄中存储了到对象实例数据和到对象类型数据的指针。其好处是稳定,因为reference中存储的是句柄的地址,所以当对象移动(因为垃圾回收等)时只需要修改句柄中到对象实例的指针即可,reference并不需要变动,缺点是需要多一次寻址。

使用直接指针的话,reference中存储的就是对象的地址,此时对象中就要存储到对象类型数据的指针了。这种方式的好处就是访问对象比较快。 

参考:《深入理解Java虚拟机第二版》 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值