前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >细思极恐!你真的理解类加载机制吗

细思极恐!你真的理解类加载机制吗

作者头像
程序猿杜小头
发布2022-12-01 21:45:34
5210
发布2022-12-01 21:45:34
举报
文章被收录于专栏:程序猿杜小头程序猿杜小头

JVM 的操作对象是 Class 文件,JVM 把 Class 文件中描述类的数据结构加载到内存中,并对数据进行校验、转换解析和初始化,最终形成可以被 JVM 直接使用的 Java 类型,这个过程被称作 JVM 的类加载机制!

掌握 JVM 的类加载机制真的很重要,希望大家在读完本文之后可以有所收获。

1 类加载过程

一个类从被加载到 JVM 内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证 (Verification)、准备 (Preparation)、解析 (Resolution)、初始化 (Initialization)、使用 (Using)和卸载 (Unloading) 这七个阶段,其中验证、准备、解析统称为连接 (Linking)。这七个阶段的发生顺序如下图所示。

整个类加载过程是相当复杂的,相对于类加载过程的其他阶段,加载阶段 初始化阶段 是大家应该熟练掌握的,因为这两个阶段是可以通过代码来掌控的。而其他阶段对大家是透明的,咱们又不是 JVM 厂商的开发人员,何必那么卷底层呢,所以大家了解即可,哈哈。

1.1 加载

加载阶段是整个类加载过程中的第一个阶段。在加载阶段,JVM 需要依次完成三件事情:1) 通过一个类的全限定名来获取定义此类的二进制字节流;2) 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构;3) 在堆内存中生成一个代表这个类的java.lang.Class对象,这个对象将作为程序访问方法区中该类数据的外部入口。我们可以自定义类加载器来控制二进制字节流的获取方式,比如从本地、网络和数据库中。

1.2 连接

1.2.1 验证

验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合 JVM 规范的全部约束要求,保证这些信息被当作代码运行后不会危害 JVM 自身的安全。验证阶段对于 JVM 的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段。如果程序运行的全部代码都已经被反复使用和验证过,在生产环境就可以考虑跳过该阶段,从而缩短类加载的时间。

验证阶段大致会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

验证阶段

描述

文件格式验证

验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。

元数据验证

对字节码描述的信息进行语义分析。如:某个类是否有父类 (除了 java.lang.Object 之外,所有的类都应当有父类) 、某个类是否继承了不允许被继承的类 (被 final 修饰的类) 等。

字节码验证

对程序语义的合法性与逻辑性进行分析。如:类型转换是否合法。

符号引用验证

主要验证某个类是否缺少或被禁止访问它依赖的某些外部类、方法、字段等资源。对于 IllegalAccessError、NoSuchFieldError、NoSuchMethodError 等异常均是在此阶段抛出的。

1.2.2 准备

准备阶段主要负责为静态变量赋予初始零值。如 int 类型的初始零值为0、double 类型的初始零值为0.0d、引用类型的初始零值为null等;但对于静态常量而言,在准备阶段该常量会被直接赋值为指定值。

1.2.3 解析

将常量池内的符号引用替换为直接引用,即虚拟机会将所有的类名、方法名、字段名替换为具体的内存地址或内存偏移量。

1.3 初始化

初始化是类加载过程的最后一个阶段,初始化阶段就是执行<clinit>()方法的过程,<clinit>() 方法并不是程序员在 Java 代码中直接编写的方法,而是由编译器自动收集类中静态变量 (不包含由final关键字修饰的静态常量) 的赋值语句和静态初始化代码块合并而产生的;编译器收集的顺序是由语句在源文件中出现的顺序决定的。注意,静态初始化代码块中只能访问到定义在静态初始化代码块之前的静态变量和静态常量,而定义在静态初始化代码块之后的静态变量和静态常量,在静态初始化代码块中仅能对其赋值,不能访问它们,否则编译器报错:Illegal forward reference,即 非法前向引用

