深入理解虚拟机执行子系统——你真的了解类加载过程吗?

一提到类加载机制,现在的人大部分都能回答双亲委派模型、加载的大致过程。是的,大部分人知道的东西一定不是错的,但知识和财富一样,唯有少部分才能真正掌握。

开始阅读之前,先统一以下约定:
1.类型:包含了类和接口;
2.Class文件:不是以文件形式保存在磁盘某一处,而是一串二进制字节流,不论其以何种形式存在。有可能是磁盘文件、网络、数据库、内存或者动态产生等。
3.本篇文中使用了较多的反编译来辅助剖析类的加载过程。如果对于反编译或者Class文件不熟悉,则最好先阅读这篇:《深入理解虚拟机执行子系统——扒开Class文件的结构 一探究竟》

类加载的时机

一个类型从被加载到虚拟机内存中,到卸载出虚拟机内存它的整个生命周期会经过:加载、校验、准备、解析、初始化、使用、卸载这七个阶段,其中校验、准备、解析三个部分统称为连接,整个过程如下:
类加载过程
加载过程按照加载、验证、准备初始化和卸载的顺序进行,但解析阶段则不一定。在某些特定的场景下,解析阶段可以在初始化阶段之后开始。这是为了支持Java语言的运行时绑定特性。
整个加载过程按部就班的“开始”,但不意味着将按部就班的结束,这是因为这些阶段通常都是互相交叉混合的进行着,会在一个阶段的执行过程中激活另一个阶段,一个阶段先开始并不意味它要先结束。

加载

加载阶段是整个类加载过程的第一个阶段。这个阶段主要完成3方面的事情:
1.通过一个类的全限定名获取此类的二进制字节流;
2.将这个二进制字节流所代表的的静态存储结构转换为方法区的运行时数据结构;
3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

《Java虚拟机规范》对这3点并没有具体的实现要求。因此留给虚拟机可发挥的空间很大,仅仅第1条就给虚拟机开放了广阔的空间。许多举足轻重的技术就是基于这一条实现的,比如:
1.从ZIP压缩包中读取,这一点很常见,也成为了日后JAR、WAR包格式的基础;
2.从网络中获取,典型应用场景有Web-Applet;
3.运行时动态生成,典型场景有动态代理,生成带有“$Proxy”后缀的代理类的二进制字节流;
4.由其他文件生成,典型应用场景有JSP文件,由JSP文件生成对应的Class文件;
5.从数据库中读取,这种场景相对较少,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发;
6.从加密文件是读取,这种就是典型的Class文件加密防止被反编译的保护措施。通过加载时解密Class文件来保障程序运行逻辑不被窥探。

相对于其他阶段,类型加载阶段是程序员可控性最强的阶段了。加载阶段可以使用Java虚拟机内置的类加载器完成,也可以由开发人员自定义类加载器完成(继承ClassLoader重写findClass或者loadClass方法)。
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

验证是连接阶段的第一步,主要用于校验二进制字节流的内容是否符合《Java虚拟机规范》全部要求,以此来保证这些内容不会对虚拟机造成威胁。整个验证阶段大致分为四个部分:

1.文件格式验证

回想一下Class文件的结构:比如魔数、版本号、常量池、属性表、方法表、局部变量表等,此阶段主要就是对这些文件结构进行校验,常见的有:
1)是否已魔数开头,版本号是否在虚拟机可接受的范围内;
2)常量池中的常量是否有不被支持的类型(检查flag标志);
3)CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据;
4)Class文件各部分结构及其本身是有有被删除或者添加附加信息的内容。

2.元数据验证

这个阶段主要是对Class文件的元数据(比如父类、属性、继承关系等)从语法的角度进行校验。常见的用:
1)这个类是否有父类;是否继承了不被允许继承的类、是否实现了接口中的方法等;
2)这个类的属性、方法是否与父类产生了矛盾。
……

3.字节码验证

这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。常见的有:
1)保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作;
2)保证任何跳转指令都不会跳转到方法以为的字节码指令上;
3)保证方法体中的类型转换总是有效的。

如果一个类型中有方法体没有通过字节码验证,那它肯定是有问题的,如果通过校验了,依然不能证明它是绝对安全的。这里涉及到一个离散数学中的经典问题:“停机问题”——不能通过程序准确地检查出另一个程序是否能在有限的时间之内结束运行。同样地,我们无法通过程序去精准判断另一段程序是否存在bug。
由于数据流分析和控制流分析的高度复杂性,Java虚拟机的设计团队为了避免过多的执行时间消耗在字节码验证阶段中,在JDK 6之后把尽可能多的校验辅助措施挪到Javac编译器里进行。具体做法是给方法体Code属性的属性表中新增加了一项名为“StackMapTable”的新属性,在字节码验证期间,Java虚拟机就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可,从而节省了大量的校验时间。

