Java虚拟机类加载器及双亲委派机制

3次阅读

共计 6726 个字符,预计需要花费 17 分钟才能阅读完成。

所谓的类加载器(Class Loader)就是加载 Java 类到 Java 虚拟机中的,前面《面试官,不要再问我“Java 虚拟机类加载机制”了》中已经介绍了具体加载 class 文件的机制。本篇文章我们重点介绍加载器和双亲委派机制。

类加载器

在 JVM 中有三类 ClassLoader 构成:启动类(或根类)加载器(Bootstrap ClassLoader)、扩展类加载器(ExtClassLoader)、应用类加载器(AppClassLoader)。不同的类加载器负责不同区域的类的加载。

启动类加载器:这个加载器不是一个 Java 类,而是由底层的 c ++ 实现,负责将存放在 JAVA_HOME 下 lib 目录中的类库,比如 rt.jar。因此,启动类加载器不属于 Java 类库,无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用 null 代替即可。

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

应用类加载器:由 sun.misc.Launcher$AppClassLoader 实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader 方法的返回值,所以也叫系统类加载器。它负责加载用户类路径上所指定的类库,可以被直接使用。如果未自定义类加载器,默认为该类加载器。

可以通过这种方式打印加载路径及相关 jar:

System.out.println("boot:" + System.getProperty("sun.boot.class.path"));
System.out.println("ext:" + System.getProperty("java.ext.dirs"));
System.out.println("app:" + System.getProperty("java.class.path"));

在打印的日志中,可以看到详细的路径以及路径下面都包含了哪些类库。由于打印内容较多,这里就不展示了。

类加载器的初始化

除启动类加载器外,扩展类加载器和应用类加载器都是通过类 sun.misc.Launcher 进行初始化,而 Launcher 类则由根类加载器进行加载。相关代码如下:

public Launcher() {
    Launcher.ExtClassLoader var1;
    try {
        // 初始化扩展类加载器,构造函数没有入参,无法获取启动类加载器
        var1 = Launcher.ExtClassLoader.getExtClassLoader();} catch (IOException var10) {throw new InternalError("Could not create extension class loader", var10);
    }

    try {
        // 初始化应用类加载器,入参为扩展类加载器
        this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    } catch (IOException var9) {throw new InternalError("Could not create application class loader", var9);
    }

    // 设置上下文类加载器
    Thread.currentThread().setContextClassLoader(this.loader);
    
   //...
}

双亲委派模型

双亲委派模型:当一个类加载器接收到类加载请求时,会先请求其父类加载器加载,依次递归,当父类加载器无法找到该类时(根据类的全限定名称),子类加载器才会尝试去加载。

双亲委派中的父子关系一般不会以继承的方式来实现,而都是使用组合的关系来复用父加载器的代码。

通过编写测试代码,进行 debug,可以发现双亲委派过程中不同类加载器之间的组合关系。

而这一过程借用一张时序图来查看会更加清晰。

ClassLoader#loadClass 源码

ClassLoader 类是一个抽象类,但却没有包含任何抽象方法。继承 ClassLoader 类并重写 findClass 方法便可实现自定义类加载器。但如果破坏上面所述的双亲委派模型来实现自定义类加载器,则需要继承 ClassLoader 类并重写 loadClass 方法和 findClass 方法。

ClassLoader 类的部分源码如下:

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) {
                    // 如果当前类没有被加载且父类加载器不为 null,则请求父类加载器进行加载操作
                    c = parent.loadClass(name, false);
                } else {
                   // 如果当前类没有被加载且父类加载器为 null,则请求根类加载器进行加载操作
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) { }

            if (c == null) {long t1 = System.nanoTime();
               // 如果父类加载器加载失败,则由当前类加载器进行加载,c = findClass(name);
                // 进行一些统计操作
               // ...
            }
        }
        // 初始化该类
        if (resolve) {resolveClass(c);
        }
        return c;
    }
}

上面代码中也提现了不同类加载器之间的层级及组合关系。

为什么使用双亲委派模型

双亲委派模型是为了保证 Java 核心库的类型安全。所有 Java 应用都至少需要引用 java.lang.Object 类,在运行时这个类需要被加载到 Java 虚拟机中。如果该加载过程由自定义类加载器来完成,可能就会存在多个版本的 java.lang.Object 类,而且这些类之间是不兼容的。

通过双亲委派模型,对于 Java 核心库的类的加载工作由启动类加载器来统一完成,保证了 Java 应用所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。

上下文类加载器

子类加载器都保留了父类加载器的引用。但如果父类加载器加载的类需要访问子类加载器加载的类该如何处理?最经典的场景就是 JDBC 的加载。

JDBC 是 Java 制定的一套访问数据库的标准接口,它包含在 Java 基础类库中,由根类加载器加载。而各个数据库厂商的实现类库是作为第三方依赖引入使用的,这部分实现类库是由应用类加载器进行加载的。

获取 Mysql 连接的代码:

// 加载驱动程序
Class.forName("com.mysql.jdbc.Driver");
// 连接数据库
Connection conn = DriverManager.getConnection(url, user, password);

DriverManager 由启动类加载器加载,它使用到的数据库驱动(com.mysql.jdbc.Driver)是由应用类加载器加载的,这就是典型的由父类加载器加载的类需要访问由子类加载器加载的类。

这一过程的实现,看 DriverManager 类的源码:

