深入理解 Java 虚拟机(六)类加载的过程

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u011330638/article/details/82718198

类加载的过程

加载

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

1) 通过一个类的全限定名来获取此类的二进制字节流

2) 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

3) 在内存中生成一个代表这个类的 java.lang.Class 对象,作为程序访问方法区中的这个类的数据的入口

虚拟机规范的这三点要求并不算具体,它没有指明要从哪里获取、怎样获取,因此实现方法有很多种,许多重要的 Java 技术都建立在加载阶段具备的灵活性的基础之上,比如:

1) 从 ZIP 包读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础

2) 从网络中读取,典型的应用就是 Applet

3) 运行时计算生成,这种场景使用得最多的就是动态代理技术。在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为 “*$Proxy” 的代理类的二进制字节流

4) 从其它文件读取,典型应用是 JSP,即由 JSP 文件生成对应的 Class 类

5) 从数据库中读取

……

一个非数组类的加载阶段是开发人员可控性最强的,既可以使用系统提供的引导类加载器来完成,也可以通过自定义的类加载器完成。

而数组类本身是由 Java 虚拟机直接创建的,创建过程遵循以下原则:

1) 如果数组元素是引用类型,则采用上述加载过程,数组 C 将在加载该组件类型的类加载器的类名称空间上被标识(这点很重要,一个类必须与类加载器一起确定唯一性)

2) 如果数组元素不是引用类型,Java 虚拟机将会把数组 C 标记为与引导类加载器关联(引导类加载器 - 启动类加载器,看下一节笔记)

3) 数组的可见性与它的组件类型的可见性保持一致,如果数组元素不是引用类型,则默认为 public

验证

验证的目的是确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

从执行性能的角度看,验证阶段的工作量在虚拟机的类加载子系统中占了相当大的一部分。

验证阶段大致上会完成 4 个检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证

该阶段主要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。

可能包括以下验证点:
1) 是否以魔数 “CAFEBABA” 开头
2) 主、次版本号是否在当前虚拟机处理范围之内
3) 常量池中的常量是否有不被支持的常量类型
4) 指向常量池中的各种索引值是否有指向不存在的常量或不符合类型的常量
5) CONSTANT_Utf8_info 常量是否有不符合 Utf8 编码的数据
……

实际上,第一阶段的验证点还远不止这些,上面只是一小部分。该阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合一个 Java 类型信息的要求。该阶段是基于二进制字节流进行验证的,通过了该阶段之后,字节流才会进入方法区,所以后面 3 个阶段全都是基于方法区的存储结构进行的,不会再直接操作字节流。

元数据验证

该阶段的工作是对字节码描述的信息进行语义分析,确保其符合 Java 语言规范的要求。

可能包括的验证点如下:
1) 是否有父类(除了 Object 之外,都应该有父类)
2) 这个类的父类是否继承了 final 类
3) 如果这个类不是抽象类,则是否实现了父类或接口要求实现的所有方法
4) 类中的字段、方法是否与父类产生矛盾
……

字节码验证

该阶段的主要目的是通过数据流和控制流分析,确保程序语义是合法的、符合逻辑的。

第二阶段对数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证不会出现危害虚拟机安全的事件,如:
1) 保证任意时刻操作数栈的数据类型与指令字节码都能配合工作,例如不会出现按照 long 类型加载一个 int 类型数据的情况
2) 保证跳转指令不会跳转到方法体以外的字节码上
3) 保证方法体中的类型转换是有效的
……

在离散数据中有一个很著名的问题 “Halting Problem”:通过程序去校验程序逻辑是无法做到绝对准确的,因此通过了字节码验证也不能保证一定安全。

由于数据流验证的高复杂性,虚拟机设计团队在 JDK 1.6 新增了一项属性 “StackMapTable”,这项描述了方法体所有的基本块应有的状态,这样在字节码验证期间只需检查 StackMapTable 即可,从而节省了一些时间。

符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段中发生。符号引用验证可以看作是对类自身以外的信息进行匹配性校验,通过需要校验如下内容:
1) 符号引用通过字符串描述的全限定名是否能找到对应的类
2) 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
3) 符号引用中的类、字段、方法的访问性(public、private 等)是否可被当前类访问
……

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

对于类加载机制而言,该阶段是非常重要但不一定必要的,如果运行的全部代码已经被反复验证过,则可以使用 -Xverify:none 参数来关闭。

准备

准备阶段是正式为类变量分配内存并赋初值的过程,这些变量所使用的内存都将在方法区中进行分配。初始值通常为 0,比如:

public static int value = 132;

value 在准备阶段过后的初始值为 0 而不是 123,把 value 赋值为 123 是在 putstatic 指令后,而 putstatic 是在程序被编译后,存放于 < clinit > 方法之中的,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。

而如果类字段的字段属性表中存在 ConstantValue 属性,那么在准备阶段,变量 value 就会被初始化为 ConstantValue 属性所指定的值:

public static final int value = 123;

ConstantValue 在 javac 编译之后生成。

对于局部变量,则必须手动赋初始值才可以使用。

解析

解析阶段是将常量池内的符号引用替换为直接引用的过程。

符合引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量。符号引用与虚拟机内存布局无关,引用的目标不一定加载到了内存中。各种虚拟机实现的内存布局不可不同,但所能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。

