JVM | 基于类加载的一次完全实践

引言

我在上篇文章:JVM | 类加载是怎么工作的 中为你介绍了Java的类加载器及其工作原理。我们简单回顾下:我用一个易于理解的类比带你逐步理解了类加载的流程和主要角色:引导类加载器扩展类加载器应用类加载器。并带你深入了解了这些“建筑工人”如何从底层工作,搬运原材料(类)并将其完整地构建在Java虚拟机(JVM)的“建筑工地”上。然后,我们跟随一个具体的Building类,亲眼目睹了其在JVM中的生命周期。我在文章末尾留了几个问题,你还记得吗?

本篇文章,我将带你了解自定义类加载器的创建和使用。我们还将探索Java的SPI机制,了解它如何利用类加载器实现服务的动态发现和加载。接着,我们再来看下Tomcat的类加载机制,尤其是它的热部署多版本共存的实现,了解类加载机制在现实世界中的高级应用。


自定义类加载器的创建和使用

当我们的类涉及到一些安全的操作,或者我们想从网络或者其它地方加载类。这种情况,我们就会创建自定义的类加载器,重写findClass方法来完成这个特殊的加载逻辑。

沿用上篇文章的例子:假如工地来活了,要求建造一个复杂的建筑物,这个建筑物不仅包括了普通的房间(普通的类),还包括了一些特殊设计的房间(特殊的类)。
在这个情况下,你可能会需要一位专门的工人来处理这些特殊的房间。这位工人需要有特殊的技能和工具,才能按照设计图纸(类的字节码)正确地建造出房间。
接下来,我们来看下类加载器怎么创建与使用的。


创建类加载器

我们来实现一个类加载器,代码如下:


public class CustomClassLoader extends ClassLoader {
    private String classPath;
    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 先检查类是否已经被加载
        Class<?> cls = findLoadedClass(name);
        if (cls != null) {
            return cls;
        }

        try {
            // 如果类还未被加载,尝试使用父类加载器加载(不破坏双亲委派机制)
            cls = getParent().loadClass(name);
        } catch (ClassNotFoundException e) {
            // 父类加载器无法加载该类,那么就调用 findClass 尝试自己加载
            cls = findClass(name);
        }

