JVM 中对象咋创建啊,又怎么访问啊 - 图2

JVM 中对象咋创建啊,又怎么访问啊

虚拟机遇到 new 指令,会根据指令参数去常量池找对应类的符号引用,如果没找到会进行类加载,此时会执行类构造器指令。类加载完成之后,初始化之前,开始进行对象内存分配,分配好之后将内存区域的值全部置为0(成员变量初始化),之后执行实例构造器指令 ,完成后返回对象引用。

目录:

  1. 对象是怎么完成创建的?
  2. 怎么分配内存?
  3. 对象在内存中都存了什么?
  4. 怎么在内存中定位访问一个对象?

对象是怎么完成创建的?

对象的创建一共有四种方式

  • new 关键字
  • 复制(clone操作)
  • 序列化(另类操作)
  • 反射(另类)

※new 关键字创建普通 java 对象的过程

  1. 在常量池中查找类信息(根据全部限定名),如果没有先进行类加载(后面在虚拟机执行章节中有具体的加载过程笔记),然后检验其是否被初始化(这个初始化是指的类初始化,也就是执行)过
  2. 类加载完成确定类的内存大小
  3. 在新生代分配内存
  4. 执行

简单总结:类初始化 - 分配内存 - 实例初始化 - 返回引用地址

多学一点,这里的几个步骤涉及多个指令操作,所以就有了 DCL 单例使用 volatile 来禁止指令重排来保证单例模式的实例同步

class 文件中的 static 关键字修饰的方法或变量成为类变量,没有被 static 修饰的部分称为实例变量

下面是对象创建细节的拆分

怎么分配内存

  • 指针碰撞
    如果内存中现有的分配情况为整齐分布,则会有一个 分界点指示器已用内存未用内存 之间。对于这种情况,只需要将该指示器的位置向后移动当前对象的内存大小位置即可。
  • 空闲列表
    更多情况下,内存的使用是不连续的,所以在 JVM 中有一个对于当前内存情况管理的一个列表,称为 空闲列表 ,可以通过查询该表来完成对象的内存的分配。

使用 指针碰撞 的前提是堆内存空间完整,而内存空间完整的前提是垃圾收集器是否有空间压缩整理能力。

SerialParNew 垃圾回收器是带有压缩整理能力的,其可以使用指针碰撞的分配方式

CMS 是不具有压缩整理能力的,所以其使用的是空闲列表方式,但在 CMS 垃圾回收器中,它仍然可以使用类似 指针碰撞 的功能,其在空闲列表中申请内存时会申请较大的一块区域,然后对这块区域是 指针碰撞 来分配。

注:指针碰撞在极客时间郑雨迪的《深入拆解Java虚拟机》中翻译成指针加法

我猜测会有留言问为什么不把 bump the pointer 翻译成指针碰撞。这里先解释一下,在英语中我们通常省略了 bump up the pointer 中的 up。在这个上下文中 bump 的含义应为“提高”。另外一个例子是当我们发布软件的新版本时,也会说 bump the version number。

内存分配的并发问题

由于多线程情况,有可能刚申请的内存被其他线程提前写入,导致内存分配出现问题。所以 JVM 提出了两种解决方案。

  1. 使用 CAS + 失败重试;
  2. 为本地线程分配缓冲区 TLAB (通过参数可选 -Xx : - UseTLAB)缓冲区用完之后在使用 CAS + 失败重试分配内存;

TLAB : 线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。

使用内存

内存分配完之后, JVM 会将这部分区域的值置为0(这就是基本数据类型的默认值的实现),如果使用的是本地线程缓冲区的方案,在分配缓冲区时即已经置为了0,然后开始设置对象头的信息,包括类信息、元数据地址、GC分代年龄、偏向锁等信息,其中哈希值延迟到调用时才会计算并设置。

至此对象在内存中”完成创建”,但此时的对象并不能使用,接着会继续执行构造函数中的内容,来完成对象程序中的初始化步骤,构造函数执行结束后,对象完成创建。

注:以上对象创建过程代码在 hotspot 虚拟机 bytecodeInterpreter.cpp line:2179

对象在内存中都存了什么?

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

对象头

  1. MarkWord —— 对象自身运行时数据
  2. 类型指针 —— 对象的类型元数据指针
  3. 数组长度 —— 如果是数组对象的话

MarkWord

  • 哈希码
  • GC分代年龄
  • 锁标志
  • 线程持有的锁
  • 偏向锁持有线程ID
  • 偏向时间戳 | 存储内容 | 锁标志 | 状态 | | —- | —- | —- | | 哈希码、分代年龄 | 01 | 未锁定 | | 指向锁记录的指针 | 00 | 轻量级锁 | | 指向重量级锁的指针 | 10 | 重量级锁 | | 空 | 11 | GC标记 | | 持有偏向锁的线程ID、时间戳 | 01 | 偏向锁 |

类型指针

JVM 通过类型指针来确定当前对象的类型。这个类型指针指向方法区中该对象的元空间数据。

数组长度

之所以会单独区分出数组的长度信息,是因为 JVM 无法通过类的元空间数据得出对象的大小,所以单独记录数组对象的长度信息在对象头中。

HotSpot虚拟机代表Mark Word中的代码(markOop.cpp)注释片段,它描述了32位虚拟机MarkWord的存储布局:

实例数据

无论是从父类继承下来的,还是在子类中定义的字段存储顺序会受到虚拟机分配策略参数

(-XX:FieldsAllocationStyle参数) 和字段在Java源码中定义顺序的影响。

HotSpot虚拟机默认的分配顺序为

  1. longs/doubles
  2. ints
  3. shorts/chars
  4. bytes/booleans
  5. oops(Ordinary Object Pointers,OOPs)

从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

如果HotSpot虚拟机的 +XX:CompactFields 参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对齐填充

hotspot 实现的虚拟机,对对象的起始地址有要求,需要是8字节的整数倍,所以对象的大小就必须是8字节的整数倍,如果不足便需要通过占位符来补充至8字节的倍数。

怎么在内存中定位访问一个对象?

Java 程序通过栈上的 reference 数据来操作堆上的对象。

《Java虚拟机规范》没有说明和约束 reference 的实现方式,所以具体的实现由虚拟机决定。

通常由下面两种方式实现

句柄

句柄保存在句柄池中

句柄保存对象数据的地址和对象类型信息的地址,多进行一次操作。但在 GC 做标记-整理操作时,无需关心对象内存地址的信息变化。

直接指针

保存对象的数据信息和对象类型信息的地址,可以直接访问到对象数据。当需要使用类信息的时候,需要在进行一次查找。

JVM 中对象咋创建啊,又怎么访问啊 - 图3

JVM 中对象咋创建啊,又怎么访问啊 - 图4

图片来自《深入理解 Java 虚拟机》(第三版)周志明

(正文完)


下一篇学习对象是怎么回收的,非常欢迎关注加群一起学习,一起学劲儿大!

推荐阅读