<clinit>() 方法与构造方法不同,它不需要显式地调用父类的 <clinit>() 方法,JVM 会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕,因此在Java虚拟机中第一个被执行的 <clinit>() 方法的类肯定是java.lang.Object

如果通过子类来引用父类中定义的静态变量,只会触发父类的初始化而不会触发子类的初始化。静态常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,而是转化为调用类对自身常量池的引用,因此调用类对静态常量的引用不会触发定义该静态常量的类的初始化。<clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态初始化代码块,也没有对静态变量的赋值操作,那么编译器不会为这个类生成 <clinit>() 方法;同样的,如果一个接口中没有对静态变量的赋值操作,那么编译器不会为该接口生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。

<clinit>() 方法常见的触发方式:

  • 当 JVM 启动时,用户需要指定一个要执行的主类 (包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 使用new关键字实例化对象。
  • 读取或设置一个类的静态变量,被final关键字修饰、已在编译期把结果放入常量池的静态常量除外。
  • 调用一个类的静态方法。
  • 使用java.lang.reflect包中的方法对类进行反射调用。
  • 通过main()方法启动虚拟机时,虚拟机会先初始化这个主类。
  • 通过Class类的静态方法forName(String className)进行加载类。
  • 当一个接口中定义了由default关键字修饰的默认方法时,如果有这个接口的实现类发生了初始化,那么该接口要在其之前被初始化。
  • 当初始化某个类的时候,如果其父类还没有进行过初始化,则需要先触发其父类的初始化。

此时大家脑海中也许会冒出一个猜想:是否可以通过 <clinit>() 方法来触发类加载过程呢?笔者认为是完全可以的。因为 JVM 在启动过程中并不会一次性将所有类都加载到内存中供程序使用;此外,如果一个类还没有被加载,那么当通过 <clinit>() 方法来触发类的初始化时,若前面几个阶段还没有执行,自然要先执行前面几个类加载阶段。我们可以通过以下代码简单验证一下,不过需要追加一个 JVM 参数,即-Xlog:class+load=info。启动类运行 15 秒后会在控制台打印若干行类加载信息。也许大家看到这么多行类加载信息有点懵逼了,明明是触发StaticLoggerBinder的类加载过程,怎么会打印这么多的类加载日志?StaticLoggerBinder 类中引用了其他类,必然也会触发它们的类加载过程。没错,类加载过程是有传导性的!

代码语言:javascript
复制
import org.slf4j.impl.StaticLoggerBinder;
import java.util.concurrent.TimeUnit;

public class App {
    public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(15);
        System.out.println(">>>>>>>>>>");
        StaticLoggerBinder.getSingleton();
    }
}

1.4 卸载

卸载指的是将 java.lang.Class 对象从堆内存中移除。那么怎么样才能将代表某一类的 java.lang.Class 对象卸载呢?下面一起来探索一下。

首先,定义一个Test类,其内有一个成员变量,该变量是一个长度为 1024 * 1024 * 100 的字节数组,用来模拟该对象实例将占用 100 MB 的堆内存。

代码语言:javascript
复制
public class Test {
    private byte[] one_hundred_mb = new byte[1024 * 1024 * 100];
}

然后,定义一个自定义类加载器SpecClassLoader,它有一个长度为 1024 * 1024 * 200 的字节数组,用来模拟该类加载器实例将占用 200 MB 的堆内存。

代码语言:javascript
复制
public class SpecClassLoader extends ClassLoader {
    
    private byte[] two_hundred_mb = new byte[1024 * 1024 * 200];
    
