深入理解JVM 虚拟机类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这个过程被称作虚拟机的类加载机制。

与那些在编译时需要进行连接的语言不同,在java语言里,类的加载连接和初始化过程都是在程序运行期间完成的,这种策略让java语言进行提前编译会面临额外的困难,也会让 类加载时稍微增加一些性能开销,但是却为java应用提供了极高的扩展性和灵活性,java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

类加载时机

一个类从被加载到到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载loading,验证verification,准备preparation,解析resolution,初始化Initializatiuon,使用using和卸载七个阶段,其中验证,准备,解析,三个部分统称为linking,

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

请注意这里笔者写的是按部就班的开始,而不是按部就班的进行或按部就班的完成,强调这点因为这些阶段通常都是相互交叉地混合进行的,会在一个阶段只行的过程调用,激活另一个阶段。

关于在什么情况下需要开始类的加载过程的第一个阶段,加载,JVM虚拟机规范中并没有强制约束,这点可以交给虚拟机具体实现来自由把握。但是对于初始化阶段,java虚拟机规范则是阉割的规定了有且只有六种情况必须对类进行初始化

  1. 遇到new,getstatic,putstatic或invokestatic这四条字节码指令,能生成这四条指令的典型java代码常见有

    • 使用new关键字实例化对象的时候
    • 读取或设置一个类型的静态字段(被final修饰,在编译期把结果放入常量池的静态字段除外)的时候
    • 调用一个类型的静态方法的时候
  2. 使用java.lang.reflect包对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先出法其初始化
  3. 当初始化类的时候,如果发现父类还没有进行初始化,则需要先出法父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类)
  5. 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic,REF_invokeStatic,REF_newInvokeSpcial四种类型的方法句柄,并且这个方法句柄对应的类没有经过初始化,则需要先出发其初始化
  6. 如果一个接口中定义了JDK8新加入的默认方法,如果有这个接口的实现类发生了初始化,那该接口需要在其之前被初始化

对于这六种会触发类型初始化的场景,Java虚拟机规范中使用了一个非常强的限定语,有且只有,这六种常见的行为对一个类进行主动引用。除此之外所有引用的类型方式都不会出发初始化,称为被动引用。

加载

加载Loading阶段是整个类加载Class Loading的第一阶段,不能混淆,加载阶段虚拟机需要完成三件事情

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将真个字节流所代表的的静态存储结构转化为方法去的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象

java虚拟机规范中对着三点的要求并不是特别具体,比如没有指明二进制字节流必须从某个Class文件中获取,可以从zip,jar,网络,运行时计算生成,数据库等待获取

相对于类加载过程的其他阶段,费数组类型的加载阶段,是开发人员可控性最强的阶段。加载阶段既可以使用java虚拟机内置的引到类加载器完成,也可以用用户自定义的类加载器去完成,开发人员通过自己定义的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性

对于数组类而言,情况就有所不同,数组本身不通过类加载器创建,它是由java虚拟机直接在内存中动态构造出来的。但数组与类加载器仍然有很密切的关系,因为数组类的元素类型,最终还是要靠类加载器来完成加载

加载阶段阶段结束后,java虚拟机的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义。类型数据妥善安置在方法区之后,会在java堆内存中实例化一个java.lang.Class类对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

加载阶段和连接阶段的部分动作是交叉运行的,加载阶段未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,任然属于连接阶段的一部分,俩个阶段开始时间仍然保持着先后顺序

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合java虚拟机规范的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。

java本身是相对安全的的编程语言(起码对于C、C++来说是相对安全的),使用纯粹的java代码无法做到访问边界以外的数组,讲一个对象类型转换为它并未实现的类型,跳转到不存在的代码行之类的事情,如果尝试这样做了,编译器就会好不留情的抛出异常,拒绝编译。但是Class文件不一定只能由java编译而来,它可以使用包括键盘0和1,直接在二进制不进去中敲出class文件在内的任何途径产生。jvm如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流导致整个系统受攻击甚至崩溃,所以字节码验证是java虚拟机保护自身的一项非必要措施

验证这个阶段是十分重要的,这个阶段是否严谨,直接决定了java虚拟机是否能承受恶意代码的攻击,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。

