面试官问我 Java 对象创建的过程 ...

73 阅读6分钟

创建对象的过程

Java 对象创建过程

说之前先捋清一个大致的思路:创建对象的过程大致分为 5 步:

  • Step1:类加载检查
  • Step2:分配内存
  • Step3:初始零值
  • Step4:设置对象头
  • Step5:执行 init

Step1:类加载检查

当我们在 Java 程序中 new 一个对象的时候,在底层其实会有大概以下几步:

  • 首先它会检查这个指令是否能在常量池中能否定位到一个类的符号引用
  • 接着会检查这个符号引用代表的类是否已经被加载、解析、初始化。如果没有会进行一个类加载

检查完类加载后就是分配内存了。(这里有人可能会问那该对象的具体内存是否确认呢?其实类加载完成后可以确认它所需要的内存了)

Step2:分配内存

现在我们已经知道了对象所占的内存,那么虚拟机是如何给对象在 Java 堆中分配内存的呢?主要有两种分配方式:

  1. 指针碰撞
  2. 空闲列表

接下来我们详细说说这两种分配内存的方式:

指针碰撞

其实这种方式理解起来比较简单的,假设 Java 堆中的内存是绝对完整的,它会把使用过的内存和未使用过的内存划分开来。

此时一边就是使用过的内存,一边就是未使用过的内存;那么他如何去给一个新的对象去划分空闲内存中的某块区域呢?

其实很简单,就是借助一个指针(这里是不是呼应上了所谓的指针碰撞);当我们分配内存的时候就是把指针在空闲的内存区域中移动一个与要被创建对象大小相等的距离。这就是指针碰撞的方式

如下图所示, 假设内存空间每一块是蓝色区域, 那么目前指针指向的就是下一个空的内存区域, 现在假设要分配一个对象, 需要两块内存分片的大小, 那么就只需要简单的将指针往下移动 2 格就可以, 如图的黄色区域就是非对象分配的内存

指针碰撞法, 分配内存示例

适用场景:内存规整,不碎片化

空闲列表

这个其实理解起来更为简单。它无非就是指在 Java 堆中的内存并非是规整的(使用的内存和未使用过的内存没有划分开来),比较杂乱无章

此时虚拟机就得需要列表记录内存中哪些是已经使用的哪些是没有使用的,然后在给对象分配内存空间的时候在该列表中找一个足够的内存分给对象实例, 并更新维护的列表

这种就叫做空闲列表(Free List)

如下图所示, 内存的分配是碎片化的, 这时候单个指针无法找到所有空白区域, 那么就需要去空闲列表里查找哪块内存是符合条件的, 例如如果要分配大小为 2 的内存空间, 前面两个空白区域明显是不够用的, 那么就得使用第三个区域分配

可以把空闲列表看作一个 Hashmap, 其中存着每个空白区域的信息, 包括起始位置和内存大小, 这样就可以快速找到满足条件的区域了

空闲列表分配

适用场景:堆内存碎片化

Tip: 说到分配内存的两种方式,就顺便提一句:

  • 当使用的是Serial/ParNew等压缩整理过程的收集器的时候,系统采用的是指针碰撞的方式。
  • 而当使用的是CMS这种基于清除的算法收集器,理论上就只能采用空闲列表。

分配内存如何保证线程安全的

Java 程序在运行过程中无时无刻不在创建对象,那么它是如何在并发环境下保证线程安全的呢?接下来我们简单的捋一下其实保证线程安全还是两种方式:

  1. CAS机制 将分配内存空间的动作进行同步处理(虚拟机底层的实现逻辑就是CAS + 失败重试)来保证分配内存空间的原子性。
  2. 本地线程分配缓冲 TLAB
  • 在Eden区里对每个线程分配不同的内存区域, 默认为Eden区 1% 的大小
  • 在分配新对象内存时直接在自己线程分配到的区域创建, 因为区域是线程单独的, 所以是安全的
  • 如果放不下在使用 CAS 机制去 Eden 区分配
  • 虚拟机是否使用 TLAB 可通过参数XX: +/-UseTLAB来控制。

Step3:初始零值

当分配完内存后,虚拟机必须将分配到的内存空间(不包含对象头)都初始化为零值。如果使用了 TLAB,那么这一步会在 TLAB 分配时进行。为什么虚拟机要有这番操作呢?

主要是为了保证对象的实例字段能够在 Java 代码中可以在不赋值的时候就可以访问直接使用,这样就能使 Java 程序访问这些字段所对应的数据类型的初始零值

Step4:设置对象头(Mark world)

  1. 设置对象的 Mark Word,他是32位对象头的标记,

  2. 描述对象的 hash、锁信息,垃圾回收标记,年龄;

  3. 指向锁记录的指针(对于来说可以记录指向锁的指针)

  4. 指向monitor的指针(monitor可以锁定对象,也可以锁定函数)

  5. GC标记(在垃圾标记的时候我们可以做一些标记)

  6. 偏向锁线程ID(在偏向锁中可以记录偏向锁的ID)

  7. mark world 是个多功能的头,在很多场合都可以用到,除了在锁中用到,比如在垃圾回收中可以记录年龄,gc的标记等等

存储内容标志位 状态
对象Hash值、对象分代年龄01 未锁定
指向锁记录的指针00 轻量级锁定
指向重量级锁的指针10 膨胀(重量级锁定)
空,不记录信息11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄01 可偏向

Step5:执行 init 方法

执行完上述操作后,对于 Java 虚拟机来说对象已经创建完了,但是对于 Java 视角来说,对象的创建才刚刚开始,还没有执行init方法, 所有的字段还都为零。

对象中需要的其它资源和状态信息还没有按照原有的意图去构造好。

所以一般来说,new指令之后就会执行init方法,按照 Java 程序员的意图去对对象做一个初始化,这样之后一个真正完整可用的对象才构造出来