[JVM] JVM的类加载机制

JVM的类加载

首先我们来看下 Java虚拟机的类加载过程:

[JVM] JVM的类加载机制

如上图。

JVM需要用到某个类的时候,虚拟机会加载它的 .class 文件。加载了相关的字节码信息之后,会常见对应的 Class 对象,这个过程就被称为类加载。

需要注意的是:类加载机制只负责 class文件的加载,至于是否可以执行,则是由执行引擎决定的。

从图上可以看出,类的加载过程被分为五个阶段:加载、验证、准备、解析、初始化。验证、准备、解析三个阶段为连接步骤。其中加载、验证、准备、初始化这几个阶段的顺序是确定的,但是解析阶段不一定,在某些情况下可以在初始化阶段之后再开始(动态绑定)。

加载

这个阶段指的是,通过全限定类名查找Class字节码文件并将其加载到内存的过程。流程分为三步:

  • 通过全限定类型查找 .class 文件,获取二进制字节流数据
  • 把字节流所代表的静态存储结构转换为运行时数据结构
  • 在堆中为其创建一个 Class队形,作为程序访问这些数据的入口。

验证

这个阶段,主要是为了保证被加载的 Class对象的正确性,检测 Class字节流中的数据是否符合虚拟机要求,确保不会危害到虚拟机的自身安全。

