JAVA面试

JVM笔记:Java虚拟机的类加载机制

2019-11-04  本文已影响0人  BigX

前言

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

上图中,加载、验证、准备、初始化和卸载这5个阶段的顺序是固定的,类的加载过程必须按照这种顺序按部就班地开始,但是解析阶段则不一定:他在否种情况下可以再初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。同事,上面这是阶段通常都是互相交叉地混合进行的,通常会在一个阶段执行的过程中调用、激活另一个阶段(例如在一个类的内部初始化另一个类)。

public class Parent {
    public static int a = 1;
    static {
        System.out.println("Parent init");
    }
}
public class Son extends Parent{
    static {
        System.out.println("Son init");
    }
}
   public static void main(String[] args) {
        System.out.println("args = [" + Son.a + "]");
    }
输出结果:
Parent init
args = [1]

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化,至于是否要触发子类的加载和验证,在虚拟机规范中并未明确规定,这点取决于虚拟机的具体实现,对于Sun HotSpot虚拟机来说,可通过-XX:_TraceClassLoading参数观察到次操作会导致子类的加载。

除此之外,通过数组定义来引用类,不会触发此类的初始化。

   public static void main(String[] args) {
        Parent[] parentArry = new Parent[10];
    }

运行上述代码后什么输出也没有,说明并没有触发Parent类的初始化阶段。但是这段代码里面触发了另一个名为[Lxxx.xxx.Parent(前面的xxx指代类的包名)的类的初始化,这里是不是看起来有点眼熟,在前面字节码的文章里可以知道[L这里表示的是一个对象数组。它是由虚拟机自动生成的、直接继承与Object的类,创建动作由字节码指令newarray触发。
这个类表示了一个元素类型为Parent的一维数组,数组中应有的属性和方法(可被用户直接调用的方法只有length和clone)都实现在这个类里。在Java语言中,当检查到数组越界时会抛出ArrayIndexOutOfBoundsException异常,但是这个异常检测不是封装在数组元素访问的类中,而是封装在数组访问的xaload、xastore字节码指令中。

当引用一个类的静态且被final修饰的常量时,不会触发此类的初始化

public class Parent {
    public static final int a = 1;
    static {
        System.out.println("Parent init");
    }
}
  public static void main(String[] args) {
        System.out.println("args = [" + Son.a + "]");
    }
输出结果:
args = [1]

因为作为final修饰的常量时一个不可变的值,所以在编译阶段会通过常量传播优化,将此常量的值1存储到了主类(main方法所在的类)的常量池中,所以以后主类中对常量1的引用实际都被转化了主类对自身常量池的引用,也就是说,实际上主类的Class文件中并没有Parent类得符号引用,这两个类在编异常Class之后就不存在任何联系了。

接口的架子啊过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点和类是一致的,但是接口中不能使用static{}语句块,但是编译器仍然会为接口生成<client>类构造器,用于初始化接口中所定义的成员变量。接口与类正则有所区别的是前面讲述的需要初始化场景的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了。但是一个接口在初始化时,并不要求其负借口全部都完成了初始化,只有在真正使用到负借口的时候(如引用接口中定义的常量)才会被初始化。

接下来详细讲解一下类加载的全过程,也就是加载、验证、准备、解析、初始化这5个阶段锁执行的具体动作。

加载是类加载过程的一个阶段,在加载阶段,虚拟机主要完成一下三件事

加载阶段没有规定加载的内容从哪来,因为它加载的是一个类的全限定名来获取定义此类的二进制字节流。所以,虚拟机根本没有制定要从那里获取,怎样获取,但是常见的获取方式有下面几种:

对于类加载过程的其他阶段,一个非数组的加载阶段(准确的说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成(例如对字节码加密,然后通过自定义类加载器来解密后加载类),开发人员可以通过定义自己的类加载器去控制字节流的获取方式。

但是数组类并不是通过类加载器创建的,它是由Java虚拟机直接创建的。不过数据类型与类加载器仍然有很密切的关系,因为数组类的元素类型最终还是要考类加载器去创建,一个数组类的创建过程就遵循以下规则:

加载阶段完成后,虚拟机将外部的二进制字节流按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机自行定义,然后在内存中实例化一个Class类的对象(并没有明确是在Java堆中,对于HotSpot虚拟机而言mClass对象比较特殊,他虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

加载阶段和后续的连接阶段的部分内容是交叉进行的,加载阶段尚未完成时,连接阶段可能已经开始了,但是这些夹在加载阶段的动作仍然属于连接阶段。

上面只是验证的一小部分点,目的是包在输入的字节流能正确地解析并且格式上符合一个Java类型的数据要求。只有通过这个阶段的兖州,字节流才会进入内存的方法区进行存储,后面的三个验证阶段全部是基于方法取得存储结构进行的,不会再直接操作字节流。

  public static  int number= 1;
  public static final int numberFinal= 123;

上面例子中number在准备阶段后的初始值为0而不是1,因为这个时候尚未开始执行仍和Java方法,而把number赋值为1的putstatic指令时程序被编译后,存放于类构造器<clinit>()方法之中,所以把number赋值为1的动作将在初始化阶段才会执行。

但是在特殊情况下,如果类字段的字段属性表中存在ConstantValue属性(被final修饰),那在准备阶段变量numberFinal就会被初始化为指定的值。编译时Javac将会为numberFinal生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将值设为123。

public class Parent {
    static {
        a=2;
        System.out.println("Parent init"+a);
    }
    public static  int a = 1;
}

上面代码中可以在代码块中对a进行赋值,但是没啥作用,因为会被后面的a重新赋值为1,而且代码块内不能调用下面的类变量,会显示illeagal forward reference错误

<clinit>()方法与类的构造方法,也就是实例构造器 <init>()不同,它不需要显示地调用它父类构造器,虚拟机会保证在子类的 <clinit>()方法执行之前,父类的 <clinit>()方法已经执行完毕,也就是说,父类中定义的静态语句块要由于子类的变量赋值操作,因此在虚拟机中第一个被执行的 <clinit>()方法的类肯定是Object。

下面例子中输出的结果就是2,因为父类的静态赋值操作比子类先执行

public class Parent {
    public static  int a = 1;
    static {
        a=2;
    }
}
public class Son extends Parent{
      public static int b=a;
}
 public static void main(String[] args) {
        System.out.println("args = [" + Son.b + "]");
    }

<clinit>()方法不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口和类一样都会生成 <clinit>()方法方法,但接口与类不同的是,魔之心接口的 <clinit>()方法不需要先执行父接口的 <clinit>()方法,只有当父接口中定义的变量使用时,父接口才会初始化,另外,接口的实现类在初始化时也不会执行接口的 <clinit>()方法。

虚拟机会保证一个类的 <clinit>()方法在多线程环境中被正确的加锁,用不,如果多个线程同时去初始化一个类,那么只有一个线程回去执行这个类 <clinit>()方法,其他线程都需要阻塞等待,这也是静态单例实现的原理。

上一篇 下一篇

猜你喜欢

热点阅读