【JVM学习笔记04】类加载子系统

五、类加载器子系统

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

类加载器的作用是负责加载Class文件,Class文件在文件开头有特定的文件标示,将Class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构并且类加载器只负责Class文件的加载,至于它是否可以运行,则由Execution Engine决定

5.1 类的生命周期

一个类型(类或接口)从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接:

image-20201110165016514

其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的“开始”(仅仅指的是开始,而非执行或者结束,因为这些阶段通常都是互相交叉的混合进行,通常会在一个阶段执行的过程中调用或者激活另一个阶段),而解析阶段则不一定(它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。

5.1.1 类加载时机

《Java虚拟机规范》中并没有对在什么情况下需要开始类加载过程的第一阶段“加载”进行强制约束,这点可以交给虚拟机的具体实现来自由把握。

5.1.2 初始化时机

《Java虚拟机规范》中对在什么情况下虚拟机需要开始初始化一个类给出了严格的规定,有且只有六种情况:

  1. 创建类的实例——new
  2. 访问类的静态变量(被final修饰的常量除外,因为常量是一种特殊的变量,编译器会把他们当作值(value)而不是域(field)来对待,编译器并不会生成字节码来从对象中载入域的值,而是直接把这个值插入到字节码中。)
  3. 访问类的静态方法
  4. 反射
  5. 当初始化一个类时,发现其父类还未初始化,则先触发父类的初始化
  6. 虚拟机启动时,定义了main()方法的那个类(主类)先初始化

以上情况称为称对一个类进行“主动引用”,即主动引用都会触发类的初始化。除此种情况之外,均不会触发类的初始化,称为“被动引用”。

5.1.3 接口的加载与初始化

接口的加载过程与初始化过程稍有不同。当一个类在初始化时,要求其父类全部都已经初始化了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候才会初始化。

5.2 类加载的过程

image-20201110161134858

类加载需要经过三个过程:

  • 加载阶段:引导类加载器、扩展类加载器、系统类加载器
  • 连接阶段:验证、准备、解析
  • 初始化阶段:初始化

5.2.1 加载阶段

加载阶段:将类的字节码文件读入内存,并为之创建一个java.lang.Class对象。

“加载”(Loading)阶段是类加载(Class Loading)过程的第一个阶段,在此阶段,虚拟机需要完成以下三件事情:

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

加载阶段即可以使用系统提供的类加载器在完成,也可以由用户自定义的类加载器来完成。加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

5.2.2 连接阶段

连接阶段:将Java类的二进制代码合并到JVM的运行状态中。

(1)验证

验证:确保加载的类信息符合JVM规范,没有安全问题。

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

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机类加载过程中占了相当大的比重。验证阶段大致分为四个阶段:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证
(2)准备

准备阶段正式为类中定义的变量(即静态变量)分配内存设置类变量初始值

从概念上讲,这些变量所使用的内存都应该在方法区中进行分配,但必须注意到方法区本身是一个逻辑区域,在JDK7以前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;但是在JDK8以后,类变量则会随着Class对象一起存放在Java堆中。

  • 进行内存分配的仅是静态变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中

  • 设置类变量的初始值,通常情况下指的是该静态变量所属数据类型的零值

    image-20201111102638857

但是如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设类变量value定义为:public static final int value = 123;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

(3)解析

解析阶段是虚拟机将常量池内的一部分符号引用替换为直接引用的过程。解析后的结果被存放于方法区中的运行时常量池中。

解析是将常量池内的一部分符号引用替换为直接引用,那么解析的内容就是常量池中的内容。常量池中主要含有两大类常量:字面量与符号引用。其中,符号引用主要是指:

  • 被模块导出或开放的包
  • 类和接口的全限定名
  • 字段的名称与描述符
  • 方法的名称与描述符

符号引用在class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中,所说的直接引用和符号引用有什么关联呢?

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

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

虚拟机规范中未规定解析阶段发生的具体时间,只要求在执行anewarray,checkcast,getfield,getstatic,instanceof,invokedynamic,invokeinterface,invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfield和putstatic 这16个用于操作符号引用的字节码指令之前,先对他们所使用的符号进行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析他。

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令之外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的是:在同一个实体中,如果一个符号引用之前被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果第一次解析失败了,那么其他指令对这个符号的解析请求也应该收到相同的异常。

对于invokedynamic,上面的规则不成立。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为invokedynamic指定的目的本来就是用于动态语言支持,他所对应的引用称为“动态调用点限定符”,这里“动态”的含义就是必须等到程序实际运行到这条指令的时候,解析工作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码的时候进行解析。

5.2.3 初始化

类初始化阶段是类加载过程的最后一步,到了这个阶段才真正开始执行类中定义的Java程序代码(或者说是字节码)。在准备阶段,静态变量已经赋过一次系统要求的初始值【零值】,而在初始化阶段,则通过代码中的显式值去初始化类变量和其他资源。

主动引用都会触发类的初始化。【参见3.1.2】

类构造器方法clinit ()是由编译器自动收集类中的所有类变量的赋值操作静态语句块中的语句合并产生的。它与类的构造函数不同,它不需要显示地调用父类构造器,Java虚拟机会保证在子类的clinit()方法执行前,父类的clinit()方法已经执行完毕。【类构造器是构造类信息的,不是构造该类对象的构造器

  • 编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量;而定义在它之后的变量,在前面的静态语句块中可以被赋值,但不能访问,代码解释如下:

    public class Test {
        static {
            i = 0;                       //给变量赋值可以正常编译通过
            System.out.print(i);         //编译器会提示“非法向前引用”
            }
        static int i = 1;
        System.out.print(i);		 	 // 1
    }
    
  • 初始化方法执行的顺序,虚拟机会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕,因此在虚拟机中第一个被执行的类初始化方法一定是java.lang.Object。另外,也意味着父类中定义的静态语句块要优先于子类的变量赋值操作。代码解释如下:

    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
    }
    
  • clinit ()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成clinit()方法。

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