验证阶段主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证 验证字节流是否符合 Class文件格式的规范。
  • CA/FE/BA/BE魔数验证
  • 主次版本号验证
  • 常量池中常量类型是否存在不被支持的类型验证
  • 指向常量池中的索引是否有指定不存在或不符合类型的常量
  • 元数据验证 对字节码描述的信息进行语义分析,保证其描述的信息符合 Java语言规范要求。
  • 类是否有父类,除了 Object之外,所有的类都应该有父类
  • 类的父类是否继承了不允许被继承的类(被 final修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
  • 类的字段/方法是否与父类的存在冲突。例如方法参数都一样,返回值却不同
  • 字节码验证 通过数据流和控制流分析,确定程序语义合法且符合逻辑。
  • 对类的方法体进行校验分析,保证在运行时不会做出危害虚拟机的行为
  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似于在操作数栈放了一个 int类型的数据,读取时却按照 long类型加载到本地变量表中的情况
  • 保障任何跳转指令都不会跳转到方法体之外的字节码指令上
  • 符号引用验证 确保后续的解析动作能正常运行
  • 通过字符串描述的全限定名是否能找到对应的类
  • 符号引用中的类、字段、方法的可访问性是否可被当前类访问

准备

这个阶段,主要是为了给在类中声明的静态变量分配内存空间,并将其初始化为默认值(零)。

注意,这个默认值并不是指在 Java代码中显示赋予的值,而是指数据类型的默认值。比如 static num = 4; 在这里只会将 num初始化为0。

在这里进行的内存分配,仅仅包括类成员( static成员),实例成员则是在创建具体的 java对象时再被一起分配在堆空间中。同时也不包含 final修饰的 static成员,因为 final在编译的时候就会分配了,准备阶段会显示初始化。

解析

这个阶段,主要是把类中对常量池的符号引用转换为直接引用的过程。

值得一提的是,解析操作往往会伴随着 JVM在执行完初始化之后再执行。

解释一下什么符号引用什么是直接引用:

  • 符号引用:用一组符号来描述引用的目标,符号引用的字面量形式明确定义在《Java虚拟机规范》的 Class文件格式中。
  • 直接引用:直接指向目标的指针、相对偏移量或者一个间接定位到目标的句柄。

而符号引用转直接引用的过程,主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符等7类符号引用进行。

初始化

这个阶段,主要是对类的静态变量赋予正确的初始值。也就是说,在声明静态变量时指定的初始化值以及静态代码块中的赋值。

本质上是执行了类构造器的 <cinit>()</cinit>的过程。

以下几种情况可以触发初始化:

  • 使用 new关键字创建一个实例对象时
  • 访问类的静态字段或静态方法时
  • 对类型进行反射调用的时候,例如 newInstance()
  • 当初始化一个类时发现父类没有初始化,那会先触发父类的初始化
  • 虚拟机启动的时候,需要指定一个要执行的主类,虚拟机会初始化这个主类(比如说 SpringBoot的启动类)
  • JDK8中,当一个接口定义了默认方法的时候,如果这个接口的实现类发生了初始化,那么先要将这个接口进行初始化。

以上这几种情况称之为主动引用。

除了以上几种情况之外,其他使用类的方式被看做嘶对类的被动引用,不会导致类的初始化。比如在子类中的调用父类的静态字段、定义该类的数组方式引用、调用该类的常量等情况都不会触发类进行初始化。

另外,初始化的大致步骤大概是:

  • 如果类还未加载、连接,则先进行加载、连接步骤。
  • 如果当前类存在直接父类未被进行初始化,则先初始化该父类。
  • 构造器方法中指令 按照语句在源码中出现的顺序执行。

使用、卸载

当一个类完整的经过了类加载过程之后,在内存中已经生成了 Class对象,同时在 Java程序中已经通过它开始创建实例对象使用时,这个阶段称之为使用阶段。

而当一个 Class对象不在被任何一个位置引用,也就是说,不可触及的时候, Class就会结束生命周期,该类的加载数据也会被卸载。

注意: Java虚拟机自带的类加载器加载的类,在 JVM的生命周期中始终不会被卸载。因为 JVM始终会保持与这些类加载器的引用,这这些类加载器也会始终保持着自己加载的 Class对象的引用。
对于虚拟机而言,这些 Class对象始终是可以被初级的。不过由用户自定义的类加载器加载的类是可以被卸载的。

类加载器类型与分析

我们来看下类加载器的类型:

[JVM] JVM的类加载机制

Bootstrap启动类加载器

也被称为引导类加载器。指的就是 BootstrapClassLoader。启动类加载器使用 C++语言实现的,是 JVM自身的一部分,主要负责将 <java_home>/lib</java_home>路径下的核心类库或者 -Xbootclasspath参数指定的路径下的 jar包加载到内存中。

注意:
因为 JVM是通过全限定类明来记载类库的,所以如果文件名不能被虚拟机识别,就算把 jar包丢进 lib目录表,启动类加载器也不会去加载它。处于安全考虑 BootstrapClassLoader只加载报名为 java&#x3001;javax&#x3001;sun等开头的类文件。

启动类加载器只为 JVM提供加载服务,开发者不能直接使用它来加载自己的类。

Extension扩展类加载器

这个类加载器是由 sun公司实现的,位于 HotSpot源码目录中的 sun.misc.Launcher$ExtClassLoader位置。它主要负责加载 <java_home>\lib\ext</java_home>目录下或者由系统变量 -Djava.ext.dir指定位路径中的类库。它可以直接被开发者使用。

Application应用类加载器

也别成为系统类加载器,也是由 sun公司实现的,位于 HotSpot源码目录中的 sun.misc.Launcher$AppClassLoader位置。它负责加载系统类路径 java -classpath-D java.class.path指定路径下的类库,也就是经常用到的 classpath路径。应用程序类加载器也可以直接被开发者使用。

一般情况下,该类加载器是程序的默认类加载器。我们可以通过 ClassLoader.getSystemClassLoader()方法来直接获取到它。

自定义类加载器

Java程序中,运行时一般都是通过如上三种类加载器相互配合执行的,当然,如果有特殊的加载需求也可以自定义类加载器,通过继承 ClassLoader类实现。

但继承 ClassLoader需要自己重写 findClass()方法并编写加载逻辑。所以如果一般没有太过复杂的需求,可以直接继承 URLClassLoader类,可以省略自己编写 findClass方法以及文件加载转换成字节码流的步骤,使自定义类加载器编写更加简洁。

那什么情况下时,我们需要自定义类加载器呢?有以下几种情况:

  • class文件不在 classpath路径下时,需要自定义类加载器加载特定路径下的 class
  • 当一个 class文件时通过网络传输过来的 bin经过了加密处理,需要授信对 class文件做了对应的解密处理之后再加载到内存中时,需要自定义类加载器。
  • 线上环境不能停机时,要动态更改某块代码,这种情况下需要自定义类加载器。(比如实现热部署功能。即一个 class文件通过不同的类加载器产生不同的 class对象从而实现热部署功能。)

案例

运维子平台有个需求,需要一个编写 Java代码的终端,那对于这种情况就需要将运维平台中编写的 class文件经过加密后,通过网络传输过来,然后对其类的字节码数据进行解密后再加载。
源码如下:

// 运维终端类加载器
public class OpsClassLoader extends ClassLoader {

    // 接收到的class文件本地的存储位置
    private String rootDirPath;

    // 构造器
    public OpsClassLoader(String rootDirPath) {
        this.rootDirPath = rootDirPath;
    }

    // 读取Class字节流并解密的方法
    private byte[] getClassDePass(String className) throws IOException {
        String classpath = rootDirPath + className;

        // 模拟文件读取过程.....

        FileInputStream fis = new FileInputStream(classpath);
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        int bufferSize = 1024;
        int n = 0;
        byte[] buffer = new byte[bufferSize];
        while ((n = fis.read(buffer)) != -1)
            // 模拟数据解密过程.....

            baos.write(buffer, 0, n);
        byte[] data = baos.toByteArray();

        // 模拟保存解密后的数据....

        return data;
    }

    // 重写了父类的findClass方法
    @SneakyThrows
    @Override
    protected Class findClass(String name) throws ClassNotFoundException {
        // 读取指定的class文件
        byte[] classData = getClassDePass(name);
        // 如果没读取到数据,抛出类不存在的异常
        if (classData == null)
            throw new ClassNotFoundException();
        // 通过调用defineClass方法生成Class对象
        return defineClass(name,classData,0,classData.length);
    }
}

在如上源码中,我们通过了 getClassDePass()方法读取了网络传输过来存储到本地的 class文件的字节流数据,并对读取到的数据做了对应的解密处理(模拟),然后通过重写了父类的 ClassLoader.findClass()方法,利用 defineClass()方法在 JVM内存中生成了最终的 Class对象。

当然,如果你想代码更简洁,也可以通过继承 URLClassLoader类实现。

热部署机制原理分析

大家对热部署机制都不陌生。在热部署机制出现之前,往往我们稍微更改了一丢丢的 Java代码,就需要对整个项目重启之后才可生效。而热部署机制出现之后,在 Java程序运行过程中,动态的修改了某个类的代码保存后,程序会自动加载更新代码。这是如何实现的呢?

在之前的类加载机制中,我们分析得知:全限定类名相同的一个类被加载过后,第二次需要用到该类的时候,会直接在类加载器的命名空间(可以理解为缓存)中进行查找,而不会被二次加载。如果强制指定同一个类加载器二次加载同一个类的时候,会抛出异常。

所以一般类被加载一次之后,就算某个类的 class文件发生了改变, JVM也不会再次加载它。

而所谓的热部署机制的实现其实比较简单,就是通过利用不同的类加载器,去加载更改后的 class文件,从而在内存中创建出两个不同的 class对象,从而达到文件更改后可以生效的目的。

四种类加载器之间的关系

从上面的分析可知类加载器的关系如下:

Bootstrap启动类加载器 → Extension拓展类加载器 → Application应用类加载器 → Custom自定义类加载器

Bootstrap类加载器是在 JVM启动时初始化的,它会负责加载 ExtClassLoader,并将其父加载器设置为 BootstrapClassLoader

BootstrapClassLoader加载完 ExtClassLoader后会接着加载 AppClassLoader系统类加载器,并将其父加载器设置为 ExtClassLoader拓展类加载器。

而自定义的类加载器会由系统类加载器加载,加载完成后, AppClassLoader会成为它们的父加载器。

注意:
类加载器之间并不存在相互继承或者包含关系,从上至下仅存在父类加载器的层级引用关系。

通过 Java代码来简单剖析一下类加载器之间的关系:

// 自定义类加载器
public class ClassLoaderDemo extends ClassLoader {
    public static void main(String[] args){
        ClassLoaderDemo classLoader = new ClassLoaderDemo();

        System.out.println("自定义加载器:" +
                classLoader);
        System.out.println("自定义加载器的父类加载器:" +
                classLoader.getParent());
        System.out.println("Java程序系统默认的加载器:" +
                ClassLoader.getSystemClassLoader());
        System.out.println("系统类加载器的父加载器:" +
                ClassLoader.getSystemClassLoader().getParent());
        System.out.println("拓展类加载器的父加载器:"
                + ClassLoader.getSystemClassLoader().getParent().getParent());
    }
}

结果输出如下:

&#x81EA;&#x5B9A;&#x4E49;&#x52A0;&#x8F7D;&#x5668;&#xFF1A;com.sixstarServiceOrder.ClassLoaderDemo@6d5380c2
&#x81EA;&#x5B9A;&#x4E49;&#x52A0;&#x8F7D;&#x5668;&#x7684;&#x7236;&#x7C7B;&#x52A0;&#x8F7D;&#x5668;&#xFF1A;sun.misc.Launcher$AppClassLoader@18b4aac2
Java&#x7A0B;&#x5E8F;&#x7CFB;&#x7EDF;&#x9ED8;&#x8BA4;&#x7684;&#x52A0;&#x8F7D;&#x5668;&#xFF1A;sun.misc.Launcher$AppClassLoader@18b4aac2
&#x7CFB;&#x7EDF;&#x7C7B;&#x52A0;&#x8F7D;&#x5668;&#x7684;&#x7236;&#x52A0;&#x8F7D;&#x5668;&#xFF1A;sun.misc.Launcher$ExtClassLoader@45ff54e6
&#x62D3;&#x5C55;&#x7C7B;&#x52A0;&#x8F7D;&#x5668;&#x7684;&#x7236;&#x52A0;&#x8F7D;&#x5668;&#xFF1A;null

因为 BootstrapClassLoader是由 C++实现的,所以在获取 ExtClassLoader的父类加载器时,获取到的结果为 null

总结

对类加载器进行一个总结。

JVM的类加载机制是按需加载的模式运行的,也就是说,所有类并不是在程序启动时就全部加载,而是当需要用到某个类的时候发现它未加载时,才会去触发加载的过程。

Java中的类加载器会被组织成存在父子关系的层级结构。同时类加载器之间也存在这代理模式。当一个类需要被加载时,首先会依次根据层级结构检查父加载器是否对这个类进行了加载,如果父类已经装载了则可以直接使用。反之如果未被装载则依次从上至下询问,是否在可加载范围内,是否允许被当前层级的加载器加载,如果可以则进行加载操作。

每个类加载器都拥有一个自己的命名空间(缓存),命名空间的作用是用于存储被自身加载过的所有类的全限定名。子类加载器查找父类加载器是否加载过一个类时,就是通过类的权限定名在父类的命名空间中进行匹配。而 Java虚拟机判断两个类是否相同的基准就是通过 ClassLoaderId + PackageName + ClassName进行判断,也就代表着, Java程序运行过程中,是允许存在两个包名和类名完全一致的 class的,只需要使用不同的类加载器加载即可,这也就是 Java类加载器存在的隔离性问题,而 Java为了解决这个问题, JVM引入了双亲委派机制。

子类加载器可以检查父类加载器中加载的类,但这个是不可逆的,也就代表着父类加载器是不可以查找子类加载器加载的类,存在可见性限制。

Bootstrap&#x3001;Ext&#x3001;APP三个类加载器加载的类是不可以被卸载的,但可以删除当前的类装载器,然后创建一个新的类装载器装载。

另:
显示加载:指的是开发者手动通过调用 ClassLoader加载一个类,比如 Class.forName(name)obj.getClass().getClassLoader().loadClass()方式加载 class对象。
隐式加载:指不会在程序中明确的指定加载某个类,属于被动式加载,比如在加载某个类时,该类中引用了另外一个类的对象时, JVM就会去自动加载另外一个类,而这种被动加载方式就被称为”隐式加载”。

双亲委派机制

前面提到过,为了解决类加载器的隔离性问题, JVM引入了双亲委派机制,而双亲委派的核心思想在于两点:

  • 自下而上检查类是否已经被加载
  • 从上至下尝试加载类

加载过程

  • App尝试加载一个类时,它不会直接尝试加载这个类,首先会在自己的命名空间中查询是否已经加载过这个类,如果没有会先将这个类加载请求委派给父类加载器 Ext完成
  • Ext尝试加载一个类时,它也不会直接尝试加载这个类,也会在自己的命名空间中查询是否已经加载过这个类,没有的话也会先将这个类加载请求委派给父类加载器 Bootstrap完成
  • 如果 Bootstrap加载失败,也就是代表着:这个需要被加载的类不在 Bootstrap的加载范围内,那么 Bootstrap会重新将这个类加载请求交由子类加载器 Ext完成
  • 如果 Ext加载失败,代表着这个类也不在 Ext的加载范围内,最后会重新将这个类加载请求交给子类加载器 App完成
  • 如果 App加载器也加载失败,就代表这个类根据全限定名无法查找到,则会抛出 ClassNotFoundException异常

优势

从上述的过程中可以很直观的感受到:当 JVM尝试加载一个类时,通常最底层的类加载器接收到了类加载请求之后,会先交由自己的上层类加载器完成。

那么采用这种模式的优势是什么?

  • 可以避免一个类在不同层级的类加载器中重复加载,如果父类加载器已经加载过该类了,那么就不需要子类加载器再加载一次。
  • 可以保障 Java核心类的安全性问题,有效防止 Java的核心 API类在运行时被篡改,从而保证所有子类共享同一基础类,减少性能开销和安全隐患问题。 比如通过网络传输过来一个 java.lang.String类,需要被加载时,通过这种双亲委派的方式,最终找到 Bootstrap加载器后,发现该类已经被加载,从而就不会再加载传输过来的 java.lang.String类,而是直接返回 Bootstrap加载的 String.class

实现原理

从代码层面了解一下 Java中双亲委派模式的实现以及 Java中定义的一些类加载器。

Java中,所有的类加载器都间接的继承自 ClassLoader类,包括 Ext&#x3001;App类加载器( Bootstrap除外,因为它是 C++实现的),如下:

// sun.misc.Launcher类
public class Launcher {
    // sun.misc.Launcher类 → 构造器
    public Launcher(){
        Launcher.ExtClassLoader var1;
        try {
            // 会先初始化Ext类加载器并创建ExtClassLoader
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError(
                "Could not create extension class loader", var10);
        }
        try {
            // 再创建AppClassLoader并把Ext作为父加载器传递给App
            loader = AppClassLoader.getAppClassLoader(extcl);
        } catch (IOException e) {
            throw new InternalError(
                "Could not create application class loader");
        }

        // 将APP类加载器设置为线程上下文类加载器(稍后分析)
        Thread.currentThread().setContextClassLoader(loader);
        // 省略......

    }

    // sun.misc.Launcher类 → ExtClassLoader内部类
    static class ExtClassLoader extends URLClassLoader {
        // ExtClassLoader内部类 → 构造器
        public ExtClassLoader(File[] var1) throws IOException {
            // 在Ext初始化时,父类构造器会被设置为null
            super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
            SharedSecrets.getJavaNetAccess().getURLClassPath(this)
                         .initLookupCache(this);
        }
    }

    // sun.misc.Launcher类 → AppClassLoader内部类
    static class AppClassLoader extends URLClassLoader {}
}

// java.net.URLClassLoader类
public class URLClassLoader extends SecureClassLoader
        implements Closeable {}

// java.security.SecureClassLoader类
public class SecureClassLoader extends ClassLoader {}

如上源码。

Ext&#x3001;App类加载器都是 sun.misc.Launcher类的内部类,而 Launcher在初始化时会首先创建 Ext类加载器,而在初始化 Ext时,它的构造器中会强行将其父类加载器设置为 null

创建完成 Ext类加载器之后,会紧接着再创建 App类加载器,同时在创建 AppClassLoader的时候会将 Ext类加载器设置为 App类加载器的父类加载器。

Ext&#x3001;App类加载器都继承了 URLClassLoader类,该类主要是用于读取各种 jar包、本地 class以及网络传递的 class文件,通过找到它们的字节码,然后再将其读取成字节流,最后通过 defineClass()方法创建类的 Class对象。而 URLClassLoader类继承了 SecureClassLoader类,该类也作为了 ClassLoader类的拓展类,新增了几个对代码源的位置及其证书的验证以及权限定义类验证(主要指对 class源码的访问权限)的方法,一般我们不会直接跟这个类打交道,更多是与它的子类 URLClassLoader有所关联。

总而言之, Ext&#x3001;App类加载器都间接的继承了 ClassLoader类, ClassLoader类作为 Java类加载机制的顶层设计类,它是一个抽象类。

下面来简单的看看 ClassLoader,如下:

// ClassLoader类 → loadClass()方法
protected Class loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    // 加锁
    synchronized (getClassLoadingLock(name)) {
        // 先尝试通过全限定名从自己的命名空间中查找该Class对象
        Class c = findLoadedClass(name);
        // 如果找到了则不需要加载了,如果==null,开始类加载
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 先将类加载任务委托自己的父类加载器完成
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 如果父类加载器为null,代表当前已经是ext加载器了
                    // 那么则将任务委托给Bootstrap加载器加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 处理异常,抛出异常
            }

            if (c == null) {
                // 如果都没有找到,则通过自定义实现的findClass
                // 去查找并加载
                long t1 = System.nanoTime();
                c = findClass(name);

                // 这是记录类加载相关数据的(比如耗时、类加载数量等)
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        // 是否需要在加载时进行解析,如果是则触发解析操作
        if (resolve) {
            resolveClass(c);
        }
        // 返回加载后生成的Class对象
        return c;
    }
}

