前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >类加载机制总结

类加载机制总结

作者头像
用户5325874
发布2020-01-16 17:49:41
5220
发布2020-01-16 17:49:41
举报
文章被收录于专栏:用户5325874的专栏

类加载机制

虚拟机把描述类的数据从class文件加载到内存,并且进行校验、解析、初始化。最终形成可以直接使用的Class对象,这就是类加载机制。

类加载

并不是一次性把所有的class文件都加载到JVM中,而是按照需要来加载。

比如JVM启动时,会通过不同的类加载器加载不同的类。当用户在自己的代码中,需要某些额外的类时,再通过加载机制加载到JVM中,并且存放一段时间,便于频繁使用。

类加载的时机

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载,共7个阶段。其中验证、准备、解析 3个部分统称为连接。这7个阶段的发生顺序如图:

加载、验证、准备、初始化、卸载 这5个阶段的顺序是确定的。类的加载过程必须按照这个顺序来执行。

而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始。这是为了支持java语言的运行时绑定(称为“动态绑定”)。

注意:这些过程只是按部就班的开始,而不是按部就班的完成。强调这点是因为这些阶段通常是相互交叉的运行,通常会在一个阶段执行的过程中调用、激活另外一个阶段。

有且仅有5种情况,必须对类进行初始化(而加载、验证、准备 自然需要在此之前开始)

  • 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令最常见的Java代码场景是:
    • 使用new关键字实例化对象的时候
    • 读取或设置一个类的静态字段(被final修饰,已在编译期把结果放入常量池的静态字段除外)
    • 调用一个类的静态方法
  • 对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
  • 当初始化一个类的时候,如果发现其父类没有初始化,那么先触发父类的初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(含main()方法的类),虚拟机就会先初始化这个主类
  • 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic 、REF_invokeStatic的方法的句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。

这5种场景的行为称为对一个类的主动引用。除此之外,所有对类的引用行为都不会触发初始化,称为被动引用。

被动引用的例子如下:

代码语言:javascript
复制
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 InitDemo {

    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }

}

输出
SuperClass init
123  

对于静态字段,只有直接定义了这个字段的类才会被初始化。因此子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

接口的初始化和类的初始化的不同:

  • 当一个类初始化时,要求其父类已经初始化了
  • 但是一个接口初始化时,并不要求父接口全部完成初始化。只有只用到父接口时(比如使用父接口中的常量),才会初始化父接口。
代码语言:javascript
复制
/**
 * 父接口
 * @author huangy on 2019-10-20
 */
public interface SuperInterface {

    Node2 tem = new Node2();

    /**
     * 如果父接口初始化,则会初始化对应的成员属性,因此Node的构造函数会被调用
     */
    Node node = new Node();
}


/**
 * 子接口
 * @author huangy on 2019-10-20
 */
public interface SubInterface extends SuperInterface {

    /**
     * 使用到父类的final静态常量。
     * 这里值得注意的是,如果引用了父类的String类型常量,则无法触发父接口初始化
     */
    Node2 node2 = SuperInterface.tem;
}

public class InitInterfaceDemo {

    public static void main(String[] args) {
        System.out.println(SubInterface.node2);
    }

}

public class Node2 {
}

public class Node {

    public Node() {
        System.out.println("node init");
    }
}

输出
node init  (表示父接口初始化了)

类加载过程

加载

在加载阶段,虚拟机需要完成3个事情:

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

没有指定二进制字节流从哪里获取,因此可以在运行时动态生成字节流,然后进行加载。在java.lang.reflect.Proxt中,就是使用ProxyGenerator.generateProxyClass为特定接口生成“*$Proxy”的代理类的二进制字节流。利用这种方式实现动态代理。

相对于类加载的其他阶段,加载阶段是开发人员可控性最强的,因为加载阶段可以使用用户自定义的类加载器完成,开发人员可以通过自定义类加载器控制字节流的获取方式。(即重写一个类加载器的loadClass()方法)。

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建它是由Java虚拟机直接创建的。但是数组类和类加载器仍然有很密切的的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建。