    public SpecClassLoader(ClassLoader parent) {
        super(parent);
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        if (!name.startsWith("io.github.dk900912.classloading")) {
            return super.loadClass(name);
        }
        synchronized (getClassLoadingLock(name)) {
            Class<?> loadedClass = findLoadedClass(name);
            if (loadedClass == null) {
                loadedClass = findClass(name);
            }
            return loadedClass;
        }
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = name.replace('.', '/').concat(".class");
        try (InputStream inputStream = getResourceAsStream(path);
             ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[1024];
            int length = 0;
            while ((length = inputStream.read(buffer)) != -1) {
                byteArrayOutputStream.write(buffer, 0, length);
            }
            byte[] classData = byteArrayOutputStream.toByteArray();
            return defineClass(name, classData, 0, classData.length);
        } catch (IOException e) {
            throw new ClassNotFoundException();
        }
    }
}

最后,定义一个启动类App,主要内容就是使用自定义类加载器 SpecClassLoader 来加载 Test 类。有一点需要注意:在反射创建 Test 实例后不能将其强制转换为 Test,尽管从名称上来看是一个类,但其实加载它们的类加载器是不一致的。Test 类内容如下:

代码语言:javascript
复制
public class App {
    public static void main(String[] args) {
        SpecClassLoader specClassLoader = new SpecClassLoader(Thread.currentThread().getContextClassLoader());
        Object test = specClassLoader.loadClass("io.github.dk900912.classloading.Test").newInstance();

        TimeUnit.SECONDS.sleep(20);

        test = null;

        for (;;) {}
    }
}

在运行该启动类之前,需要追加一个 JVM 参数,即-Xlog:class+unload=info,方便大家观察代表 Test 类的 java.lang.Class 对象是否被成功卸载。接下来,咱们看一下堆内存在手动触发GC之后的变化。

从上图来看,初始堆内存共计占用 300 MB ( 一个 Test 对象占用 100 MB,一个 SpecClassLoader 对象占用 200 MB,刚好 300 MB ),但在手动触发 GC 后,堆内存依然占用 200 MB,这说明 Test 对象被 JVM 回收了,而 SpecClassLoader 对象却没有,而且也没有观察到卸载日志。故我们有充分理由猜测:代表 Test 类的 java.lang.Class 对象没有被成功卸载,很可能就是 SpecClassLoader 对象没有被 JVM 回收导致的。至于为什么没有被回收,那是很显然的。下面,笔者修改一下代码,打断这条引用链,再看看情况。改动内容如下所示:

代码语言:javascript
复制
public class App {
    public static void main(String[] args) {
        SpecClassLoader specClassLoader = new SpecClassLoader(Thread.currentThread().getContextClassLoader());
        Object test = specClassLoader.loadClass("io.github.dk900912.classloading.Test").newInstance();

        TimeUnit.SECONDS.sleep(20);

        test = null;
        specClassLoader = null;

        for (;;) {}
    }
}

继续运行 App 启动类并手动 GC!果不其然,堆内存不仅趋近于 0 MB,如下图所示。

而且在控制台也可以观察到类卸载日志:

代码语言:javascript
复制
[27.601s][info][class,unload] unloading class io.github.dk900912.classloading.Test 0x0000000800c02000

综上所述,java.lang.Class 对象与普通对象一样,JVM 均会对其进行垃圾回收操作,但触发 java.lang.Class 对象的回收更为复杂,那就是负责加载它的类加载器先要被 JVM 回收才行!当然,回收咱们自定义的类加载器还好操作,可要想回收掉 BootClassLoader 可就难了哈。

2 类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 JVM 中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个 JVM 加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

为了适配 JDK 9 模块化系统,类加载器的继承体系也做出了相应的变动调整。一是 ExtClassLoader 被 PlatformClassLoader 取代;二是 PlatformClassLoader 和 AppClassLoader 不再继承自 URLClassLoader。此外,BootClassLoader 现在由 JVM 和 JDK 类库协作实现,同时为了与之前的代码保持兼容,在所有获取 BootClassLoader 的场景中仍然会返回 null,而不会得到 BootClassLoader 实例。

代码语言:javascript
复制
# JDK 9 之前的类加载器继承体系
ClassLoader (java.lang)
    SecureClassLoader (java.security)
        URLClassLoader (java.net)
            ExtClassLoader in Launcher (sun.misc)
            AppClassLoader in Launcher (sun.misc)
   
