通过 ClassLoader 类加载器理解 SpringBoot 启动原理以及 Classpath

1,697 阅读10分钟

为什么 Classpath,ClassLoader 和 SpringBoot 启动原理放一起

Classpath 决定了需要加载 class 的地方(一般 Classpath 的值会给到 AppClassLoader
不同的 ClassLoader 决定了如何加载 class(通过 URI 加载或网络文件等)去哪里加载 class(有些内置的 ClassLoader 只能固定加载具体某个文件夹下的类),SpringBoot 启动的前提就是先把所有需要的 Jar,class以及各种配置文件加载进来,所以 SpringBoot 对JDK内置的 AppClassLoader 进行了扩展,可以使其加载 Jar 包内部的 Jar

Jar包规范

Jar 包其实就是 ZIP 包,所以可以直接用ZIP工具解压压缩 Jar 包,其作用是将整个 java 工程编译打包成一个整体。同时 Jar 包分为资源 Jar 和可执行 Jar 。两种 Jar 主要区别在 MEAT-INF/MANIFEST.MF 文件,这个文件主要用于定义版本,Main-Class 和 Class-Path(依赖 Jar 的位置)。Main-Class 指定了整个 Jar 的启动类 main 方法,单个 java 启动可以直接编译后使用 java 执行,但一个项目需要依赖的包太多了,所以要指定一下

普通资源 Jar 包 MANIFEST.MF内容如下:

Manifest-Version: 1.0
Created-By: 11.0.17 (Microsoft)

普通可执行 Jar 包 MANIFEST.MFClasspath 也可以运行时通过参数-cp指定

Manifest-Version: 1.0
Created-By: 11.0.17 (Microsoft)
Class-Path: /zs/demo/ZhangSan.jar
Main-Class: ls.demo.LiSi

Jar 这一层并不涉及到 Spring 相关的东西, Jar的规范是 Java 工程化的基石。所有 Java 程序(Tomcat,SpringBoot等)启动方式都是通过main函数启动, 并没有什么其他特别难的地方。 比如Tomcat启动脚本就是执行了/bin/bootstrap.jar,bootstrap下的MANIFEST.MF内容如下,那么拉起Tomcat的main方法所在类就是org.apache.catalina.startup.Bootstrap

Manifest-Version: 1.0
Ant-Version: Apache Ant 1.10.14
Created-By: 22.0.2+9 (Eclipse Adoptium)
Main-Class: org.apache.catalina.startup.Bootstrap
Specification-Title: Apache Tomcat Bootstrap
Specification-Version: 10.1
Specification-Vendor: Apache Software Foundation
Implementation-Title: Apache Tomcat Bootstrap
Implementation-Version: 10.1.28
Implementation-Vendor: Apache Software Foundation
X-Compile-Source-JDK: 11
X-Compile-Target-JDK: 11
Class-Path: commons-daemon.jar

Tomcat 是一种WEB容器,那什么是WEB容器? 说到容器,总感觉像一个盒子一样,但是这个概念在代码中不好理解。对于Tomcat启动后会初始化环境以及监听对应端口,然后会产生一系列的子线程用于监听请求信息。当有请求过来时,Tomcat线程上文会解析好请求内容,构造出request,response等。然后调用我们写的Servlet代码,之后将相应写到请求端等下文处理。从这里可以看出我们的业务逻辑好像身处一个范围内由Tomcat提供的运行环境范围所以看起来Tomcat就像把我们的代码包起来了所以就叫做容器。

同样大名鼎鼎的Docker本质也是一个进程,内部运行的进程是容器进程的子进程,配合操作系统提供的一些隔离技术看似就在一个单独的系统里

ClassLoader 和 Classpath是 Java 中的概念,并不是Spring或SpringBoot引入的

类加载过程分为:加载 ---> 链接【验证,准备,解析】---> 初始化 ---> 使用 ---> 卸载 类加载过程的第一步,主要完成下面3件事情:
通过全类名获取定义此类的二进制字节流,将字节流所代表的静态存储结构转换为方法区(永久代)的运行时数据结构 ,在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

其中 链接下的准备阶段 是给 静态变量 分配内存,同时赋默认值,如0,null等,这里不涉及到实例变量因为实例变量在 类实例化 为对象的时候在堆中分配内存时进行的,举个例子:

public class Singleton{
    private static Singleton s = new Singleton();
    public static int counter1 ;
    public static int counter2 = 0;
    private Singleton(){
        counter1++;
        counter2++;
    }
    public static Singleton getSingleton(){
        return s;
    }
}
public class Test{
    public static void main (String[] args){
        Singleton s = Singleton.getSingleton();
        System.out.println(singleton.counter1);
        System.out.println(singleton.counter2);
    }
}
//输出1, 0
//执行过程:
//执行Test.main第一句时发现没有对Singleton进行类加载,所以开始【加载】
//在 【链接-准备】阶段赋予初始值(默认) s = null, counter1 = 0,counter2 = 0
//加载-链接后【初始化】从上往下,首先s = new Singleton()会执行构造方法 counter1 = 1,counter2 = 1
//但counter2又要给赋值0(我们给的值),所以counter2 = 0,初始化完毕后调用Singleton.getSingleton()

image.png

JVM 中内置了三个重要的 ClassLoader

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 文件)进行加密,加载时再利用自定义的类加载器对其解密。

