浅谈jvm类加载流程,实现自定义类加载器


类加载概念


​ Java 虚拟机把描述类的数据从数据源(通常是 Class 文件)加载到内存,并对其校验、解析和初始化,最终生成 Java 可以使用的 Java 类型,这个过程被称为类加载。


简介

​ 在 Java 中类一般分为 4 种:普通类(以下均简称为类)、接口、数组类、泛型参数。其中由于 Java 会对泛型进行擦除,所以实际上到 JVM 中,类就只剩下前面三种。

​ 在 Java 中数组类是由 JVM 直接生成的,而接口和类都需要从直接流中读取,当然这里的直接流不一定是来自 Class 文件的,也可以是生成的或者网络等其他环境。

类文件结构

ClassFile {
    u4             magic; //Class 文件的标志
    u2             minor_version;//Class 的小版本号
    u2             major_version;//Class 的大版本号
    u2             constant_pool_count;//常量池的数量
    cp_info        constant_pool[constant_pool_count-1];//常量池
    u2             access_flags;//Class 的访问标记
    u2             this_class;//当前类
    u2             super_class;//父类
    u2             interfaces_count;//接口
    u2             interfaces[interfaces_count];//一个类可以实现多个接口
    u2             fields_count;//Class 文件的字段属性
    field_info     fields[fields_count];//一个类可以有多个字段
    u2             methods_count;//Class 文件的方法数量
    method_info    methods[methods_count];//一个类可以有个多个方法
    u2             attributes_count;//此类的属性表中的属性数
    attribute_info attributes[attributes_count];//属性表集合
}

类型名称说明长度数量
u4magic魔数,识别类文件格式4个字节1
u2minor_version副版本号(小版本)2个字节1
u2major_version主版本号(大版本)2个字节1
u2constant_pool_count常量池计数器2个字节1
cp_infoconstant_pool常量池表n个字节constant_pool_count-1
u2access_flags访问标识2个字节1
u2this_class类索引2个字节1
u2super_class父类索引2个字节1
u2interfaces_count接口计数2个字节1
u2interfaces接口索引集合2个字节interfaces_count
u2fields_count字段计数器2个字节1
field_infofields字段表n个字节fields_count
u2methods_count方法计数器2个字节1
method_infomethods方法表n个字节methods_count
u2attributes_count属性计数器2个字节1
attribute_infoattributes属性表n个字节attributes_count

​ 构成class文件的基本数据单位是字节,可以把整个class文件当成一个字节流来处理。稍大一些的数据由连续多个字节构成,这些
数据在class文件中以大端(big-endian)方式存储。为了描述class文件格式,Java虚拟机规范定义了u1、u2和u4三种数据类型来表示1、
2和4字节无符号整数

Class 文件格式只有两种数据类型:无符号数和表

  • 无符号数属于基本的数据类型,以 u1、u2、u4来分别代表1个字节、2个字节、4个字节无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串
  • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,表都以 _info 结尾,用于描述有层次关系的数据,整个 Class 文件本质上就是一张表,由于表没有固定长度,所以通常会在其前面加上个数说明

类加载流程


生命周期

按照Java虚拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7个阶段:

  • 加载(Loading)
  • 链接:验证(Verification)、准备(Preparation)、解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

类加载过程

系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

加载(Loading)

​ 加载就是把字节码从各种来源通过类加载器装载到 JVM 的过程。字节码的来源有许多种,最常见的是通过 class 文件或者 jar 包中读取,也可以在 JVM 中生成(如数组类),或者从网络中加载。只要符合字节码的规范,同时类加载器支持的话就可以完成加载。当输入的数据不是 ClassFile 则会抛出 ClassFormatError

加载过程完成以下三件事:

  1. 通过全限定类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(Java类模型)
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

链接(Linking)

​ 链接就是将加载完成类合并到 JVM 中,使之可以被执行的过程。

链接有以下三个子步骤:

验证

这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 Class 的加载。

