Jvm类加载机制

类加载机制

类加载机制是指Java虚拟机(JVM)如何 ==将.class文件(实际为二进制字节流)加载到内存,并创建出对应的Class对象==的过程。这一过程涉及加载、验证、准备、解析和初始化五个阶段。

类加载过程

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如图所示:

  1. 加载:查找并加载类的二进制数据,存放到方法区中。

  2. 验证:确保加载的类信息符合JVM规范,没有安全威胁。

  3. 准备:为类的静态变量分配内存,并赋予初始值(如int型默认为0)。

  4. 解析:将常量池中的符号引用转为直接引用,以便直接定位到类、方法、字段等信息在内存中的地址。

  5. 初始化:执行类的静态初始化代码(如静态变量赋值、静态代码块),赋予静态变量最终的值。

加载

在JVM的类加载过程中,加载阶段(Loading)是第一个阶段,其主要任务是将类的二进制字节流读入到内存中,并转换为JVM可以处理的形式。加载阶段由类加载器(ClassLoader)执行,类加载器负责以下主要任务:

  1. 获取类的二进制字节流:类加载器需要获取类的二进制字节流,这个字节流可以来自不同的来源,如文件系统中的.class文件、网络资源、数据库甚至是动态生成的字节码。加载器通过类的全限定名(例如com.example.MyClass)来定位并获取相应的字节流。

  2. 将字节流转换为数据结构:加载器将获取到的字节流转换为JVM可以理解的内部数据结构,这个过程会生成方法区(Method Area)中的类信息结构,以及堆中对应的java.lang.Class对象。

  3. 生成Class对象:在堆中创建一个java.lang.Class对象,这个对象将作为方法区中类数据的外部接口,用于封装类的元数据信息和运行时数据。Class对象是程序与JVM类数据结构交互的主要途径。

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

验证

验证(Verification)阶段是类加载过程中的重要环节,其目的是确保类文件的字节码符合Java虚拟机的规范,不会对JVM造成危害,保证程序的正确性和安全性。验证阶段主要分为四个部分:

  • 文件格式验证(File Format Verification) 检查输入的类文件是否符合Class文件格式的规范,确保字节流能正确地解析并存储到方法区中。

    ==文件格式验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了==

  • 元数据验证(Metadata Verification) 对字节码描述的信息进行语义分析,确保其描述的信息符合Java语言规范。

  • 字节码验证(Bytecode Verification) 确保程序的运行不会危害JVM的安全性,如避免栈溢出、非法的类型转换、空指针引用等问题。

  • 符号引用验证(Symbolic Reference Verification) 确保解析动作能够正确执行,即验证类文件中的符号引用(如类、字段、方法)是否可以成功转换为直接引用。

bash
- 魔数是否正确(通常是`0xCAFEBABE`)。
- 主次版本号是否在当前虚拟机的处理范围之内。
- 常量池中的常量类型是否合法。
- 访问标志是否合法。
- 字段表、方法表以及属性表的条目是否有正确的数目。
- 字段和方法的访问标志是否有效。
- 操作码的合法性检查,例如不允许出现`return`后面跟有操作数的情况。
bash
- 类是否有父类(除了`java.lang.Object`)。
- 继承链的正确性,确保不存在循环继承。
- 方法和字段是否有正确的修饰符。
- 方法签名是否正确,如参数类型和返回类型是否匹配。
- 父类和接口的继承一致性,确保实现接口的方法签名与接口声明的一致。
- 构造器和方法的参数类型和返回类型是否正确。
bash
- 操作数栈的数据类型与指令集是否匹配。
- 类中的方法是否有可能抛出异常,异常处理器是否正确。
- 类是否试图违反访问控制。
- 类是否试图执行特权操作。
- 类中的运算是否符合类型安全规则,如尝试将`long`赋值给`int`类型变量。
bash
- 类是否存在。
- 字段或方法是否在类中声明。
- 访问权限是否允许当前类访问目标类、字段或方法。
- 字段或方法的类型是否与引用类型匹配。