        return cls;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 你的类加载逻辑...
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] loadClassData(String className) {
        String fileName = getFileName(className);
        try {
            InputStream is = new FileInputStream(fileName);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead;
            while ((bytesNumRead = is.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String getFileName(String name) {
        int index = name.lastIndexOf('.');
        // 如果没有找到'.'则直接在classPath中查找
        if (index == -1) { 
            return classPath + name + ".class";
        } else {
            return classPath + name.substring(index + 1) + ".class";
        }
    }
}

上面的类加载器CustomClassLoader 通过构造的方式传入文件路径。当我们要加载类时,它会调用loadClass方法从我们定义的类路径下读取字节流。好,
接下来,我们来使用我们自己定义的类加载器。

使用类加载器

代码如下:

// 把上篇文章的Building类放在桌面
CustomClassLoader customClassLoader = new CustomClassLoader("C:\\Users\\xxx\\Desktop\\");

try {
    // 使用自定义类加载器加载 Building 类,若你的包名不叫这个,请更换。
    Class<?> cls = Class.forName("org.kfaino.jvm.Building", true, customClassLoader);

    // 创建类的实例
    cls.newInstance();

    System.out.println("实例名:" + cls.getName() + " 被加载器:" + cls.getClassLoader() + "创建");

} catch (Exception e) {
    e.printStackTrace();
}

执行!

Connected to the target VM, address: '127.0.0.1:4706', transport: 'socket'
建筑蓝图已被创建!
实例名:org.kfaino.jvm.Building 被加载器:sun.misc.Launcher$AppClassLoader@3f3fdda9创建

Disconnected from the target VM, address: '127.0.0.1:4706', transport: 'socket'
Process finished with exit code 0

类被AppClassLoader加载了,为啥? 原因是我的项目中有Building类, 这个类可以被应用类加载器加载,因此就轮不到·CustomClassLoader 加载了。我们把项目内的Building先改名一下。然后执行!

Connected to the target VM, address: '127.0.0.1:5032', transport: 'socket'
建筑蓝图已被创建!
实例名:org.kfaino.jvm.Building 被加载器:org.kfaino.jvm.CustomClassLoader@6379b5ed创建
Disconnected from the target VM, address: '127.0.0.1:5032', transport: 'socket'

Process finished with exit code 0

这次,我们的类成功被CustomClassLoader加载,并且加载的是我们桌面上的字节码文件。


使用Java自带的类加载器工具类

当然,如果你想要从外部加载字节码文件,可以不必这么繁琐。JDK提供了一个功能更强大的URLClassLoader。我们一起来看下它怎么用:

        // 把Building放在桌面
        URL[] urls = new URL[] {new URL("file:C:\\Users\\xxx\\Desktop\\")};
        URLClassLoader customClassLoader = new URLClassLoader(urls);

        try {
            // 使用自定义类加载器加载 Building 类
            Class<?> cls = Class.forName("org.kfaino.jvm.Building", true, customClassLoader);

            // 创建类的实例
            cls.newInstance();

            System.out.println("实例名:" + cls.getName() + " 被加载器:" + cls.getClassLoader() + "创建");

        } catch (Exception e) {
            e.printStackTrace();
        }

我们执行看下:

Connected to the target VM, address: '127.0.0.1:9734', transport: 'socket'
建筑蓝图已被创建!
实例名:org.kfaino.jvm.Building 被加载器:java.net.URLClassLoader@28634811创建
Disconnected from the target VM, address: '127.0.0.1:9734', transport: 'socket'

Process finished with exit code 0

没有问题,本地的字节码文件Building 被成功读取。因此,当你有从外部读取字节码文件的需求,可以试试用JDK自带的·URLClassLoader类加载器。同时,它还提供了其它更强大的功能。

从网络URL加载类和资源

若你想从网络加载字节码文件,你可以这么做:

URL url = new URL("http://www.github.com/xxx/");
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
Class<?> clazz = urlClassLoader.loadClass("包名.类名");

更多的URL加载类和资源

细心的你肯定发现URLClassLoader的构造入参是数组类型,也就意味着可以传入多个URL,具体用法如下:

URL url1 = new URL("http://www.github.com/xxx1/");
URL url2 = new URL("http://www.github.com/xxx2/");
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url1, url2});
Class<?> clazz = urlClassLoader.loadClass("包名.类名");

从JAR文件加载类和资源

它可以从完整的jar包中读取字节码文件,代码如下:

File file = new File("/xxx/jarfile.jar");
URL url = file.toURI().toURL();
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
Class<?> clazz = urlClassLoader.loadClass("包名.类名");

加载外部配置文件

它可以从外部读取配置文件,代码如下:

File file = new File("/xxx/resources/");
URL url = file.toURI().toURL();
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
URL resourceUrl = urlClassLoader.getResource("xxx.properties");
InputStream stream = resourceUrl.openStream();
Properties properties = new Properties();
properties.load(stream);

自定义类加载器的注意事项

类加载器在类的加载过程中起着至关重要的作用。因此,在使用时,我们必须倍加警惕。接下来,让我们来看下哪些需要注意的问题:

内存泄漏

长期存活的类加载器持有类的引用就会导致内存泄露。为了避免这个问题,我建议你在关键代码处适当使用如下代码,让旧的类加载器和类实例进行解绑:

// 释放对 ClassLoader 的引用,使其有可能被垃圾回收
classLoader = null;
System.gc();

当然,每个问题都需要我们针对性地分析。我这里只是提供可能导致内存泄漏的一个说法。实际上,引发内存泄漏的原因有很多,如果你在工作中遇到了这个问题,可以使用一些可视化分析工具来综合性的分析。

不要轻易破坏双亲委派机制

双亲委派模型是为了保证Java核心类库的安全性。当然,我们也可以选择破坏双亲委派模型,前提是,你已考虑好这些风险并规避。

在上述代码中,我们没有违背双亲委派模型的原则。回顾一下我们在之前文章中提到的双亲委派模型的概念:在类加载的过程中,我们首先会让父类加载器进行加载,只有在父类加载器无法加载的情况下,我们才会使用自定义的类加载器进行加载。顺便我把上篇缺少的自定义类加载器也补充进去,你可以看下:
JVM | 基于类加载的一次完全实践_第1张图片

线程安全问题

如果我们在多线程中使用类加载器,可能会导致类被重复加载多次。除了会浪费资源外,还会导致我们一些静态初始化代码被执行多次,造成一些诡异的问题。我在上篇专栏中说到,解决线程安全的方式有多种。为了保险起见,你可以采用同步方案来解决它。


自定义类加载器使用场景

在上面的例子中,我为你展示如何从外部加载字节码文件。接下来,我们来看下还有哪些使用场景:

安全检查

安全,是软件工程中永恒的话题。为了防止第三方的潜在干扰,我们通常在获取外部文件的同时,做一些过滤的机制。你看代码:

public class SecurityCheckingClassLoader extends ClassLoader {
    private static final String CLASS_NAME_PREFIX = "Safe";

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
    	// 加上你想要的安全校验逻辑
        if (!name.startsWith(CLASS_NAME_PREFIX)) {
            throw new ClassNotFoundException("不安全的类: " + name);
        }

        return super.loadClass(name);
    }
}