校验的过程大致分为 4 个部分:

  1. 文件格式验证:校验字节流是否符合 Class 文件规范。验证内容包括是否以 0xCAFEBABE 魔数开头。主、次版本号是否在当前虚拟机的处理范围之内。常量池中的常量类型是否有不支持的。等等。
  2. 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。验证内容包括是否实现了接口,接口的方法是否都被实现了,是否继承自 final 标注的类等等。
  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。验证的内容包括类型转换是否有效,跳转是否有效等等。
  4. 符号引用验证:该验证在符号引用转换为直接引用的时候(解析阶段)进行校验,验证是否通过符号引用验证指向的实际引用是否有效。
准备

​ 准备阶段则是为类的静态字段分配内存。除了分配内存外,部分虚拟机还会在此阶段构造其他跟类相关数据结构,如实现虚方法的动态绑定方法表。

解析

​ 解析阶段就是将符号引用转换为实际引用。如果符号引用指向了一个未被加载的类,那么就会触发这个类的加载。在 Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。在 Class 文件未被加载进 JVM 的时候,JVM 并不知道这个类的方法、字段的地址,所以当需要引用这些未被加载的成员的时候,JVM 会生成一个符号引用(Symbolic reference),用于代替实际引用。

初始化(Initialization)

在初始化阶段,JVM 会执行类初始化的逻辑(静态字段赋值、执行静态代码段、父类的初始化)。当初始化完成后,类才算真正的变成可执行状态。

静态字段赋值、静态代码段这些操作会被 Java 编译器编译到构造方法中,JVM 在初始化的时候就执行这个方法,同时在执行该方法的过程中,JVM 会对其进行加锁来确保构造方法只被执行一次。

以下是一些常见的类初始化的时机:

  • 当虚拟机启动时,初始化用户指定的主类;
  • 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  • 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  • 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  • 子类的初始化会触发父类的初始化;
  • 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  • 使用反射 API 对某个类进行[[反射调用]]时,初始化这个类;
  • 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

类加载器


在 Java 中类加载器一般被分成以下 4 种:

引导类加载器(启动类加载器 Bootstrap ClassLoader)

​ 该类加载器是采用 C/C++ 实现的,在 JVM 内部,所以 Java 程序无法操作这个类加载器。主要加载 Java 的核心类库,如 rt.jar 等,用于提供 JVM 运行所需的最基础类。

由于不是 Java 实现的,所以也没有继承自 java.lang.ClassLoader 一说,因为是最基础的类加载器,所以没有父类加载器。同时是扩展类加载器和应用程序类加载器的父类加载器。

扩展类加载器(Extension ClassLoader)

​ 从扩展类加载器开始就是使用 Java 语言实现的了,所以继承了 java.lang.ClassLoader,父类加载器是启动类加载器。主要加载 加载扩展的Java类库 ,如java.ext.dirs 目录下的类。

系统类加载器( 应用程序类加载器 Application ClassLoader)

​ 系统类加载器Java 程序中默认的加载器,我们写的类,依赖的类基本都是由应用程序类加载器加载的。系统类加载器通常用于在应用程序类路径、模块路径和JDK特定工具上定义类(主要负责加载 classpath 里的类,也负责加载用户类)。平台类加载器是系统类加载器的父类或祖先,因此系统类加载器可以通过委托给其父类来加载平台类。

可以通过 ClassLoader.getSystemClassLoader() 获取这个类加载器。

自定义类加载器

​ 自定义加载器就是由我们自行实现的类加载器,通过继承 java.lang.ClassLoader 类,我们可以通过不同的方式不同的数据源中加载需要的类。

