类加载子系统之类加载器(待整理)

类加载器(ClassLoader)

类加载过程的描述

image-20231024170913691

当编写完 Java 源代码之后,需要通过 javac 编译成 Java 字节码,编译后的字节码仍然以文件的形式存储在硬盘中。而程序是在内存中运行的,因此从硬盘到内存这一个过程就是(狭义上的)类的加载过程。

类加载过程包括加载(Loading)、链接(Linking)、初始化(Initialization)这三个步骤,其中类加载器(ClassLoader)便是负责加载(Loading)过程。

Loading 过程:

  1. ClassLoader 将硬盘中的字节码文件加载到 JVM 内存的方法区中字节码对象
    1. 选择 ClassLoader
    2. 从 ClassLoader 负责的区域加载字节码文件(.class文件)
  2. 在堆区建立一个 Class 对象,引用方法区中的这个字节码对象

Linking 过程:

  1. Verification:验证字节码的格式,确保不会损害 JVM
  2. Preparation:为静态变量(static)分配内存,并设置初始值(0)。目的是预分配内存。
  3. Resolve:

Initialization 过程:

  1. 为静态变量(static)赋值
  2. 为成员变量初始化

ClassLoader 加载顺序

  1. BootstrapClassLoader(负责 $JRE_HOME/lib 下的 rt.jar 和 resources.jar 等 jar 包)
  2. ExtClassLoader、PlatformClassLoader(负责 $JRE_HOME/lib/ext 目录或系统变量 java.ext.dirs 对应目录下的 jar 包)
  3. SystemClassLoader、AppClassLoader(负责从项目的类路径 classpath 或系统变量 java.class.path 对应的目录中加载类,是程序的默认类加载器)
  4. MyClassLoader(加载自定义类,当前项目下的类)
  5. 抛出 ClassNotFoundException 异常

双亲委派机制(父委派机制)优点:层级设计体现一个优先级概念,可以避免核心类库被破坏,例如自己写一个 java.lang.String 类,由于是 BootstrapClassLoader 加载,会优先加载 JDK 中的 java.lang.String。

如果上一级的 ClassLoader 不能够顺利加载指定的类,那么会交给下一级的 ClassLoader。

判断是否加载的顺序是反过来的,由 AppClassLoader 先判断。

BootstrapClassLoader 由 C++ 编写,无法通过 Java 代码进行获取和访问。

自定义类加载器

loadClass 方法

  1. 调用 findLoadedClass 方法,判断这个类是否已经被加载过。若没有被加载,才进行后续处理
  2. 在没有被加载的情况下,优先委托给父加载器,让父加载器进行加载
  3. 上面都没有找到的情况下,调用 findClass 方法(默认抛出异常,自定义类加载器需要重载该方法)查找该类,然后调用 resolveClass 方法进行解析。

总结:loadClass 方法总得来说分两步,(1)找到字节码的位置,(2)解析

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查类是否已加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 2. 这里的if-else只是因为BootStrapClassLoader不是Java实现的,本质上都是优先委托给父加载器加载
                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
            }

            // 如果上面都没有加载成功,通过findClass查找该类
            if (c == null) {
                // If still not found, then invoke findClass in order to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        // 解析类的方法,AppClassLoader中也调用了该方法
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}
  • findClass 方法: 默认是抛出异常,对于自定义类加载器而言,需要重写该方法

      protected Class<?> findClass(String name) throws ClassNotFoundException {
          throw new ClassNotFoundException(name);
      }
    
  • defineClass 方法: 将字节数组加载为 Class 对象,在自定义类加载器中一般搭配 findClass 方法使用。

  • resolveClass 方法: 在 loadClass 方法中,findClass 和 resolveClass 搭配使用。当然在 AppClassLoader 内部实际也使用了 resolveClass

    resolve 变量用来判断是否需要进行 Linking 阶段

