高频八股:new 一个对象在堆中的历程

飞天小牛肉

共 4200字,需浏览 9分钟

 ·

2022-02-15 01:52

小伙伴们大家好呀,我是小牛肉~ 我写文章的流程一般都是先在看书和看博客的过程中做做笔记,然后过一段时间再把这些笔记总结成文章输出出来,这样一来能够加深影响,二来也不至于文章的质量太低。从这篇文章的草稿笔记到现在决定开始成文,其实已经有一个月了,本来觉得趁着寒假可以顺理成章地脱离恶心的深度学习然后好好地把 JVM 知识点全都扫一遍,正好囤几篇文章,谁知道回家后根本无心看书,只能每天刷几道 LeetCode 来弥补下日积月累的焦虑和罪恶感。

STOP,废话结束

今天介绍两个 JVM 中的高频基础题:

  1. 对象的创建过程(new 一个对象在堆中的历程)
  2. 对象在堆上分配的两种方式

对象的创建过程分五步走,如下图:

我感觉 JVM 如果不看 GC 收集器那块(滑稽),似乎东西还不多

老规矩,背诵版在文末。点击阅读原文可以直达我收录整理的各大厂面试真题

类加载检查

对象创建过程的第一步,所谓类加载检查,就是检测我们接下来要 new 出来的这个对象所属的类是否已经被 JVM 成功加载、解析和初始化过了(具体的类加载过程会在后续文章详细解释~)

具体来说,当 Java 虚拟机遇到一条字节码 new 指令时:

1)首先检查根据 class 文件中的常量池表(Constant Pool Table)能否找到这个类对应的符号引用

此处可以回顾一波常量池表 (Constant Pool Table) 的概念:

用于存放编译期生成的各种字面量(字面量相当于 Java 语言层面常量的概念,如文本字符串,声明为 final 的常量值等)与符号引用。有一些文章会把 class 常量池表称为静态常量池

都是常量池,常量池表和方法区中的运行时常量池有啥关系吗?运行时常量池是干嘛的呢?

运行时常量池可以在运行期间将 class 常量池表中的符号引用解析为直接引用。简单来说,class 常量池表就相当于一堆索引,运行时常量池根据这些索引来查找对应方法或字段所属的类型信息和名称及描述符信息

2)然后去方法区中的运行时常量池中查找该符号引用所指向的类是否已被 JVM 加载、解析和初始化过

  • 如果没有,那就先执行相应的类加载过程
  • 如果有,那么进入下一步,为新生对象分配内存

分配内存

类加载检查通过后,这个对象待会儿要是被创建出来得有地方放他对吧?

所以接下来 JVM 会为新生对象分配内存空间。

至于 JVM 怎么知道这个空间得分配多大呢?事实上,对象所需内存的大小在类加载完成后就已经可以完全确定了。在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

1)Hotspot 虚拟机的对象头包括两部分信息:

  • 第一部分用于存储对象自身的运行时数据(如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特,官方称它为 “Mark Word”。学过 synchronized 的小伙伴对这个一定不陌生~)
  • 另一部分是类型指针,即对象指向它的类型元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

2)实例数据部分存储的是这个对象真正的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

3)对齐填充部分不是必须的,也没有什么特别的含义,仅仅起占位作用。因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全

对象在堆上的两种分配方式

为对象分配内存空间的任务通俗来说把一块确定大小的内存块从 Java 堆中划分出来给这个对象用

根据堆中的内存是否规整,有两种划分方式,或者说对象在堆上的分配有两种方式:

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

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

选择哪种分配方式由 Java 堆是否规整决定,那又有同学会问了,堆是否规整又由谁来决定呢?

Java 堆是否规整由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定的(或者说由垃圾收集器采用的垃圾收集算法来决定的,具体垃圾收集算法见后续文章):

  • 因此,当使用 Serial、ParNew 等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效
  • 而当使用 CMS 这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存

对象创建时候的并发安全问题

