【Java】JVM - 类加载机制

484 阅读18分钟

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

  • 本文所有信息基于作者实践验证,如有错误,恳请指正,不胜感谢。
  • 转载请于文首标明出处:【Java】JVM - 类加载机制

类的唯一性

类的唯一性由类全限定符及其类名称空间决定。即类的唯一性不是仅仅是由类本身决定,而是由类本身和负责加载它的类加载器共同决定。每个类加载器都有自己的类名称空间。

类生命周期

类的生命周期为:加载(Loading)、验证(Verfication)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个阶段统称为连接(Linking)。

七个阶段中除了解析阶段执行时间不确定之外,其他阶段的进行顺序是确定的。解析阶段在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 运行时动态绑定的特性。

类加载过程

类加载过程为类加载机制中的前五个步骤:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)和初始化(Initialization)。其中验证、准备和解析这三个步骤被称为连接(Linking)。

加载

'Loading' is a part of 'Class Loading'.

加载阶段的作用是将类的二进制字节流解析存储到方法区中,并生成 Class 实例。分为三个步骤:

  1. (获取)通过类的全限定名来获取定义这个类的二进制字节流。负责这一操作的代码称为“类加载器(ClassLoader)

  2. (转换)将这个字节流所代表的静态存储结构转化为方法区内的运行时数据结构。方法区中的数据存储格式完全由 JVM 实现自行定义。

  3. (创建)在内存中实例化一个代表该类的 java.lang.Class 对象,作为方法区中这个类各种数据的访问入口。

除了启动类加载器(Bootstrap Class Loader)是内置在 JVM 中的之外,其他系统默认的或者用户自行实现的类加载器都在 JVM 之外。JVM 设计团队故意将这个操作放在 JVM 外部去实现,以便让用户自行决定去哪些地方获取所需的类,因此加载阶段的第一步是整个类加载机制中用户可控性最强的阶段。

字节码流可以来自于 .class 文件、压缩包(jar、war、ear)、网络、计算生成(动态代理)、其他文件生成(JSP)、加密文件读取、数据库读取等等途径。开发人员可以通过定义自己的类加载器去控制字节流的获取方式。

唯一不由类加载器加载的是数组类型。数组类是由 JVM 直接在内存中动态构造出来的,其本身不通过类加载器创建,但它的组件类型依旧需要由类加载器加载。一个数组类的类名称空间由其组件类型决定:

  • 如果它的组件类型为原语类型,则它的类名称空间为启动类加载器的类名称空间。

  • 如果它的组件类型是引用类型,则递归加载它的组件类型,它的类名称空间与它的组件类型的类名称空间一致。

同时数组类的可访问性与它的组件类型的可访问性一致。

第一个步骤执行完毕之后,会进行验证阶段的文件格式验证,如果无法通过验证,这段字节流并不会被允许进入方法区中存储。因此加载阶段和连接阶段是交叉运行的。第一步骤执行完毕之后,开始由 JVM 完全掌控进行。

验证

验证阶段的作用是确保 Class 文件字节流中包含的信息符合 JVM 规范的全部约束要求,保证这些信息不会危害虚拟机的安全。

虽然 Java 代码的语法错误在编译期间可以检测出来并拒绝编译,但 JVM 是语言无关的虚拟机,并不仅仅是服务于 Java 代码,即使是 Java 代码也不是所有字节码流都是从 javac 编译而来,因此类加载过程必须非常严谨地对字节码流进行验证。验证阶段大致分为四个步骤:文件格式验证元数据验证字节码验证符号引用验证

  1. 文件格式验证(文件):验证 Class 文件是否符合规范,这发生在加载阶段的一二步骤之间。

  2. 元数据验证(类):对字节码描述的类元数据信息进行语义分析和校验,保证不存在与《Java 语言规范》定义相悖的元数据信息。

  3. 字节码验证(代码):通过数据流及控制流分析,确定程序的语义是合法的、符合逻辑的。

  4. 符号引用验证(引用):发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作是在解析阶段中发生的,因此符号引用验证发生在解析阶段。验证内容包括:

    • 通过全限定名是否能找到类。
    • 指定类中是否存在符合字段或者方法。
    • 符号引用中类、字段、方法的可访问性及是否能被当前类访问。
    • ...

    若无法通过符号引用验证,将抛出 java.lang.IncompatibleClassChangeError 的子异常,如 IllegalAccessErrorNoSuchFieldErrorNoSuchMethodError 等。