双亲委派模型:每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。而且如果同一个类被两个不同类加载器加载他们也是不相等的。
注意:JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。

启动jar可以使用 classpath 指定类加载的路径,但 classpath 的生效是有条件的:

命令参数生效说明
java   -cp   lib/x.jar   Test运行 jar 包中某个带有 Main 函数的 Class
java   -cp   lib/x.jar   -jar    MyApplication.jarJVM 会屏蔽所有的外部 classpath 参数,而只以本身 MyApplication.jar 作为类的寻找范围,会去执行 Jar 包中 META-INF/MANIFEST.MF 中的 Main-Class

首先 SpringBoot 能使用 java -jar 启动,说明 SpringBoot 打出的 Jar 肯定是符合标准 Jar 包规范的。只不过做了一些扩展,比如:通过 自定义的classLoader 来加载 Jar 包内部的 Jar,以及解析配置文件等,SpringBoot Jar 包下的 class 除去嵌套 Jar 内的 class 还是由 AppClassLoader 进行加载的

IDEA项目视图中的classpath和运行jar的classpath的关系

之前一直搞不懂 classpath 的定义,对于使用 maven 进行项目管理的SpringBoot项目来说classpath指的是项目视图中src.main.java 和 src.main.resources 路径以及第三方 Jar 包的根路径,存放在这三个路径下的文件,都可以通过 classpath 作为相对路径来引用。因为实际打包后这三个地方的文件都被移到jar包里的Boot-INFO下了 所以本质 classpath 默认还是当前运行 jar 下的目录文件范围

image.png

image.png

我们放到resource下的文件打成jar包后如何读取jar包里的文件以及目录?

使用如下代码可以读取到项目里的资源文件,但不能读取class文件和META-INF下的文件,也读取不到嵌套jar里的资源文件。

this.getClass().getClassLoader().getName("logback.xml")

那么为什么他能读取到jar包里的资源文件?
首先可以看出这都是 jdk 提供的方法,jdk自身就提供了从jar包中读取资源文件的能力。同时因为classpath 默认就是当前jar,jar下存在三个目录分别是BOOT-INF, META-INF, org。classLoader就是在classpath下查找资源的。所以他会对这三个文件夹遍历查找,最终会在BOOT-INF下找到
为什么读取不到嵌套jar里的资源文件?
因为jdk就不能读取解析嵌套jar,所以不能。此时需要使用Spring提供的ClassPathResource类来读取了

package org.springframework.core.io;
ClassPathResource classPathResource = new ClassPathResource(fileName);

SpringBoot jar 和 标准jar

可以看到和标准 Jar 一样,同样有 META-INF/MANIFEST.MF ,虽然 MANIFEST.MF 内容比之前要多(具体如下),但主要的 MAIN-Class 还存在,所以这个 JarLauncher 的 main 方法就是 SpringBoot 程序的启动入口!

Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.3.0
Build-Jdk-Spec: 21
Implementation-Title: rank
Implementation-Version: 0.0.1-SNAPSHOT
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.tang.rank.RankApplication
Spring-Boot-Version: 3.2.0
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx

SpringBoot jar

最大的不同点是:SpringBoot打包插件spring-boot-maven-plugin打出来的jar内部包含依赖的Jar包,同时SpringBoot能对其进行加载,这种Jar包称为FatJar。这些依赖的Jar包都放在lib目录下,并在Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx(内容如下)中指定了需要扫描的Jar包列表。

但普通Jar都是通过java -cp 或者Class-Path参数指定外部依赖包的,也并不能解析Jar包中的Jar。SpringBoot为了实现对嵌套Jar的加载自己定义了类加载器:
org\springframework\boot\loader\launch\LaunchedClassLoader.class(父类java.net.URLClassLoader其实最终还是委托AppClassLoader完成加载的),类加载器的本质就是通过某种方式找到jar然后通过某种方式解析jar包类的class就行了。

内置的几个类加载器加载目录比较固定。就算最灵活的AppClassLoader他也只读取当前jar里的class以及-cp参数指定的或者Class-Path指定的,这导致局限性大扩展性差。比如说我们对Class进行了加密防止别人反编译或者要从网络加载Class,这时我们就可以自定义类加载器。

还有一种常用的打包插件maven-assembly-plugin,这个插件也可以打包SpringBoot应用他打出来的不是FatJar,他是把所有依赖的Class和配置都编译进来了

