JVM 中对象咋创建啊,又怎么访问啊
虚拟机遇到 new 指令,会根据指令参数去常量池找对应类的符号引用,如果没找到会进行类加载,此时会执行类构造器指令。类加载完成之后,初始化之前,开始进行对象内存分配,分配好之后将内存区域的值全部置为0(成员变量初始化),之后执行实例构造器指令 ,完成后返回对象引用。
目录:
- 对象是怎么完成创建的?
- 怎么分配内存?
- 对象在内存中都存了什么?
- 怎么在内存中定位访问一个对象?
对象是怎么完成创建的?
对象的创建一共有四种方式
- new 关键字
- 复制(clone操作)
- 序列化(另类操作)
- 反射(另类)
※new 关键字创建普通 java 对象的过程
- 在常量池中查找类信息(根据全部限定名),如果没有先进行类加载(后面在虚拟机执行章节中有具体的加载过程笔记),然后检验其是否被初始化(这个初始化是指的类初始化,也就是执行)过
- 类加载完成确定类的内存大小
- 在新生代分配内存
- 执行
简单总结:类初始化 - 分配内存 - 实例初始化 - 返回引用地址
多学一点,这里的几个步骤涉及多个指令操作,所以就有了 DCL 单例使用 volatile 来禁止指令重排来保证单例模式的实例同步
class 文件中的 static 关键字修饰的方法或变量成为类变量,没有被 static 修饰的部分称为实例变量
下面是对象创建细节的拆分
怎么分配内存
- 指针碰撞
如果内存中现有的分配情况为整齐分布,则会有一个 分界点指示器 在 已用内存 和 未用内存 之间。对于这种情况,只需要将该指示器的位置向后移动当前对象的内存大小位置即可。 - 空闲列表
更多情况下,内存的使用是不连续的,所以在 JVM 中有一个对于当前内存情况管理的一个列表,称为 空闲列表 ,可以通过查询该表来完成对象的内存的分配。
使用 指针碰撞 的前提是堆内存空间完整,而内存空间完整的前提是垃圾收集器是否有空间压缩整理能力。
Serial 和 ParNew 垃圾回收器是带有压缩整理能力的,其可以使用指针碰撞的分配方式
CMS 是不具有压缩整理能力的,所以其使用的是空闲列表方式,但在 CMS 垃圾回收器中,它仍然可以使用类似 指针碰撞
的功能,其在空闲列表中申请内存时会申请较大的一块区域,然后对这块区域是 指针碰撞
来分配。
注:指针碰撞在极客时间郑雨迪的《深入拆解Java虚拟机》中翻译成指针加法
我猜测会有留言问为什么不把 bump the pointer 翻译成指针碰撞。这里先解释一下,在英语中我们通常省略了 bump up the pointer 中的 up。在这个上下文中 bump 的含义应为“提高”。另外一个例子是当我们发布软件的新版本时,也会说 bump the version number。
内存分配的并发问题
由于多线程情况,有可能刚申请的内存被其他线程提前写入,导致内存分配出现问题。所以 JVM 提出了两种解决方案。
- 使用 CAS + 失败重试;
- 为本地线程分配缓冲区 TLAB (通过参数可选 -Xx : - UseTLAB)缓冲区用完之后在使用 CAS + 失败重试分配内存;
TLAB : 线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。
使用内存
内存分配完之后, JVM 会将这部分区域的值置为0(这就是基本数据类型的默认值的实现),如果使用的是本地线程缓冲区的方案,在分配缓冲区时即已经置为了0,然后开始设置对象头的信息,包括类信息、元数据地址、GC分代年龄、偏向锁等信息,其中哈希值延迟到调用时才会计算并设置。
至此对象在内存中”完成创建”,但此时的对象并不能使用,接着会继续执行构造函数中的内容,来完成对象程序中的初始化步骤,构造函数执行结束后,对象完成创建。
注:以上对象创建过程代码在 hotspot 虚拟机 bytecodeInterpreter.cpp line:2179
对象在内存中都存了什么?
- 对象头
- 实例数据
- 对齐填充
对象头
- MarkWord —— 对象自身运行时数据
- 类型指针 —— 对象的类型元数据指针
- 数组长度 —— 如果是数组对象的话
MarkWord
- 哈希码
- GC分代年龄
- 锁标志
- 线程持有的锁
- 偏向锁持有线程ID
- 偏向时间戳 | 存储内容 | 锁标志 | 状态 | | —- | —- | —- | | 哈希码、分代年龄 | 01 | 未锁定 | | 指向锁记录的指针 | 00 | 轻量级锁 | | 指向重量级锁的指针 | 10 | 重量级锁 | | 空 | 11 | GC标记 | | 持有偏向锁的线程ID、时间戳 | 01 | 偏向锁 |
类型指针
JVM 通过类型指针来确定当前对象的类型。这个类型指针指向方法区中该对象的元空间数据。
数组长度
之所以会单独区分出数组的长度信息,是因为 JVM 无法通过类的元空间数据得出对象的大小,所以单独记录数组对象的长度信息在对象头中。
HotSpot虚拟机代表Mark Word中的代码(markOop.cpp)注释片段,它描述了32位虚拟机MarkWord的存储布局:
实例数据
无论是从父类继承下来的,还是在子类中定义的字段存储顺序会受到虚拟机分配策略参数
(-XX:FieldsAllocationStyle参数) 和字段在Java源码中定义顺序的影响。
HotSpot虚拟机默认的分配顺序为
- longs/doubles
- ints
- shorts/chars
- bytes/booleans
- oops(Ordinary Object Pointers,OOPs)
从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
如果HotSpot虚拟机的 +XX:CompactFields 参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
对齐填充
hotspot 实现的虚拟机,对对象的起始地址有要求,需要是8字节的整数倍,所以对象的大小就必须是8字节的整数倍,如果不足便需要通过占位符来补充至8字节的倍数。
怎么在内存中定位访问一个对象?
Java 程序通过栈上的 reference 数据来操作堆上的对象。
《Java虚拟机规范》没有说明和约束 reference 的实现方式,所以具体的实现由虚拟机决定。
通常由下面两种方式实现
句柄
句柄保存在句柄池中
句柄保存对象数据的地址和对象类型信息的地址,多进行一次操作。但在 GC 做标记-整理操作时,无需关心对象内存地址的信息变化。
直接指针
保存对象的数据信息和对象类型信息的地址,可以直接访问到对象数据。当需要使用类信息的时候,需要在进行一次查找。
图片来自《深入理解 Java 虚拟机》(第三版)周志明
(正文完)
下一篇学习对象是怎么回收的,非常欢迎关注加群一起学习,一起学劲儿大!