从整体上看,验证阶段大致会完成下面四个阶段的检验动作,文件格式验证,元数据验证,字节码验证和符号引用验证

  1. 文件格式验证:第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理
  2. 元数据验证:第二阶段是对字节码描述信息的语义分析,以保证其描述的信息符合java语言规范的要求。
  3. 字节码验证:这个阶段是整个验证过程最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定语义是合法的符合逻辑的。这阶段对方法体进行校验分析,保证被校验的类的方法在运行时不会做出危害虚拟机的安全行为
  4. 符号引用验证:最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在第三阶段,解析阶段中发生。符号引用可以看做是对类自身以外(常量池中的各种符号的引用)的各类信息进行匹配校验,通俗来说就是该类是否缺少或者被禁止访问它依赖的某些外部类,方法,字段,等资源。

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

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配。但必须主义到方法区本身是一个逻辑上的区域,在JDK7之HotSpot使用永久代来实现方法去时,实现是完全符合这种逻辑概念的,而在JDK8及之后,类变量则随着Class对象一起存放在堆中,这时候“类变量在方法区”就完全是一种逻辑概念的描述了

这时候进行内存分配的仅包括类变量,不包括实例变量,实例变量将会随着对象的实例化随着对象一起分配在java堆中。

这里所说的初始值通常情况下是数据类型的零值,例如

public static int value = 123;

那变量在准备阶段过后的初始值是0而不是123,因为这时尚未开始执行任何java方法,而把value复制为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法中,所以把value复制为123的动作要到类初始化阶段才会被执行

但是修改以上定义为

public static final int value = 123;

编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value复制为123

解析

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

符号引用

symbolic References:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可用不相同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在java虚拟机规范的class文件格式中

直接引用

direct references:直接引用是可以直接指向目标的指针,相对偏移量,或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

解析动作

解析动作主要针对类或接口,字段,类方法, 接口方法,方法类型,方法句柄,调用点这七类符号进行

初始化

类的初始化是类加载过程中的最后一个步骤,之前介绍的几个类加载器的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式布局参与外,其余动作都是完全由java虚拟机来主导控制。直到初始化阶段,java虚拟机才开始执行类中编写的java程序代码,将主导权移交给应用程序

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码指定的主管计划去初始化类变量和其他资源

初始化阶段就是执行类构造器<clinit>()方法执行过程中各种可能会影响程序运行行为的细节,这部分比起其他类加载过程更贴近普通的程序开发人员的实际工作

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

public class Test{
    static{
        i=0;
        System.out.print(i);   //这句编译器会提示非法向前引用
    }
    static int i=1;
}

<clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,java虚拟机会保证在子类的<clinit()>方法执行前,父类的<clinit()>方法也叫执行完毕,因此在java虚拟机中第一个被执行的<clinit()>方法肯定是java.lang.object

由于父类的<clinit()>方法限制性,也就意味着父类中定义静态快语句要优于子类变量的复制操作,如下的代码中字段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);
    }
}

<clinit()>方法对于类或接口来说不是必须的,如果一个类中没有静态语句快,也吗,没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit()>方法

接口中不能使用静态语句快,但任然有变量初始化的复制操作,因此接口与类一样都会生成<clinit()>方法。但接口不同的是,执行<clinit()>方法不需要限制性父类接口的<clinit()>方法,因为只有当父接口中定义的变量被使用时,父类接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit()>方法

java虚拟机必须保证一个雷的<clinit()>方法在多线程中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行<clinit()>方法,其他的线程都需要等待,知道活动线程执行完毕<clinit()>方法。如果一个类的<clinit()>中有耗时很长的操作,那就可能造成多个进程的阻塞,在实际应用中这个阻塞往往是很隐蔽的

static class DeadLoopClass{
        static {
            if(true){
                System.out.println(Thread.currentThread()+"init DeadLoopClass");
                while (true){

                }
            }
        }
    }

public static void main(String[] args) {
        Runnable r = new Runnable() {
            @Override
            public void run() {
            System.out.println(Thread.currentThread() + "start");
            DeadLoopClass dd = new DeadLoopClass();
            System.out.println(Thread.currentThread() + "end");
            }
        };
        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);
        t1.start();
        t2.start();
           }
}

结果为

要注意,其他线程虽然会被阻塞,但如果执行<clinit()>方法退出<clinit()>方法后,其他线程唤醒后则不会再次进入<clinit()>方法。同一个类加载器下,一个类型只会被初始化一次

类加载器

java虚拟机设计团队有意吧加载阶段中的通过一个类的全限定名称来获取描述该类的二进制流这个动作放到java虚拟机外部实现,以便让应用程序去决定如何去获取所需的类。实现这个动作的代码被称为类加载器(class Loader)