这些验证动作确保了加载到JVM中的类文件是安全和有效的,防止了潜在的运行时错误和安全漏洞。验证阶段是JVM类加载过程中的重要组成部分,它在类被加载后立即执行,是类初始化之前的必要步骤。通过这一系列的检查,JVM可以保证加载的类能够安全地在虚拟机中运行。

准备

准备(Preparation)阶段主要负责==为类的静态变量分配内存,并设置默认初始值==。

  1. 内存分配:为类的静态变量分配内存空间。这些变量的内存通常位于方法区(在JVM术语中,方法区是JVM内存区域的一部分,用于存储类信息、常量、静态变量等)。

  2. 设置默认值:为静态变量设置默认初始值。例如,对于整型变量,默认值为0;对于浮点型变量,默认值为0.0;对于布尔型变量,默认值为false;对于引用类型,默认值为null。

假设有一个类Example,如下所示:

java
public class Example {
    public static int staticInt = 10;
    public static String staticString = "Hello";
}

在准备阶段,staticIntstaticString都将被分配内存,并分别被初始化为0和null。但是,当Example类被初始化时,staticInt将被设置为10,staticString将被设置为"Hello"。

解析

解析(Resolution)的主要任务是==将类中的符号引用替换为直接引用==。符号引用是编译期间产生的,而直接引用则是运行时可以直接定位到目标的引用,如内存地址。

解析阶段的处理内容:

  1. 符号引用转直接引用:将类、字段、方法和构造器的符号引用转换为直接引用。符号引用是类文件中的字符串描述,比如类名、方法名和字段名。直接引用则是指向内存中数据的指针、偏移量或间接定位指令。

  2. 方法解析:解析静态方法和私有方法,因为这些方法的调用在编译期就可以确定,不需要在运行时查找。

  3. 字段解析:解析类的静态字段和实例字段,确保对字段的引用能够直接定位到具体的数据位置。

  4. 接口解析:如果类实现了接口,解析阶段会确定接口中声明的所有方法的实际实现。

解析阶段是类加载过程中的最后一个正式阶段,但并不总是发生,具体取决于类的具体使用情况。解析阶段的灵活执行和延迟策略是JVM设计中的一个重要特性,它平衡了类加载的速度和动态性,同时也支持了Java语言的核心特性,如多态和动态绑定。

初始化

初始化阶段是类加载过程的最后一个步骤,也是类从被动状态转变为主动状态的关键时刻。在这个阶段 JVM 主要执行以下操作:

  1. 执行静态初始化器:JVM 执行类中的静态初始化块(如果存在)。静态初始化块是被static关键字修饰的代码块,其语法形式为 { ... },位于类体内部。这些初始化块在类的第一次使用时执行,且只执行一次。

  2. 初始化静态变量:类的所有静态变量被赋予其初始值或默认值(如果未显式初始化)。初始化顺序遵循代码中声明的顺序。

初始化阶段就是执行类构造器 <clinit>() 方法的过程, <clinit>() 方法包含了所有静态初始化块和对静态变量赋值语句的字节码。该方法在类的首次使用时调用,且只执行一次。

静态语句块中只能访问到定义在静态语句块之前的变量,因为编译器收集的顺序是由语句在源文件中出现的顺序决定的,定义在它之后的变量,在前面的静态语句块可以赋值但是不能访问,参照下例:

java
public class MyClass {
    static int var1 = 10; // 声明并初始化
    static {
        System.out.println("Static block 1");
        var1 = 20; // 修改 var1
        var2 = 30; // 给 var2 赋值,但不能访问
    }
    static int var2; // 声明,但未初始化

    static {
        System.out.println("Static block 2");
        System.out.println(var1); // 可以访问 var1
        System.out.println(var2); // 可以访问 var2
    }
}

var2 在第一个静态初始化块之后被声明,所以这个初始化块可以给 var2 赋值,但是不能访问它(因为 var2 还没有被初始化)。

初始化时机

在 Java 中,类的加载和初始化是由 Java 虚拟机(JVM)自动管理的。类加载和初始化的过程并非在程序启动时一次性完成,而是按需进行的,即在==类被首次主动使用时才触发类加载==。这种按需加载的机制有助于提高应用程序的启动速度和运行效率。

类的加载和初始化过程是由特定的事件驱动的,这些事件可以分为两大类:被动引用(Passive Use)和主动引用(Active Use)。

