引言

在 Java 技术体系中,类加载机制作为连接源代码与运行时环境的桥梁,构成了 Java 虚拟机动态性的重要基础。当 Java 程序启动时,JVM 需要将磁盘上的.class文件加载至内存,并在运行时环境中构建类的元数据结构,这一过程的准确性与高效性直接决定了程序的执行性能与稳定性。从 JDK 1.2 引入双亲委派机制至今,该机制已成为 JVM 类加载体系的核心范式,其设计思想深刻影响着 Java 生态系统的安全性、类库隔离性与跨平台兼容性。

JVM 类加载器介绍

在深入探讨双亲委派机制之前,先来认识一下 JVM 中的类加载器家族成员,它们各自承担着独特的职责,共同协作完成类加载的重任。

启动类加载器(Bootstrap ClassLoader)

启动类加载器是 JVM 类加载器层级结构中的最顶层,犹如大厦的基石,是整个类加载体系的根基。它由 C++ 编写,如同 JVM 的内置核心组件,在 Java 代码层面无法直接访问 。它的使命是加载 JVM 运行所必需的核心类库,这些类库如同 JVM 运行的 “中枢神经”,支撑着整个 Java 运行时环境的稳定运作。比如位于%JAVA_HOME%/jre/lib目录下的rt.jar,这个核心类库包含了 Java 最基础、最核心的类,像java.lang.Objectjava.lang.String等,它们是构建 Java 程序世界的基础砖块。

我们可以通过一段简单的代码来一窥启动类加载器的加载路径:

import java.net.URL;

public class ClassLoaderPathTest {

   public static void main(String[] args) {
       URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
       for (URL url : urls) {
           System.out.println(url);
       }
   }
}

运行上述代码,将会输出启动类加载器加载的一系列核心类库的路径,例如:

file:/D:/java/jdk1.8.0_161/jre/lib/resources.jar
file:/D:/java/jdk1.8.0_161/jre/lib/rt.jar
file:/D:/java/jdk1.8.0_161/jre/lib/sunrsasign.jar
file:/D:/java/jdk1.8.0_161/jre/lib/jsse.jar
file:/D:/java/jdk1.8.0_161/jre/lib/jce.jar
file:/D:/java/jdk1.8.0_161/jre/lib/charsets.jar
file:/D:/java/jdk1.8.0_161/jre/lib/jfr.jar
file:/D:/java/jdk1.8.0_161/jre/classes

这些路径下的类库被启动类加载器加载,为 JVM 的运行提供了最基本的支持。

扩展类加载器(Extension ClassLoader)

扩展类加载器是启动类加载器的 “得力助手”,作为其"子类",它主要负责加载 Java 的扩展类包。这些扩展类包位于%JAVA_HOME%/jre/lib/ext目录下,像是 JVM 功能的扩展插件,为 JVM 的运行提供了额外的功能支持 。例如,jce.jar提供了 Java 加密扩展(JCE)框架相关的类,sunpkcs11.jar则与 PKCS11 标准的加密服务提供程序相关,这些扩展类库丰富了 JVM 的功能边界。

通过以下代码可以获取扩展类加载器的加载路径:

import java.net.URL;
import java.net.URLClassLoader;

public class ExtensionClassLoaderPathTest {

   public static void main(String[] args) {
       URLClassLoader extClassLoader = (URLClassLoader) ClassLoader.getSystemClassLoader().getParent();
       URL[] urls = extClassLoader.getURLs();
       for (URL url : urls) {
           System.out.println(url);
       }
   }
}

运行结果类似如下:

file:/D:/java/jdk1.8.0_161/jre/lib/ext/access-bridge-64.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/cldrdata.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/dnsns.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/jaccess.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/jfxrt.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/localedata.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/nashorn.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/sunec.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/sunjce_provider.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/sunmscapi.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/sunpkcs11.jar
file:/D:/java/jdk1.8.0_161/jre/lib/ext/zipfs.jar