准备

准备阶段的作用是为类变量分配内存并设置初始值。

类变量所使用的内存在逻辑上应当在方法区中分配。但是 JDK7 之后字符串常量池和类静态变量都从方法区中移出,转移到堆中,因此 JDK7 之后准备阶段的内存分配是在堆中,JDK7 即之前的内存分配是在方法区中。

准备阶段所赋予的初始值是变量数据类型的零值,如果类变量在声明时有赋予特定值,将在类初始化阶段进行赋值。不过如果变量是常量的话,其初始值就是声明时的值。如:

public static int value = 42;		// 准备阶段赋予 0;
public static final int finValue = 42;  // 准备阶段赋予 42;

解析

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

JVM 规范中并未规定解析符号引用的具体时间,只要求在用到它们之前对它们进行解析即可。

解析分为静态解析动态解析,静态解析可以在符号引用验证通过之后进行。在静态解析某个符号引用之后,后续不会再对它进行解析,会直接返回之前解析的结果,无论是成功还是失败。

动态解析仅对于 invokedynamic 指令生效,每次 invokedynamic 指令的执行,都需要重新进行解析,因为该指令就是为了支持 Java 的动态特性,它对应的符号引用称为“动态调用点限定符”。

  • 类或接口的解析:

    如果要加载的类不是数组,就交给当前类的类加载器加载。如果是数组就调用当前类的类加载器逐层加载数组的组件类型。

    如果已经加载在方法区中了,进行符号引用验证(验证中的第四步),判断是否有访问权限,否则将抛出 IllegalAccessError

  • 字段解析:

    要进行字段解析,需要先解析字段所属的类或接口。解析时先搜索当前类是否有该字段,接着从下到上搜索实现和继承的接口,接着从下到上搜索继承的父类,找到了就马上返回字段的直接引用,最终找不到就抛出 NoSuchFieldError

    找到之后进行符号引用验证,如果没权限抛出 IllegalAccessError

    同样,解析完成之前需要进行符号引用验证,如果当前类不具备该字段访问权限,抛出 IllegalAccessError。虽然这种解析方式表明,子类与父类如果声明相同的字段也是可以确定唯一的访问字段的,但是某些 Java 编译器会不允许这种写法通过编译。

  • 方法解析

    要进行方法解析,同样需要先解析方法所属的类或接口。解析时先判断所属的类是否是接口,是的话抛出 IncompatibleClassChangeError。不是的话先搜索当前类,再自下而上递归查找父类是否有该方法,没有的话再自下而上递归查找实现或继承的接口,如果接口有,表明当前类是抽象类,抛出 AbstractMethodError,如果没有,抛出 NoSuchMethodError

    找到之后进行符号引用验证,如果没权限抛出 IllegalAccessError

    判断方法是否匹配需要验证简单名称和方法描述符是否相同。

  1. 接口方法解析

    接口方法解析也需要先解析所属的类或接口。解析时先判断当前类是否是接口,如果不是就抛出 IncompatibleClassChangeError。是就先查询当前接口,再自下而上递归查询父接口(接口的顶级父类也是 Object)。如果没有就抛出 NoSuchMethodError

    在 JDK9 中增加了接口的静态私有方法,因此自 JDK9 起,也需要对接口方法进行符号引用验证。由于接口支持多继承,因此可能实现类中继承的多个接口存在相同的方法,JVM 规范中并没有限制或约束该使用哪一个接口的方法,但同样某些 Java 编译器会不允许这种写法通过编译。

初始化

类的初始化是类加载过程的最后一个步骤,是执行类构造器 <clinit>() 的过程。

<clinit>() 是编译器自动生成的产物,编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生,收集顺序是由语句在源文件中出现的顺序决定。因此,静态块只能访问定义在静态块之前的类变量,定义在它之后的变量,前面的静态块可以为它赋值,但不能访问。

子类构造时不需要显示调用父类的 <clinit>() 构造器,JVM 会在子类构造前执行父类类构造器。这也意味着父类静态块要优先于子类静态变量的赋值。而接口的 <clinit>() 方法不需要在实现类或子接口初始化前执行,因为只有当接口中的静态域被使用时,接口才会被初始化。

public class TestInitialization {
    /* 父类 */
    static class Parent {
        static {
            a = 12;
            b = 12; // 若类变量声明语句带有赋值操作,则静态块提前赋值没有任何意义。
        }
        static int a;
        static int b = 1;
        static {
            System.out.println("a : " + a); // 12
            System.out.println("b : " + b); // 1
            a = 42;
        }
    }
    