// ClassLoader类 → findClass()方法
protected Class findClass(String name)
            throws ClassNotFoundException {
    // 直接抛出异常(这个方法是留给子类重写的)
    throw new ClassNotFoundException(name);
}

// ClassLoader类 → defineClass()方法
protected final Class defineClass(String name, byte[] b,
        int off, int len) throws ClassFormatError
{
    // 调用了defineClass方法,
    // 将字节数组b的内容转换为一个Java类
    return defineClass(name, b, off, len, null);
}

// ClassLoader类 → resolveClass()方法
protected final void resolveClass(Class c) {
    // 调用本地(navite)方法,解析一个类
    resolveClass0(c);
}

// ClassLoader类 → getParent()方法
@CallerSensitive
public final ClassLoader getParent() {
    // 如果当前类加载器的父类加载器为空,则直接返回null
    if (parent == null)
        return null;
    // 如果不为空则先获取安全管理器
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        // 然后检查权限后返回当前classLoader的父类加载器
        checkClassLoaderPermission(parent,
                Reflection.getCallerClass());
    }
    return parent;
}

述简单的罗列了一些 ClassLoader类的关键方法,具体作用如下:

  • loadClass(name,resolve):加载名称为 name的类,加载后返回 Class对象实例
  • findClass(name):查找名称为 name的类,返回是一个 Class对象实例(该方法是留给子类重写覆盖的,在 loadClass中,在父类加载器加载失败的情况下会调用该方法完成类加载,这样可以保证自定义的类加载器也符合双亲委托模式)
  • defineClass(name,b,off,len):将字节流 b转换为一个 Class对象
  • resolveClass(c):使用该方法可以对加载完生成的 Class对象同时进行解析操作
  • getParent():获取当前类加载器的父类加载器