从输出结果可以清晰地看到扩展类加载器所负责加载的类包路径,它在类加载体系中起到了连接核心类库与应用程序类库的桥梁作用,扩展了 JVM 的基本功能。

应用程序类加载器(AppClassLoader)

应用程序类加载器是扩展类加载器的子类,也是我们在日常 Java 开发中最常打交道的类加载器。它肩负着加载ClassPath路径下类包的重任,我们自己编写的应用类、引入的第三方依赖类等,都由它来加载 ,可以说它是 Java 应用程序运行的 “主力军”。

通过下面的代码可以查看应用程序类加载器的加载路径:

import java.net.URL;
import java.net.URLClassLoader;

public class AppClassLoaderPathTest {
   public static void main(String[] args) {
       URLClassLoader appClassLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();
       URL[] urls = appClassLoader.getURLs();
       for (URL url : urls) {
           System.out.println(url);
       }
   }
}

在不同的开发环境下,输出结果会有所不同。比如在 Eclipse 中运行,可能会输出类似file:/C:/Users/Welcome/Documents/Eclipse/JVMInPractice/bin/的路径,而在命令行中运行,加载路径则取决于环境变量CLASSPATH的值。它让我们编写的代码能够顺利地被 JVM 加载并运行,是 Java 应用程序得以正常执行的关键环节。

自定义类加载器(Custom ClassLoader)

虽然启动类加载器、扩展类加载器和应用程序类加载器能够满足大多数的类加载需求,但在某些特殊情况下,我们需要自定义类加载器。自定义类加载器就像是一个定制化的工具,用于加载用户自定义路径下的类包,以实现特殊的加载需求,比如从网络、数据库中加载类,或者对类进行加密和解密等操作 。

下面是一个简单的自定义类加载器的代码示例:

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

public class CustomClassLoader extends ClassLoader {

   private String classPath;

   public CustomClassLoader(String classPath) {
       this.classPath = classPath;
   }

   @Override
   protected Class<?> findClass(String name) throws ClassNotFoundException {

       byte[] bytes = loadClassData(name);
       if (bytes == null) {
           throw new ClassNotFoundException();
       }
       return defineClass(name, bytes, 0, bytes.length);
   }


   private byte[] loadClassData(String name) {
       try {
           String path = classPath + File.separatorChar + name.replace('.', File.separatorChar) + ".class";
           FileInputStream in = new FileInputStream(path);
           ByteArrayOutputStream out = new ByteArrayOutputStream();
           byte[] buffer = new byte[1024];
           int len;
           while ((len = in.read(buffer)) != -1) {
               out.write(buffer, 0, len);
           }
           in.close();
           return out.toByteArray();
       } catch (IOException e) {
           e.printStackTrace();
           return null;
       }
   }


}

使用这个自定义类加载器的示例代码如下:

public class CustomClassLoaderTest {


   public static void main(String[] args) throws Exception {
       CustomClassLoader customClassLoader = new CustomClassLoader("D:/classes");
       Class<?> clazz = customClassLoader.loadClass("com.example.MyClass");
       Object obj = clazz.newInstance();
       // 这里可以继续调用obj的方法等操作
   }
}

在上述代码中,CustomClassLoader通过重写findClass方法,实现了从指定路径D:/classes加载类的功能。这展示了自定义类加载器的创建和使用方式,为满足复杂多变的类加载需求提供了灵活的解决方案。

双亲委派机制

机制概述

双亲委派机制是 Java 类加载机制的核心规则,它就像是类加载过程中的 “秩序维护者” 。其核心思想简洁而高效:当一个类加载器收到加载类的请求时,不会擅自行动去加载这个类,而是先将这份责任向上委托给它的父类加载器。父类加载器收到请求后,同样会优先将请求再向上传递,如此层层递进,直到请求抵达最顶层的启动类加载器。只有当所有父类加载器都明确表示无法加载该类时,最初收到请求的类加载器才会亲自尝试加载。这一机制就如同公司的层级汇报制度,基层员工接到任务先向上级汇报,上级再向上级汇报,直到高层领导,高层无法处理时才会逐级向下安排处理,确保了类加载的有序性,避免了混乱和冲突。