4.符号引用验证

符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:
1)符号引用中通过字符串描述的全限定名是否能找到对应的类。
2)在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
3)符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段
因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

准备阶段是正式为类中定义的变量(被static修饰的变量)分配内存并为它们赋初始值。
这里需要仔细说明两个容易混淆的点:
1.仅包括类变量的的内存分配和初始值分配,而不包括实例变量!实例变量是随着对象的实例化一起被分配到内存中;
2.这里的赋初始值指的是这些类型的零值。这些类型的零值如下:
基本类型的零值
3.如果类变量同时被final关键字修饰,那么就会在字段属性表中有对应的ConstantValue属性,在准备阶段这个类变量就会被初始化成ConstantValuse属性所指定的值。这个类变量也被称为常量。我们对比源代码和反编译结果:

package com.leon.util;

public class Test {

    private static int VAL_1 = 1;
    private static final int VAL_2 = 2;

    public static void main(String[] args) {
        System.out.println("test class.");
    }
}

// 对应的反编译结果如下:
public class com.leon.util.Test
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#22         // java/lang/Object."<init>":()V
   #2 = Fieldref           #23.#24        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #25            // test class.
   #4 = Methodref          #26.#27        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Fieldref           #6.#28         // com/leon/util/Test.VAL_1:I
   #6 = Class              #29            // com/leon/util/Test
   #7 = Class              #30            // java/lang/Object
   #8 = Utf8               VAL_1
   #9 = Utf8               I
  #10 = Utf8               VAL_2
  #11 = Utf8               ConstantValue
  #12 = Integer            2
……

通过反编译结果我们清晰的看到常量池中存在VAL_2常量和其所对应的ConstantValue属性,以及值为2.而VAL_1类变量虽然指定了值为1,但并没有为其对应的ConstantValue属性。
那么类变量所指定的值什么时候赋值呢?我们带着这个问题先继续往下剖析。

解析

解析阶段是Java虚拟机将Class文件中的符号引用转换为直接引用的过程。
符号引用
在Class文件中,符号引用以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现来描述所引用的目标,符号可以是任何形式的字面量,只要在使用时能够准备的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。
直接引用
直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
对同一个符号引用进行多次解析请求是很常见的事情。除了invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而可以避免解析动作重复进行。
而对于invokedynamic指令而言,上面的规则就不成立了。因为该指令本身的目的是用于支持动态语言,必须等到程序实际运行至此才会触发解析动作,因此每次解析的结果是不一样的。具体原理在以后“动态语言原理”中进行详细剖析。
解析阶段中大致有以下几部分:
1)类或者接口的解析
2)字段的解析
3)方法的解析
4)接口方法解析

初始化

初始化阶段是类加载阶段的最后一个过程。实际上初始化阶段就是执行类构造方法< clinit >()方法的过程。此方法并不是Java代码中直接编写的方法,而是由javac编译器自动生成的,必须要和类的构造方法< init >()方法区分开来。
那么类构造方法是如何诞生的,包含哪些信息呢?
< clinit >()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
< clinit >()方法与类的构造函数(实例构造器< init >()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的< clinit >()方法执行前,父类的< clinit >()方法已经执行完毕。因此在Java虚拟机中第一个被执行的< clinit >()方法的类型肯定是java.lang.Object。
< clinit >()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法。
Java虚拟机必须保证一个类的< clinit >()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的< clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法。
我们通过Java源码和反编译结果对比来看:

package com.leon.util;

public class Test {

    private static int VAL_1 = 1;
    private static final int VAL_2 = 2;

    static {
        System.out.println("static block");
    }

    public static void main(String[] args) {
        System.out.println("test class.");
    }
}

// 省略部分……
// 类构造器反编译如下:
 static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: iconst_1
         1: putstatic     #5                  // Field VAL_1:I
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #6                  // String static block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: return
      LineNumberTable:
        line 5: 0
        line 9: 4
        line 10: 12
// 省略部分……

可以看到,类构造器反编译的结果中确实是按照顺序收集了类变量和静态代码并进行执行。

对于初始化阶段,《java虚拟机规范》严格规定了有且只有6种情况必须立即对类进行“初始化”(加载、连接自然要在此之前开始):
1.遇到new、getstatic、putstatic或者invokestatic这4个指令时,如果类型没有初始化过则必须要先执行初始化过程。
能够生成这4个指令的典型场景如下:
1)使用new关键字实例化对象的时候;

package com.leon.util;