​ 通常Java虚拟机以与平台相关的方式从本地文件系统加载类。但某些类可能不是来自文件。虽然在jvm规范对class文件格式进行了严格的规定。但在另一方面,jvm对于从哪里加载class文件,给了足够多的自由。Java虚拟机实现可以从文件系统读取和从JAR(或ZIP)压缩包中提取class文件。除此之外,也可以通过网络下载、从数据库加载,甚至是在运行中直接生成class文件。方法defineClass将字节数组转换为Class类的实例。可以使用Class.newInstance创建这个新定义类的实例。由类加载器创建的对象的方法和构造函数可能引用其他类。为了确定所引用的类,Java虚拟机会调用最初创建类的类加载器的loadClass方法。

例如,应用程序可以创建一个网络类加载器来从服务器下载类文件。示例代码可能如下所示:

 ClassLoader loader = new NetworkClassLoader(host, port);
 Object main = loader.loadClass("Main", true).newInstance();
 . .

下面将通过一个网络类加载器来说明如何通过类加载器来实现组件的动态更新。即基本的场景是:Java 字节代码(.class)文件存放在服务器上,客户端通过网络的方式获取字节代码并执行。当有版本更新的时候,只需要替换掉服务器上保存的文件即可。通过类加载器可以比较简单的实现这种需求。

​ 网络类加载器子类必须定义findClassloadClassData方法来从网络加载类。一旦下载构成类的字节,它应该使用defineClass方法创建一个类实例。

一个示例实现如下:

类 NetworkClassLoader负责通过网络下载Java类字节代码并定义出Java类。它的实现与FileSystemClassLoader类似。

package com.bingbaihanji.Main;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.logging.Level;
import java.util.logging.Logger;

public class NetworkClassLoader extends ClassLoader {

    // 定义一个日志记录器用于记录日志信息
    private static final Logger LOGGER = Logger.getLogger(NetworkClassLoader.class.getName());

    // 定义缓冲区大小常量
    private static final int BUFFER_SIZE = 8192;

    // 定义类加载器的主机、端口和协议
    private final String host;
    private final int port;
    private final String pact;

    // 构造函数,用于初始化主机、端口和协议
    public NetworkClassLoader(String host, int port, String pact) {
        this.host = host;
        this.port = port;
        this.pact = pact;
    }

    /**
     * @MethodName: findClass
     * @Author 冰白寒祭
     * @Description: //重写findClass方法,尝试加载指定名称的类  获取类的字节码
     * 二进制名称: 作为ClassLoader方法中的String参数提供的任何类名必须是由Java语言规范定义的二进制名称。
     * 有效类名的示例包括:
     * "java.lang.String"
     * "javax.swing.JSpinner$DefaultEditor"
     * "java.security.KeyStore$Builder$FileBuilder$1"
     * "java.net.URLClassLoader$3$1"
     * @Date: 2024-07-08 21:21:46
     * @param: String name
     * @return: Class<?>
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 加载类数据
        byte[] classData = loadClassData(name);

        // 如果类数据为空,则抛出ClassNotFoundException异常
        if (classData == null) {
            throw new ClassNotFoundException("找不到类: " + name);
        } else {
            // 使用defineClass方法定义类
            return defineClass(name, classData, 0, classData.length);
        }
    }

    /**
     * 加载指定类的字节码数据
     *
     * @param className 类名
     * @return 类的字节数组
     */
    private byte[] loadClassData(String className) {
        // 将类名转换为URL路径
        String path = classNameToPath(className);
        LOGGER.log(Level.INFO, "从{0}中加载类数据", path);
        // 使用try-with-resources语句确保资源正确关闭
        try (InputStream inputStream = new URL(path).openStream();
             ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[BUFFER_SIZE];
            int bytesNumRead;
            // 读取类文件的字节数据并写入ByteArrayOutputStream
            while ((bytesNumRead = inputStream.read(buffer)) != -1) {
                byteArrayOutputStream.write(buffer, 0, bytesNumRead);
            }
            // 返回类的字节数组
            return byteArrayOutputStream.toByteArray();
        } catch (MalformedURLException e) {
            LOGGER.log(Level.SEVERE, "URL 格式错误: " + path, e);
        } catch (IOException e) {
            LOGGER.log(Level.SEVERE, "I/O 加载类数据时出错", e);
        }
        // 发生异常时返回null
        return null;
    }