工作流程详细解析

  1. 向上委托请求:当类加载器收到类加载请求,它首先会在自己的 缓存中检查该类是否已经被加载过。如果已经加载,就直接返回已经加载的类,这就像是从仓库中直接取货,无需再次生产,节省了时间和资源。若未加载,它便会开启向上委托的旅程。以应用程序类加载器为例,当它收到加载com.example.User类的请求时,会先把这个请求交给它的父类 —— 扩展类加载器。扩展类加载器收到请求后,也会先检查自身是否已加载该类,若未加载,又会将请求传递给它的父类 —— 启动类加载器。这个过程就像接力赛中的传递接力棒,请求不断向上传递,直至到达启动类加载器。

  2. 向下委派加载:启动类加载器作为类加载器层级的 “塔顶”,率先尝试加载类。它会在自己负责的核心类库路径(如%JAVA_HOME%/jre/lib)中寻找对应的类文件。如果找到了com.example.User类,就会将其加载到内存中,完成类加载任务,整个流程也就顺利结束。但如果启动类加载器在自己的搜索范围内没有找到该类,它会将 “加载失败” 的信息传递回给扩展类加载器。扩展类加载器收到消息后,在自己的加载路径(%JAVA_HOME%/jre/lib/ext)中寻找,若找不到,再将请求传递给应用程序类加载器。应用程序类加载器会在ClassPath路径下搜索,如果还是找不到,就会抛出ClassNotFoundException异常,表示类加载失败。这就像在不同的仓库中依次寻找货物,找不到就去下一个仓库,直到找到或者所有仓库都找遍。

源码分析

在 JDK 1.8 中,ClassLoader类的loadClass方法是双亲委派机制的核心实现代码,下面结合源码来详细分析:

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 {
                    // 如果父类加载器为空,说明当前类加载器是扩展类加载器,那么父类就是启动类加载器
                    // 因为启动类加载器由c++实现,所以这里获取不到对应的实例
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父类加载器未找到类,捕获异常,继续后续操作
            }

            if (c == null) {
                // 如果父类加载器都没有加载成功,当前类加载器尝试自己加载
                long t1 = System.nanoTime();
                c = findClass(name);
            }
        }
        // 如果需要解析类,进行类的解析操作
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

在这段代码中,synchronized关键字确保了多线程环境下类加载的安全性,避免了多个线程同时加载同一个类可能导致的冲突。findLoadedClass方法用于检查类是否已经被当前类加载器加载,体现了对已加载类的缓存机制。parent.loadClass(name, false)语句将加载请求委托给父类加载器,而findBootstrapClassOrNull(name)则是启动类加载器尝试加载类的操作。当所有父类加载器都无法加载时,findClass方法会被调用,这个方法需要子类重写,以实现自定义的类加载逻辑,比如从自定义的路径加载类文件 。最后,如果需要解析类,resolveClass(c)方法会被调用,完成类的解析工作。通过对这段源码的分析,我们可以清晰地看到双亲委派机制在 Java 代码层面的具体实现逻辑,深入理解其运行原理。

双亲委派机制的优势

避免类的重复加载

在 Java 程序运行过程中,类的加载是一个相对复杂且耗费资源的过程。如果一个类被重复加载,不仅会占用额外的内存空间,还会导致加载性能的下降,就像在仓库中反复存放相同的货物,既浪费空间又影响存取效率。双亲委派机制就像是仓库的智能管理系统,能够有效避免这种情况的发生。