应用案例:遵循双亲委派机制的自定义类加载器(重写 findClass 方法)

  1. 编写一个类,生成字节码文件,即 HelloWorldDemo.class,用于自定义类加载器的输入

    package org.example;
    
    public class HelloWorldDemo {
        public void show() {
            System.out.println("Hello World");
        }
    }
    
  2. 将上面的源文件使用 javac 手动编译后,将生成的字节码文件移动到下面自定义类加载器的 BASE_PATH 路径下

  3. 编写自定义类加载器,加载合法的字节码文件

    注:如果需要从 URL 中获取 InputStream,调用 URL 对象的 openStream 方法即可

    网络地址、jar 包都可以封装成 URL 对象

    public class MyClassLoader extends ClassLoader {
    
        // 1. 这里体现出自定义类加载器加载自定义位置路径上的class字节码文件,可以修改成任意指定路径。可以通过有参构造进行改造
        private static final String BASE_PATH = System.getProperty("user.dir");
        private static final String POSTFIX = ".class";
    
        // 2. 重载findClass方法
        @Override
        protected Class<?> findClass(String name) {
            // 自定义类加载器可以加载任意后缀的文件,只要该文件的实际内容是符合Java字节码文件规范的
            // 例如将HelloWorld.class重命名为HelloWorld.myclass,然后希望自定义类加载器能够加载以myclass为后缀的字节码文件,那么只需要改变这里的后缀即可。
            String classFilePath = BASE_PATH + File.separator + name.replace('.', File.separatorChar) + POSTFIX;
            byte[] bytes = new byte[0];
            try {
                // 如果是从url中获取InputStream,调用URL对象的openStream方法即可
                // URLClassLoader将文件路径、网络地址、压缩包地址等统一封装成URL,再从URL中获取输入流。这里其实也可以使用相同的方式
                FileInputStream fis = new FileInputStream(classFilePath);
                bytes = new byte[fis.available()];
                fis.read(bytes);
            } catch (IOException e) {
                e.printStackTrace();
            }
            // 在findClass方法中调用defineClass方法
            return defineClass(name, bytes, 0, bytes.length);
        }
    
    
        // 直接使用自定义的类加载器,调用loadClass方法
        public static void main(String[] args) throws Exception {
            ClassLoader classLoader = new MyClassLoader();
    
            // 这里传入类的全类名,必须包含package,否则报错
            Class<?> clazz = classLoader.loadClass("org.example.HelloWorldDemo");
    
            Object obj = clazz.newInstance();
    
            Method method = clazz.getDeclaredMethod("show");
            method.invoke(obj);
        }
    }
    

URLClassLoader

对 ClassLoader 的扩展,可以从本地或者网络上的指定位置加载类。自定义类加载器可以直接使用 URLClassLoader。

public class URLClassLoaderDemo {
    private static final String BASE_PATH = System.getProperty("user.dir");

    public static void main(String[] args) throws Exception {
        // 为URLClassLoader指定加载目录
        URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{Paths.get(BASE_PATH).toUri().toURL()});

        // 输入包含包名在内的全类名
        Class<?> clazz = urlClassLoader.loadClass("org.example.HelloWorldDemo");

        Object obj = clazz.newInstance();
        Method method = clazz.getMethod("show");

        method.invoke(obj);
    }
}

还可以将字节码文件放入到 Tomcat 服务器中,同样注意包和全类名,使用 new URL(<网络根路径>)

应用案例:不遵循双亲委派机制的自定义类加载器(Tomcat)

TODO:下面的文字内容有待完善整理


越过双亲委派机制(不是破坏)

委派流程由 loadClass 方法实现,要破坏上面提到的双亲委派机制,就不要调用 loadClass 方法。

疑问:为什么不在自定义类加载器中重写 loadClass 方法呢?

答:在加载类的时候,如果父类没有被加载那么会先加载父类,假设此时 Object 还未被加载,那么 Object 也会被自定义类加载器加载,但是该自定义类加载器是破坏了双亲委派机制的,因此 Object 不会被 BootStrapClassLoader 加载,而自定义类加载器一般是加载自定义的指定目录,假设为 D:/,那么使用破坏了双亲委派机制的自定义类加载器加载 java.lang.Object 时的绝对路径就是 D:/java/lang/Object,与实际 java.lang.Object 所在的 JDK_HOME 下的路径并不相同,因而产生错误。所以推荐,使用双亲委派机制 loadClass 方法,不使用双亲委派机制 findClass 方法。(不完全的理解,也可以在 loadClass 中做出一些判断)

疑问:为什么重写 loadClass 方法,并在其中调用 findClass 方法和直接调用 findClass 方法会产生不同的类加载顺序?

使用 clazz.getClassLoader() 来查看真正加载类的 ClassLoader,有时候调用自定义的类加载器,但是真正加载类的并不是这个自定义的类加载器,而是自定义的类加载器的父加载器

疑问:为什么 Tomcat 要破坏双亲委派机制呢?

答:

  1. Tomcat 是 web 容器,一个 web 容器可能需要部署多个应用程序
  2. 不同的应用程序可能会依赖同一个第三方库的不同版本(类似 SpringBoot2、SpringBoot3),不同版本中可能有同名类(大多数类大概率是一样的)
  3. 按照默认的双亲委派机制,是无法加载两个同名类的,就好比 HashMap 中不能存在两个相同的 key
  4. 所以 Tomcat 破坏双亲委派机制,提供隔离机制,即为每一个应用程序提供一个自定义的 WebAppClassLoader,这个 WebAppClassLoader 重写了 loadClass 方法,会优先加载当前应用程序下的类,而不是优先交给父加载器。

