【2021-07-07】请尽可能详细地描述当我们 new 一个对象时,发生了什么?

未匹配的标注

请移步至::octocat:每日一题 查看更多的题目 ~

答:

当 JVM 遇到一条 new 指令时,首先会去检查该指令的参数是否能在常量池中定位到一个类的符号引用(Symbolic Reference),并检查这个符号引用代表的类是否已经被加载,解析,初始化过,即验证是否是第一次使用该类。如果该类是第一次被使用,那么就会执行类的加载过程

注:符号引用是指,一个类中引入了其他的类,可是 JVM 并不知道引入其他类在什么位置,所以就用唯一的符号来代替,等到类加载器去解析时,就会使用符号引用找到引用类的具体地址,这个地址就是直接引用

第一步:类加载

类从被加载到 JVM 到卸载出内存,整个生命周期如图所示:

【2021-07-07】请尽可能详细地描述当我们 new 一个对象时,发生了什么?
加载 -> 连接(验证 -> 准备 -> 解析) -> 初始化 ->使用 -> 卸载

各个阶段的主要功能为:

  • 加载:查找并加载类文件的二进制数据

  • 连接:将已经读入内存的类的二进制数据合并到 JVM 运行时环境中去,包含如下几个步骤:

    • 验证:确保被加载类的正确性

    • 准备:为类的静态变量分配内存,赋默认值;例如:public static int a = 1; 在准备阶段对静态变量 a 赋默认值 0

    • 解析:把常量池中的符号引用转换成直接引用

  • 初始化:为类的静态变量赋初始值,这个时候才对静态变量 a 赋初始值 1

我们可以看到,类的静态成员在类加载过程中就已经被加载到内存中了!

那么类是如何被加载的呢?答案是:类加载器

类加载器

Java 虚拟机自带的加载器包括如下几种:( JDK9 开始)

  • 启动类加载器(BootstrapClassLoader)

  • 平台类加载器(PlatformClassLoader)

  • 应用程序类加载器(AppClassLoader)

JDK8 虚拟机自带的加载器:

  • BootstrapClassLoader

  • ExtensionClassLoader

  • AppClassLoader

除了虚拟机自带的类加载器外,用户也可以自定义类加载器。

类加载器之间的层级关系:

  • UserClassLoader (用户自定义类加载器)的父级为 AppClassLoader

  • AppClassLoader 的父级为 PlatformClassLoader

  • PlatformClassLoader 的父级为 BootstrapClassLoader

关系图如下所示:

【2021-07-07】请尽可能详细地描述当我们 new 一个对象时,发生了什么?

双亲委派模型

JVM 中的 ClassLoader 采用双亲委派模型的方式加载一个类:

那么什么是双亲委派模型呢?

双亲委托模型就是:如果一个类加载器(ClassLoader)收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载。

使用双亲委托机制的好处是:能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。

在类加载完成后,JVM 就可以完全确定 new 出来的对象的内存大小了,接下来,JVM 会执行为该对象分配内存的工作。

第二步: 为对象分配内存空间

为对象分配空间的任务等同于把一块确定大小的内存从 JVM 堆中划分出来,目前常用的有两种方式(根据使用的垃圾收集器的不同而使用不同的分配机制):

  • Bump the Pointer(指针碰撞)
  • Free List(空闲列表)
指针碰撞

所谓的指针碰撞是指:假设 JVM 堆内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一半,中间有一个指针指向分界点,那新的对象分配的内存就是把那个指针向空闲空间挪动一段与对象大小相等的距离。

【2021-07-07】请尽可能详细地描述当我们 new 一个对象时,发生了什么?

空闲列表

如果 JVM 堆内存并不是规整的,即:已用内存空间与空闲内存相互交错,JVM 会维护一个空闲列表,记录那些内存块是可用的,在为该对象分配空间时,JVM 会从空闲列表中找到一块足够大的空间划分给对象使用。