// 建立数据库连接底层方法
private static Connection getConnection(String url, java.util.Properties info, Class<?> caller) throws SQLException {
    // 获取调用者的类加载器
    ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
    synchronized(DriverManager.class) {
        // 由启动类加载器加载的类,该值为 null,使用上下文类加载器
        if (callerCL == null) {callerCL = Thread.currentThread().getContextClassLoader();}
    }

    //...

    for(DriverInfo aDriver : registeredDrivers) {
        // 使用上下文类加载器去加载驱动
        if(isDriverAllowed(aDriver.driver, callerCL)) {
            try {
                // 加载成功,则进行连接
                Connection con = aDriver.driver.connect(url, info);
                //...
            } catch (SQLException ex) {if (reason == null) {reason = ex;}
            }
        } 
        //...
    }
}

在上面的代码中留意改行代码:

callerCL = Thread.currentThread().getContextClassLoader();

这行代码从当前线程中获取 ContextClassLoader,而 ContextClassLoader 在哪里设置呢?就是在上面的 Launcher 源码中设置的:

// 设置上下文类加载器
Thread.currentThread().setContextClassLoader(this.loader);

这样一来,所谓的上下文类加载器本质上就是应用类加载器。因此,上下文类加载器只是为了解决类的逆向访问提出来的一个概念,并不是一个全新的类加载器,本质上是应用类加载器。

自定义类加载器

自定义类加载器只需要继承 java.lang.ClassLoader 类,然后重写 findClass(String name) 方法即可,在方法中指明如何获取类的字节码流。

如果要破坏双亲委派规范的话,还需重写 loadClass 方法 (双亲委派的具体逻辑实现)。但不建议这么做。

public class ClassLoaderTest extends ClassLoader {

    private String classPath;

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

    /**
     * 编写 findClass 方法的逻辑
     *
     * @param name
     * @return
     * @throws ClassNotFoundException
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 获取类的 class 文件字节数组
        byte[] classData = getClassData(name);
        if (classData == null) {throw new ClassNotFoundException();
        } else {
            // 生成 class 对象
            return defineClass(name, classData, 0, classData.length);
        }
    }

    /**
     * 编写获取 class 文件并转换为字节码流的逻辑
     *
     * @param className
     * @return
     */
    private byte[] getClassData(String className) {
        // 读取类文件的字节
        String path = classNameToPath(className);
        try {InputStream is = new FileInputStream(path);
            ByteArrayOutputStream stream = new ByteArrayOutputStream();
            byte[] buffer = new byte[2048];
            int num = 0;
            // 读取类文件的字节码
            while ((num = is.read(buffer)) != -1) {stream.write(buffer, 0, num);
            }
            return stream.toByteArray();} catch (IOException e) {e.printStackTrace();
        }
        return null;
    }

    /**
     * 类文件的完全路径
     *
     * @param className
     * @return
     */
    private String classNameToPath(String className) {
        return classPath + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }

    public static void main(String[] args) {
        String classPath = "/Users/zzs/my/article/projects/java-stream/src/main/java/";
        ClassLoaderTest loader = new ClassLoaderTest(classPath);

        try {
            // 加载指定的 class 文件
            Class<?> object1 = loader.loadClass("com.secbro2.classload.SubClass");
            System.out.println(object1.newInstance().toString());
        } catch (Exception e) {e.printStackTrace();
        }
    }
}

打印结果:

SuperClass static init
SubClass static init
com.secbro2.classload.SubClass@5451c3a8

关于 SuperClass 和 SubClass 在上篇文章《面试官,不要再问我“Java 虚拟机类加载机制”了》已经贴过代码,这里就不再贴出了。

通过上面的代码可以看出,主要重写了 findClass 获取 class 的路径便实现了自定义的类加载器。

那么,什么场景会用到自定义类加载器呢?当 JDK 提供的类加载器实现无法满足我们的需求时,才需要自己实现类加载器。比如,OSGi、代码热部署等领域。

Java9 类加载器修改

以上类加载器模型为 Java8 以前版本,在 Java9 中类加载器已经发生了变化。在这里主要简单介绍一下相关模型的变化,具体变化细节就不再这里展开了。

java9 中目录的改变。

Java9 中类加载器的改变。

在 java9 中,应用程序类加载器可以委托给平台类加载器以及启动类加载器;平台类加载器可以委托给启动类加载器和应用程序类加载器。

在 java9 中,启动类加载器是由类库和代码在虚拟机中实现的。为了向后兼容,在程序中仍然由 null 表示。例如,Object.class.getClassLoader() 仍然返回 null。但是,并不是所有的 JavaSE 平台和 JDK 模块都由启动类加载器加载。

举几个例子,启动类加载器加载的模块是 java.base,java.logging,java.prefs 和 java.desktop。其他 JavaSE 平台和 JDK 模块由平台类加载器和应用程序类加载器加载。

java9 中不再支持用于指定引导类路径,-Xbootclasspath 和 -Xbootclasspath/ p 选项以及系统属性 sun.boot.class.path。-Xbootclasspath/ a 选项仍然受支持,其值存储在 jdk.boot.class.path.append 的系统属性中。

java9 不再支持扩展机制。但是,它将扩展类加载器保留在名为平台类加载器的新名称下。ClassLoader 类包含一个名为 getPlatformClassLoader() 的静态方法,该方法返回对平台类加载器的引用。

小结

本篇文章主要基于 java8 介绍了 Java 虚拟机类加载器及双亲委派机制,和 Java8 中的一些变化。其中,java9 中更深层次的变化,大家可以进一步研究一下。该系列持续更新中,欢迎关注微信公众号“程序新视界”。

原文链接:《Java 虚拟机类加载器及双亲委派机制》

《面试官》系列文章:

  • 《JVM 之内存结构详解》
  • 《面试官,不要再问我“Java GC 垃圾回收机制”了》
  • 《面试官,Java8 JVM 内存结构变了,永久代到元空间》
  • 《面试官,不要再问我“Java 垃圾收集器”了》
  • 《Java 虚拟机类加载器及双亲委派机制》

<center> 程序新视界 :精彩和成长都不容错过 </center>

正文完
 0