JVM

剑指JVM - 虚拟机类加载机制

《深入理解Java虚拟机》第二版的知识搬运

Posted by Chris on April 28, 2017

类的生命周期

以类被加载到虚拟机内存开始直至卸载出内存,他的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载。

The phases of class loading

加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始。而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。这些阶段通常都是交叉进行,会在一个阶段执行的过程中调用另一个阶段。

Java虚拟机并没有规定什么情况下需要进行加载过程,我们可以使用-XX:+TraceClassLoading打印加载的日志。对于初始化阶段、虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)

  1. 遇到new、getstatic、putstatic和invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
  2. 使用java.lang.reflect的方法进行反射时,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个Main类。
  5. 当使用JDK7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

上述这5中行为成为对一个类的主动引用,相对的有成为被动引用的情况则不会触发初始化,如 子类引用父类的静态字段、通过数组定义来引用类、常量传播优化至调用者的常量池。

类加载过程

加载 - Loading

在加载阶段,虚拟机需要完成3件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区(Method Area)的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。

连接 - Linking

验证 - Verification

验证阶段非常重要,直接决定JVM是否能抵御恶性代码攻击,确保Class文件的字节流中包含的信息符合当前虚拟机的要求。

文件格式验证

验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。只有通过此阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

元数据验证

对类的元数据(class metadata)信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。

字节码验证

该阶段是整个验证过程中最复杂的一个阶段,主要是对类的方法体进行校验分析,保证被校验类的方法在运行时不会作出危害虚拟机安全的事件。

符号引用验证

目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类。

准备 - Preparation

准备阶段是正式为类变量(static)分配内存并设置类变量零值的阶段,这些变量所使用的内存都将在方法区中进行分配。举个例子:

private static boolean flag = true;

准备阶段,该变量会被设置为boolean型的零值,即false;在该类变量持有类进行初始化的时候才会调用类构造器<clinit>将其初始化为true。

但是当我们将其设置为常量的时候,情况就会不一样

private final static boolean flag = true;

在Class文件中,字段表(field_info)用于描述接口或者类中声明的类变量或实例变量。字段表会带有属性表集合(attributes),当中有一个专门对于字段表描述的属性ConstantValue。目前HotSpot javac前端编译器的行为是:如果同时使用final和static来修饰一个变量,且这个变量的数据类型是基本类型或字符串(即字面量)的话,就生成ConstantValue属性来对其进行初始化,如果这个变量没有被final修饰,或者为非字面量,则将会选择在<clinit>方法中进行初始化。

所以该变量在准备阶段虚拟机就会根据ConstantValue的设置将其赋值为true。

解析 - Resolution

解析阶段是虚拟机将Class常量池内的符号引用替换成直接引用的过程。所有方法调用中的目标方法在Class文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。

JVM提供的5个方法调用的字节码指令

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:JDK8 Lambda相关指令。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法和final方法(尽管是用invokevirtual指令调用,但是一个非虚方法),他们在类加载的时候就会把符号引用解析为该方法的直接引用。

初始化 - Initialization

在准备阶段,变量已经设置过一次零值,而在初始化阶段会按照用户对变量的实际定义执行类构造器。

<clinit>方法是由编译期自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并而成的,编译期收集的顺序是由语句在源文件中出现的顺序所决定,静态语句块中只能访问到定义在静态语句块之前的变量,定义在他之后的变量,在前面的静态语句块可以赋值,但是不能访问。

<clinit>方法与类的构造函数不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>方法的类肯定是java.lang.Object。

<clinit>方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译期可以不为这个类生成<clinit>方法。

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

虚拟机会保证一个类的<clinit>方法在多线程环境中被正确地加锁,同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其他线程都需要阻塞等待,知道活动线程执行<clinit>方法完毕。如果在一个类的<clinit>方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。