其实双亲委派模型的实现逻辑全在于 loadClass()方法,而 ExtClassLoader加载器是没有重写 loadClass()方法, AppClassLoader加载器虽然重写了 loadClass()方法,但其内部最终还是调用父类的 loadClass()方法,如下:

// sun.misc.Launcher类 → AppClassLoader内部类 → loadClass()方法
 public Class loadClass(String name, boolean resolve)
     throws ClassNotFoundException
 {
     int i = name.lastIndexOf('.');
     if (i != -1) {
         SecurityManager sm = System.getSecurityManager();
         if (sm != null) {
             sm.checkPackageAccess(name.substring(0, i));
         }
     }
     // 依旧调用的是父类loadClass()方法
     return (super.loadClass(name, resolve));
 }

所以无论是 ExtClassLoader还是 AppClassLoader加载器,其本身都未打破 ClassLoader.loadClass()方法中定义的双亲委派逻辑, Bootstrap&#x3001;Ext&#x3001;App这些 JVM自带的类加载器都默认会遵守双亲委派模型。

双亲委派机制的破坏者

在Java中,官方为我们提供了很多 SPI接口,例如 JDBC&#x3001;JBI&#x3001;JNDI等。这类 SPI接口,官方往往只会定义规范,具体的实现则是由第三方来完成的,比如 JDBC,不同的数据库厂商都需自己根据 JDBC接口的定义进行实现。