被动引用和主动引用是 Java 类加载过程中的两种关键行为。==主动引用会触发类的完全加载和初始化,而被动引用仅加载类而不执行初始化==。

类的加载和初始化是 Java 应用程序运行时的重要机制,它确保了类的资源在首次使用时按需加载,既节省了内存又提高了程序的启动速度。理解类加载和初始化的触发时机对于编写高效、可靠的 Java 程序至关重要。

类加载器

在 Java 中,类加载器(Class Loader)是负责加载 Java 类到 Java 虚拟机(JVM)中的组件。每个类在 JVM 中都是通过类加载器加载的。类加载器在 Java 中起到隔离作用,可以确保加载的类是正确的,并且避免类的重复加载。

自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构,尽管这套架构在Java模块化系统出现后有了一些调整变动,但依然未改变其主体结构

  • 启动类加载器(Bootstrap Class Loader):加载Java核心库。
  • 扩展类加载器(Extension Class Loader):加载扩展目录中的类库。
  • 应用程序类加载器(Application Class Loader):加载用户类路径下的类。
  • 自定义类加载器:用户自定义,加载特定位置的类。
  1. 引导类加载器(Bootstrap Class Loader):是最顶层的类加载器,负责加载 Java 核心类库(存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的类,如rt.jar),它没有父类加载器。( 按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载 )

  2. 扩展类加载器(Extension Class Loader):负责加载/lib/ext目录下的类库,或者是由java.ext.dirs系统变量指定的目录下的类库。

  3. 应用程序类加载器(Application Class Loader):也称为系统类加载器,负责加载用户应用程序类路径(classpath)上的类。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

在某些场景下,可能需要自定义类加载器以适应特定需求。Java 提供了java.lang.ClassLoader类作为自定义类加载器的基础,可以通过重写findClass(String name)方法来实现自定义的类加载逻辑。

双亲委派模型

双亲委派模型(Parent Delegation Model)是 Java 类加载器体系中的一种设计模式,用于解决类的唯一性问题,并增强系统的安全性。其核心思想是:==当一个类加载器收到加载类的请求时,首先不会尝试自己加载这个类,而是把类加载请求委托给父类加载器,依次向上递归,直到顶层的引导类加载器。如果父类加载器无法加载这个类,子类加载器才会尝试加载==。

双亲委派模型的优点包括:

  1. 类的唯一性:通过双亲委派模型,可以确保加载的类在 Java 虚拟机中只有一个实例,避免了类的重复加载,确保了类的一致性。 例如:Object类,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。
  2. 安全性:双亲委派模型增强了 Java 系统的安全性,因为系统类库由顶层的引导类加载器加载,避免了用户自定义的类加载器替换或篡改核心类库的类。
  3. 稳定性:双亲委派模型有助于保持 Java 平台的稳定,因为系统的核心类库不会受到外部类加载器的影响。

双亲委派模型的源码实现

双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现却异常简单,用以实现双亲委派的代码只有短短十余行,全部集中在java.lang.ClassLoaderloadClass() 方法之中