- "BOOT-INF/lib/spring-boot-3.2.0.jar"
- "BOOT-INF/lib/spring-context-6.1.1.jar"
- "BOOT-INF/lib/spring-boot-autoconfigure-3.2.0.jar"
- "BOOT-INF/lib/logback-classic-1.4.11.jar"
- "BOOT-INF/lib/logback-core-1.4.11.jar"
- "BOOT-INF/lib/log4j-to-slf4j-2.21.1.jar"
- "BOOT-INF/lib/log4j-api-2.21.1.jar"
- "BOOT-INF/lib/jul-to-slf4j-2.0.9.jar"
- "BOOT-INF/lib/jakarta.annotation-api-2.1.1.jar"
- "BOOT-INF/lib/spring-core-6.1.1.jar"
- "BOOT-INF/lib/jakarta.xml.bind-api-4.0.1.jar"
- "BOOT-INF/lib/jakarta.activation-api-2.1.2.jar"
- "BOOT-INF/lib/mybatis-3.5.13.jar"
- "BOOT-INF/lib/jsqlparser-4.6.jar"
- "BOOT-INF/lib/mybatis-plus-spring-boot-autoconfigure-3.5.4.1.jar"
- "BOOT-INF/lib/spring-jdbc-6.1.1.jar"
- "BOOT-INF/lib/tomcat-embed-core-10.1.16.jar"
- "BOOT-INF/lib/tomcat-embed-el-10.1.16.jar"
- "BOOT-INF/lib/tomcat-embed-websocket-10.1.16.jar"
......

image.png

总结Spring Boot 打包后的 fatjar 对比 源 jar 主要有以下差异:

  • 源 jar 中主项目的 .class 文件被移至 fatjar 的 BOOT-INF/classes 文件夹下。
  • 新增 BOOT-INF/lib 文件夹,里面存放三方 jar 文件。
  • 新增 BOOT-INF/classpath.idx,用来记录 classpath 的加载顺序。
  • 新增 org/springframework/boot/loader 文件夹,这是 spring-boot-loader 编译后的 .class 文件。
  • 清单文件 MANIFEST.MF中新增以下属性:
  • Spring-Boot-Classpath-Index: 记录 classpath.idx 文件的地址。
  • Start-Class: 指定 Spring Boot 的启动类。
  • Spring-Boot-Classes: 记录主项目的 .class 文件存放路径。
  • Spring-Boot-Lib: 记录三方 jar 文件存放路径。
  • Spring-Boot-Version: 记录 Spring Boot 版本信息
  • Main-Class: 指定 jar 程序的入口类(可执行 jar 为 org.springframework.boot.loader.JarLauncher类)。

public abstract class Launcher {
   

    protected void launch(String[] args) throws Exception {
        if (!this.isExploded()) {
            Handlers.register();
        }

        try {
            //构造一个类加载器
            ClassLoader classLoader = this.createClassLoader((Collection)this.getClassPathUrls());
            String jarMode = System.getProperty("jarmode");
            String mainClassName = this.hasLength(jarMode) ? JAR_MODE_RUNNER_CLASS_NAME : this.getMainClass();
            this.launch(classLoader, mainClassName, args);
        } catch (UncheckedIOException var5) {
            throw var5.getCause();
        }
    }
    
     
    protected ClassLoader createClassLoader(Collection<URL> urls) throws Exception {
        return this.createClassLoader((URL[])urls.toArray(new URL[0]));
    }

    private ClassLoader createClassLoader(URL[] urls) {
        //当前类加载逻辑是jdk的AppClassLoader所以parent就是AppClassLoader,所以可以看出最终还是AppClassLoader加载的
        ClassLoader parent = this.getClass().getClassLoader();
        return new LaunchedClassLoader(this.isExploded(), this.getArchive(), urls, parent);
    }

    ......
}

上面直接带出了打包插件,但是为啥要打包插件?如果没有打包插件就自己需要写MANIFEST.MF文件,同时自己理清楚依赖。加之SpringBoot的jar包不同于标准的jar写起来更加麻烦所以就需要使用打包插件。

最后我们再推测一下SpringBoot的启动原理:
JVM虚拟机根据META-INF/MANIFEST.MF的配置项Main-Class运行JarLauncher的main函数,
然后LaunchedClassLoader根据 BOOT-INF/classpath.idx 加载Class(可以看到这里还包含内嵌的Tomcat Jar包)
之后根据META-INF/MANIFEST.MF的配置项Start-Class运行 SpringBoot的主函数
SpringBoot一定是先拉起Tomcat线程,然后初始化Spring环境,主要是将我们写的业务代码加载到Spring容器里(这里的容器本质是Map,这里也有个加载过程,但是这里的加载过程不同于之前的ClassLoader这里是加载对象到Map里),然后就可以接受请求了