一个数组类的创建过程遵循以下规则:

  • 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组将在加载该组件类型的类加载器的类名称空间上被标识
  • 如果数组的组件类型不是引用类型(例如int[]数组),Java虚拟机将会把数组标记为与引导类加载器关联
  • 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public

加载阶段完成后,虚拟机外部的二进制字节流就存储在方法区之中(方法区中数据存储格式由虚拟机自行定义),然后在内存中实例化一个Class对象(在HotSpot虚拟机中,该对象存放在方法区),这个对象将作为程序访问方法区中类型数据的接口

验证

验证是连接阶段的第一步。这一阶段的目的是确保class文件中的字节流的信息符合虚拟机的要求,并且不会危害虚拟机的安全。验证阶段大概分为4个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证

字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理。比如说:

  • 是否以魔数开头
  • 主、次版本号是否在当前虚拟机的处理范围内
  • 常量池的常量是否有不被支持的常量类型
  • 指向常量的各种索引值是否有指向不存在的常量或者 不符合类型的常量

文件格式验证的目的是保证输入的字节流能正确解析并且存储在方法区之内。这个阶段的验证是基于二进制字节流进行的,只有通过这个阶段的验证后,字节流才会进入内存的方法区中进行存储。后面的3个阶段都是基于方法区的数据结构进行的,不再直接操作字节流。

元数据的验证

这个阶段是对字节码的描述的信息进行语义分析,以保证其描述的信息符合Java语言规范。比如

  • 这个类是否有父类
  • 这个类是否继承了不允许被继承的类(比如final修饰的类)
字节码验证

通过数据流和控制流分析,确定程序语义是合法的(也就是对类的方法体进行校验)。

  • 例如把对象赋值给没有继承关系的类型,是不合法的
符号引用验证

符号引用验证发生在虚拟机将符号引用转化为直接引用的时候。校验的内容如下:

  • 符号引用中通过字符串描述的全限定名能否找到对应的类

符号引用验证的目的是确保解析动作(解析阶段会把符号引用转化为直接引用)能正常执行。

准备

准备阶段是为类变量(静态变量)分配内存,并且设置类变量的初始值。这些变量所使用的内存都将在方法区中进行分配。合格阶段进行的内存分配仅包括类变量(static修饰的变量),而不包括实例变量,实例变量将在对象实例化时,分配在Java堆中。这里说的初始值指的是默认值。

解析

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

符号引用
  • 以一组符号来描述所引用的目标。
  • 符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
  • 符号引用与虚拟机实现的内存布局无关,各种虚拟机实现的内存布局可以不同,但他们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范中。
  • 引用的目标并不一定已经加载到内存中
直接引用
  • 可以是指向目标的指针、相对偏移量、一个间接定位到目标的句柄
  • 直接引用和虚拟机实现的内存布局相关。同一个符号引用在不同虚拟机上翻译出来的直接引用一般不一样。
  • 如果有了直接引用,那么引用的目标在内存中一定存在
解析时机
解析目标

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型。下面将讲解前面4种引用的解析过程。

类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:

  1. 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
  2. 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
  3. 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
字段解析

要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。

  1. 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  2. 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和他的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  3. 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段直接引用,查找失败。
  4. 否则,查找失败,抛出java.lang.NoSuchFieldError异常。

如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。

在实际应用中,虚拟机的编译器实现可能会比上述规范要求的更加严格一些,如果有一个同名字段同时出现在C的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译。在下面代码示例中,如果注释了Sub类中的“public static int A=4; ”,接口与父类同时存在字段A,那编译器将提示“The field Sub.A is ambiguous”,并且拒绝编译这段代码。

代码语言:javascript
复制
public class FieldResolution {

    interface Interface0 {
        int A = 0;
    }

    interface Interface1 extends Interface0 {
        int A = 1;
    }

    interface Interface2 {
        int A = 2;
    }

    static class Parent implements Interface1 {
        public static int A = 3;
    }

    static class Sub extends Parent implements Interface2 {
        public static int A = 4;
    }

    public static void main(String[] args) {
        System.out.println(Sub.A);
    }
}
类方法解析