当一个类加载器收到加载类的请求时,它首先会检查自己是否已经加载过该类。这就好比在仓库取货前先查看库存记录,如果库存中有该货物,就无需再去采购(加载)。如果未加载,它会将请求向上委托给父类加载器。由于父类加载器在整个类加载器体系中处于更高层级,它的 “视野” 更广阔,已经加载过的类更多。当父类加载器收到请求后,同样会检查自身是否已加载该类。如果父类加载器已经加载过,就会直接返回已加载的类,子类加载器也就无需再次加载。这就如同上级仓库有货,下级仓库就不用再去进货,从而确保了一个类在 JVM 中只会被加载一次。

以一个简单的 Java Web 项目为例,假设项目中依赖了log4j日志框架。在项目启动过程中,多个模块可能都会用到log4j中的类,比如Logger类。如果没有双亲委派机制,每个模块的类加载器可能都尝试去加载Logger类,这就会导致Logger类被重复加载多次。但在双亲委派机制下,当第一个模块的类加载器(通常是应用程序类加载器)收到加载Logger类的请求时,它会向上委托给扩展类加载器,扩展类加载器再向上委托给启动类加载器。如果这些父类加载器中已经有加载过Logger类的(因为log4j可能是作为 Java 核心类库的扩展或者在启动类加载器的加载路径相关的环境中被加载过),就会直接返回已加载的Logger类,后续其他模块的类加载器收到相同的加载请求时,也会遵循这个流程,最终避免了Logger类的重复加载。这样一来,大大节省了内存资源,提高了类加载的效率,让 Java 程序能够更加高效地运行。

保障核心类库的安全性

Java 核心类库是 Java 语言的基石,如同大厦的承重墙,支撑着整个 Java 程序世界的稳定运行。如果核心类库被恶意篡改,就像承重墙被破坏,整个大厦将岌岌可危,Java 程序的安全性和稳定性将受到严重威胁。双亲委派机制就像是大厦的安保系统,为核心类库的安全保驾护航。

由于双亲委派机制的存在,核心类库总是由启动类加载器优先加载。启动类加载器加载的是位于%JAVA_HOME%/jre/lib目录下的核心类库,这些类库是 Java 官方提供的标准实现,经过了严格的测试和验证,具有高度的安全性和稳定性。例如java.lang.Object类,它是 Java 中所有类的父类,是 Java 核心类库的核心组成部分。当任何类加载器收到加载java.lang.Object类的请求时,都会向上委托给启动类加载器。启动类加载器会在其负责的核心类库路径中找到并加载真正的java.lang.Object类,而不会加载用户自定义的同名类。这就从根本上防止了恶意用户通过自定义类来替代核心类库中的类,从而保护了 Java 核心类库的完整性和安全性。

我们可以通过一个自定义类的例子来更直观地理解。假设我们在项目中错误地或者恶意地在java.lang包下自定义了一个String类,如下所示:

package java.lang;

public class String {
   static {
       System.out.println("这是自定义的String类");
   }
}

然后在其他类中尝试使用这个自定义的String类:

package com.example;

public class Main {
   public static void main(String[] args) {
       java.lang.String str = new java.lang.String();
   }
}

在运行Main类时,并不会输出 “这是自定义的 String 类”,因为双亲委派机制会确保加载的是 Java 核心类库中真正的java.lang.String类,而不是我们自定义的这个类。这就体现了双亲委派机制对核心类库的保护作用,维护了 JVM 的安全稳定运行,让 Java 程序能够在一个安全可靠的环境中执行。

总结

JVM 类加载的双亲委派机制,作为 Java 程序运行背后的关键规则,为我们展现了一个有序、安全且高效的类加载机制。从类加载器家族的各个成员,到双亲委派机制的有序工作流程,再到它在避免类重复加载和保障核心类库安全方面的显著优势,以及在特殊场景下打破它的方式,每一个环节都紧密相连,共同构成了 Java 类加载体系的基石。

文章作者: Z
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 微博客
性能调优
喜欢就支持一下吧