# JDK 9 之后的类加载器继承体系   
ClassLoader (java.lang)
    SecureClassLoader (java.security)
        BuiltinClassLoader (jdk.internal.loader)
            BootClassLoader in ClassLoaders (jdk.internal.loader)
            PlatformClassLoader in ClassLoaders (jdk.internal.loader)
            AppClassLoader in ClassLoaders (jdk.internal.loader)

下面来认识一下ClassLoader,其核心内容如下。

代码语言:javascript
复制
public abstract class ClassLoader {
    private final ClassLoader parent;
 
    protected ClassLoader() {
        this(getSystemClassLoader());
    }
    private ClassLoader(ClassLoader parent) {
        this.parent = parent;
    }
 
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }
 
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded.  
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order to find the class.
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
 
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    } 
}

ClassLoader 类由 abstract 关键字修饰,这在向我们疯狂暗示:You are welcome to extend me。当通过构造方法来创建 ClassLoader 及其子类的实例时,如果不传递父 ClassLoader 实例,那么默认就是 AppClassLoader,由 getSystemClassLoader() 方法得到。loadClass()findClass()是两个最为重要的方法,我们自定义的类加载器究竟应该覆盖哪一个呢?如果想打破 双亲委派模型 (Parent Delegation Model),那只能覆盖 loadClass() 方法,而 findClass() 方法更多扮演定位二进制字节流然后借助defineClass()方法将二进制字节流转换为 java.lang.Class 实例的角色。loadClass() 方法并不是与类加载过程中的加载阶段一一对应的,而是对应加载阶段与连接阶段。如何验证涉及连接阶段呢?笔者在 Test 类的 Class 文件中输入了一串中文,然后自定义类加载器加载时报错:java.lang.ClassFormatError。如何验证不涉及初始化阶段呢?可以在 Test 类中定义一个静态初始化代码块,当通过 loadClass() 方法加载 Test 类时,静态初始化代码块中的逻辑并没有运行,Class.forName()可以破局,不信看其源码:

代码语言:javascript
复制
public final class Class<T> {
    public static Class<?> forName(String className) throws ClassNotFoundException {
        Class<?> caller = Reflection.getCallerClass();
        return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
    }
    private static native Class<?> forName0(String name, boolean initialize, ClassLoader loader, Class<?> caller) throws ClassNotFoundException;
}

什么是双亲委派模型呢?仔细看 loadClass() 方法中的内容:如果findLoadedClass()这一 native 方法返回非空,说明该类已被加载过,那么直接返回其对应的 java.lang.Class 实例;否则当前类加载器将尝试委派父类加载器去加载,如果其父类加载器为 null,这说明当前类加载器是 BootClassLoader,直接由自己完成加载动作;最后才尝试自己完成加载动作。此外,父类加载所加载的类对子类加载器是可见的,反之不成立。类加载器委派模型图如下所示:

敲黑板!!!在双亲委派模型中,子类加载器的确会优先委派其父类加载器完成加载动作,但千万不要误以为父子类加载器是一种继承关系,准确地说是一种组合关系。

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的 BootClassLoader 进行加载,因此 Object 类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为 java.lang.Object 的类,那系统中就会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

还记得 Java SPI 机制吗?其是 JDK 6 引入的一种基于接口或抽象类的服务发现机制。得益于 SPI 机制,开发人员只需为第三方预留出 SPI 拓展接口,这样就可以在不修改代码的前提下,通过增删第三方依赖来实现系统的灵活拓展。既然是亲儿子,SPI 机制在 JDK 内部还是有若干应用场景的,其中大家最为熟悉的应该就是JDBC API了。JDBC API 是 JDK 内部类库,由 BootClassLoader 加载,而各数据库厂商提供的具体实现是第三方依赖,肯定由 AppClassLoader 加载。这个结论虽然正确,但有点让人困扰,DriverManager是 JDBC API 中用来管理各数据库厂商驱动的核心类,它由 BootClassLoader 加载,在其静态初始化代码块中会通过 ServiceLoader 去发现特定数据库厂商提供的驱动类,可类的加载过程是有传导性的,BootClassLoader 是如何叫唤 AppClassLoader 去干活的呢?

