Spring Boot Fat Jar 启动原理深度解析
Spring Boot 打包生成的 "Fat Jar"(或称 Executable Jar)可以直接通过 java -jar 启动。这种机制的精妙之处在于,它解决了 Java 原生类加载器无法加载嵌套 Jar 包(Jar in Jar,即嵌套在 Jar 内部的第三方依赖库)中类和资源的问题。
一、 Fat Jar 的目录结构
一个典型的 Spring Boot Fat Jar(通过 spring-boot-maven-plugin 的 repackage 目标构建)内部物理构造如下:
example-app.jar
├── META-INF/
│ ├── MANIFEST.MF # 关键入口配置文件
│ └── maven/ # Maven 构建元数据
├── org/
│ └── springframework/
│ └── boot/
│ └── loader/ # Spring Boot 自定义类加载器源码 (直接解压存放于根部)
│ ├── JarLauncher.class
│ ├── archive/
│ ├── jar/
│ └── LaunchedURLClassLoader.class
└── BOOT-INF/
├── classes/ # 业务代码及应用自身的 .class 字节码、配置文件
└── lib/ # 嵌套的第三方依赖 Jar 包 (Jar in Jar)
[!NOTE] 在标准的 Jar 规范中,JVM 默认的 AppClassLoader 只能加载根目录下直接存放的类或通过
Class-Path指定的外部依赖,不支持直接加载嵌套在BOOT-INF/lib下的嵌套 Jar 包。因此,Spring Boot 必须在根部存放它自己的引导器(org/springframework/boot/loader),用以接管后续的类加载。
二、 核心启动流程
当我们在终端运行 java -jar example-app.jar 时,整套加载引导链条随即启动:
1. MANIFEST.MF 入口定义
JVM 调取并解析 MANIFEST.MF 文件:
Manifest-Version: 1.0
Spring-Boot-Version: 2.7.0
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.MyApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Main-Class:这是 JVM 规范的物理入口类,它会被 JVM 默认类加载器加载并首先执行其main方法。Start-Class:这是开发者编写的包含@SpringBootApplication标注的实际业务引导类。
2. Archive 归档模型抽象
为了统一处理“已打包成 Fat Jar 运行”与“在开发环境下解压后运行”两种场景,Spring Boot Loader 设计了 Archive(归档)模型:
JarFileArchive:当以单 Jar 包文件运行时,将整个 Jar 抽象为此对象。ExplodedArchive:当解压目录后运行时,将其目录结构抽象为此对象。 它支持遍历其内部的实体(Entry),并提取出子级实体的URL以供加载。
三、 LaunchedURLClassLoader 与自定义 URL 协议处理
LaunchedURLClassLoader 是 Spring Boot 自定义的类加载器,它继承自 Java 官方的 URLClassLoader。
1. 自定义 Jar 协议处理器
Java 原生的 URLClassLoader 只支持标准的 file: 协议,或指向某个物理 Jar 包的 jar:file:/path/to/app.jar!/ 协议。它无法识别并解析双重惊叹号的嵌套路径,例如:
jar:file:/app.jar!/BOOT-INF/lib/commons-lang3.jar!/
为了打破这一局限,Spring Boot 在初始化类加载器时,往 JVM 中注册了自定义的 URL 协议处理器:
- 注入 Handler:在启动时,JVM 会读取系统属性
java.protocol.handler.pkgs,Loader 将org.springframework.boot.loader追加到此路径中。 - 包寻址机制:当 JVM 遇到
jar:协议的 URL 时,会依据命名规范去寻找org.springframework.boot.loader.jar.Handler类。 - 重写连接:该自定义
Handler会返回一个特殊的JarURLConnection,其底层调用了自定义的org.springframework.boot.loader.jar.JarFile。这个类能够精确计算出嵌套在母 Jar 内部的子 Jar 包的物理偏移量(Offset)与数据长度(Length),并在内存中通过文件流直接定位,无需在磁盘上进行临时解压,极大地节省了 I/O 资源。
2. 线程上下文类加载器切换
在类加载器构造完毕后,JarLauncher 并不能直接执行反射调用。因为如果直接调用,后续由 Spring Framework 动态加载的第三方库(如 Spring 的 AOP 增强类)会因为使用 Thread.currentThread().getContextClassLoader() 而获取到系统类加载器(AppClassLoader),从而抛出 ClassNotFoundException。
因此,在反射调用 Start-Class.main 前,必须进行上下文线程加载器的切换:
// 切换当前执行线程的类加载器为 LaunchedURLClassLoader
Thread.currentThread().setContextClassLoader(launchedUrlClassLoader);
// 随后执行反射拉起
MainMethodRunner runner = new MainMethodRunner(startClass, args);
runner.run();
四、 spring-boot-maven-plugin 的 repackage 原理
当我们在 pom.xml 中配置并执行 mvn clean package 时,Maven 默认的打包插件 maven-jar-plugin 首先会将我们编译出来的类和配置文件打包成一个普通的 Jar 包(暂称为原始包)。
随后,spring-boot-maven-plugin 的 repackage 目标启动,执行以下重构步骤:
- 备份原始包:将原始包重命名为
*.jar.original。 - 创建新归档:新建一个空白的 Jar 包,作为最终输出。
- 写入引导类:解压
spring-boot-loader.jar的.class字节码文件,并写入到新 Jar 包的根部(即org/目录)。 - 移动业务代码:将备份的原始包解压,并将其中的业务类和配置文件写入到新 Jar 包的
BOOT-INF/classes目录下。 - 归档第三方依赖:从 Maven 项目依赖树中找出所有的依赖包,将其原封不动地拷入到新 Jar 包的
BOOT-INF/lib目录下。 - 注入 MANIFEST.MF:自动生成符合规范的元数据清单,并写入到新 Jar 包的
META-INF/MANIFEST.MF中。
五、 面试高频问题
Q1:为什么不能直接用 JVM 原生的 AppClassLoader 加载 Spring Boot 项目?
- 原因:JVM 默认的
AppClassLoader只认识平铺的目录结构或者外挂的第三方 Jar 包(通过-classpath指定)。它对于隐藏在BOOT-INF/lib下的嵌套 Jar 包是完全“盲目”的,因为它无法解析嵌套包的 URL 协议。若直接启动业务类,会在遇到第一个外部类时报出NoClassDefFoundError。
Q2:LaunchedURLClassLoader 会破坏 JVM 的双亲委派模型吗?
- 分析:没有破坏。
- 机制:双亲委派的核心在于:先将类加载请求委派给父类加载器(Parent),只有父类加载器无法加载时,才由子类加载器自己尝试加载。
LaunchedURLClassLoader的loadClass仍然遵循这一流程:首先将其委托给父类加载器(如 AppClassLoader 甚至 Extension/Bootstrap ClassLoader)。由于父类加载器确实在它们的搜索路径(CLASSPATH)下找不到BOOT-INF/里面的类,因此最终退回到LaunchedURLClassLoader本身,由其通过自定义的jar:协议处理器在BOOT-INF/classes和BOOT-INF/lib下检索并载入字节码。整个委派链条完好无损。