类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。

  1. 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
  2. 如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3. 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4. 否则,在类C实现的接口列表及他们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象,这时查找结束,抛出java.lang.AbstractMethodError异常。
  5. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

​ 最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。

接口方法解析

接口方法也需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。

  1. 与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
  2. 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3. 否则,在接口C的父接口中递归查找,直到java.lang.Object(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
  5. 由于接口中的所有方法默认都是public,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。

初始化

类初始化阶段是类加载过程的最后一步。前面的类加载过程中,除了加载阶段,程序员可以自定义类加载进行干预,其他阶段完全由虚拟机主导和控制。到了初始化阶段,才开始真正执行类中定义的java程序代码(或者说字节码)。

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器()方法的过程

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

代码语言:javascript
复制
public class Test {
    static {
        i = 0;  //  给变量复制可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”  
    }
    static int i = 1;
}

()方法与类的构造函数(或者说实例构造器())不同,他不需要显示的调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕。因此在虚拟机中第一个被执行的()方法的类肯定是java.lang.Object。

由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下面代码示例中,字段B的值将会是2而不是1。

代码语言:javascript
复制
public class InitClassDemo {

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

}

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

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

​ 虚拟机会保证一个类的()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的()方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。下面代码示例演示了这种场景。

代码语言:javascript
复制
public class DeadLoopClassDemo {
    
    static class DeadLoopClass {
        static {
            // 如果不加上这个if语句,编译器将提示“Initializer does not complete normally”并拒绝编译
            if (true) {
                System.out.println(Thread.currentThread() + "init DeadLoopClass");
                while (true) {
                }
            }
        }
    }

    public static void main(String[] args) {
        Runnable script = new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };

        Thread thread1 = new Thread(script);
        Thread thread2 = new Thread(script);
        thread1.start();
        thread2.start();
    }
}

输出
Thread[Thread-0,5,main]start
Thread[Thread-1,5,main]start
Thread[Thread-0,5,main]init DeadLoopClass  

一个线程执行完类构造器以后,会唤醒第二条线程,但是第二条线程不会再次执行类构造器。同一个类加载器下,一个类型只会加载一次。

代码语言:javascript
复制
public class DeadLoopClassDemo {