类与类加载器

类加载器虽然只用于实现类的加载动作,但它在java程序中起到的作用远超类的加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在java虚拟机中的唯一性,每一个类加载器都有一个独立的类名称空间。

比较俩个类是否相等,只有在这俩个类是同一个类加载器的前提下才有意义,否则即使这个类来源于同一个class文件,被同一个java虚拟机加载,只要加载它们的类加载器不同,那这俩个类就必定不相等

这里的相等包括代表类的Class对象的equals,isAssignableFrom()方法和isInstance()方法的返回结果。

双亲委派模型

站在java虚拟机的角度来看,只存在俩种不同的类加载器,一种是启动类加载器,这个类加载器使用C++实现(只限hotspot),是虚拟机自身的一部分,另一种就是其他所有类加载器,这些类加载器都是由java语言实现,独立存在虚拟机外部,并且全都继承自抽象类,java.lang.ClassLoader

  1. 启动类加载器:也叫引导类加载器,C++实现,在jav中无法获取,负责加载JAVA_HOME/lib下的类
  2. 拓展类加载器:JAVA实现,在java里获取,负责加载JAVA_HOME/lib/ext下的类
  3. 应用程序类加载器:也称为系统类加载器,一般情况下这个就是程序中默认使用的类加载器,我们写的代码默认就是由他来进行加载

JDK9之前的java应用都是由这三种类加载器互相配合来完成的,如果用户认为有必要还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的Class文件来源,或者通过类加载器实现类的隔离,重载功能。

图中的层次关系被称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载都应有自己的父类加载器。不过这里类加载器之间的父子关系不是通过集成来实现的,而是通过组合关系来复用父类加载器的代码

双亲委派的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子类加载器才会去尝试自己完成加载。

使用双亲委派模型组织类加载器之间的关系,一个显而易见的好处就是java中类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能保证是同一个类,反之如果没有使用双亲委派模型,都由各个类加载器去自行加载的话,java类型体系中最基础的行为也就无从保证。

破坏双亲委派模型

一、第一次打破
第一次被打破是在Java的原始版本,那时候用户自定义类加载器已经存在,双亲委派机制为了兼容这些代码,但又无法保证loadClass不被子类重写,所以提供了findClass的方法。用户加载类的时候就去重写这个方法。如此一来,类加载的时候还是会调用加载器的loadClass向上请求,只有当父类加载器请求失败的时候,才会回来调用该类加载器被用户重写的findClass方法。

众所周知,双亲委派机制是通过调用类加载器的loadClass去实现的,一旦被重写,逻辑不符合双亲委派的逻辑,那么双亲委派机制就被打破了。

二、第二次打破
第二次打破则是由于JNDI服务(JDBC/JCE/JAXB/JBI),JNDI的目的就是对资源进行查找和集中管理,该类由启动类加载器去加载,但是却需要调用其他厂商部署在类路径下的JNDI服务提供者接口,由于父亲不认识儿子,启动类加载器是不认识这些接口的,那怎么办呢?

线程上下文类加载器:提供父类加载器访问子类加载器的行为。

这样一来就打通了父类到子类加载器的通道,如何去规范这种行为呢?

使用services配置信息,以责任链模式进行辅助。有兴趣的可以深入去了解一下具体信息。

三、第三次打破
第三次打破是热部署、热替换引起的。

Java热部署模块的规范化模块是OSGi提供的,热部署实现的关键就是OSGi自定义了类加载器,它为每个模块都配了一个类加载器。当需要动态地更换一个模块的时候,就把模块连通这个模块的类加载器一起替换,从而实现了热替换。

这种情况下,类加载器再也不是树状结构了,而是网状。

自己打破双亲委派

双亲委派 - 随意的世界 (nasuiyile.cn)

继承classLoader

, 并重写loadClass()方法来实现对双亲委派机制的打破(一般情况下不重写,但是面试最好这样回答), 大多数情况下还需要实现findClass()方法.在findClass的时候需要加一些加载自定义类的判断

import java.io.*;

public class MyClassLoader extends ClassLoader{
    String path ;
    MyClassLoader(String dir){
        this.path = dir ;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            this.path = this.path + name +".class";
            File f = new File(path);
            InputStream in = new FileInputStream(f);
            byte [] bys = new byte[  (int)f.length() ];
            int len = 0;
            while( (len = in.read(bys) )!= -1  ){

            }
            // byte[] -> .class
            return defineClass(name,bys,0,bys.length);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return super.findClass(name);
    }
}

