类加载过程

166 阅读14分钟

类加载顺序

一个Java文件从编码到最终执行,主要包括:编译期和运行期。所谓的编译,就是把.java文件通过javac命令编译成字节码.class文件;运行则是把.class文件交给JVM执行。

类加载过程是指JVM把.class文件中类信息加载进内存,并进行解析生成对应的class对象的过程。一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 、验证、准备、解析、初始化 、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为 连接。

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

按部就班地“开始” 并不意味 按部就班地“进行” 或 按部就班地“完成”。这些阶段通常是互相交叉的混合进行的,会在一个阶段执行的过程中调用,激活另一个阶段

类加载的过程

加载

把class字节码文件从各个来源通过类加载器装载入内存中:

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

其中字节码来源除了本地路径下编译生成的.class文件,jar包中的.class文件之外,还可以是:

  • 从网络中获取,比如 Applet。
  • 动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。

为什么需要自定义类加载器?

  1. java代码容易被反编译,因此我们可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
  2. 我们可能会从非标准来源加载代码,比如网络来源,那就需要自己实现一个类加载器,从指定源进行加载。

对于数组类而言,它本身并不由类加载器创建,而是由Java虚拟机直接在内存中动态构造出来的。不过数组类的元素类型(数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。

验证

Class文件并不一定只能由Java源码编译而来,它可以使用包括靠键盘0和1直接在二进制编辑器中敲出 Class文件在内的任何途径产生。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为 载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟 机保护自身的一项必要措施。

准备

为静态变量(类变量)在方法区中分配内存并设置类变量初始值。

此阶段不会对实例变量进行内存分配,实例变量是在对象实例化时随着对象一起分配在Java堆中。

此处说的类变量初始值“通常情况下”是数据类型的零值,假设一个类的变量的定义如下代码所示,则value在准备阶段后的初始值是0而不是123,因为此时尚未执行任何Java方法。把 value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值 为123的动作要到类的初始化阶段才会被执行。

public static int value = 123;

点击并拖拽以移动

解析

JVM将常量池内的符号引用 替换为直接引用。

符号引用:任意类型的字面量。只要能无歧义的定位到目标。在编译期间由于暂时不知道类的直接引用,因此先使用符号引用代替。最终还是会转换为直接引用访问目标。比如java/lang/StringBuilder

直接引用:一个内存地址或偏移量。比如静态方法(类方法),静态变量(类变量)的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始一直到到该实例变量位置的偏移量

类或接口的解析

假设当前代码所处的类为D,若要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,则需要:

(1)若C不是一个数组类型,则虚拟机把N的全限定名传递给D的类加载器去加载类C。在加载过程中,由于类数据验证,字节码验证的需要,可能触发其他相关了类的加载动作,例如加载这个类的父类或其实现的接口。如果加载过程出现任何异常,解析过程失败。

(2)若C是一个数组类型,且数组的元素为对象,则按上一点的规则去加载数组元素类型。如果N的描述符如前面假设的形式,需要加载的元素类型类似为java.lang.Integer,则会由虚拟机生成一个代表该数组维度和元素的数组对象。

(3)若上面两步没有出现任何异常,则C在虚拟机实际已成为一个有效的类或接口。但在解析完成前 还要进行符号引用验证,确认D是否具备对C的访问权限。若发现不具备权限,则抛出java.lang.IllegalAccessError异常。

字段解析

要解析一个未被解析过的字段符号引用,首先会对字段表内的class_index项中索引的 CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果解析成功,此处我们把该字段所属的类或接口用C表示。接下来的步骤:

(1)若C本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

(2)否则,若在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口。如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

(3)否则,如果C不是java.lang.Object,则按照继承关系从下往上递归搜索其父类,若在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个租佃的直接引用,查找结束。

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

方法解析

先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,此处用C表示这个类,虚拟机进行如下步骤:

(1)如果在类的 方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError 异常。

(2)如通过第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则 返回这个方法的直接引用,查找结束。

(3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返 回这个方法的直接引用,查找结束。

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

(5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

接口方法解析

首先解析出接口方法表的class_index 项中索引的方法所属的类或接口的符号引 用,如果解析成功,此处用C表示这个接口,接下来虚拟机执行如下步骤:

(1)如果在接口方法表中发现class_index中的索引C是个类而不是接口,则直接抛出java.lang.IncompatibleClassChangeError异常。

(2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。

(3)否则,在接口C的父接口中递归查找,直到java.lang.Object类为止,查看是否有简单名称和描述符都与目标相匹配的方法,若有则返回这个方法的直接引用,查找结束。对于此步骤的查找,如果C的不同父接口中存有多个简单名称和描述符 都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找。

(4)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

初始化

JVM开始真正执行 类中编写的Java代码。在前面的准备阶段中,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据类中的代码去初始化类变量和其他资源。初始化阶段就是执行类构造器()方法的过程。

()方法是编译器自动收集所有类变量的赋值动作和静态语句块(static{}块)中的 语句合并产生的,收集的顺序由语句在源文件中出现的顺序决定。

在初始化阶段,jvm主要完成对静态变量的初始化,静态块执行等工作。

在静态语句块中只能访问到 定义在静态语句块之前的变量。对于定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

()方法会显示的调用父类构造器,JVM保证在子类的()方法执行前,父类的()方法已经执行完毕。

父类的()方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值。下面的代码中,字段B的值是2而非1

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);
}

此外:

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

(2)接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成()方法。但执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。

(3)JVM保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完。

主动引用和被动引用

《Java虚拟机规范》 规定了有且只有六种情况必须立即对类进行“初始化”(加载、验证、准备需要在此之 前开始):

(1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

  • 使用new关键字实例化对象的时候。
  • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
  • 调用一个类型的静态方法的时候。

(2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。

(3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

(4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类。

(5)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

(6)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

上述六种场景的行为称为对一个类型的主动引用,除此之外的所有引用类型方式都不会触发初始化,称为被动引用。下面来说明什么是被动引用:

(1)通过子类引用父类的静态字段,不会导致子类初始化

package org.fenixsoft.classloading;

public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}
public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}
/**
 * 非主动使用类字段演示
 **/
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

上述代码运行之后,只会输出“SuperClass init!”,而不会输出“SubClass init!”。

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

(2)通过数组定义来引用类,不会触发此类的初始化

public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];       //上面代码的SuperClass
    }
}

运行后没有输出“SuperClass init!”,说明并没有触发类org.fenixsoft.classloading.SuperClass的初始化阶段。不过这段代码触发了一个类的初始化:Lorg.fenixsoft.classloading.SuperClass。它是一个由虚拟机自动生成的,创建动作由字节码指令newarray触发。这个类代表了一个元素类型为org.fenixsoft.classloading.SuperClass的一维数组,数组中应有的属性 和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。

Java语 言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问,而C/C++中则是直接对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出 java.lang.ArrayIndexOutOfBoundsException异常,避免了直接造成非法内存访问。

(3)常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类初始化。

public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}
/**
 * 非主动使用类字段演示
 **/
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}

上面的代码没有输出语句:虽然代码中确实引用了ConstClass类的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”直接存储在NotInitialization类的常量池中,以后NotInitialization类对常量 ConstClass.HELLOWORLD的引用,实际都被转化为NotInitialization类对自身常量池的引用了。实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。

接口的初始化

一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

上面的代码都是用静态语句块“static{}”来输出初始化信息的,而接口中不能使 用“static{}”语句块,但编译器仍然会为接口生成““” ”类构造器,用于初始化接口中所定义的 成员变量。

卸载

GC将无用对象从内存中卸载。

参考资料

《深入理解Java虚拟机笔记》