    /* 子类 */
    static class Child extends Parent{
        static int c = a; // 父类所有静态语句会在子类初始化前执行。
    }

    public static void main(String[] args) {
        System.out.println("c : " + Child.c); // 42
    }
}

<clinit>() 方法对于类或接口而言不是必须的,如果类或接口没有任何静态块和对类变量的赋值操作,则不会生成 <clinit>() 方法。

需要注意的是,<clinit>() 方法的执行必然要加锁,因此如果 <clinit>() 方法执行时间太长,可能会造成非常隐蔽的系统阻塞。

初始化时间

JVM 规范中没有规定类的加载必须在什么时候进行,但是有规定类的初始化必须在什么时候进行,因此类的加载、验证、准备阶段就必须在此之前进行。以下情况必须进行初始化:

  1. 当虚拟机启动时,执行 main 方法的主类。

  2. 执行与类有关的字节码指令时,即 newgetstaticputstaticinvokestatic 指令。

  3. 当使用 JDK7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial 这四种类型方法句柄时。

    字节码指令类型方法句柄操作
    newREF_neInvokeSpecial实例化
    getstaticREF_getStatic读取类变量
    putstaticREF_putStatic修改类变量
    invokestaticREF_invokeStatic调用类方法

    需要注意的是,对静态常量执行 getstatic 指令并不需要实例化类,因为常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类。

  4. 使用 java.lang.reflect 包对类进行反射调用时。

  5. 该类的子类发生初始化时。

  6. 若实现类实现的接口中存在 default 方法,则实现类发生初始化时会初始化接口,如果接口没有 default 方法,并不会初始化该接口,只有用到时才会初始化。

上述六种场景称为类的主动引用。除此之外的所有引用类型都不会触发初始化。称为被动引用,如:

  • 子类引用父类字段,子类不必初始化

  • 定义某类型数组,该类型不必初始化

  • 调用类的静态常量,该类不必初始化

    public class TestPassiveReference {
        private static class Parent {
            public static int value = 42;
            static {System.out.println("Parent Class init!");}
        }
        private static class Child extends Parent {
            public static final String WORD = "HELLO WORLD";
            static {System.out.println("Child class init!");}
        }
        public static void main(String[] args) {
            System.out.println(Child.value); // 子类引用父类字段,子类不必初始化
            Child[] children = new Child[2]; // 定义某类型数组,该类型不必初始化
            System.out.println(Child.WORD);  // 调用类的静态常量,该类不必初始化
        }
    }
    /*
     * OUTPUT:
     * Parent Class init!
     * 42
     * HELLO WORLD
     */
    

类加载器

JVM 规范特意将 Loading 阶段的“通过一个类的全限定名来获取描述该类的二进制流字符串”放到 JVM 之外实现,是为了让程序自己决定如何去获取类所需的字节码流。实现这个动作的代码被称为“类加载器”(Class Loader)。

类的唯一性由其类加载器和类自身决定,每个类加载器都有一个独立的类名称空间。因此,并不是两个类的全限定名一致两个类就相等。有时由于类加载器的不同,两个相同类的对象 instanceOf 的结果却是不同。

在虚拟中只有两种类加载器:启动类加载器(Bootstrap ClassLoader)与其他所有的类加载器(全部继承自 ClassLoader)。自 JDK1.2 以来,Java 一直保持着三层类加载器 + 双亲委派的类加载架构。

三层类加载器

JDK9 之前,类主要是由三层类加载器完成加载的,分别为启动类加载器、拓展类加载器、应用程序类加载器。

  • 启动类加载器(Bootstrap Class Loader):

    负责加载 $JAVA_HOME$\lib 目录或者被 -Xbootclasspath 参数所指定的路径中存放的,且 JVM 能够识别的类库。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器处理,直接在 ClassLoader::getClassLoader 中返回 null 即可。

  • 拓展类加载器(Extension Class Loader):

    sun.misc.Launcher$ExtClassLoader
    负责加载 $JAVA_HOME$\lib\ext 目录或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。JDK 开发团队出于让用户拓展 JAVASE 功能的目的,允许用户将类库放置在 ext 目录里使用拓展类加载器进行加载。这种拓展机制被模块化带来的天然拓展能力所取代。

  • 应用程序类加载器(Application Class Loader):

    sun.misc.Launcher$AppClassLoader
    ClassLoader::getSystemClassLoader 的返回值,因此也有称之为系统类加载器,负责加载用户类路径 ClassPath 上所有类库,开发者可以在代码中使用这个类加载器,如果用户没有自定义类加载,则默认使用该加载器。