代码语言:javascript
复制
public class DriverManager {
    
    static {
        loadInitialDrivers();
    }
    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;
            }
        });
    }
}

为了解开这个谜团,咱们去看一下 ServiceLoader 中 load() 方法的内容。原来,在 load() 方法内部会通过Thread.currentThread().getContextClassLoader()来拿到 AppClassLoader。

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

再进一步,AppClassLoader 是如何与当前线程绑定的,最终定位到了System类中 (若是 JDK 8 ,请关注sum.misc.Launcher类的构造方法) 的initPhase3()方法。

代码语言:javascript
复制
public final class System {
    /*
     * Invoked by VM.  Phase 3 is the final system initialization:
     * 1. eagerly initialize bootstrap method factories that might interact
     *    negatively with custom security managers and custom class loaders
     * 2. set security manager
     * 3. set system class loader
     * 4. set TCCL
     *
     * This method must be called after the module system initialization.
     * The security manager and system class loader may be a custom class from
     * the application classpath or modulepath.
     */
    @SuppressWarnings("removal")
    private static void initPhase3() {
        // initializing the system class loader
        VM.initLevel(3);

        // system class loader initialized
        ClassLoader scl = ClassLoader.initSystemClassLoader();

        // set TCCL
        Thread.currentThread().setContextClassLoader(scl);

        // system is fully initialized
        VM.initLevel(4);
    }
}

public abstract class ClassLoader {
    static synchronized ClassLoader initSystemClassLoader() {
        ClassLoader builtinLoader = getBuiltinAppClassLoader();

        String cn = System.getProperty("java.system.class.loader");
        if (cn != null) {
            try {
                Constructor<?> ctor = Class.forName(cn, false, builtinLoader)
                                           .getDeclaredConstructor(ClassLoader.class);
                scl = (ClassLoader) ctor.newInstance(builtinLoader);
            } catch (Exception e) {
                Throwable cause = e;
                if (e instanceof InvocationTargetException) {
                    cause = e.getCause();
                    if (cause instanceof Error) {
                        throw (Error) cause;
                    }
                }
                if (cause instanceof RuntimeException) {
                    throw (RuntimeException) cause;
                }
                throw new Error(cause.getMessage(), cause);
            }
        } else {
            scl = builtinLoader;
        }
        return scl;
    }
}

也就是说,JVM 会调用 initPhase3() 方法为 main 线程绑定 AppClassLoader。假如在 main 线程中创建了子线程,而且没有显式地为其设定类加载器,那么该子线程将默认继承与父线程绑定的类加载器。

3 类加载器的应用场景

3.1 灵活的可插拔机制

slf4j是一款日志门面框架,为了将 Java 应用与特定日志系统解耦开来,其提供了若干binding module。如果某一 Java 应用的日志系统为 jul,此时只需要引入绑定模块 slf4j-jdk14,那么面向 slf4j-api 的日志记录请求最终会被路由到 jul 日志系统中去;如果后续想将日志系统更换为 log4j,那么只需要引入 log4j 以及将绑定模块替换为 slf4j-log4j12 即可。也就是说,slf4j-api 与日志系统的绑定关系是动态可插拔的,绑定关系的维护位于 slf4j-api 模块,只有一行代码。如下所示:

代码语言:javascript
复制
package org.slf4j;