而这些 SPI接口直接由 Java核心库来提供,一般位于 rt.jar包中,而第三方实现的具体代码库则一般被放在 classpath的路径下。

位于 rt.jar包中的 SPI接口,是由 Bootstrap类加载器完成加载的,而 classpath路径下的 SPI实现类,则是 App类加载器进行加载的。但往往在 SPI接口中,会经常调用实现者的代码,所以一般会需要先去加载自己的实现类,但实现类并不在 Bootstrap类加载器的加载范围内,而经过前面的双亲委派机制的分析,我们已经得知:子类加载器可以将类加载请求委托给父类加载器进行加载,但这个过程是不可逆的。也就是父类加载器是不能将类加载请求委派给自己的子类加载器进行加载的。

所以此时就出现了这个问题:如何加载 SPI接口的实现类?

答案是打破双亲委派模型。

注: SPIService Provider Interface): JavaSPI机制,其实就是可拔插机制。在一个系统中,往往会被分为不同的模块,比如日志模块、 JDBC模块等,而每个模块一般都存在多种实现方案,如果在 Java的核心库中,直接以硬编码的方式写死实现逻辑,那么如果要更换另一种实现方案,就需要修改核心库代码,这就违反了可拔插机制的原则。为了避免这样的问题出现,就需要一种动态的服务发现机制,可以在程序启动过程中,动态的检测实现者。而 SPI中就提供了这么一种机制,专门为某个接口寻找服务实现的机制。
当第三方实现者提供了服务接口的一种实现之后,在 jar包的 META-INF/services/目录里同时创建一个以服务接口命名的文件,该文件就是实现该服务接口的实现类。而当外部程序装配这个模块的时候,就能通过该 jarMETA-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。同时, JDK官方也提供了一个查找服务实现者的工具类: java.util.ServiceLoader