双亲委派模型

双亲委派模型(Parents Delegation Model)是各种类加载器之间的层次关系。其要求为:

除启动类加载器外,其它所有类加载器都应有自己的父加载器(这个“父子”关系不是由继承实现而是由复合实现,每个子类加载器中都有一个 parent 实例指向父类加载器),当类加载器在加载类时,如果存在 parent 实例,则调用 parent 执行加载,仅当 parent 无法成功加载时子类加载器才执行加载。

这也是在 JVM 中加载器仅分为启动类加载器和其他类加载器的原因,因为启动类加载器没有父类加载器。双亲委派模型保证了对于相同类限定名的类型,使用各种类加载器都能加载到同一个类。如用户自己定义了 java.lang.String 类,JVM 永远都会调用启动类加载器去加载 Java 类库中的 String 类型。

OSGi

OSGi 曾经是 Java 业界“事实上”的 Java 模块化标准。OSGi 实现模块化热部署的方式是通过自定义类加载器实现的,每一个程序模块 Bundle 都有自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一同换掉实现代码的热替换。当接收到类加载请求时,按照下面的顺序进行类搜索:

  1. java.* 开头的类,委派给父类加载器加载。
  2. 否则,将委派名单中配置的类委派给父类加载器加载。
  3. 否则,将 Import列表中的类委派给 Export 这个类的 Bundle 的类加载器加载。
  4. 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。
  5. 否则,查找类是否在自己的 Fragment Bundle 中,是则委派给 Fragement Bundle 的类加载器加载。
  6. 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。
  7. 抛出 ClassNotFoundException

模块化

模块化的关键目标 —— 可配置的封装隔离机制。除了隔离代码之外,Java 的模块定义还包含以下内容:

  • 依赖其他模块的列表

  • 导出的包列表,即其他模块可以使用的列表

  • 开放的包列表,即其他模块可反射访问模块的列表

  • 使用的服务列表

  • 提供服务的实现列表

此前,如果类路径中缺失了运行时依赖的类,只能等程序运行到发生该类型加载、链接时才会抛出异常。而如果启用模块化封装,模块可以声明对其他模块的显式依赖,这样 JVM 就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如果缺失则启动失败,从而避免很大一部分由于类型依赖而引发的运行时异常。但对于模块内封闭的类型,依旧可能在运行期间抛出异常。

模块化系统依据以下规则保证传统类路径依赖的程序可以自己运行在模块化系统上:

  • JAR 文件在类路径的访问规则:所有类路径下的 jar 文件及其他资源文件,都被视为自动打包在一个匿名模块(Unnamed Module)中,这个匿名模块不进行任何隔离,它可以看到和使用类路径上的所有包、JDK 系统模块中所有的导出包,以及模块路径上所有模块中导出的包。

  • 模块在模块路径的访问规则:具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块中所有的内容对具名模块都是不可见的,即具名模块看不见传统 jar 包的内容。

  • JAR 文件在模块路径的访问规则:该 Jar 文件会变成一个自动模块(Automatic Module)。自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,同时也将默认导出自己所有的包。

模块化下的类加载器

拓展类加载器被平台类加载器(Platform Class Loader)取代。新版 JDK 也取消 jre 目录,因为可以随时组合模块构建出程序运行所需的 JRE。平台类加载器和应用程序类加载器不再派生自 java.net.URLClassLoader,如果有程序直接依赖了这种继承关系,或者依赖 URLClassLoader 的特定方法,那代码可能将在 JDK9 及更高版本崩溃。启动类加载器、平台类加载器、应用程序类加载器全部继承于 jdk.internal.loader.BuiltinClassLoader

同时启动类加载器现在由 JVM 和 Java 类库共同协作实现,为“BootClassLoader”。尽管有 BootClassLoader,在需要获取启动类加载器时,仍然使用 null 来获取启动类加载器而不是获取 BootClassLoader 实例。

平台类加载器与应用程序类加载器变成平级的关系,二者都可以直接委派给启动类加载器,也可以委派给对方。如果带加载类能归属到某一个系统模块中,则委派给负责该模块的加载器。