上面,我为你举了一个简单的例子。我在加载类方法loadClass前校验类名的前缀,如果你不是Safe开头的类,我们就不予放行。

解密加密的类文件

网络环境充满不确定性,如果你选择从网络获取字节码文件,我建议你首先做好加密工作。既然是从外部获取文件,我们可以通过继承URLClassLoader来实现。代码如下:

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

public class DecryptingURLClassLoader extends URLClassLoader {
    public DecryptingURLClassLoader(URL[] urls) {
        super(urls);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
    	// 获取字节码文件
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        }
        byte[] decryptedClassData = decrypt(classData);
        return defineClass(name, decryptedClassData, 0, decryptedClassData.length);
    }

    private byte[] loadClassData(String className) {
    	// 从网络中获取字节码文件
    }

    private byte[] decrypt(byte[] classData) {
		// 解密字节码文件
    }
}

上面两个是工作中相对比较容易用到的两种场景,还有没有其它类加载器的优秀案例呢?


基于自定义类加载器的其它实现

我们从官网文档中得知,Tomcat会为每个web应用创建类加载器。图片如下:
JVM | 基于类加载的一次完全实践_第2张图片
现在,我们来看下Tomcat用自定义加载器做了哪些事情。

Tomcat中的热部署

先来解释下什么是热部署? 热部署是指我们的应用在运行过程中,可以在不关闭应用的前提下更新应用。
假如你想开启热部署,你可以在context.xml里面设置reloadable="true”。限于篇幅有限,我在这里只是为你说明Tomcat热部署到底是怎么实现的,如果你感兴趣,建议您亲自动手实操。

热部署实现原理

Tomcat通过一个BackgroundProcessor 后台线程周期性的检查web 应用的 WEB-INF/classes 和 WEB-INF/lib 目录下的 class 文件或 jar 文件是否有变化。具体做法是比较文件的最后修改时间和上次记录的最后修改时间是否一致。如果有变化,就触发 web 应用的重载。
Tomcat重新加载一个web应用时,会创建一个新的WebappClassLoader实例,并使用这个新的类加载器来加载web应用的类。这样,新的类加载器就会加载最新版本的类,而旧的类加载器加载的旧版本的类会在它们不再被引用时被垃圾回收。这就是Tomcat的热部署。

Tomcat中的多版本共存

那什么是多版本共存? 我们在上面说到,每一个web应用都有自己独立的类加载器,这就意味着每一个web应用都有自己的类和库的命名空间。即使同一Tomcat实例中运行的多个web应用使用了同名的类和库,它们也不会相互干扰。
也就是说Tomcat的多版本共存关键也在于每个应用都有不同的类加载器。
限于篇幅有限,更多细节,建议你移步到官方文档,我在文末参考文献中为你贴出官方地址。


ServiceLoader和SPI

我们经常会听到许多Java框架包括Dubbo、Spring等都使用了SPI这个机制,SPI究竟是什么东西?ServiceLoader和我们今天讲的类加载器又有什么关系?

SPI(Service Provider Interface)是什么?

我从服务提供方和服务调用方两个视角来为你讲解:

服务提供方

对于服务提供方而言,它只需要根据接口实现对应方法。并且把配置文件放在META-INF/services/ 告知服务调用方。

服务调用方

服务调用方根据约定,使用ServiceLoader来遍历实现该约定的实现类。加载进内存中供服务提供方调用。

