一篇认清HotSpot虚拟机对象创建、内存布局及访问定位

「这是我参与2022首次更文挑战的第14天,活动详情查看:2022首次更文挑战


  • j3_liuliang
  • 老是调侃没有对象就自己new一个对象呗!以前我是经常这样干,不过new对象供我们使用(不要想歪了)相对于我们来说很简单,但你有没有想过你使用的对象(Object)是如何被创建出来的,今天这篇我们就来讨论一个对象是如何被虚拟机创建并供我们使用的;

在上篇我们认清了Java虚拟机的运行时数据区(没看过的可以点这里)知道了什么地方放什么类型的数据,那相信小伙伴因该是知道我们经常用的对象几乎都是在堆中创建并分配内存的(本人用了“几乎”一词,不考虑逃逸分析标量替换的情况),别高兴得太早哦!我也相信你们肯定不知道其中的细节(微微得意的表情),如:虚拟机如何知道要创见对象、如何知道创建那个对象、如何分配对象内存等问题;

那,你们耐心的听我往下絮叨絮叨!

一、对象创建

1.1 图解对象创建流程

在这里插入图片描述

大致看一下这个图,下面将具体的介绍着五部分内容

1.2 类加载检查

前面提到过,虚拟机如何知道程序要创建对象的,当然是我们new的时候拉!

当虚拟机遇到new指令的时候它就知道这个小伙子是要我给他创建对象呀!那它也不是会完全听这个小伙子的(要验证真身),它首先是要检验这个小伙子是否是我这办卡会员(检查该指令的参数是否能够在常量池中定位到这个类的符号引用,并且检查该符号引用代表的类是否已经被加载过、解析和初始化过)如果不认识,那不好意思先去办张卡(就是执行对应的类加载过程)再过来找我创建对象;

在这里插入图片描述

1.3 分配内存

既然小伙子在我这办卡了(类加载检查通过),那就开始后面的流程吧!——分配内存

分配空间内存的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。

小伙子我要说明一点,我这里可是正规的创建对象中心,你这里办的卡才88元的,那我只能给你对接相应的服务了——分配内存大小在加载(办卡)的时候就可以确定(88元)

既然是服务,那肯定是有多种了,在虚拟机中分配内存有两种方式:

  1. 指针碰撞

    假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。

  2. 空闲列表

    如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

这可麻烦了,到底是要给这小子提供那种服务呢!

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。

提一下垃圾收集算法(四种)后续文章会细讲,现在只要知道一下就行:

  1. 复制算法
  2. 标记-清楚
  3. 标记-压缩
  4. 分代收集

当使用带压缩整理过程的垃圾回收算法(Serial、ParNew垃圾收集器)时,系统采用的分配算法是指针碰撞,既简单又高效;反之(CMS垃圾收集器)就只能采用较为复杂的空闲列表来分配内存。

1.3.1 内存分配时候出现的并发问题

好了,现在开始要给这个小伙子使用某种方式进行创建对象了,但问题又来了?

要是在给这个小伙子创建对象的时候,有人中间插一杠横刀夺爱,那个咋整!

虚拟机创建对象的过程不是一个原子操作,而是多步操作在并发的时候是不安全的

好歹我(虚拟机)也是干这一行的,没点能耐那以后还怎么混;

虚拟机采用两种方式来保证线程安全:

  1. CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是 设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

  2. TLAB: 为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。

    关于TLAB

    TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。相当于线程的私有对象。

    1. 堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的
    2. Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer), 其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配, 在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配
    3. TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效

    如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

    TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

    TLAB的本质其实是三个指针管理的区域**:start,top 和 end,**每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。

    TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。从这一点看,它被翻译为线程私有分配区更为合理一点

    当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。

1.4 初始化零值

既然造出来了对象,那是不是要给这个对象取个名字,包装包装呢!我(虚拟机)这可是一条龙服务;

没错,内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

1.5 设置对象头

到这里,对象的创建已经差不多要结束了,但奈何虚拟机是一个追求完美的人(意会就行),所以接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息;