5.3 双亲委派模型

类加载器:将class字节码文件加载到内存中,并将其中的静态数据转换为方法区的运行时数据结构,然后在堆中生成一个代表这个类的java.lang.Class对象,作为方法区中类数据的访问入口。

image-20201111142448725

对于任意一个类,都必须由加载它的类和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间。即比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义;否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。一个对象有一个唯一的标识,一个载入JVM的类也有一个唯一的标识。

  • 在Java中,一个类用其全限定类名(包名+类名)作为标识;
  • 在JVM中,一个类用其全限定类名和其类加载器(包名+类名+类加载器名)作为其唯一标识。

5.3.1 类加载器分类

类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载进JVM中,同一个类就不会被再次载入了。

JVM预定义有三种类加载器,当一个 JVM启动的时候,Java开始使用以下三种类加载器:

image-20210417204534958
(1)启动类加载器(Bootstrap)

启动类加载器负责加载存放在 <JAVA_HOME>\lib 目录而且是 JVM 可以识别的类库,如 rt.jar、tools.jar 等。

启动类加载器由C++编写的,程序员无法在程序中获取该类。负责加载虚拟机的核心库,比如java.lang.Object;没有继承ClassLoader类,无法直接被Java程序直接引用,所以会返回null。

(2)扩展类加载器(Extension)

扩展类加载器负责加载 <JAVA_HOME>\lib\ext 目录中的类库。

扩展类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。负责从指定目录中加载类库,负责扩展Java系统类库的功能。其父加载器是根类加载器,同时该类是ClassLoader的子类。

(3)应用程序类加载器(System)

应用程序类加载器负责加载用户类路径上所有的类库。如果用户没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器。

系统类加载器由sun.misc.Launcher$AppClassLoader以Java代码的形式实现。其父加载器是扩展类加载器。从环境变量或者class.path中加载类,是用户自定义类加载的默认父加载器。同时该类是ClassLoader的子类。

(4)代码演示
/**
 * 类加载器种类
 */
public class ClassLoaderdemo {
    public static void main(String[] args) {
        // Object类的加载器——bootstrap加载器
        Object o = new Object();
        System.out.println(o.getClass().getClassLoader());
        System.out.println("************************************");
		// 自定义类对象——app类加载器
        MyObject myObject = new MyObject();       
        System.out.println(myObject.getClass().getClassLoader().getParent().getParent());
        System.out.println(myObject.getClass().getClassLoader().getParent());
        System.out.println(myObject.getClass().getClassLoader());
    }
}

image-20201111145643421

