Skip to content

Latest commit

 

History

History
189 lines (128 loc) · 11.9 KB

2.HotSpot虚拟机对象.md

File metadata and controls

189 lines (128 loc) · 11.9 KB

HotSpot虚拟机对象

目录


对象的创建、如何布局以及如何访问这种细节问题,必须把讨论范围限定在具体虚拟机才有意义。下面以最常用的HotSpot虚拟机和最常用的内存区域Java堆为例,深入探讨HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。

下文中讨论的对象不包括数组和Class对象

1. 对象的创建

当Java虚拟机遇到一条字节码new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有被使用过的内存都放在一边,空闲的内存被放在另一边,中间防着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。 但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。Java堆是否规则是由所采用的垃圾收集器是否带有空间压缩整理的能力决定。

对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。解决这个问题有2种方案:

  1. 对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性
  2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。

内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值。接下来,虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的GC分代年龄信息等。

上面的工作完成之后,从虚拟机视角来看,一个新的对象已经产生了。但从Java程序的视角来看,对象创建还需要经历构造函数(即Class文件中的<init>()方法)的流程。

2. 对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据、对齐填充。

对象头:包含两类信息,第一类:用于存储自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。官方称它为Mark Word;第二类:类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。此外,如果对象是Java数组,那在对象头中还必须有一块用于记录数组长度的数据。

实例数据:对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

对齐填充:并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,意思是任何对象的大小都必须是8字节的整数倍。

3. 对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式取决于虚拟机实现而定。目前主流的访问方式有使用句柄和直接指针两种。

使用句柄:Java堆中将会划分出一块内容作为句柄池,reference中存储的就是对象的句柄池地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

直接指针:reference中存储的直接就是对象地址。

两种方式各有优势,使用句柄的最大好处就是reference中存储的就是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是很普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要改变。使用直接指针的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。HotSpot也是使用的直接指针的方式进行对象访问。但是整个生态来看使用句柄来访问的情况也十分常见。

4. Interview

4.1 描述new一个对象的过程

Java中对象的创建过程包括 类初始化 和 类实例化 两个阶段。而new只是创建对象的一种方式和时机。当执行到new的字节码指令的时候,会先判断这个类是否已经初始化,如果没有初始化就要进行类的初始化。

  • 类的初始化:是类的生命周期的一个阶段,会为类中各个类成员赋初始值
  • 类的实例化:是指创建一个类的实例的过程

但是在类的初始化之前,JVM会保证类的装载,链接(验证、准备、解析)四个阶段都已经完成。

  • 装载是指Java虚拟机查找.class文件并生成字节流,然后根据字节流创建java.lang.Class对象的过程
  • 链接:验证创建的类,并将其解析到JVM中使之能够被JVM执行

那类加载的时机是什么时候?JVM没有规范何时具体执行,不同虚拟机实现有点不同,常见情况如下:

  • 隐式装载:在程序运行过程中,当碰到通过new等方式生成对象时,系统会隐式调用ClassLoader去装载对应的class到内存中
  • 显示装载:在编写代码时,主动调用Class.forName()等方法也会进行class装载操作,这种方式称为显示装载

到这里大的流程框架就搞清楚了:

  • 当JVM碰到new字节码的时候,会先判断类是否已经初始化,如果没有初始化(有可能类还没有加载,如果是隐式装载,此时应该还没有类加载,就会先进行装载、验证、准备、解析四个阶段),然后进行类初始化
  • 如果已经初始化过了,就直接开始类对象的实例化工作,这时候会调用类对象的方法

初始化的执行流程:

  1. 父类静态变量和静态代码块
  2. 子类静态变量和静态代码块
  3. 父类普通成员变量和普通代码块
  4. 父类的构造函数
  5. 子类普通成员变量和普通代码块
  6. 子类的构造函数

4.2 类初始化的触发时机

在同一个类加载器下,一个类型只会被初始化一次,刚才说到new对象是类初始化的一个判断时机,其实一共有六种能够触发类初始化的时机:

  1. 虚拟机启动时,初始化包含main方法的主类
  2. 遇到new等指令创建对象实例时,如果目标对象类没有被初始化则进行初始化操作
  3. 当遇到访问静态方法或者静态字段的指令时,如果目标对象类没有被初始化则进行初始化操作
  4. 子类的初始化过程如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  5. 使用反射API进行反射调用时,如果类没有进行过初始化则需要先触发其初始化
  6. 第一次调用java.lang.MethodHandle实例时,需要初始化MethodHandle指向方法所在的类

4.3 多线程进行类的初始化会出问题吗?

不会,<clinit>()方法是阻塞的,在多线程环境下,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>(),其他线程都会被阻塞。

4.4 类的实例化触发时机

  • 使用new关键字创建对象
  • 使用Class类的newInstance方法,Constructor类的newInstance方法
  • 使用clone方法创建对象
  • 使用(反)序列化机制创建对象

4.5 <clinit>()方法和<init>()方法区别

  • <clinit>()方法发生在类初始化阶段,会执行类中的静态类变量的初始化和静态代码块中的逻辑,执行顺序就是语句在源文件中出现的顺序
  • <init>()方法发生在类实例化阶段,是默认的构造函数,会执行普通成员变量的初始化和普通代码块的逻辑,执行顺序就是语句在源文件中出现的顺序

4.6 类没有初始化完毕之前,能直接进行实例化相应的对象吗?

是可以的,类静态变量是自己的一个实例的情况。

public class Run {
    public static void main(String[] args) {
        new Person2();
    }
}

public class Person2 {
    public static int value1 = 100;
    public static final int value2 = 200;

    public static Person2 p = new Person2();
    public int value4 = 400;

    static{
        value1 = 101;
        System.out.println("1");
    }

    {
        value1 = 102;
        System.out.println("2");
    }

    public Person2(){
        value1 = 103;
        System.out.println("3");
    }
}

先初始化静态变量,然后初始化普通成员变量和普通代码块,最后是构造函数。 所以这里在初始化过程中就进行了实例化。

所以,实例化不一定要在初始化结束之后才开始初始化,有可能在初始化过程中就进行了实例化。

4.7 类的初始化过程与类的实例化过程的异同

  • 类的初始化,是指在类装载,链接之后的一个阶段,会执行<clinit>()方法,初始化静态变量,执行静态代码块等
  • 类的实例化,是指在类完全加载到内存中后创建对象的过程,会执行 <init>()方法,初始化普通变量,调用普通代码块

4.8 一个实例变量在对象初始化的过程中最多可以被赋值几次

  1. 对象被创建的时候,分配内存会把实例变量赋予默认值,这是肯定会发生的
  2. 实例变量本身初始化的时候,就给它赋值一次
  3. 初始化代码块的时候,也赋值一次
  4. 构造函数中,再赋值一次

可以是四次,看下示例代码

public class Person3 {
    public int value1 = 100;

    {
        value1 = 102;
        System.out.println("2");
    }

    public Person3(){
        value1 = 103;
        System.out.println("3");
    }
}