这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。关于对象头的具体内容,稍后会详细介绍。

1.6 执行init方法

现在一个对象可以说是造好了对于我(虚拟机)来说,但小伙子(Java程序员)不认账呀!你这才刚开始一样,我的一些要求还没有完成能呢!——不怪小伙子要求高,确实还有后续工作;

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。

一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

在这里插入图片描述

二、对象的内存布局

(画图能手,看图就行)

在这里插入图片描述

首先我们要知道的是,对象在内存中的布局分为三块区域:

  • 对象头
  • 实例数据
  • 对齐填充

接下来我们具体分析这三个区域的都存放对象的那些数据

2.1 对象头

在HotSpot虚拟机对象的对象头(Mark Word)部分包括两类其实有三类(数组对象)信息

  1. 一类是用于存储对象自身的运行时数据
  2. 一类是类型指针
  3. 一类是数组长度(数组特有)

2.1.1 对象自身的运行时数据

对象头的运行时数据区用于存储如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等信息。这部分数据的长度在32位和64位虚拟机(默认开启指针压缩)中,分别为4个字节(32bit)和8个字节(64bit)

对象头是一个有动态定义的数据结构。这样可以在极小的空间内存存储尽量多的数据,根据对象的状态复用存储空间,来节省存储成本和虚拟机的空间效率。

在不同的状态下对象头中所存储的内容会有所不同,具体情况可以看下表所示:

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 无锁状态
指向栈中锁记录的指针 00 轻量级锁
指向互斥量(重量级锁)的指针 10 重量级锁
空,不记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 偏向锁,是否偏向锁为1

例:

在64位的HotSpot虚拟机中,如果对象处于无锁状态下时,对象头的64个字节中的26个bit处于空闲状态,HashCode占了31个bit,4个bit用来描述分代年龄,1个bit固定为0表示不是偏向锁,2个bit用于存储锁标志位。

下面是32和64操作系统的对象头结构图:

在这里插入图片描述

解释几个名词:

HashCode:类似于对象的ID,通过Hash算法生成,常用equals()比较对象是否相等;

分代年龄:是指该对象经历了多少次垃圾回收,默认情况下,一个对象在新生代中经历15次垃圾回收(分代年龄>15),仍然存活的话,便会进入老年代

锁标志位:是JVM用来识别该对象是否被上锁,以及锁的级别(JVM根据锁膨胀过程会有偏向锁,轻量级锁和重量级锁三个等级);

2.1.2 类型指针

对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例;

并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,这点我们会在对象的访问定位具体讨论;

类指针占用内存大小: -XX:+UseComparessedClassPointers 开启的话是4个字节,不开启则为8个字节

2.1.3 数组长度

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

2.2 实例数据

接下来实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响;

HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs也可以理解为Reference),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空 隙之中,以节省出一点点空间;

在父类中定义的变量会出现在子类前,但是我们可以通过将CompactFileds参数设置为true,将子类中较小的变量插入到父类大变量的空隙中。

在这里插入图片描述

2.3 对齐填充

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍;

对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

CPU在进行内存访问时,一次寻址的指针大小是8字节

对齐填充存在的意义就是为了提高CPU访问数据的效率,这是一种以空间换时间的做法;它虽然访问效率提高了(减少了内存访问次数),但是会产生空间浪费。

三、对象的访问定位

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference (引用)数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:

  1. 句柄: 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

    对象句柄包含:

    • 实例数据地址信息
    • 对象类型数据地址信息

    在这里插入图片描述

  2. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址。

    在这里插入图片描述

这两种对象访问方式各有优势:

  • 使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
  • 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

结束语

  • 由于博主才疏学浅,难免会有纰漏,假如你发现了错误或偏见的地方,还望留言给我指出来,我会对其加以修正。
  • 如果你觉得文章还不错,你的转发、分享、点赞、留言就是对我最大的鼓励。
  • 感谢您的阅读,十分欢迎并感谢您的关注。

在这里插入图片描述

Guess you like

Origin juejin.im/post/7060441160812724255