java
protected synchronized Class<?> loadClass(String name, boolean resolve) 
  throws ClassNotFoundException{
    // 首先,检查请求的类是否已经被加载过了
    Class c = findLoadedClass(name);
    if (c == null) {
        try {
        if (parent != null) {
            c = parent.loadClass(name, false);
        } else {
            c = findBootstrapClassOrNull(name);
        }
        } catch (ClassNotFoundException e) {
            // 如果父类加载器抛出ClassNotFoundException
            // 说明父类加载器无法完成加载请求
        }
        if (c == null) {
            // 在父类加载器无法加载时
            // 再调用本身的findClass方法来进行类加载
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}

破坏双亲委派

破坏双亲委派模型(Parent Delegation Model)在 Java 中通常指的是对标准类加载机制的改变,这种改变可能出于多种原因,比如实现热部署、解决特定的类加载冲突、或是满足一些特殊的运行时需求。下面是几种常见的破坏双亲委派模型的方式及其背后的原因。

1. 重写 loadClass 方法

在 Java 的 ClassLoader 类中,loadClass 方法是实现双亲委派的核心。如果想要破坏这个模型,最常见的做法就是重写 ClassLoaderloadClass 方法。在默认实现中,loadClass 方法会尝试先让父类加载器加载类,只有在父类加载器无法加载时,子类加载器才会尝试加载。

java
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name);
                } else {
                    c = findBootstrapClass(name);
                }
            } catch (ClassNotFoundException e) {
                // If still not found, then invoke findClass in order
                // to find the class.
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

重写 loadClass 方法可以改变类加载的顺序,例如,可以优先尝试加载类,然后再委托给父类加载器,或者完全绕过父类加载器。

2. 使用线程上下文类加载器

线程上下文类加载器(Thread Context ClassLoader)是 Java 中一个重要的概念,它允许在特定线程中更改当前类加载器。这在 JNDI、JDBC 等服务中特别常见,这些服务需要调用由不同类加载器加载的 SPI(Service Provider Interface)实现。

线程上下文类加载器可以通过 Thread.currentThread().getContextClassLoader() 获取,并通过 Thread.currentThread().setContextClassLoader(ClassLoader cl) 设置。在多线程环境中,这允许临时改变类加载器,以便访问特定的类或资源。

3. OSGi 和模块化环境

在模块化环境中,如 OSGi(Open Service Gateway Initiative),每个模块都有自己的类加载器,这导致类加载器不再是简单的树形结构,而是变成了一个网状结构。OSGi 实现了热部署和热替换功能,允许在不重启整个应用程序的情况下更新和替换模块,这显然打破了传统的双亲委派模型。

4. 热部署和热替换

热部署(Hot Deployment)和热替换(Hot Swapping)是指在不重启应用的情况下更新代码。在某些应用服务器和开发环境中,为了支持代码的实时更新,自定义的类加载器被设计成能够识别类的更新并重新加载它们。这通常涉及对类加载器的定制以及对双亲委派模型的调整。

破坏双亲委派模型可能带来一些潜在的问题,如类的多重定义、类加载器泄漏、资源泄露以及类的版本冲突等。因此,这种操作需要谨慎执行,并且要确保充分理解其后果。在大多数情况下,双亲委派模型提供了必要的隔离和安全保证,破坏这一模型应被视为最后的手段。

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况

::: details 双亲委派模型的三次被破坏详情

  • 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的“远古”时代。由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的.

  • 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?

    • 这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?
    • 为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
    • 有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加载提供了一种相对合理的解决方案。
  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。说白了就是希望Java应用程序能像我们的电脑外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用关机也不用重启。对于个人电脑来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是大型系统或企业级软件开发者具有很大的吸引力。

    • 早在2008年,在Java社区关于模块化规范的第一场战役里,由Sun/Oracle公司所提出的JSR-294、JSR-277规范提案就曾败给以IBM公司主导的JSR-291(即OSGi R4.2)提案。尽管Sun/Oracle并不甘心就此失去Java模块化的主导权,随即又再拿出Jigsaw项目迎战,但此时OSGi已经站稳脚跟,成为业界“事实上”的Java模块化标准。曾经在很长一段时间内,IBM凭借着OSGi广泛应用基础让Jigsaw吃尽苦头,其影响一直持续到Jigsaw随JDK 9面世才算告一段落。而且即使Jigsaw现在已经是Java的标准功能了,它仍需小心翼翼地避开OSGi运行期动态热部署上的优势,仅局限于静态地解决模块间封装隔离和访问控制的问题,这部分内容笔者在7.5节中会继续讲解,现在我们先来简单看一看OSGi是如何通过类加载器实现热部署的。
    • OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:
      • 1)将以java.*开头的类,委派给父类加载器加载。
      • 2)否则,将委派列表名单内的类,委派给父类加载器加载。
      • 3)否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
      • 4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
      • 5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
      • 6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
      • 7)否则,类查找失败。
    • 上面的查找顺序中只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的

注:此处的 “被破坏” 并不一定是带有贬义的。只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新。正如OSGi中的类加载器的设计不符合传统的双亲委派的类加载器架构,且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但对这方面有了解的技术人员基本还是能达成一个共识,认为OSGi中对类加载器的运用是值得学习的,完全弄懂了OSGi的实现,就算是掌握了类加载器的精粹。 :::