另外,在为对象创建内存的时候,还需要考虑一个问题:并发安全问题

对象创建在虚拟机中是非常频繁的行为,以上面介绍的指针碰撞法为例,即使只修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现某个线程正在给对象 A 分配内存,指针还没来得及修改,另一个线程创建了对象 B 又同时使用了原来的指针来分配内存的情况。

解决这个问题有两种可选方案:

  • 方案 1:CAS + 失败重试:CAS 大伙应该都熟悉,比较并交换,乐观锁方案,如果失败就重试,直到成功为止
  • 方案 2:本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):每个线程在堆中预先分配一小块内存,每个线程拥有的这一小块内存就称为 TLAB。哪个线程要分配内存了,就在哪个线程的 TLAB 中进行分配,这样各个线程之间互不干扰。如果某个线程的 TLAB 用完了,那么虚拟机就需要为它分配新的 TLAB,这时才需要进行同步锁定。可以通过 -XX:+/-UseTLAB 参数来设定是否使用 TLAB。

初始化零值

内存分配完成之后,JVM 会将分配到的内存空间(当然不包括对象头啦)都初始化为零值,比如 boolean 字段都初始化为 false 啊,int 字段都初始化为 0 啊之类的

这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

如果使用了 TLAB 的话,初始化零值这项工作可以提前至 TLAB 分配时就顺便进行了

设置对象头

上面我们说过,对象在内存中的布局可以分为 3 块区域:对象头(Object Header)、实例数据和对齐填充

对齐填充并不是什么有意义的数据,实例数据我们在上一步操作中进行了初始化零值,那么对于剩下的对象头中的信息来说,自然不必多说,也是要进行一些赋值操作的:例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

执行 init 方法

上面四个步骤都走完之后,从 JVM 的视角来看,其实一个新的对象已经成功诞生了。

但是从我们程序员的视角来看,这个对象确实是创建出来了,但是还没按照我们定义的构造函数来进行赋值呢,所有的字段都还是默认的零值啊。

构造函数即 Class 文件中的 () 方法,一般来说,new 指令之后会接着执行 () 方法,按照构造函数的意图对这个对象进行初始化,这样一个真正可用的对象才算完全地被构造出来了,皆大欢喜。


最后放上这道题的背诵版:

🥸 面试官:讲一下对象的创建过程

😎 小牛肉:new 一个对象在堆中的过程主要分为五个步骤:

1)类加载检查:具体来说,当 Java 虚拟机遇到一条字节码 new 指令时,它会首先检查根据 class 文件中的常量池表(Constant Pool Table)能否找到这个类对应的符号引用,然后去方法区中的运行时常量池中查找该符号引用所指向的类是否已被 JVM 加载、解析和初始化过

  • 如果没有,那就先执行相应的类加载过程
  • 如果有,那么进入下一步,为新生对象分配内存

2)分配内存:就是在堆中给划分一块内存空间分配给这个新生对象用。具体的分配方式根据堆内存是否规整有两种方式:

  • 堆内存规整的话采用的分配方式就是指针碰撞:所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,分配内存就是把这个指针向空闲空间方向挪动一段与对象大小相等的距离
  • 堆内存不规整的话采用的分配方式就是空闲列表:所谓内存不规整就是已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,JVM 就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的连续空间划分给这个对象,并更新列表上的记录,这就是空闲列表的方式

3)初始化零值:对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充,对齐填充仅仅起占位作用,没啥特殊意义,初始化零值这个操作就是初始化实例数据这个部分,比如 boolean 字段初始化为 false 之类的

4)设置对象头:这个步骤就是设置对象头中的一些信息

5)执行 init 方法:最后就是执行构造函数,构造函数即 Class 文件中的 () 方法,一般来说,new 指令之后会接着执行 () 方法,按照构造函数的意图对这个对象进行初始化,这样一个真正可用的对象才算完全地被构造出来了



心之所向,素履以往,我是小牛肉,小伙伴们下篇文章再见 👋

浏览 21
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报