线程上下文类加载器就是双亲委派模型的破坏者。

它可以在执行线程中打破双亲委派机制的加载链关系,从而使得程序可以逆向使用类加载器。

我们可以通过分析 JDBC驱动的源码来看看是怎么打破双亲委派机制是的程序逆向使用类加载器。

JDBC角度分析线程上下文类加载器

JavaSPI定义的一个核心类: DriverManager,该类位于 rt.jar包中,是 Java中用于管理不同数据库厂商实现的驱动,同时这些各厂商实现的 Driver驱动类,都继承自 Java的核心类 java.sql.Driver

MySQLcom.mysql.cj.jdbc.Driver的驱动类。先看看 DriverManager的源码:

// rt.jar包 → DriverManager类
public class DriverManager {
    // .......

    // 静态代码块
    static {
        // 加载并初始化驱动
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }

// DriverManager类 → loadInitialDrivers()方法
 private static void loadInitialDrivers() {
    // 先读取系统属性 jdbc.drivers
    String drivers;
    try {
        drivers = AccessController.doPrivileged(new PrivilegedAction() {
            public String run() {
                return System.getProperty("jdbc.drivers");
            }
        });
    } catch (Exception ex) {
        drivers = null;
    }

    AccessController.doPrivileged(new PrivilegedAction() {
        public Void run() {
            //通过ServiceLoader类查找驱动类的文件位置并加载
            ServiceLoader loadedDrivers =
            ServiceLoader.load(Driver.class);
            //省略......

        }
    });
    //省略......

}

观察如上源码,在 DriverManager类的静态代码块中调用了 loadInitialDrivers()方法,该方法中,会通过 ServiceLoader查找服务接口的实现类。前面分析 JavaSPI机制时,曾提到过: JavaSPI存在一种动态的服务发现机制,在程序启动时,会自动去 jar包中的 META-INF/services/目录查找以服务命名的文件, mysql-connector-java-6.0.6.jar包文件目录如下:

[JVM] JVM的类加载机制

如上工程结构,我们明确可以看到,在 MySQLjar包中存在一个 META-INF/services/目录,而在该目录下,存在一个 java.sql.Driver文件,该文件中指定了 MySQL驱动 Driver类的路径,该类源码如下:

// com.mysql.cj.jdbc.Driver类
public class Driver extends NonRegisteringDriver
                        implements java.sql.Driver {
    public Driver() throws SQLException {
    }
    // 省略.....

}

可以看到,该类是实现了 Java定义的 SPI接口 java.sql.Driver的,所以在启动时, SPI的动态服务发现机制可以发现指定的位置下的驱动类。

看看 SPI机制是如何加载对应实现类的, ServiceLoader.load()源码如下:

// ServiceLoader类 → load()方法
public static  ServiceLoader load(Class service) {
    // 获取线程上下文类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    // 使用线程上下文类加载器对驱动类进行加载
    return ServiceLoader.load(service, cl);
}

通过如上源码可以看到:最终是通过 Thread.currentThread().getContextClassLoader()获取的当前执行线程的线程上下文类加载器对 SPI接口的实现类进行了加载。

在前面我们分析 Java中的双亲委派实现时,曾提到了 Ext&#x3001;App类加载器都是 Launcher类的内部类, Ext&#x3001;App类加载器的初始化操作都是在 Launcher的构造函数中完成的,同时,在该构造函数中, Ext&#x3001;App初始化完成后,会执行下面这句代码:

Thread.currentThread().setContextClassLoader(loader);

通过如上这句代码,在 Launcher的构造函数中,会将已经创建好的 AppClassLoader系统类加载器设置为默认的线程上下文类加载器。

我们总结一下流程:

  • Java程序启动
  • JVM初始化 C++编写的 Bootstrap启动类加载器
  • Bootstrap加载Java核心类(核心类中包含 Launcher类)
  • Bootstrap加载 Launcher类,其中触发 Launcher构造函数
  • Bootstrap执行 Launcher构造函数的逻辑
  • Bootstrap初始化并创建 Ext&#x3001;App类加载器
  • Launcher类的构造函数中将 Ext设置为 App的父类加载器,同时再将 App设置为默认的线程上下文类加载器
  • Bootstrap继续加载其他Java核心类(如: SPI接口)
  • SPI接口中调用了第三方实现类的方法
  • Bootstrap尝试去加载第三方实现类,发现不在自己的加载范围内,无法加载
  • 依赖于 SPI的动态服务发现机制,这些实现类会被交由线程上下文类加载器进行加载(在前面讲过,线程上下文加载器在 Launcher构造函数被设置为了 App类加载器)
  • 通过 App系统类加载器加载第三方实现类,发现这些实现类在 App的加载范围内,可以被加载, SPI接口的实现类加载完成

很明显的就可以感觉出来,线程上下文类加载器介入后,轻而易举的打破了原有的双亲委派模型,同时,也正是因为线程上下文类加载器的出现,从而使得 Java的类加载器机制更加灵活,方便。

总结

简单来说, Java提供了很多核心接口的定义,这些接口被称为 SPI接口,同时为了方便加载第三方的实现类, SPI提供了一种动态的服务发现机制(约定),只要第三方在编写实现类时,在工程内新建一个 META-INF/services/目录并在该目录下创建一个与服务接口名称同名的文件,那么在程序启动的时候,就会根据约定去找到所有符合规范的实现类,然后交给线程上下文类加载器进行加载处理。

总结

总归来说, Java类加载机制的核心点也就是开篇提到的那几个重点:类加载过程、类加载器、双亲委派模型、自定义类加载器以及线程上下文类加载器,掌握这几部分即可。

Original: https://www.cnblogs.com/knqiufan/p/16369198.html
Author: knqiufan
Title: [JVM] JVM的类加载机制

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/570445/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