5.3.2 ClassLoader类

image-20210810150319465

除了虚拟机自带的类加载器之外,用户还可以定制自己的类加载器。Java 提供了抽象类 Java.lang.ClassLoader,所有用户自定义的类加载器都应该继承 ClassLoader 类。

引导类加载器是由 C++ 进行编写的,无法直接调用。

(1)loadClass()

loadClass() 加载名称为 name 的类,返回结果是一个 Java.lang.Class 的实例。如果找不到类,则返回 ClassNotFoundException 异常。该方法中逻辑就是“双亲委派模型”的实现。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    // 同步操作,保证只被加载一次
    synchronized (getClassLoadingLock(name)) {
        // 首先,在缓存中判断是否已经加载同名的类
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 获取当前类加载器的父加载器
                if (parent != null) {
                    // 调用父加载器
                    c = parent.loadClass(name, false);
                } else {
                    // parent==null,说明此时是引导类加载器
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            // 当前类的加载器父类加载器未成功加载此类
            if (c == null) {
                // 调用当前 ClassLoader 的 findClass()
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}
(2)findClass()

查找二进制名称为 name 的类,返回结果为 Java.lang.Class 的实例。这是一个受保护的方法,JVM 鼓励重写这个方法来进行类的加载。在JDK1.2 之前,在自定义类加载时,总会去继承 ClassLoader 类并重写 loadClass() 方法,从而实现自定义类的加载类。但是在 JDK1.2 之后,不再建议用户去覆盖 loadClass() 方法,而是建议把自定义的类加载器逻辑写在 findClass() 方法中,同时该方法会在 loadClass() 方法中被调用,当 loadClass() 方法中的父加载器加载失败后,则会调用自己的 findClass() 方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委派模式。

protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

可以看到 ClassLoader 类中并没有实现 findClass() 方法的具体逻辑,取而代之的是抛出 ClassNotFoundException 异常。从之前类之间继承关系图中可以看到 findClass() 方法被 URLClassLoader 类重写了。其重写部分如下:

protected Class<?> findClass(final String name)
    throws ClassNotFoundException
{
    final Class<?> result;
    try {
        result = AccessController.doPrivileged(
            new PrivilegedExceptionAction<Class<?>>() {
                public Class<?> run() throws ClassNotFoundException {
                    String path = name.replace('.', '/').concat(".class");
                    Resource res = ucp.getResource(path, false);
                    if (res != null) {
                        try {
                            return defineClass(name, res);
                        } catch (IOException e) {
                            throw new ClassNotFoundException(name, e);
                        }
                    } else {
                        return null;
                    }
                }
            }, acc);
    } catch (java.security.PrivilegedActionException pae) {
        throw (ClassNotFoundException) pae.getException();
    }
    if (result == null) {
        throw new ClassNotFoundException(name);
    }
    return result;
}

可以看到 findClass() 方法中又调用了 defineClass() 方法。一般情况下,在自定义类加载器时,会直接覆盖 ClassLoader 中的 findClass() 方法并编写加载规则,取得要加载类的字节码后转换为流,然后调用 defineClass() 方法生成类的 Class 对象。

(3)defineClass()

根据给定的字节数组转换为 Class 实例,该方法只有在自定义 ClassLoader 子类中可以使用。

defineClass() 方法时用来将 byte 字节流解析为 JVM 能够识别的 Class 对象,通过这个方法不仅能够通过 Class 字节码文件实例化为 class 对象,也可以通过其他方式来实例化 class 对象,如网络。

5.3.3 双亲委派模型

上述四种类加载器通常的协作关系如图:

image-20201111151912570

这种类加载器之间的层次关系被称为类加载器的“双亲委派模型”。该模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里的类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。

(1)工作过程

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。

  1. 当Application ClassLoader 收到一个类加载请求时,他首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器Extension ClassLoader去完成。
  2. 当Extension ClassLoader收到一个类加载请求时,他首先也不会自己去尝试加载这个类,而是将请求委派给父类加载器Bootstrap ClassLoader去完成。
  3. 如果Bootstrap ClassLoader加载失败,就会让Extension ClassLoader尝试加载。
  4. 如果Extension ClassLoader也加载失败,就会使用Application ClassLoader加载。
  5. 如果Application ClassLoader也加载失败,就会使用自定义加载器去尝试加载。
  6. 如果均加载失败,就会抛出ClassNotFoundException异常。
(2)双亲委派机制的实现

双亲委派机制在 java.lang.ClassLoader 中的 loadClass() 方法中进行了实现:

  • 先在当前加载器的缓存中查找有无目标类,如果有,直接返回;
  • 判断当前加载器的父加载器是否为空。若不为空,则调用其父加载器进行加载,向上递归;
  • 若当前加载器的父加载器为空,则调用findBootstrapClassOrNull() 方法,让引导类加载器进行加载;
  • 如果通过以上3条路径都没能成功加载,则调用 findClass() 进行加载。该方法最终会调用 java.lang.ClassLoader 中的 defineClass 系列的native 接口加载目标 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) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

思考

如果在自定义的类加载器中重写 java.lang.ClassLoader.loadClass() 方法,将其中双亲委派机制的逻辑抹去,那么会不会实现对加载核心类库呢?

答案是不可以。因为 JDK 还为核心类库提供了一层保护机制。不管是自定义类加载器,还是系统类加载器或扩展类加载器,最终都必须调用 java.lang.ClassLoader.defineClass() 方法,该方法内部会调用 preDefineClass() 方法,该方法提供了对 JDK 核心类库的保护。

private ProtectionDomain preDefineClass(String name,
                                        ProtectionDomain pd)
{
    if (!checkName(name))
        throw new NoClassDefFoundError("IllegalName: " + name);

    // Note:  Checking logic in java.lang.invoke.MemberName.checkForTypeAlias
    // relies on the fact that spoofing is impossible if a class has a name
    // of the form "java.*"
    if ((name != null) && name.startsWith("java.")) {
        throw new SecurityException
            ("Prohibited package name: " +
             name.substring(0, name.lastIndexOf('.')));
    }
    if (pd == null) {
        pd = defaultDomain;
    }

    if (name != null) checkCerts(name, pd.getCodeSource());

    return pd;
}
(3)优点与缺点

优点

  • Java的类加载器具备一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要使用子加载器再加载一次。
  • 其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改

缺点

检查类是否加载的是单向的,这个方式在结构上是比较清晰的,使得各个类加载器的职责都非常的明确,但是同时会带来一个问题,即顶层的类加载器无法访问底层类加载器所加载的类。

5.3.4 破坏双亲委派模型

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

(1)破坏双亲委派模型1

第一次破坏是为了兼容JDK1.2之前的代码逻辑。

由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写 loadClass() 方法,因此为了能够兼容这些已经存在的用户自定义的这部分 loadClass() 方法的重写代码,Java 设计者们不得不做出一些妥协,只能在 JDK1.2 后引入一个新的方法 findClass() ,并引导用户编写的类加载逻辑尽可能去重写这个方法,而不是在 loadClass() 方法中编写方法。

在上面介绍过的 loadClass() 方法,其中实现了双亲委派机制的具体逻辑,按照 loadClass() 方法的逻辑,如果其所有的父类加载失败,那么将会自动调用自己的 findClass() 方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类的加载器是符合双亲委派机制的。

(2)破坏双亲委派模型2

第二次破坏是为了解决双亲委派机制本身的缺陷。解决父级类加载器调用子级类加载器来完成类加载动作的问题。

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美。如果基础类又要调用回用户的代码,那该么办?

一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者的代码,但启动类加载器不可能“认识”这些代码。

为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

image-20210809221824558

有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

(3)破坏双亲委派模型3

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简答的说就是机器不用重启,只要部署上就能用。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)否则,类加载器失败。