上下文类加载器

线程上下文类加载器是从JDK1.2开始引入的,类Thread中的getContextClassLoader()于setContextClassLoader(ClassLoader cl)分别用来获取和设置上下文类加载器。

如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。

Java应用运行时的初始线程的上下文加载器是系统类加载器。线程中运行的代码,可以通过该类加载器来加载类与资源。

使用线程上下文类加载器,父ClassLoader可以使用当前线程Thread.currentThread().getContextClassLoader()所指定的classloader加载的类。这就改变了父ClassLoader不能使用子ClassLoader,或是其他没有直接父子关系的ClassLoader加载的类的情况,即改变了双亲委托模型。

java模块化系统

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

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

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

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

模块的兼容性

为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK9提出了与类路径(ClassPath)想对应的模块化路径的概念。简单来说,就是某个类库到底是模块化还是传统的jar包,只取决于它存放在哪种路径上。只要是放在类路径上的jar文件,无论其中是否包含模块化信息(module-info.class文件),它都会被当做传统的jar包来对待,响应地,只要放在模块化路径上的jar文件,即使没有使用JMOD后缀,甚至不包含modul-info.class文件,它也仍然会被当做一个模块来对待

模块哦华系统将按照以下规则来保证使用传统路径依赖的java程序可以不经过修改直接运行在JDK9及以后的java版本上,即使这些版本的JDK已经使用了模块来封装了JavaSe的标准类库,模块化系统的这套规则也仍然保证了传统系统程序可以访问到所有标准库模块中导出的jar包

  • jar文件在类路径的访问规则:所有类路径下的jar包文件及其他资源文件,都是被视为自动打包在一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上的所有包,jdk系统模块中所有的导出包,以及模块路径上所有模块中导出的包
  • 模块在模块路径的访问规则:模块路径下的具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统jar包的内容
  • jar文件在模块路径的访问规则,如果把一个传统的,不包含模块定义的jar文件房知道模块路径中,它就会变成一个自动模块(Automatic module)。景观不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己的所有包

以上三条规则保证了即时java应用依然依赖使用传统的类路径,升级到JDK9对应用来说几乎不会有任何感觉,项目也不需要专门为了升级JDK版本而去把jar包替换成模块

但如果一个模块发行了多个不同的版本,那只能由开发者在编译打包时人工选择好正确的模块来保证依赖的正确性。java模块化系统目前不支持在模块定义中加入版本号来管理约束依赖,本身也不支持多版本号的概念和版本选择功能。

模块化下的类加载器

为了保证兼容性,JDK9并没有从根本上动摇从JDK1.2依赖运行了二十年之久的三层类加载器以及双亲委派模型。但是为了模块化的顺利运行还是发生了一些应该被主义到的改动

首先是扩展类加载器被平台类加载器取代。这其实是一个很顺理成章的改动,既然整个JDK都给予模块化进行构建,(原来的rt.jar和tools.jar被拆封成数十个JMOD文件),其中java类库天然就满足了可扩展的需求,那么自然无需保留libext目录(该目录存放的是Jmeter的插件或者扩展组件。Jmeter会自动在lib和ext下寻找需要的类)。类似的新版JDK也取消了jre目录,因为随时可以组合构建出程序运行所需的jre来。

其次,平台类加载器和应用程序类加载器都不再派生自,java.net.URILClassLoader,如果程序直接依赖了这种集成关系,或者依赖了URILClassLoader类的特定方法,那代码很可能会在JDK9以及更高版本中崩溃。现在应用程序类加载器,平台类加载器全都继承于jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoaders中实现了新的模块架构

启动类加载器现在是在java虚拟机内部和java类库共同写作实现的类加载器,尽管有了BootClassLoader这样的类,单位了与之前代码保持兼容,所以在获取启动类加载器的场景(Object.getClassLLoader())中仍然会返回null来替代,而不会得到BootClassLoader

JDK9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序加载器收到类加载请求,在委派给父类加载器加载前,要先判断该类是否能归属到某一个系统模块中,如果可以找到这样的归属关系,就要有限委派给负责那个模块的加载器完成加载。

Last modification:January 11, 2023
如果觉得我的文章对你有用,请随意赞赏