    /**
     * 将类名转换为相应的URL路径
     *
     * @param className 类名
     * @return 类文件的URL路径
     */
    private String classNameToPath(String className) {
        return pact + host + ':' + port + "/class/" + className.replace('.', '/') + ".class";
    }
}


 在通过NetworkClassLoader加载了某个版本的类之后,一般有两种做法来使用它。第一种做法是使用Java反射API。另外一种做法是使用接口。需要注意的是,并不能直接在客户端代码中引用从服务器上下载的类,因为客户端代码的类加载器找不到这些类。使用Java反射API可以直接调用Java类的方法。而使用接口的做法则是把接口的类放在客户端中,从服务器上加载实现此接口的不同版本的类。在客户端通过相同的接口来使用这些实现类。我们使用接口的方式。示例如下:

客户端接口:

package com.bingbaihanji.Main;

public interface ServiceInterface {

    void run();

} 

网络上的实现类:

package com.bingbaihanji.Main;

/**
 * @author 冰白寒祭
 * @date 2024-07-09 21:04:35
 * @description //TODO
 */
public class ServiceInterfaceImpl implements ServiceInterface {
    @Override
    public void run() {
        System.out.println("ServiceInterfaceImpl is running ···");
    }
}

在客户端加载网络上的类的过程:

package com.bingbaihanji.Main;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * @author 冰白寒祭
 * @date 2024-07-08 20:43:21
 * @description //TODO
 */
public class Main {
    // 定义一个日志记录器用于记录日志信息
    private static final Logger LOGGER = Logger.getLogger(Main.class.getName());

    public static void main(String[] args) {

        // 创建一个具有单个线程的ScheduledExecutorService实例
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

        // 定义一个Runnable任务,用来加载网络的class文件
        Runnable task = () -> {
            NetworkClassLoader ncl = new NetworkClassLoader("127.0.0.1", 8080, "http://");
            String basicClassName = "com.bingbaihanji.Main.ServiceInterfaceImpl";
            try {
                // 加载获取来自网络的类
                Class<?> clazz = ncl.loadClass(basicClassName);
                // 创建对象
                ServiceInterface serviceInterface = (ServiceInterface) clazz.getDeclaredConstructor().newInstance();
                serviceInterface.run();
            } catch (Exception e) {
                LOGGER.log(Level.INFO, e.getMessage());
            }
        };

        // 安排任务每10秒执行一次
        // 第一个参数是要执行的任务
        // 第二个参数是初始延迟时间,这里设置为0表示立即开始
        // 第三个参数是任务之间的间隔时间,这里设置为10秒
        // 第四个参数是时间单位,这里设置为秒
        scheduler.scheduleAtFixedRate(task, 0, 10, TimeUnit.SECONDS);

        // 添加一个JVM钩子,在JVM关闭时执行,以确保线程池被优雅地关闭
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            // 关闭ScheduledExecutorService
            scheduler.shutdown();
            try {
                // 等待现有任务执行完成,最多等待5秒
                if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
                    // 如果等待超时,则强制关闭所有正在执行的任务
                    scheduler.shutdownNow();
                }
            } catch (InterruptedException e) {
                // 如果当前线程在等待时被中断,也强制关闭所有正在执行的任务
                scheduler.shutdownNow();
            }
        }));
    }
}


运行结果

"C:\Program Files\Eclipse Adoptium\jdk-17.0.2.8-hotspot\bin\java.exe" -javaagent:D:\develop\ideaIU-2024.1.1.win\lib\idea_rt.jar=59496:D:\develop\ideaIU-2024.1.1.win\bin -Dfile.encoding=UTF-8 -classpath D:\javaProject\networkclassloader\target\classes com.bingbaihanji.Main.Main
7月 09, 2024 9:33:15 下午 com.bingbaihanji.Main.NetworkClassLoader loadClassData
信息: 从http://127.0.0.1:8080/class/com/bingbaihanji/Main/ServiceInterfaceImpl.class中加载类数据
ServiceInterfaceImpl is running ···
现在时间: 2024-07-09 21:33:15