public final class LoggerFactory {
    private final static void bind() {
        try {
            Set<URL> staticLoggerBinderPathSet = findPossibleStaticLoggerBinderPathSet();
            if(staticLoggerBinderPathSet.size() > 1) {
                Util.report("Class path contains multiple SLF4J bindings.");
                for (URL path : staticLoggerBinderPathSet) {
                    Util.report("Found binding in [" + path + "]");
                }
            }
            // the next line does the binding
            StaticLoggerBinder.getSingleton();
        } catch (NoClassDefFoundError ncde) {
            // Ignore details
        } catch (java.lang.NoSuchMethodError nsme) {
            // Ignore details
        } catch (Exception e) {
            // Ignore details
        }
    }
}

在 slf4j-api 和一众绑定模块中,均含有 org/slf4j/impl/StaticLoggerBinder.java,那么如何确保一定是绑定模块中的 StaticLoggerBinder 脱颖而出呢?slf4j-api 模块在打包的时候会排除掉该 Class 文件。StaticLoggerBinder.getSingleton() 这一行代码究竟有何深意呢?这是为了唤醒 JVM 去主动加载 StaticLoggerBinder,当 StaticLoggerBinder 调用其 getSingleton() 这一静态方法时,JVM 便会执行 StaticLoggerBinder 中的 <clinit>() 方法,而执行 <clinit>() 方法的过程恰恰对应着类的初始化,初始化是类加载过程的最后一个阶段,必须先执行完加载和连接这俩阶段才行,进而针对 StaticLoggerBinder 类加载的三个阶段也就完成了,使得绑定模块中的 StaticLoggerBinder 成为可以被 JVM 直接使用的 Java 类型。如果当前应用 classpath 下有多个绑定模块,那么 JVM 究竟加载哪一个绑定模块中的 StaticLoggerBinder 呢?这就取决于类加载的顺序了!关于 slf4j 更多细节可以参考笔者之前写的《你好,SLF4J》 一文。

3.2 类隔离

依赖冲突问题大家很容易忽略,几乎笔者参与的每个项目都有依赖冲突问题,大家只需使用maven helper插件就可以探测出哪些组件冲突了。之所以这些依赖冲突没有得到重视,是因为项目运行时没有爆出NoClassDefFoundErrorNoSuchMethodErrorNoSuchFieldError等错误;可能大家又会问了:为啥编译时不容易暴露不出来呢?也许咱们业务代码中没有直接引用这些类,但第三方组件引用了这些类。这也从侧面告诉我们:第三方组件支持向下兼容是多么重要!!!

依赖冲突问题如何解决呢?配合插件以及 maven 的一些知识 (比如引用路径最短、引用声明顺序等)的确可以解决。但最终极的解决方案是通过类加载器实现类隔离,这一切的理论基础是在 JVM 中类加载器+类名组合唯一标识一个类。具体实现细节笔者也还没有玩过,不敢多言,到此为止吧。

3.3 开发时热重载

笔者写了一个工具 classloading-statistics-agent ,它可以分析出 Java 应用中类加载器相关统计信息,比如:存在哪些类加载器、每个类加载器有多少实例、每种类加载器所加载的类的数量、每种类加载器分别加载了哪些包。以笔者本地一 Spring Boot 应用来说,分析结果如下。

代码语言:javascript
复制
----------------------------------------------------------------------------------------------
classloaderName                                              instanceCount        loadedCount 
jdk.internal.loader.ClassLoaders$AppClassLoader              1                    4081        
jdk.internal.reflect.DelegatingClassLoader                   81                   81          
BootClassLoader                                              1                    3174        
jdk.internal.loader.ClassLoaders$PlatformClassLoader         1                    46          
sun.reflect.misc.MethodUtil                                  1                    1           
----------------------------------------------------------------------------------------------

>>>>>>>>>>>>>>>>>>>> package list loaded by jdk.internal.loader.ClassLoaders$AppClassLoader             
javax.websocket
org.aopalliance
org.slf4j
javax.security
com.mysql
com.intellij
com.ulisesbocchio
javax.annotation
javax.servlet
com.zaxxer
ch.qos
org.springframework
com.sun
io.github
com.example
org.jasypt
org.apache
com.fasterxml

