类加载器高级部分

200 阅读14分钟

基础篇

1、类加载器的分类:

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。

  2. ExtensionClassLoader(扩展类加载器) :主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。

  3. AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。

2、自定义类加载器

我们前面也说说了,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader抽象类。

ClassLoader 类有两个关键的方法:

  • protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现了双亲委派机制 。name 为类的二进制名称,resolve 如果为 true,在加载时调用 resolveClass(Class<?> c) 方法解析该类。
  • protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法。

官方 API 文档中写到:

Subclasses of ClassLoader are encouraged to override findClass(String name), rather than this method.

建议 ClassLoader的子类重写 findClass(String name)方法而不是loadClass(String name, boolean resolve) 方法。

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

3、双亲委派

双亲委派模型(Parent Delegation Model)是 Java 类加载器使用的一种机制,用于确保 Java 程序的稳定性和安全性。在这个模型中,类加载器在尝试加载一个类时,首先会委派给其父加载器去尝试加载这个类,只有在父加载器无法加载该类时,子加载器才会尝试自己去加载。

  1. 委派给父加载器:当一个类加载器接收到类加载的请求时,它首先不会尝试自己去加载这个类,而是将这个请求委派给它的父加载器。
  2. 递归委派:这个过程会递归向上进行,从启动类加载器(Bootstrap ClassLoader)开始,再到扩展类加载器(Extension ClassLoader),最后到系统类加载器(System ClassLoader)。
  3. 加载类:如果父加载器可以加载这个类,那么就使用父加载器的结果。如果父加载器无法加载这个类(它没有找到这个类),子加载器才会尝试自己去加载。
  4. 安全性和避免重复加载:这种机制可以确保不会重复加载类,并保护 Java 核心 API 的类不被恶意替换。

image.png

高级篇

1、知识点明确

类初始加载器(initiating loader):启动这个类加载的类加载器,是通过类加载器的loadClass来实现。
类定义加载器(defining loader) :真正完成类加载工作的类加载器,通过类加载器的defineClass来实现。
两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器。 如类 com.example.Outer引用了类 com.example.Inner,则由类 com.example.Outer的定义加载器负责启动类 com.example.Inner的加载过程。方法 loadClass()抛出的是 java.lang.ClassNotFoundException异常;方法 defineClass()抛出的是 java.lang.NoClassDefFoundError异常。

类加载器在成功加载某个类之后,会把得到的 java.lang.Class类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用。

2、以JDBC为例谈双亲委派模型的破坏

java本身有一套资源管理服务JNDI,是放置在rt.jar中,由启动类加载器加载的。以对数据库管理JDBC为例,
java给数据库操作提供了一个Driver接口: rt.jar(内)

package java.sql;
public interface Driver { 
    Connection connect(String url, java.util.Properties info) throws SQLException; 
    boolean acceptsURL(String url) throws SQLException; 
    DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info) throws SQLException; 
    int getMajorVersion(); 
    int getMinorVersion(); boolean jdbcCompliant();
    public Logger getParentLogger() throws SQLFeatureNotSupportedException; 
}

代码块1

然后提供了一个DriverManager来管理这些Driver的具体实现:`rt.jar(内)`
package java.sql;
public class DriverManager {
    // List of registered JDBC drivers 这里用来保存所有Driver的具体实现
    private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
    public static synchronized void registerDriver(java.sql.Driver driver)
        throws SQLException {
        registerDriver(driver, null);
    }

    public static synchronized void registerDriver(java.sql.Driver driver,
            DriverAction da)
        throws SQLException {
        /* Register the driver if it has not already been added to our list */
        if(driver != null) {
            registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
        } else {
            // This is for compatibility with the original DriverManager
            throw new NullPointerException();
        }
        println("registerDriver: " + driver);
    }

}

代码块2

这里省略了大部分代码,可以看到我们使用数据库驱动前必须先要在DriverManager中使用registerDriver()注册,然后我们才能正常使用。

不破坏双亲委派模型的情况 加载Mysql驱动

// 1.加载数据访问驱动 
Class.forName("com.mysql.jdbc.Driver"); 
//2.连接到数据"库"上去
Connection conn= DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb?characterEncoding=GBK", "root", "");

代码块3

核心就是这句Class.forName()触发了mysql驱动的加载,我们看下mysql对Driver接口的实现:

package com.mysql.jdbc;
public class Driver extends NonRegisteringDriver implements java.sql.Driver { 
    public Driver() throws SQLException { }
    static { 
        try { 
            DriverManager.registerDriver(new Driver()); 
        } catch (SQLException var1) { 
            throw new RuntimeException("Can't register driver!"); 
        }
    } 
}

代码块4

可以看到,Class.forName()其实触发了静态代码块,然后向DriverManager中注册了一个mysql的Driver实现。
这个时候,我们通过DriverManager去获取connection的时候只要遍历当前所有Driver实现,然后选择一个建立连接就可以了。
类关系图如下:

image.png 流程详解
1、Class.forName() 方法默认使用调用它的类的类加载器来加载指定的类。代码块3中 应该在业务代码中调用 ,所以com.mysql.jdbc.Driver会被AppClassLoader加载。
2、com.mysql.jdbc.Driver 加载过程中会触发父类 java.sql.Driver加载。类初始化加载器为 AppClassLoader 由于java.sql.Driver 在rt.jar中 ,双亲委派机制确认最终会被BootstrapClassLoader 加载。同理 DriverManager 也会被 BootstrapClassLoader 加载。
3、com.mysql.jdbc.Driver被AppClassLoader加载,其依赖的接口java.sql.DriverBootstrapClassLoader ,com.mysql.jdbc.Driver 的对象可以赋值给 java.sql.Driver 接口。 由此Java 语言的设计,类加载器之间的这种关系允许实现类的对象可以赋值给由不同类加载器加载的接口引用 这点很重要 ,在很多框架类设计中都用到了此特性

破坏双亲委派模型的情况

在JDBC4.0以后,开始支持使用spi的方式来注册这个Driver,具体做法就是在mysql的jar包中的META-INF/services/java.sql.Driver 文件中指明当前使用的Driver是哪个,然后使用的时候就直接这样就可以了:

Connection conn= DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb?characterEncoding=GBK", "root", "");

可以看到这里直接获取连接,省去了上面的Class.forName()注册过程。
现在,我们分析下看使用了这种spi服务的模式原本的过程是怎样的:

  • 第一,从META-INF/services/java.sql.Driver文件中获取具体的实现类名“com.mysql.jdbc.Driver”
  • 第二,加载这个类,这里肯定只能用class.forName("com.mysql.jdbc.Driver")来加载

好了,问题来了,Class.forName()加载用的是调用者的Classloader,这个调用者DriverManager是在rt.jar中的,ClassLoader是启动类加载器,而com.mysql.jdbc.Driver肯定不在<JAVA_HOME>/lib下,所以肯定是无法加载mysql中的这个类的。这就是双亲委派模型的局限性了,父级加载器无法加载子级类加载器路径中的类。

那么,这个问题如何解决呢?按照目前情况来分析,这个mysql的drvier只有应用类加载器能加载,那么我们只要在启动类加载器中有方法获取应用程序类加载器,然后通过它去加载就可以了。这就是所谓的线程上下文加载器。
线程上下文类加载器可以通过Thread.setContextClassLoaser()方法设置,如果不特殊设置会从父类继承,一般默认使用的是应用程序类加载器

很明显,线程上下文类加载器让父级类加载器能通过调用子级类加载器来加载类,这打破了双亲委派模型的原则

现在我们看下DriverManager是如何使用线程上下文类加载器去加载第三方jar包中的Driver类的。

public class DriverManager {
    static {
        loadInitialDrivers();
    }
    private static void loadInitialDrivers() {
        //省略代码
        //这里就是查找各个sql厂商在自己的jar包中通过spi注册的驱动
        ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
        Iterator<Driver> driversIterator = loadedDrivers.iterator();
        try{
            while(driversIterator.hasNext()) {
                driversIterator.next();
            }
        } catch(Throwable t) {
            // Do nothing
        }

        //省略代码
    }
}

使用时,我们直接调用DriverManager.getConn()方法自然会触发静态代码块的执行,开始加载驱动
然后我们看下ServiceLoader.load()的具体实现:

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

在这个例子中我们可以看到 Thread.setContextClassLoaser() 的用法使用 。在我们使用自定义classLoader类 对象执行时 可以把 Thread.setContextClassLoaser() 设置成同一个自定义classLoader。可以避免一些不好情况的发生
SPI 详解

3、Thread.contextClassLoader

如果你稍微阅读过 Thread 的源代码,你会在它的实例字段中发现有一个字段非常特别

class Thread {
  private ClassLoader contextClassLoader;

  public ClassLoader getContextClassLoader() {
    return contextClassLoader;
  }

  public void setContextClassLoader(ClassLoader cl) {
    this.contextClassLoader = cl;
  }
}

contextClassLoader「线程上下文类加载器」,这究竟是什么东西?

首先 contextClassLoader 是那种需要显示使用的类加载器,如果你没有显示使用它,也就永远不会在任何地方用到它。你可以使用下面这种方式来显示使用它

Thread.currentThread().getContextClassLoader().loadClass(name);

这意味着如果你使用 forName(string name) 方法加载目标类,它不会自动使用 contextClassLoader。那些因为代码上的依赖关系而懒惰加载的类也不会自动使用 contextClassLoader来加载。

其次线程的 contextClassLoader 是从父线程那里继承过来的,所谓父线程就是创建了当前线程的线程。程序启动时的 main 线程的 contextClassLoader 就是 AppClassLoader。这意味着如果没有人工去设置,那么所有的线程的 contextClassLoader 都是 AppClassLoader。

那这个 contextClassLoader 究竟是做什么用的?我们要使用前面提到了类加载器分工与合作的原理来解释它的用途。

它可以做到跨线程共享类,只要它们共享同一个 contextClassLoader。父子线程之间会自动传递 contextClassLoader,所以共享起来将是自动化的。