为了加深理解,我为你画了一张图:
JVM | 基于类加载的一次完全实践_第3张图片

ServiceLoader和类加载器的关系

它和类加载器又有什么关系?我通过代码为你分析,你看:

public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

如果你不指定类加载器,load方法默认获取当前线程的类加载器去加载该类。它通过扫描META-INF/services/目录下的配置文件,找到服务接口的实现类的全限定名。有了全限定名就等于掌握了花名册,当我们遍历ServiceLoader时,服务加载器会通过类加载器将这些服务提供者实例化,这样我们就可以使用这些服务了。


文中重要部分解析

命名空间

我在上面Tomcat多版本共存中提到命名空间,什么是命名空间?每个类加载器实例其实就是一个命名空间。也就是说,在一个应用程序中允许一个类被多个类加载器实例加载,并且共存于应用程序中。暂停30秒,思考一下,这样会出现什么问题。
好,在回答这个问题之前,我为你展示一个代码:

try {
    URL[] urls = new URL[] {new URL("file:C:\\Users\\xxx\\Desktop\\")};
    URLClassLoader customClassLoader1 = new URLClassLoader(urls);
    URLClassLoader customClassLoader2 = new URLClassLoader(urls);

    Class cls1 = customClassLoader1.loadClass("org.kfaino.jvm.Building");
    Class cls2 = customClassLoader2.loadClass("org.kfaino.jvm.Building");

    Object obj1 = cls1.newInstance();
    Object obj2 = cls2.newInstance();

    System.out.println("obj1 class: " + obj1.getClass());
    System.out.println("obj2 class: " + obj2.getClass());

    System.out.println("obj1 class loader: " + obj1.getClass().getClassLoader());
    System.out.println("obj2 class loader: " + obj2.getClass().getClassLoader());
	// Building对象已经重写hashCode和equals方法
    System.out.println("obj1 equals obj2: " + obj1.equals(obj2));

} catch (Exception e) {
    e.printStackTrace();
}

在Building已经重写hashCode和equals方法的前提下,obj1 equals obj2: 会是true吗?我们看下结果:

建筑蓝图已被创建!
建筑蓝图已被创建!
obj1 class: class org.kfaino.webTemplate.jvm.Building
obj2 class: class org.kfaino.webTemplate.jvm.Building
obj1 class loader: java.net.URLClassLoader@da236ecf
obj2 class loader: java.net.URLClassLoader@8e02f9da
obj1 equals obj2: false

Process finished with exit code 0

结果是否定的,和我之前说的吻合。也就是说使用不同的类加载器,不同类加载器的对象(命名空间不同),在JVM中就是类型不一致的。

生产环境中的热部署

BackgroundProcessor 后台线程,需要周期性地检查(checkResources())文件的状态。处于对性能方面的考虑,在生产环境中,通常会关闭 Tomcat 的热部署功能。

SPI配置文件存放位置META-INF/services/可以更改吗?

查阅官方文档,我们可以知道SPI是JDK内置的一种服务提供发现机制。在SPI机制中,服务提供者的配置文件默认放在META-INF/services/目录下。这是Java SPI规范的一部分,无法更改。


总结

至此,本篇完结。我们来回顾下:首先,我带你创建并使用了类加载器完成从本地文件夹下加载自己的类。这些工作我们可以通过Java自带的类加载器来简化,我也为你演示其用法。当然,我们在使用自定义类加载器要格外注意,因为涉及到类初始化往往你会碰到一些不可预见的诡异BUG。然后,我为你介绍自定义类加载器场景的使用场景。顺便看一下Tomcat和Java是怎么用自定义类加载器的特性实现高级功能的。


常见面试题

如何自定义类加载器?

在什么情况下会需要自定义类加载器?

Tomcat的类加载器有什么特点?如何实现热部署和多版本共存?#### 什么是ServiceLoader和SPI,它们如何利用类加载器?

类加载器可能存在的问题有哪些?


参考文献

  1. Java Guide SPI机制详解
  2. class-loader-howto
  3. baeldung-java-classloaders
  4. Inside the Java Virtual Machine
  5. 老大难的 Java ClassLoader 再不理解就老了

你可能感兴趣的:(JVM,jvm,开发语言,java,安全,后端)