>>>>>>>>>>>>>>>>>>>> package list loaded by jdk.internal.reflect.DelegatingClassLoader                  
jdk.internal

>>>>>>>>>>>>>>>>>>>> package list loaded by BootClassLoader                                        
javax.xml
java.rmi
sun.reflect
java.util
sun.management
sun.net
java.beans
javax.security
javax.naming
java.nio
sun.nio
jdk.jfr
sun.invoke
sun.launcher
java.time
org.xml
jdk.net
java.net
sun.rmi
com.sun
sun.instrument
sun.text
java.math
java.security
sun.security
javax.management
sun.io
jdk.xml
sun.util
java.lang
java.text
jdk.management
java.io
jdk.internal
javax.net
javax.swing

>>>>>>>>>>>>>>>>>>>> package list loaded by jdk.internal.loader.ClassLoaders$PlatformClassLoader        
java.sql
sun.util
sun.security
org.jcp
com.sun
javax.sql
sun.text

>>>>>>>>>>>>>>>>>>>> package list loaded by sun.reflect.misc.MethodUtil                                 
sun.reflect

基于上述分析结果,我们可以很容易得出一个结论:Spring Boot 应用中第三方类库和开发人员编写的本地类主要由 AppClassLoader 负责加载 (即使没有该工具,大家也应该能总结出这个结论),如下图所示。

废话不多说!spring-boot-devtools是 Spring 官方提供的一款面向开发时的热重载组件。当我们在本地通过 IDEA 启动 Spring Boot 应用时,如果修改了本地的某一个类,只需要重新编译该类,Spring Boot 应用会自动快速重启,这极大地提高了本地调测的效率。这是什么原理呢?devtools 开发人员认为:在本地开发阶段,相较于 JDK 官方类库和第三方开源类库,开发人员编写的本地类才需要频繁改动,如果能设计一个自定义类加载器单独负责加载这些本地类,必然可以节约 Spring Boot 应用的重启时间。笔者当时看了 spring-boot-devtools 的源码后,一句卧槽走天下的小头还能说什么呢?RestartClassLoader 覆盖了 loadClass() 方法,当然是为了打破双亲委派模型,核心逻辑如下:

代码语言:javascript
复制
public class RestartClassLoader extends URLClassLoader implements SmartClassLoader {
    @Override
    public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        String path = name.replace('.', '/').concat(".class");
        ClassLoaderFile file = this.updatedFiles.getFile(path);
        if (file != null && file.getKind() == Kind.DELETED) {
            throw new ClassNotFoundException(name);
        }
        synchronized (getClassLoadingLock(name)) {
            Class<?> loadedClass = findLoadedClass(name);
            if (loadedClass == null) {
                try {
                    loadedClass = findClass(name);
                } catch (ClassNotFoundException ex) {
                    loadedClass = Class.forName(name, false, getParent());
                }
            }
            if (resolve) {
                resolveClass(loadedClass);
            }
            return loadedClass;
        }
    }
}

Spring Boot Devtools 大致重启流程如下图所示。

总结

本文对 JVM 的类加载机制进行了简要的介绍,并结合类加载机制的特征并分享了其典型的使用场景。

参考文档

  1. 周志明《深入理解 Java 虚拟机》
  2. 金雅博《Java类加载器 — classloader 的原理及应用》
  3. 肖汉松《如何实现Java类隔离加载?》
  4. https://github.com/dk900912/classloading-statistics-agent
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-11-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 程序猿杜小头 微信公众号,前往查看

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 类加载过程
    • 1.1 加载
      • 1.2 连接
        • 1.3 初始化
          • 1.4 卸载
          • 2 类加载器
          • 3 类加载器的应用场景
            • 3.1 灵活的可插拔机制
              • 3.2 类隔离
                • 3.3 开发时热重载
                • 总结
                • 参考文档
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档