自定义类加载器

在 Java 中,自定义类加载器通常是通过继承 java.lang.ClassLoader 类并重写其 findClass 方法来实现的。

下面是一个简单的自定义类加载器示例(它从文件系统中的指定目录加载类,而不是从标准的类路径):

java
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class MyClassLoader extends ClassLoader {

    private String path;

    public MyClassLoader(String path) {
        this.path = path;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            // 将包名转换为文件路径
            String fileName = name.replaceAll("\\.", "/") + ".class";
            File file = new File(path + File.separator + fileName);

            if (!file.exists()) {
                throw new ClassNotFoundException("Class not found: " + name);
            }

            byte[] bytes = new byte[(int) file.length()];
            try (FileInputStream fis = new FileInputStream(file)) {
                fis.read(bytes);
            }

            // 加载并定义类
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException("Error loading class: " + name, e);
        }
    }
}

接下来,可以使用上面的自定义类加载器:

java
public class CustomClassLoaderDemo {
    public static void main(String[] args) {
        try {
            // 指定包含类文件的目录
            String classPath = "path/to/your/classes";

            // 创建自定义类加载器实例
            MyClassLoader customLoader = new MyClassLoader(classPath);

            // 加载类
            Class<?> myClass = customLoader.loadClass("com.example.MyClass");

            // 创建类的实例
            Object instance = myClass.getDeclaredConstructor().newInstance();

            // 调用类的方法
            myClass.getMethod("myMethod").invoke(instance);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

自定义类加载器的 parent:

自定义类加载器的 parent 可以通过构造器来指定,默认情况下将是 System Class Loader

在创建自定义类加载器实例时,可以通过以下两种方式之一来指定 parent

  1. 使用默认构造器:当你使用默认构造器创建自定义类加载器时,parent 将被设置为调用该构造器的类加载器。这通常意味着你的自定义类加载器的 parent 会是 Application Class Loader(应用程序类加载器)。

    java
    public class MyCustomClassLoader extends ClassLoader {
        public MyCustomClassLoader() {
            super();
            // 使用默认构造器,parent 将是 System Class Loader
        }
    }
  2. 使用带参数的构造器:如果你希望自定义类加载器的 parent 是某个特定的类加载器,你可以使用带 ClassLoader 参数的构造器,并传入相应的类加载器作为 parent

    java
    public class MyCustomClassLoader extends ClassLoader {
        public MyCustomClassLoader(ClassLoader parent) {
            super(parent);
            // 这里可以传入任何有效的类加载器作为 parent
        }
    }

    例如,如果你想让你的自定义类加载器的 parentExtension Class Loader,你可以这样:

    java
    ClassLoader extensionLoader = URLClassLoader.getSystemClassLoader().getParent();
    MyCustomClassLoader customLoader = new MyCustomClassLoader(extensionLoader);

通过正确设置 parent,自定义类加载器能够遵循双亲委派模型,从而确保类加载的一致性和安全性。如果需要打破双亲委派模型,需要重写 loadClass 方法而不是仅仅重写 findClass 方法,并在方法中自己实现加载逻辑。但是,这样做需要非常小心,因为打破了双亲委派模型可能会引入类加载的不一致性和潜在的安全问题。

模块化系统

在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。JDK 9的模块不仅仅像之前的JAR包那样只是简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:

  • 依赖其他模块的列表。
  • 导出的包列表,即其他模块可以使用的列表。
  • 开放的包列表,即其他模块可反射访问模块的列表。
  • 使用的服务列表。
  • 提供服务的实现列表。

可配置的封装隔离机制首先要解决JDK 9之前基于类路径(ClassPath)来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接时才会报出运行的异常。而在JDK 9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。

可配置的封装隔离机制还解决了原来类路径上跨JAR文件的public类型的可访问性问题。JDK 9中的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类加载过程中完成的。

在 Java 9 及更高版本中,模块化系统引入了新的类加载器:

  • Platform Module Loader(平台模块加载器):负责加载 Java 平台模块系统(JRT)中的模块。
  • Application Module Loader(应用程序模块加载器):负责加载应用程序模块路径中的模块。