public class Test {
    public static void main(String[] args) {
        Object obj = new Object();
    }
}
// 部分反编译结果:
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class java/lang/Object
         3: dup
         4: invokespecial #1                  // Method java/lang/Object."<init>":()V
         7: astore_1
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8

2)读取或设置一个类型的静态字段;

package com.leon.util;

public class Test {
    public static void main(String[] args) {
    	// 读取常量
        String val1 = TestConstant.VAL_1;
        System.out.println(val1);
    }
}
// 部分反编译结果如下:
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: ldc           #3                  // String TestConstant
         2: astore_1
         3: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         6: aload_1
         7: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        10: return

3)调用一个类型的静态方法;

package com.leon.util;

public class Test {
    public static void main(String[] args) {
        // invoke static method.
        TestConstant.staticMethod();
    }
}
// 部分编译结果如下:
 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=1, args_size=1
         0: invokestatic  #2                  // Method com/leon/util/TestConstant.staticMethod:()V
         3: return

以上便是这4个指令的典型使用场景,当出现了这4个指令,并且没有进行类的初始化时,会立即执行类的初始化过程。

2.使用java.lang.reflect包的方法对类型进行反射调用的时候,如果此时没有进行类的初始化则会立即执行类的初始化过程。

package com.leon.util;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class Test {
    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    	// 使用java.lang.reflect包进行反射调用方法
        Class<?> clazz = Class.forName("com.leon.util.TestConstant");
        Method staticMethod = clazz.getMethod("staticMethod");
        staticMethod.invoke(clazz, null);
    }
}

3)当类在进行初始化时,发现其父类还没有进行初始化,则先执行其父类的初始化。
4)当虚拟机启动时,用户需要指定一个主类(包含main方法的那个类),虚拟机会先初始化这个主类。
5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。关于动态语言,后续会有一篇专门的文章进行剖析。
6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

《java虚拟机规范》使用了及其严格的方式申明有且只有以上6中情况会先执行类的初始化过程。我们来看这个例子:

public class Parent {
	static {
		System.out.println("Parent static block.");
	}
	public static int PARENT_VAL = 5;
}

public class Child extends Parent {
	static {
		System.out.println("Child static block.");
	}
}

public class Test {
	public static void main(String[] args) {
		System.out.println(Child.PARENT_VAL);
	}
}

以上程序执行的结果是:
Parent static block.
5

我们前面说过,当调用了getstatic指令时,如果类没有进行初始化,则先进行类的初始化。在这个例子中,调用了静态变量,必然会生成getstatic指令,因此必然要执行类的初始化过程。只不过这里执行的是父类的初始化过程,并不会因为使用了子类调用父类的静态变量的方式而执行子类的初始化过程。但是是否执行了子类的加载和验证阶段,《java虚拟机规范》并未明确说明,不同的虚拟机实现不同。

对于接口而言,其加载过程与类的加载过程有些不同。主要不同之处在于初始化阶段。对于一个类的初始化而言,必须要先完成父类的初始化,但对于接口而言,没有这一约束,只有在真正使用到了父类的时候才会进行父类的初始化。

使用(实例化)

类实例化之前,必须先经过类的加载过程。类加载过程更多的是虚拟机内部的活动,而实例化过程开发人员有更多的控制空间。
实例化一个对象的方式有很多种,具体如下:
1.通过new关键字实例化一个类的对象;
2.通过反射的方式实例化一个类的对象;

Class.forName(“java.lang.Object”).newInstance();
Object.class.newInstance();
Person .class.getConstructor().newInstance();

3.通过clone方法实例化一个类的对象;
4.通过I/O流反序列化,实例化一个类的对象。

不论采用哪种方式实例化一个类的对象,都要完成对象在内存中的布局,最终完成构造方法的调用。如果有父类,则必须完成父类的构造方法的调用。
包括整个加载过程,其具体初始化顺序如下:
父类的类构造器——>子类的类构造器——>父类的成员变量初始化(开发人员赋值)——>父类的构造方法——>子类的成员变量初始化(开发人员赋值)——>子类的构造方法

如果看懂了类的加载过程,那么上面的初始化顺序能够很轻松的理解到。

很多博客所描述的的初始化顺序其实都不是特别严谨,并且没有阐述清楚背后的原理,不信你可以在百度上搜索关键字“类的初始化顺序”并随便打开一篇博客,看看是否如此,很容易就被误导了……

卸载

当一个对象使用完成之后,如果符合垃圾回收,那么虚拟机将会在合适的时间将其回收。但是类的的卸载条件则是十分严苛的。具体我们可以参考这篇文章:《深入理解Java垃圾回收——对象已死?》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章