双亲委派机制

​ 双亲委派机制(Parent Delegation Model)是Java类加载器的一种工作机制,用于在加载类时保证类加载的有序性和安全性。其基本思想是:在一个类加载器尝试加载类时,它首先将加载任务委派给它的父类加载器,只有在父类加载器无法加载该类时,它才会自己去尝试加载。这一机制避免了类的重复加载,确保了Java核心类库的安全性和稳定性。

简单通俗的来讲就是,先把类加载的任务交给父加载器,父加载器做不到,那么子加载器才会取尝试加载。

image-20240708172538280

双亲委派机制的工作流程

  1. 检查缓存:类加载器首先检查它的缓存,看类是否已经被加载过。如果已经加载,则直接返回该类。
  2. 委派父加载器:如果类没有被加载,类加载器会将加载任务委派给父类加载器。
  3. 递归委派:父类加载器再继续将任务委派给它的父类加载器,直到委派到最顶层的引导类加载器(Bootstrap ClassLoader)。
  4. 加载类:最顶层的启动类加载器开始尝试加载类。如果它无法加载,则逐层返回,由下层的加载器尝试加载。
  5. 加载失败处理:如果所有的父加载器都无法加载该类,最终由当前的类加载器尝试加载。如果当前的类加载器也加载失败,则抛出 ClassNotFoundException 异常。

优势

安全性:避免了核心类库被篡改的风险。例如,用户自定义的类加载器不会替换掉 java.lang.Object 类,因为在加载 java.lang.Object 时,始终是启动类加载器最先尝试加载它。

避免重复加载:通过缓存机制和委派机制,保证类只会被加载一次,减少了内存开销。

破坏双亲委培机制

​ 部分情况下也可能不是使用这种模型来加载,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如 JDK 内部的 ServiceProvider/ServiceLoader 机制,用户可以在标准 API 框架上,提供自己的实现,JDK 也需要提供些默认的参考实现。 例如,Java 中 JNDI、JDBC、文件系统、Cipher 等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。

举一个例子吧:

public class Demo {
    static {
        Connection connection = DriverManager.getConnection(url, username, password);
    }
}

这是 JDBC 用来获取数据库连接的一种方式,在 JDBC 4.0 之前需要使用 Class.forName 将对应的驱动加载进来,而在 JDBC 后可以利用 SPI 来进行加载。DriverManager 是启动类加载器加载的,启动类加载器只负责加载核心的类,所以数据库驱动需要子类加载器来完成加载,这就破坏了双亲委派机制。

除了这种情况还有一种是 Tomcat 的,Tomcat 的 WebappClassLoader 只会加载自己目录下的类,不会将其委派给父类加载器。之所以这么做其实挺简单的,通常我们使用 Tomcat 会部署不同的 Web 应用,不同的 Web 应用的类也不会一样,所以需要互相隔离,否则会导致出现问题。

二进制字节流可以从以下方式中获取:

  • 通过文件系统读入一个class后缀的文件
  • 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础
  • 从网络中获取,最典型的应用是 Applet
  • 由其他文件生成,例如由 JSP 文件生成对应的 Class 类
  • 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 生成字节码

文章引用参考:
[1] 简言之:文章链接: https://jwt1399.top/posts/14485.html

[2] 青空之蓝 (ixk.me): https://blog.ixk.me/post/talking-about-jvm-classloader

[3] Java 虚拟机规范 Java SE 21 版(The Java® Virtual Machine Specification ): https://docs.oracle.com/javase/specs/jvms/se21/html/index.html

[4] 《自己动手写java虚拟机》

[5] Overview (Java SE 21 & JDK 21) (oracle.com): https://docs.oracle.com/en/java/javase/21/docs/api/index.html)

人生不作安期生,醉入东海骑长鲸