  • 日常踩坑_点击链接后自动下载文件

    照旧说一下前情提要:将文件上传到文件服务器以后,会返回一个链接,本来是想通过这个链接直接看到文件内容的,结果返回的链接一点击就自动强制下载了,非常烦人想要使该链接点击后是直接查看而…

    Java 2023年6月7日
    091
  • Nginx配置,413 Request Entity Too Large错误解决

    今天有同事找我,说图片上传之后,不知道去哪里了。分析了一下问题,找到原因之后做了处理,这里简要记录一下。 问题原因: 1.首先后台log并无错误信息; 2.捡查了一下浏览器,发现n…

    Java 2023年5月30日
    081
  • 来一起写一个跳表吧

    跳表定义,初始化,查找,节点新增与删除 跳表全称叫做跳跃表,简称跳表,是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序列表上面增加多级索引,通过索引…

    Java 2023年6月9日
    060
  • 动态调整日志级别思路&实现

    引言 上篇文章 性能调优——小小的 log 大大的坑 已将详细的介绍了高并发下,不正确的使用日志姿势,可能会导致服务性能急剧下降问题。文末也给各位留下了解决方案——日志级别动态调整…

    Java 2023年6月15日
    0114
  • nginx 转发 minio 服务

    现有3台服务器 192.168.1.225 nginx 192.168.1.229 其他应用服务 192.168.1.234 minio nginx配置文件如下 1 # For m…

    Java 2023年5月30日
    083
  • docker

    一、docker安装 VMware centos7 卸载原有docker yum remove docker docker-common docker-selinux docker…

    Java 2023年6月9日
    098
  • fastposter v2.8.3 发布 电商海报生成器

    fastposter v2.8.3 发布 电商海报生成器 🔥🔥🔥 fastposter海报生成器,电商海报编辑器,电商海报设计器,fast快速生成海报 海报制作 海报开发。贰维🐴海…

    Java 2023年6月5日
    074
  • Java核心技术-方法引用

    Day6 方法引用 可以将一个方法引用传递给一个函数式接口。 System.out::println//就是一个函数引用,它指示编译器生成一个函数式接口的实例,覆盖这个接口的抽象方…

    Java 2023年6月5日
    090
  • 历时2月,动态线程池 DynamicTp 发布里程碑版本 V1.0.8

    关于 DynamicTp DynamicTp 是一个基于配置中心实现的轻量级动态线程池管理工具,主要功能可以总结为动态调参、通知报警、运行监控、三方包线程池管理等几大类。 经过多个…

    Java 2023年6月14日
    090
  • 【力扣】98. 验证二叉搜索树

    给定一个二叉树,判断其是否是一个有效的二叉搜索树。假设一个二叉搜索树具有如下特征:节点的左子树只包含小于当前节点的数。节点的右子树只包含大于当前节点的数。所有左子树和右子树自身必须…

    Java 2023年6月8日
    092
  • 动态规划:LeetCode.H0629.K个逆序对数组

    给出两个整数 n 和 k,找出所有包含从 1 到 n 的数字,且恰好拥有 k 个逆序对的不同的数组的个数。 逆序对的定义如下:对于数组的第i个和第 j个元素,如果满i < j…

    Java 2023年6月7日
    0122
  • 通过实现仿照FeignClient框架原理的示例来看清FeignClient的本质

    前言 FeignClient的实现原理网上一搜一大把,此处我就不详细再说明,比如:Feign原理 (图解) – 疯狂创客圈 – 博客园 (cnblogs.c…

    Java 2023年6月9日
    082
  • Linux常用文件管理命令详解

    cat cat命令用于连接文件并打印到标准输出设备上。 命令语法: cat [&#x53C2;&#x6570;] [&#x6587;&#x4EF6;…

    Java 2023年6月7日
    074
  • 【每日算法】二分查找法II

    left,right=1,n while left<=right: mid="left+(right-left)//2" if 条件: right=&qu…

    Java 2023年6月9日
    091
  • springboot多环境下如何进行动态配置

    在平时的开发中,经常会有多个环境,如何管理多个环境中的配置呐?一个是我们本地的开发环境,可以称为dev,一个是测试环境,我们称为test,最后还要有生产环境,称为prod。每个环境…

    Java 2023年6月9日
    088
  • 16.服务端、客户端解决粘包问题,服务端加入退出指令线程

    客户端: DataHeader.hpp EasyTcpClient.hpp main.cpp 服务端: 对客户端的管理要进行升级,每个客户端要有自己的消息缓冲区。 对客户端的管理使…

    Java 2023年5月29日
    0120
亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球