5.3.5 Tomcat类加载机制

(1)Tomcat的作用

Tomcat是个web容器, 那么它要解决以下问题:

  1. 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
  2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机,这是扯淡的。
  3. web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
  4. web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启。

Tomcat 如果使用默认的类加载机制行不行? 答案是不行的。

  • 第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的累加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。

  • 第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。

  • 第三个问题和第一个问题一样。

  • 第四个问题,我们想我们要怎么实现jsp文件的热修改,jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

(2)Tomcat类加载机制

image-20210818172303718

前面3个类加载和默认的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/*/server/*/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见;

从图中的委派关系中可以看出:CommonClassLoader能加载的类都可以被Catalina ClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。

双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。很显然,tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。如果tomcat 的 Common ClassLoader 想加载 WebApp ClassLoader 中的类,可以使用线程上下文类加载器实现,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。

5.4 沙箱安全机制

Java安全模型的核心就是Java沙箱(sandbox)。

沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限定在虚拟机特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,包括CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。所有的Java程序运行都可以指定沙箱,可以定制安全策略。

5.5 自定义类加载器

jvm自带的三个加载器只能加载指定路径下的类字节码。如果某个情况下,我们需要加载应用程序之外的类文件呢?比如本地D盘下的,或者去加载网络上的某个类文件,这种情况就可以使用自定义加载器了。