【2021-07-07】请尽可能详细地描述当我们 new 一个对象时,发生了什么?

第三步:完善对象内存布局的信息

在我们为对象分配好内存空间后,JVM 会设置对象的内存布局的一些信息。

对象在内存中存储的布局(以 HotSpot虚拟机为例)分为:对象头,实例数据以及对齐填充。

  • 对象头
    对象头包含两个部分:
    • Mark Word:存储对象自身的运行数据,如:Hash Code,GC 分代年龄,锁状态标志等等
    • 类型指针:对象指向它的类的元数据的指针
  • 实例数据
    • 实例数据是真正存放对象实例的地方
  • 对齐填充
    • 这部分不一定存在,也没有什么特别含义,仅仅是占位符。因为HotSpot要求对象起始地址都是8字节的整数倍,如果不是就对齐

JVM 会为所有实例数据赋零值(默认值),即:将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值,例如整型的默认值为 0,引用类型的默认值为 null 等等。

并且,JVM 会为对象头进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的 Hash Code,对象的 GC 分带年龄等等,这些信息都存放在对象的对象头中。

第四步:调用对象的实例化方法 <init>

在 JVM 完善好对象内存布局的信息后,会调用对象的 <init> 方法,根据传入的属性值为对象的变量赋值。

我们在上文介绍了类加载的过程(加载 -> 连接 -> 初始化),在初始化这一步骤,JVM 为类的静态变量显示赋值,并且执行了静态代码块。实际上这一步骤是由 JVM 生成的 <clinit> 方法完成的。

<clinit> 的执行的顺序为:

  1. 父类静态变量初始化
  2. 父类静态代码块
  3. 子类静态变量初始化
  4. 子类静态代码块

而我们在创建实例 new 一个对象时,会调用该对象类构造器进行初始化,这里面就会执行 <init> 方法。

<init>的执行顺序为:

  1. 父类变量初始化
  2. 父类普通代码块
  3. 父类构造函数
  4. 子类变量初始化
  5. 子类普通代码块
  6. 子类构造函数

关于<init> 方法:

  1. 有多少个构造器就会有多少个 <init> 方法
  2. <init> 具体执行的内容包括非静态变量的赋值操作,非静态代码块的执行,与构造器的代码
  3. 非静态代码赋值操作与非静态代码块的执行是从上至下顺序执行,构造器在最后执行

关于 <clinit><init> 方法的差异:

  1. <clinit>方法在类加载的初始化步骤执行,<init> 在进行实例初始化时执行
  2. <clinit> 执行静态变量的赋值与执行静态代码块,而<init> 执行非静态变量的赋值与执行非静态代码块以及构造器
第五步:在栈中新建对象的引用,并指向堆中新建的对象实例

这一点没什么好解释的,我们是通过操作栈的引用来操作一个对象的。

拓展:对象的访问定位

在描述完 new 一个对象后,我们再来简单看一下如何去访问这个对象。

在 JVM 规范中只规定了 reference 类型是一个指向对象的引用,但没有规定这个引用具体如何去定位,访问堆中对象,因此对象的访问取决于 JVM 的具体实现,目前主流的访问对象的方式有两种:句柄间接访问直接指针访问

句柄

JVM 堆中会划分一块内存来作为句柄池,reference 中存储句柄的地址,句柄中则存储对象的实例数据何类的元数据的地址:

【2021-07-07】请尽可能详细地描述当我们 new 一个对象时,发生了什么?

直接指针

直接指针的方式为:JVM 堆中会存放访问访问类的元数据的地址,reference存储的是对象实例的地址:

【2021-07-07】请尽可能详细地描述当我们 new 一个对象时,发生了什么?

通过句柄访问对象是一种间接引用(2次引用)的方式来进行访问堆内存的对象,它导致的缺点是运行的速度稍微慢一些;通过指针的方式则速度快一些,因为它少了一次指针定位的开销,所以,当前最主流的 JVM: HotSpot 采用的就是直接指针的方式。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~