对象的实例化、内存布局与访问定位
本文整理自尚硅谷宋红康老师在B站的视频,侵权即删。
1 对象的实例化
1.1 对象实例化的方式
创建对象的方式主要有以下几种:
- new关键字,这是最常用的方式,常见的变形有:
- Xxx的静态方法
- XxxBulider/XxxFactory的静态方法
- Class的newInstance()方法,反射的方式,只能调用空餐的构造器,且权限必须够。
- Constructor的newInstance(Xxx),反射的方式,可以调用空参、带参的构造器,对权限没有要求。
- 使用clone(),不调用任何构造器,当前类需要实现Cloneable接囗,实现clone()方法
- 使用反序列化:从文件中、从网络中获取一个对象的二进制流。
- 使用第三方库Objenesis。
1.2 对象实例化的步骤
对象的实例化主要有以下几个步骤:
(1) 判断对象对应的类是否加载、链接、初始化
虚拟机遇到一条new指令,首先检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检章这个符号引用代表的类是否已经被加载、解析和初始化(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以Classloader+包名+类名为Key进行查找对应的class文件,如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。
(2) 为对象分配内存
首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例的成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。
分配内存时,需要考虑内存是否规整:
1)内存规整
如果内存是规整的,虚拟机将采用指针碰撞法(Bump The Pointer)
来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针,作为分界点的指示器,分配内存的过程仅仅是把指针向空闲的一边挪动一段与对象大小相等的距离。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有compact(整理)过程的收集器时,使用指针碰撞法。
2)内存不规整
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用空闲列表法(Free List)
来为对象分配内存。意思是虚拟机维护一个列表,记录哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。
说明:选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
(3) 处理并发安全问题
分配内存时是有线程安全问题的,所以先通过TLAB的方式分配内存,如果分配失败,再采用CAS失败重试、区域加锁等方式,分配到Eden区。
(4)初始化分配到的空间
为所有字段设置默认值,保证对象的实例字段在不赋值时可以直接使用。
(5) 设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中,这个过程的具体设置方式取决于JVM具体实现。
(6) 执行init方法进行初始化
在Java程序的视角看来,初始化才正式开始。init()方法有如下要点:
- init()方法的首行是super()/super(实参列表),即对应父类的init()方法;
- init方法由super()/super(实参列表)、实例变量显式赋值代码、非静态代码块、和对应构造器代码组成;
- 先执行super()/super(实参列表),实例变量显式赋值代码和非静态代码块从上到下顺序执行,而对应构造器的代码最后执行;
- 每次创建实例对象,都会执行对应的init()方法;
执行完init()方法后,堆内对象的首地址赋值给引用变量。
因此一般来说(由字节码中是否跟随有invokespe指令所决定),new指令之后接着就是执行方法,把对象按照开发人员的意愿进行初始化,这样一个真正可用的对象才算完全创建。
2 对象的内存布局
在HotSpot虚拟机里, 对象在堆内存中的存储布局可以划分为三个部分: 对象头(Header) 、 实例数据(Instance Data) 和对齐填充(Padding) 。
2.1 对象头
HotSpot虚拟机对象的对象头部分包括两类信息。
第一类是用于存储对象自身的运行时数据
, 如哈希码(HashCode) 、 GC分代年龄、 锁状态标志、 线程持有的锁、 偏向线程ID、 偏向时间戳等, 官方称这部分数据为“Mark Word”
。
第二类是类型指针
, 即对象指向它的类型元数据的指针, Java虚拟机通过这个指针来确定该对象是哪个类的实例
。
说明:如果对象是数组,还需记录数组的长度。
2.2 实例数据
示例数据是对象真正存储的有效信息
,包括程序代码中定义的各种类型的字段
(包括从父类继承下来的和本身拥有的字段)。
这部分数据的规则是:
- 相间宽度的字段总是被分配在一起;
- 父类中定义的变量会出现在子类之前;
- 如果CompactFields参数为true(默认为true):子类的变量可能插入到父类变量的空隙。
2.3 对齐填充
这一部分不是必然存在的,也没有特别的含义, 它仅仅起着占位符的作用
。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍, 换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍), 因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
举例:
package com.yupeng.objecttest;
/**
* @author Yupeng
* @create 2021-07-24 23:45
*/
public class ObjectLayoutTest {
public static void main(String[] args) {
Customer cust = new Customer();
cust.setId(1001);
cust.setName("张三");
cust.setAcct(new Account());
}
}
class Customer{
private int id;
private String name;
private Account acct;
// setter and getter
}
class Account{
private double balance;
}
以上代码对应的对象内存布局如图所示:
3 对象的访问定位
创建对象的目的是使用它,那如何访问到这个对象呢?通过栈帧中的reference变量访问:
对象访问方式主要有两种:句柄访问和直接指针,HotSpot采用的是直接指针方式。
句柄访问的优点是:Reference中存储稳定句柄地址,对象被移动时(垃圾收集时可能会移动),只会改变句柄中到对象实例数据的指针,而不会改变reference本身。
直接指针的优点是:1)节省空间,不用专门开辟句柄池的空间;2)速度快,可以直接访问到对象实例数据。