用户自定义类加载器是 Java.lang.ClassLoader 类的子类,用户可以定制类的加载方式,其父类加载器是应用程序类加载器。

(1)自定义类加载器的实现

JVM 中所有的类加载都会使用 Java.lang.ClassLoader.loadClass() 来进行类的加载。

  • 继承 ClassLoader
  • 重写 findClass() 方法。调用 findClass() 方法的原因是尽量保留 loadClass() 方法中的双亲委派处理逻辑,同时 loadClass() 方法最后还会调用 findClass() 方法。所以建议只在 findClass() 方法里面重写自定义类的加载方法,不去破坏 loadClass() 方法中的双亲委派处理逻辑。
  • 调用 defineClass() 方法
(2)代码演示
package com.zdp.learn.studyJVM.vmClassLoader;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;

/**
 * @author zdp
 * @date 2021/8/10
 * @apiNote 自定义类加载器
 */
public class MyClassLoader extends ClassLoader {

    private String byteCodePath;

    public MyClassLoader(ClassLoader parent, String byteCodePath) {
        super(parent);
        this.byteCodePath = byteCodePath;
    }

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

    // 自定义重写 findClass 类加载逻辑
    @Override
    protected Class<?> findClass(String className) throws ClassNotFoundException {
        BufferedInputStream bufferedInputStream = null;
        ByteArrayOutputStream baos = null;
        try {
            // 字节码文件位置
            String fileName = byteCodePath + className + ".class";

            bufferedInputStream = new BufferedInputStream(new FileInputStream(fileName));
            baos = new ByteArrayOutputStream();

            int len;
            byte[] data = new byte[1024];
            while ((len = bufferedInputStream.read(data)) != -1) {
                baos.write(data, 0, len);
            }

            byte[] byteCodes = baos.toByteArray();
            Class<?> clazz = defineClass(null, byteCodes, 0, byteCodes.length);
            return clazz;
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (bufferedInputStream != null)
                    bufferedInputStream.close();
                if (baos != null)
                    baos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }
}
package com.zdp.learn.studyJVM.vmClassLoader;

/**
 * @author zdp
 * @date 2021/8/10
 * @apiNote
 */
public class MyClassLoaderTest {
    public static void main(String[] args) {
        // 设置该类位于D盘中,不在原三大加载器中固定的目录中
        MyClassLoader myClassLoader = new MyClassLoader("d:/");

        try {
            // 自定义类加载器加载
            Class test = myClassLoader.loadClass(
                		"IntelliJ IDEA/basicStudy/src/com/zdp/learn/studyJVM/vmClassLoader/Test"
            			);
            System.out.println("加载此类的类加载器为" + 
                               test.getClassLoader().getClass().getName());
            System.out.println("加载此类的类加载器的父加载器为" + 
                               test.getClassLoader().getParent().getClass().getName());

            System.out.println("===============================================");

            // 使用应用类加载器加载
            Class clazz1 = ClassLoader.getSystemClassLoader().loadClass(
                		"com.zdp.learn.studyJVM.vmClassLoader.Test"
            			);
            System.out.println("加载此类的类加载器为" + 
                               clazz1.getClassLoader().getClass().getName());
            System.out.println("加载此类的类加载器的父加载器为" + 
                               test.getClassLoader().getParent().getClass().getName());

            // 使用不同的类加载器去加载统一路径下的Class文件
            System.out.println(test == clazz1);                 // false
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

image-20210810164928052

通过上述自定义的类加载器,我们可以实现加载除固定类加载目录以外的其他任何位置的 Class 字节码文件,用以对象的实例化。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我姓弓长那个张

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值