如果不同的线程使用不同的 contextClassLoader,那么不同的线程使用的类就可以隔离开来。

如果我们对业务进行划分,不同的业务使用不同的线程池,线程池内部共享同一个 contextClassLoader,线程池之间使用不同的 contextClassLoader,就可以很好的起到隔离保护的作用,避免类版本冲突。

如果我们不去定制 contextClassLoader,那么所有的线程将会默认使用 AppClassLoader,所有的类都将会是共享的。

线程的 contextClassLoader 使用场合比较罕见,如果上面的逻辑晦涩难懂也不必过于计较。

JDK9 增加了模块功能之后对类加载器的结构设计做了一定程度的修改,不过类加载器的原理还是类似的,作为类的容器,它起到类隔离的作用,同时还需要依靠双亲委派机制来建立不同的类加载器之间的合作关系。

4、JVM在搜索类的时候,又是如何判定两个class是相同的呢?

在JVM中表示两个class对象是否为同一个类对象存在两个必要条件

  1. 类的完整类名必须一致,包括包名。
  2. 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。

也就是说,在JVM中,即使这个两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的,这是因为不同的ClassLoader实例对象都拥有不同的独立的类名称空间,所以加载的class对象也会存在不同的类名空间中,但前提是覆写loadclass方法,从前面双亲委派模式对loadClass()方法的源码分析中可以知,在方法第一步会通过Class<?> c = findLoadedClass(name);从缓存查找,类名完整名称相同则不会再次被加载,因此我们必须绕过缓存查询才能重新加载class对象。当然也可直接调用findClass()方法,这样也避免从缓存查找,
参考:blog.csdn.net/javazejian/…

5、ClassLoader卸载Class

JVM中的Class只有满足以下三个条件,才能被GC回收,也就是该Class被卸载(unload):

  • 该类所有的实例都已经被GC。
  • 加载该类的ClassLoader实例已经被GC。
  • 该类的java.lang.Class对象没有在任何地方被引用。

GC的时机我们是不可控的,那么同样的我们对于Class的卸载也是不可控的。

package testjvm.testclassloader;
public class TestClassUnLoad {
    public static void main(String[] args) throws Exception {
        SimpleURLClassLoader loader = new SimpleURLClassLoader();
        // 用自定义的加载器加载A
        Class clazzA = loader.load("testjvm.testclassloader.A");
        Object a = clazzA.newInstance();
        // 清除相关引用
        a = null;  //清除该类的实例
        clazzA = null;  //清除该class对象的引用
        loader = null;  //清楚该类的ClassLoader引用
        // 执行一次gc垃圾回收
        System.gc();
        System.out.println("GC over");
    }
}

6、在什么情况下回触发类加载

在Java中,类加载是指将类的字节码读入JVM并进行验证、准备、解析和初始化的过程。类加载是由类加载器完成的,触发类加载的情况包括以下几种:

  1. 创建类的实例
    • 当使用 new 关键字创建类的实例时,如果类还没有被加载,则会触发类加载。
  2. 访问类的静态成员(字段或方法)
    • 当访问类的静态字段或调用静态方法时,如果类还没有被加载,则会触发类加载。
  3. 调用类的静态块
    • 类的静态初始化块在类加载时执行,因此如果类还没有被加载,访问静态块也会触发类加载。
  4. 通过反射访问类
    • 使用反射(如 Class.forName()ClassLoader.loadClass())来获取类的信息时,如果类还没有被加载,则会触发类加载。
  5. 子类初始化
    • 当初始化一个类时,如果其父类还没有被加载,则会首先加载其父类。
  6. Java Virtual Machine (JVM) 启动
    • 当 JVM 启动时,会加载包含 main 方法的类。
  7. 动态代理
    • 创建动态代理类时,代理类和被代理类都需要被加载。
  8. 序列化和反序列化
    • 在反序列化过程中,JVM 需要加载对象所属的类。
  9. 调用 Thread 类的 start() 方法
    • 当一个线程启动时,JVM 会加载 Thread 类。

这些情况都是在程序执行过程中可能会触发类加载的场景。需要注意的是,类加载和类初始化是两个不同的概念,类加载是把类的字节码加载到内存中,类初始化是对类的静态变量进行初始化和执行静态块。
注意,在类加载时,成员变量依赖的类默认不会别加载 (如:A类有个成员变量B b),当A类内的 B类被加载时会用A类定义加载器做B类的类初始加载器 。这点也很重要

7、ClassLoader 在加载类时,不会自动递归查找子目录么?

  • 只会在目录下查找符合包名结构的类文件。例如,若包名为 com.exampleClassLoader 会查找 com/example/YourClass.class,而不是在子目录中递归查找。
  • 中包含 JAR 文件,ClassLoader 会在 JAR 文件的根目录及其内部结构中查找类。

参考:
javaguide.cn/java/jvm/cl…
javabetter.cn/jvm/class-l…
11楼的日记
以JDBC为例谈双亲委派模型的破坏
zhuanlan.zhihu.com/p/51374915