直接引用:直接引用可以是指向目标的指针、相对偏移量、句柄。与虚拟机实现的内存布局相关,如果有了直接引用,则引用的目标必定存在于内存中。

虚拟机规范并未明确规定解析阶段发生的时间,只要求在 anewarray、getfield、invokestatic 等 16 个用于操作符号引用的字节码指令之前完成即可。

同一个符号引用可能被多次解析,第一次解析的结果和之后解析的结果应该相同。但对于 invokedynamic 指令,这个规则不成立。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于 CONSTANT_Class_info、CONSTANT_Fieldref_info 等 7 中常量类型。下面讲解前 4 中,后面 3 中将在讲解 invokedynamic 时一起讲解。

类或接口的解析

如果要把类 D 中的一个符号引用 N 解析为一个类或接口 C,则需要以下 3 个步骤:

1) 如果 C 不是一个数组类型,则虚拟机会把 N 的全限定名传递给 D 的类加载器去加载,加载过程中可能会触发其它相关类的加载动作,比如 C 的父类

2) 如果 C 是一个数组类型,且数组元素为对象,则按照第 1 点所说的规则加载数组元素,接着由虚拟机生成一个代表此数组维度和元素的数组对象

3) 如果上述步骤没有出现异常,那么 C 在虚拟机中已经是一个有效的类或接口了,但在解析完成之前还需要进行符号引用验证

如果 D 不具备对 C 的访问权限,则抛出 java.lang.IlleagalAccessError 异常。

字段解析

要解析一个从未被解析过的字段,首先需要对它指向的 CONSTANT_Class_info 符号引用进行解析,如果解析失败,则字段解析失败,成功后按照如下步骤解析:

1) 如果类 C 中包含简单名称和描述符都与目标相匹配的字段,则返回该字段的直接引用

2) 否则,如果类 C 中实现了接口,则按照继承关系从下往上搜索各个接口,如果找到则返回

3) 否则,如果类 C 不是 Object 类,则按照继承关系从下往上搜索其父类,如果找到则返回

4) 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常

如果找到后发现不具备访问权限,则抛出 java.lang.IlleagalAccessError 异常

在实际应用中,规则可能更严格,如果有一个同名字段同时出现在类 C 的接口和父类中,或者同时在自己或父类的多个接口中出现,则编译器可能拒绝编译。

类方法解析

类方法的解析和字段解析类似:

1) 如果在类方法表中发现 class_index 指向的是个接口方法,则抛出 java.lang.IncompatibleClassChangeError 异常

2) 如果在类 C 中简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用

3) 否则,在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果找到则返回

4) 否则,在类 C 的接口列表及它们的父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,则说明 C 是一个抽象类,抛出 java.lang.AbstractMethodError 异常

5) 否则,查找失败,抛出 java.lang.NoSuchMethodError

接口方法解析

同上:

1) 如果在接口方法表中发现 class_index 指向的是个类方法,则抛出 java.lang.IncompatibleClassChangeError 异常

2) 如果在类 C 中简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用

3) 否则,在类 C 的父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果找到则返回

4) 否则,查找失败,抛出 java.lang.NoSuchMethodError

由于所有接口方法默认都是 public 的,因此不存在访问权限的问题。

初始化

到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。初始化阶段是执行 < clinit > 方法的过程:

1) < clinit > 方法是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块中的语句合并产生的,收集顺序取决于源文件的顺序,静态语句块只能访问到定义静态静态语句块之前的变量,之后的变量可以赋值但不能访问:

public class Test {
    static {
        i = 0;
        System.out.println(i); // 错误
    }

    static int i = 1;
}

2) < clinit > 方法与 < init > 方法不同,不需要显式地调用父类的 < clinit >,虚拟机保证在执行子类的 < clinit > 方法之前,父类的 < clinit > 方法已经执行完毕

3) 由于父类的 < clinit > 方法先执行,因此父类中定义的静态语句块优先于子类执行,比如:

static class Parent {
        public static int A = 1;
        static {
            A = 2;
        }
}

static class Sub extends Parent {
        public static int B = A;
}

public static void main(String[] args) {
        System.out.println(Sub.B);
}

输出:2。

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

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

6) 虚拟机保证一个类的 < clinit> 方法在多线程环境下被正确地加锁、同步,一个类的 < clinit> 方法只会执行一次,如果有多个线程同时初始化一个类,则只有一个线程会执行 < clinit> 方法,其它线程会被阻塞。因此如果 < clinit> 方法耗时很长,则可能造成多个线程阻塞:

static class DeadLoopClass {
    static {
        // 如果不加上这个if语句,编译器将提示“Initializer does not complete normally”并拒绝编译
        if (true) {
            System.out.println(Thread.currentThread() + "init DeadLoopClass");
            while (true) {
            }
        }
    }
}

public static void main(String[] args) {
    Runnable script = new Runnable() {
        public void run() {
            System.out.println(Thread.currentThread() + "start");
            DeadLoopClass dlc = new DeadLoopClass();
            System.out.println(Thread.currentThread() + " run over");
        }
    };

    Thread thread1 = new Thread(script);
    Thread thread2 = new Thread(script);
    thread1.start();
    thread2.start();
}

运行结果如下:

Thread[Thread-0,5,main]start
Thread[Thread-1,5,main]start
Thread[Thread-1,5,main]init DeadLoopClass

猜你喜欢

转载自blog.csdn.net/u011330638/article/details/82718198