不同类加载器加载多个不同版本的类

强制类型转换要求:类相同并且类加载器相同

标识类和类是否相同,不仅仅看全类名是否相同,还要看类加载器是否相同。疑问:这里的类加载器相同是类型相同还是要同一个类加载器对象?

在双亲委派机制下,不会出现全类名相同,但是类加载器不同的情况。但是打破双亲委派机制后,就有可能出现,这种情况下,两个不同类加载器加载的类并不能够进行强制类型转换。

问题描述

在当前项目和依赖的 jar 包中创建全类名相同的类,使用两个不同的类加载器对其进行加载,这两个类不同够进行强制类型转换。


问题描述

在项目(假设为 A)中使用某个 jar 包,如果对 jar 包中的类进行修改并覆盖,运行中的项目 A 并不能够立即生效,需要在项目 A 重启后才能够再次加载修改后的jar 包中的类。但实际使用时,项目 A 不能够因为这种小事情重启,因此希望项目 A 在不重启的情况下,让修改后的 jar 包中的类能够生效。(本质上是 loadClass 方法中的缓存造成无法实现热加载)

核心思路

类加载器对象(ClassLoader)中含有缓存,想要实现对某一个修改后的字节码文件的热加载,需要对每次修改后的字节码使用新的 ClassLoader 对象。

为什么热加载不常用?

  • 热加载产生非常多的垃圾对象,给 GC 系统造成非常大的负担
  • jar 包修改过程中,可能存在一个临时过程,即文件还没有完全修改完成的时候去读取该文件会报错,需要给一个时间缓冲或重复读取。

SpringBoot 中 devtool 是通过重启来实现重新加载修改后的 class 文件,而 JRebel 是这里介绍的热加载的方式

在双亲委派机制下,一个类只能够被加载一次。而热部署就是希望能够在同一个类发生改变之后再次被加载,因此一个类需要被加载多次。所以,为了实现热部署,必须越过双亲委派机制。而双亲委派机制在 loadClass 方法中实现,因此不要调用 loadClass 方法而直接调用 findClass 方法

public class HotDeploymentClassLoader extends ClassLoader {
    private String basePath;

    public HotDeploymentClassLoader(String basePath) {
        this.basePath = basePath;
    }

    // Tomcat中的加载机制:优先当前类加载器加载逻辑
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        Class<?> c = null;
        // 先查找缓存
        if((c = findLoadedClass(name)) != null){
            return c;
        }
        // 再查找自定义的目录
        if((c = findClass(name)) != null){
            return c;
        }
        // 最后双亲委派机制
        return super.loadClass(name);        
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String classFilePath = basePath + File.separator + name.replace('.', File.separatorChar) + ".class";
        byte[] bytes = new byte[0];
        try {
            FileInputStream fis = new FileInputStream(classFilePath);
            bytes = new byte[fis.available()];
            fis.read(bytes);
        } catch (IOException e) {
            e.printStackTrace();
        }
        Class<?> clazz = defineClass(name, bytes, 0, bytes.length);
        resolveClass(clazz);
        return clazz;
    }
    
    // 测试代码:同一个类需要使用不同的类加载器实例才能够多次加载
    // 越过双亲委派机制体现在,加载的两个Class对象的hashCode不相同
    public static void main(String[] args) throws Exception {
        ClassLoader classLoaderA = new HotDeploymentClassLoader(System.getProperty("user.dir"));
		// 隐式加载(先加载依赖类)Object类
        Class<?> clazz = classLoaderA.loadClass("org.example.HelloWorldDemo");
        System.out.println(clazz.hashCode());

        ClassLoader classLoaderB = new HotDeploymentClassLoader(System.getProperty("user.dir"));
        Class<?> aClass = classLoaderB.loadClass("org.example.HelloWorldDemo");
        System.out.println(aClass.hashCode());

        Object obj = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("show");
        method.invoke(obj);
    }
}

替换掉反射调用

前提背景

MyClassLoader:加载 jar 包中的类

AppClassLoader:加载 classpath 下的类

需求

希望通过 MyClassLoader 加载的类可以像普通的正常类(由 AppClassLoader 加载的类)一样使用,而不是必须得通过反射调用。

SPI 机制

SPI(Service Provider Interface),是 Java 提供的一套 API 接口,其实现由第三方厂商提供,常见的 SPI 有 JDBC、JNDI 等。

产生背景

这些 SPI 接口,例如 java.sql.Driver 属于核心类库,存在于 rt.jar 包中,应该由 BootStrapClassLoader 加载;而第三方依赖的 jar 包,例如 mysql-connector-java 等,存在于项目的 classpath(类路径)下,应该由 AppClassLoader 加载。但是双亲委派机制决定了只能向上委托,因此 BootStrapClassLoader 委托 AppClassLoader 加载第三方的 jar 包。为解决这个问题,出现了 ContextClassLoader(线程上下文类加载器)。