    static class DeadLoopClass {
        static {
            // 如果不加上这个if语句,编译器将提示“Initializer does not complete normally”并拒绝编译
            if (true) {
                System.out.println(Thread.currentThread() + "init DeadLoopClass");

                try {
                    Thread.sleep(1000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        Runnable script = new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };

        Thread thread1 = new Thread(script);
        Thread thread2 = new Thread(script);
        thread1.start();
        thread2.start();
    }
}

输出
Thread[Thread-0,5,main]start
Thread[Thread-1,5,main]start
Thread[Thread-0,5,main]init DeadLoopClass
Thread[Thread-1,5,main] run over
Thread[Thread-0,5,main] run over

类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名获取二进制字节流”这个动作放到虚拟机外部实现,以便让应用程序自己决定如何去获取所要的类。实现这个动作的代码模块称为类加载器

类与类加载器

对于每一个类,都需要由加载这个类的类加载器和这个类本身在Java虚拟机中确定唯一性。

每一个类加载器,都有一个独立的类名称空间,也就是说,要比较这两个类是否相等,首先要判断是否同一个类加载器加载的类。也就是说,即时某个类是同一个class文件加载而来的,如果使用不同的类加载器进行加载,那么也会认为是不相等的类。

这个相等性比较会影响一些方法,比如Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法等,还有instanceof关键字做对象所属关系判定等。下面的代码演示了不同的类加载器对instanceof关键字的影响:

代码语言:javascript
复制
import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {

    public static void main(String[] args) throws Exception{

        // 使用自定义类加载器加载
        ClassLoader loader=new ClassLoader() {
            @Override
            public Class<?> loadClass(String name)throws ClassNotFoundException{
                try{
                    String filename=name.substring(name.lastIndexOf(".")+1)+".class";
                    InputStream is=getClass().getResourceAsStream(filename);
                    if(is==null){
                        return super.loadClass(name);
                    }
                    byte[] b=new byte[is.available()];
                    is.read(b);
                    return defineClass(name,b,0,b.length);
                }catch(IOException e){
                    throw new ClassNotFoundException(name);
                }
            }
        };

        // 通过全限定名加载这个类的二进制字节流
        Object obj =
                loader.loadClass("huangy.jvm.ClassLoaderTest").newInstance();


        System.out.println(obj.getClass());
        System.out.println(obj.getClass().getClassLoader());
        
        System.out.println(ClassLoaderTest.class.getClassLoader());

        // ClassLoaderTest 是系统加载器加载的类
        System.out.println(obj instanceof huangy.jvm.ClassLoaderTest);

        /**
         * 因为这时虚拟机中有两个temp.ClassLoaderTest类,一个是系统应用程序类加载器加载的,
         * 另一个是自定义的类加载器加载的,这两个类虽然来自同一个Class文件,但是加载它们的类加载器不同,
         * 导致类型检查时结果是false
         */
    }
}

双亲委派模型

从Java虚拟机的角度来讲,只存在2种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器使用C++语言实现,是虚拟机自身的一部分。
  • 其他类加载器:由Java语言实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。

从程序员角度,还可以分得更细一些:

####启动类加载器

启动类加载器(Bootstap ClassLoader)

  • 这个类加载器负责将<JAVA_HOME>/lib目录中的类库加载到虚拟机中。(能够被虚拟机按名字识别的文件才能被加载)
  • 启动类加载器无法被程序直接使用
扩展类加载器

扩展类加载器(Extension ClassLoader)

  • 这个加载器由sun.misc.Launcher$ExtClassLoader实现
  • 负责加载<JAVA_HOME>\lib\ext目录下的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类加载器

应用程序类加载器(Application ClassLoader)

  • 这个类加载器是由sun.misc.Launcher$AppClassLoader实现的。
  • 由于这个类加载器是ClassLoader中的getSystemClassLoader方法的返回值,所以也叫系统类加载器
  • 它负责加载用户类路径上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
代码语言:javascript
复制
public class ApplicationClassLoaderDemo {

    public static void main(String[] args) throws Exception {
        ClassLoader applicationClassLoader =
                ClassLoader.getSystemClassLoader();

        Class zclass =
                applicationClassLoader.loadClass("huangy.io.AvailableDemo");


        System.out.println(zclass);
    }

}
用户自定义类加载器

用户自己写一个类加载器,来决定从哪里加载class二进制字节流。

如何自己实现一个classloader打破双亲委派?就是重写loadClass方法,不使用父类加载器进行加载。

代码语言:javascript
复制
public class CustomClassLoaderDemo {

    public static void main(String[] args) throws Exception {

        /*
         * 自定义类加载器
         */
        ClassLoader customClassLoader = new ClassLoader() {
            @Override
            protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
                // 这里加载二进制字节流的方式可以自己决定
                return super.loadClass(name, resolve);
            }
        };

        Class zclass = customClassLoader.loadClass("huangy.java8.Demo");

        System.out.println(zclass);
    }

}

用户的应用程序就是在这三个类加载器的配合下加载的。不过,用户还可以加入自己的类加载器,这些类加载器的关系如下图:

工作过程
  • 如果一个类加载器收到一个类加载请求,它首先不会自己尝试去加载这个类,而是把这个请求委托给父类加载器去完成。每一个层次的类加载器都是如此。因此,所有的请求最终都应该传递到顶层的启动类加载器中。
  • 只有当父类加载器在搜索范围内没有找到所需要的类,子类加载器才尝试自己去加载。
作用
  • 保证核心库的类型安全
    • 通过双亲委派模型,对于 Java 核心库的类的加载工作由启动类加载器来统一完成.
  • 避免存在相同限定名的类而造成混乱
    • 如果没有双亲委派模型,由各个类加载器去加载的话,将可能出现多个同名的类。比如说用户自己编写了一个java.lang.Object类,并且通过自定义类加载器加载,那么JVM中就存在2个java.lang.Object,就会造成混乱。

不同的类加载器为相同名称的类创建了额外的名称空间。相同名称的类可以并存在 Java 虚拟机中,只需要用不同的类加载器来加载它们即可。不同类加载器加载的类之间是不兼容的,这就相当于在 Java 虚拟机内部创建了一个个相互隔离的 Java 类空间。

破坏
JDBC案例

启动类加载器在加载DriverManager的时候,会去加载实现了Driver接口的数据库实现类,这些类需要使用系统类加载器进行加载。

代码语言:javascript
复制
查看DriverManager类的源码,看到在使用DriverManager的时候会触发其静态代码块,调用 loadInitialDrivers() 方法,并调用ServiceLoader.load(Driver.class) 加载所有在META-INF/services/java.sql.Driver 文件里边的类到JVM内存,完成驱动的自动加载。

根据类加载机制,当被加载的类引用了另外一个类的时候,需要使用第一个类的类加载器进行加载。因此会尝试用启动类加载器去加载实现了Driver接口的实现类,但是启动类加载器是从JAVA_HOME/lib目录下加载类,实现了Drvier接口的实现类不在该路径下,因此加载不成功。这也导致了DriverManager的接口无法正常加载完成。

为了解决这个问题,java设计者团队使用了线程上下文类加载器

