JVM - 探索HotSpot虚拟机中的对象
文章目录
- JVM - 探索HotSpot虚拟机中的对象
- 我们所熟悉的对象是如何创建的?
- 1.1 类加载检查
- 1.2 分配内存
- 1.2.1 指针碰撞
- 1.2.2 空闲列表
- 1.2.3 内存分配方式总结以及如何解决并发问题
- 1.3 初始化零值
- 1.4 设置对象头
- 1.5 执行 init 方法
- 对象的内存结构
- 我们是如何访问对象的?
- 3.1 句柄方式
- 3.2 直接访问方式
- 3.3 两种访问方式比较
JVM - 探索HotSpot虚拟机中的对象
1. 我们所熟悉的对象是如何创建的?
在前面的文章中我们已经大概了解了虚拟机的各个结构,这里我们就来详细去探索一下我们所熟知的对象在HotSpot 虚拟机的堆中是如何进行分配、布局和访问的。
我们每天每时每刻都在不停地new
各种各样的对象,毫不夸张地说我们都已经形成了肌肉记忆了。但是我们new
的每一个对象,他们都是怎样去创建的呢?我们先看一段简单的代码慢慢入局。
public class User {
static {
System.out.println("static");
}
private String name;
private int age;
public User() {
System.out.println("constructor name:" + this.getName() + " age:" + this.getAge());
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public static void main(String[] args){
User user = new User();
}
}
static
constructor name:null age:0
这是一个很简单的对象的创建,从上面的结果我们可以得出两个很明显的结论:
- 静态方法在构造方法前执行
- 基本数据类型会根据数据类型初始化对应的零值,而引用类型会默认赋值null
这其中的奥秘就是我们接下来要探索的对象在虚拟机中的创建过程。对象的创建过程主要就是以下五个步骤,我们先做大概了解之后再各个击破。
1.1 类加载检查
我们每次
new
对象的时候,虚拟机就会接收到一条对应的指令。接收指令之后,它就会去检查通过该指令的参数是否能在常量池中定位到这个类的符号引用,并检查该符号引用代表的类是否已被加载、解析和初始化。若没有,那就会先执行相应的类加载过程。
1.2 分配内存
当类加载检查通过后,接下来虚拟机将为新生对象去分配内存空间。每个对象所需的内存空间大小在类加载完成后便已确定,给对象分配内存空间其实就是从Java堆中将一块已知大小的内存空间划分出来。
Java堆分配方式主要有指针碰撞
和空闲列表
两种,选择哪一种分配方式则由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
1.2.1 指针碰撞
指针碰撞方式原理就是将已使用和空闲的内存空间单独分开,使用一个分界指针去分隔两块内存。
当我们申请新的内存空间时,只需将指针向空闲内存空间移动指定内存空间大小的位置即可。
这种内存分配方法有一个很大的局限性就是内存的分配需要是整齐有规律的,不能有大量的内存碎片,否则浪费极大的内存空间。
1.2.2 空闲列表
既然指针碰撞的分配方式有局限性,那必然构思一种其他的分配方式弥补其不足。空闲列表的内存分配方式就出现了。
这种方式主要就是用于内存碎片多、堆内存不规整的情况。虚拟机通过维护一个列表去记录空闲内存块的相关信息。当我们需要分配一块新的内存时,会通过记录表中的信息挑选一个足够大小空间的内存指定给对象实例。
1.2.3 内存分配方式总结以及如何解决并发问题
指针碰撞
- 适用场景:堆内存规整(没有内存碎片)
- 原理:已使用和未使用的内存单独放在两边,中间使用一个分界指针隔开,只需将指针向未使用内存方向移动申请新的内存空间大小的位置即可
- 对应GC收集器:Serial、ParNew
空闲列表
- 适用场景:堆内存不规整
- 原理:虚拟机会维护一个列表用于记录空闲内存块的相关信息,分配内存时会指定一块足够大小空间的内存给对象实例并更新记录表信息
- 对应GC收集器:CMS
内存分配并发问题
在我们实际开发中,创建对象是一个十分频繁的行为,所以其实在这个过程中是存在线程安全问题的。第一是因为无论哪种内存分配方式其实都不是一气呵成的,例如开辟内存和移动指针、修改空闲列表都是独立单元的行为,第二是因为在同一时间很可能存在多个对象都进行内存分配。
作为一个优秀的虚拟机,是早就考虑到了这一点的,为了保证线程安全主要采用两种方式:
CAS+失败重试
: CAS是乐观锁的一种实现方式。所谓乐观锁就是每次不加锁,而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。TLAB
: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
1.3 初始化零值
当内存分配完成后,虚拟机会将分配到的内存空间都初始化为零值,这是为了保证对象的实例属性在 代码中不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
1.4 设置对象头
初始化零值完成后,虚拟机会给对象实例进设置对象头相关数据:GC分代年龄、对象哈希吗(HashCode)、元数据信息等。
1.5 执行 init 方法
当我们前面几步都完成了之后,对于虚拟机来说其实新对象的创建已经完成了,但是对于程序的角度来说对象实例的构建才刚刚开始。一般来说,
new
指令执行后会紧接着执行对象的init
方法,例如对象的构造方法。通过init
方法将对象按照我们的需要进行初始化,最终一个真正可用的对象才算创建完成。
2. 对象的内存结构
上面我们知道了对象是怎么在内存中进行分配的,这里我们再来看看对象在内存中的结构具体是怎么样的。在
Hotspot
虚拟机中,对象在内存中的结构可以简单分为3部分:对象头
、实例数据
和对齐填充
。
对象头
对象头主要用于存储对象的元数据信息,这其中又可以分为两部分:
Mark Word
:这一部分的数据长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit,存储对象自身的运行时数据如哈希码、GC分代年龄、锁状态等。Mark Word
一般被设计为非固定的数据结构,以便存储更多的数据信息和复用自己的存储空间。类型指针
:指向它的类元数据的指针,用于判断该对象属于哪个类的实例。
实例数据
实例数据部分存储的是真正有效数据,也就是我们程序中定义的各种类型的字段内容。各字段的分配策略为
longs/doubles
、ints
、shorts/chars
、bytes/boolean
、oops(ordinary object pointers)
,为了方便读取数据,相同宽度的字段总是被分配到一起。
对齐填充
对齐填充部分不是必然存在的,没有特别的含义,仅仅起占位作用。 由于Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说对象大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数,所以当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
3. 我们是如何访问对象的?
我们创建了对象实例之后,Java程序就是通过虚拟机栈上的
reference
类型数据来操作堆上具体某个对象的。对象的访问方式由虚拟机实现决定,目前主流的访问方式有两种:
- 使用句柄访问
- 直接指针访问
(HotSpot虚拟机采用该方式访问对象实例)
3.1 句柄方式
使用句柄方式访问对象实例,Java堆就会划分一块内存空间作为句柄池。即
reference
中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针。
3.2 直接访问方式
如果使用直接指针访问对象实例,那么
reference
中存储的就是对象的地址,相当于一级指针。
3.3 两种访问方式比较
既然同时存在两种访问方式,那肯定是各有优劣,这里主要从两个方面去比较:
垃圾回收
:站在垃圾回收角度进行分析,当垃圾回收移动对象时,使用句柄访问方式由于reference
中存储的是稳定的句柄地址,仅需要修改句柄中实例数据指针,reference
不需要改变。而直接访问指针方式,则需要修改reference
中存储的地址。访问效率
:使用句柄访问需要进行两次指针定位,而直接访问方式仅需要进行一次指针的定位,节省了时间开销,访问的效率更快。
还没有评论,来说两句吧...