以 JDBC 为例,使用 java.sql.DriverManager 中的 loadInitialDrivers 方法来注册实现了 java.sql.Driver 接口的驱动类

关键目录

classpath 目录下的 META-INF/services 目录

一般 classpath 目录在项目中表示为 resources 目录

SPI 机制

SPI 机制是在被依赖的那个项目中提供 services 目录,而不是在提供仅仅规定了接口的那个项目中提供 services 目录。当我们在自己的项目中同时引入 JDK 和 mysql-connector-java 这两个 jar 包时,那么 mysql-connector-java 项目中 classpath 下的 META-INF/services 目录同样会被我们当前项目整合,此时相当于将目录进行了一次复制粘贴,因此我们的项目可以通过 SPI 机制,顺利加载 mysql-connector-java 这个 jar 包中的类。

疑问:将 META-INF 目录复制粘贴到当前项目是个人猜测,还没有经过实际检验,也许要将项目打包后才能看出来这个结论是否正确

已经验证是正确的

image-20231026205635945

  1. 在 services 目录中提供以接口全类名为文件名的文件,该文件中每一行对应接口的一个实现类的全类名
  2. 使用 ServiceLoader.load(xxx, xxx) 来获取接口的实现类的集合(迭代器)

SpringBoot 的自动加载也是基于这种 SPI 机制,只是规定的目录、文件规范不相同。还有 SLF4J 和 Logback、Log4j 等之间的关系。

线程上下文中可以保存(设置)一个类加载器

ServiceLoader
private class LazyIterator implements Iterator<S>{
	// ServiceLoader.load()方法传入的接口的Class对象
    Class<S> service;
    // 默认情况下是线程上下文中设置的类加载器
    ClassLoader loader;
    Enumeration<URL> configs = null;
    Iterator<String> pending = null;
    String nextName = null;

    private LazyIterator(Class<S> service, ClassLoader loader) {
        // ServiceLoader将实际工作委托给LazyIterator
        this.service = service;
        this.loader = loader;
    }

    private boolean hasNextService() {
        if (nextName != null) {
            return true;
        }
        if (configs == null) {
            // PREFIX是META-INF/services目录
            // service.getName()是接口的全类名
            // fullName是META-INF/services下的文件名(正式因为这行代码,所以规定了文件的命名规范)
            String fullName = PREFIX + service.getName();
            if (loader == null)
                configs = ClassLoader.getSystemResources(fullName);
            else
                configs = loader.getResources(fullName);
        }
        while ((pending == null) || !pending.hasNext()) {
            if (!configs.hasMoreElements()) {
                return false;
            }
            pending = parse(service, configs.nextElement());
        }
        nextName = pending.next();
        return true;
    }

    private S nextService() {
        if (!hasNextService())
            throw new NoSuchElementException();
        String cn = nextName;
        nextName = null;
        Class<?> c = null;
        try {
            c = Class.forName(cn, false, loader);
        } catch (ClassNotFoundException x) {
            fail(service,
                 "Provider " + cn + " not found");
        }
        if (!service.isAssignableFrom(c)) {
            fail(service,
                 "Provider " + cn  + " not a subtype");
        }
        try {
            S p = service.cast(c.newInstance());
            providers.put(cn, p);
            return p;
        } catch (Throwable x) {
            fail(service,
                 "Provider " + cn + " could not be instantiated",
                 x);
        }
        throw new Error();          // This cannot happen
    }

    public boolean hasNext() {
        if (acc == null) {
            return hasNextService();
        } else {
            PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
                public Boolean run() { return hasNextService(); }
            };
            return AccessController.doPrivileged(action, acc);
        }
    }

    public S next() {
        if (acc == null) {
            return nextService();
        } else {
            PrivilegedAction<S> action = new PrivilegedAction<S>() {
                public S run() { return nextService(); }
            };
            return AccessController.doPrivileged(action, acc);
        }
    }

    public void remove() {
        throw new UnsupportedOperationException();
    }

}

疑问

SPI 机制中剩下的最关键的问题:如果同时引入两个实现类,services 下面出现两个相同文件名的文件,经过测试,这两个文件不会进行整合,而是其中一个被丢弃。进而引出两个问题,Spring 中的自动配置类也是在不同项目下面出现相同的文件,这些文件应该会按文件内容整合到一起,为什么不按照 Spring 那种设计机制?第二个问题,这两个文件哪一个会被丢弃,规律是什么?即如果导入一个日志门面和两个日志实现框架,那么哪一个日志实现框架会被使用。

  • 24
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值