  • 这个类加载器,作为线程的一个属性,被设置到Thread线程中 private ClassLoader contextClassLoader;
  • 如果创建线程的时候没有设置,那么该属性将继承父线程的线程上下文类加载器
  • 如果程序全局范围内都没有设置过,那么这个加载器默认就是系统类加载器。

那么,如果使用线程上下文加载器解决上面的问题呢?

代码语言:javascript
复制
static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }

    private static void loadInitialDrivers() {

        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {

                ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
                Iterator<Driver> driversIterator = loadedDrivers.iterator();

                try{
                    while(driversIterator.hasNext()) {
                        driversIterator.next();
                    }
                } catch(Throwable t) {
                // Do nothing
                }
                return null;
            }
        });

  }
代码语言:javascript
复制
public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
}

查看代码就知道,在加载DriverManager的时候,会去使用线程上下文加载器完成Driver实现类的加载,然后就能顺序完成DriverManager的加载了。

Tomcat案例

我们看到,前面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可见
  • 默认情况下,CommonLoader、CatalinaLoader、SharedLoader都是同一个CommonLoader,这样子是为了简化架构。如果默认不满足需求,用户可以自行配置CatalinaLoader、SharedLoader

从图中的委派关系中可以看出:

CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。

WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。

而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热部署功能。(只有ClassLoader被卸载了,对应的类才能被卸载)

WebappClassLoader的类加载逻辑

tomcat的类加载机制是违反了双亲委托模型的,各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader走双亲委托。具体的加载逻辑位于WebAppClassLoaderBase.loadClass()方法中,代码篇幅长,这里以文字描述加载一个类过程:

  • 先在本地缓存中查找是否已经加载过该类(对于一些已经加载了的类,会被缓存在resourceEntries这个数据结构中),如果已经加载即返回,否则 继续下一步。
  • 让系统类加载器(AppClassLoader)尝试加载该类,主要是为了防止一些基础类会被web中的类覆盖,如果加载到即返回
  • 前两步均没加载到目标类,那么web应用的类加载器将自行加载,如果加载到则返回,否则继续下一步。
  • 最后还是加载不到的话,则委托父类加载器(Common ClassLoader)去加载。

问题

findClass和loadClass的区别?

findClass是双亲加载不到类,由子类加载,如果重写findClass,那么实际上还是会走双亲委派模型。

loadClass是双亲委派模型的代码实现,在ClassLoader中的实现为从双亲类加载器加载,加载不到,从子类加载器加载。因此我们重写loadClass,就可以破坏双亲委派模型了。

参考

https://blog.csdn.net/q982151756/article/details/81540903

https://www.cnblogs.com/yueshutong/p/11430885.html?from=singlemessage

[Tomcat类加载器机制

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 类加载机制
    • 类加载
      • 类加载的时机
        • 类加载过程
          • 加载
          • 验证
          • 准备
          • 解析
          • 初始化
        • 类加载器
          • 类与类加载器
          • 双亲委派模型
        • 问题
          • 参考
          相关产品与服务
          容器服务
          腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档