1. 一个看似“理所当然”的问题
很多 Java 开发者在第一次接触 Spring Boot 时,都会对一个现象感到惊讶:我们不再需要把项目打成 war 包并部署到 Tomcat,也不再需要手动配置一堆 classpath 和外部依赖,只需要执行一条命令:
java -jar demo-0.0.1-SNAPSHOT.jar程序就直接跑了起来。更神奇的是,Tomcat、Spring、JDBC 驱动、JSON 库这些依赖,明明都打包在同一个 jar 文件里,JVM 居然能一个不落地找到它们。很多人的第一反应可能是“因为 jar 本来就支持 java -jar 啊”。但事情远没有这么简单。
如果只是使用jar命令或者 IDE 普通导出方式打出的 jar,你大概率会得到这样的报错:
no main manifest attribute, in demo.jar或者即便在MANIFEST.MF里手动指定了Main-Class,启动后又会因为找不到 Spring 相关的类而抛出ClassNotFoundException。换句话说,jar 文件本身并不会天然具备“可以直接运行并携带全部依赖”的能力,它只是支持通过java -jar启动而已。真正让 Spring Boot 的 jar 可以直接运行的,是 Spring Boot 在打包阶段对 jar 结构、启动入口和类加载机制做的一系列深度改造。
这篇文章的目标,就是把这个“为什么”彻底讲清楚。我们会从 jar 文件的底层格式讲起,再一步步拆解 Spring Boot Maven/Gradle 插件在打包时做了什么,最后深入到JarLauncher、LaunchedURLClassLoader和JarFile的源码实现,把 Spring Boot 可执行 jar 的启动全流程完整还原出来。读完以后,你不仅能回答面试官的问题,还能明白为什么它会这么设计,以及在遇到启动类相关问题、嵌套 jar 类加载问题、部署和瘦身问题时应该如何分析和解决。
2. 先回到原点:jar 文件和 java -jar 究竟做了什么
2.1 jar 的本质是一个 ZIP 压缩包
在理解 Spring Boot 可执行 jar 之前,必须先明确一个基本事实:jar(Java Archive)文件本质上就是一个 ZIP 格式的归档文件。它把.class字节码文件、资源文件、配置文件等内容按照目录结构压缩在一起,方便分发和部署。
我们可以用任何解压工具打开一个普通 jar,也可以使用命令查看其内部结构:
jar tf ordinary.jar一个普通的、只包含应用自身代码的 jar,内部结构通常像这样:
META-INF/ META-INF/MANIFEST.MF com/ com/example/ com/example/App.class application.properties可以看到,除了应用自己的 class 和资源文件之外,jar 里通常还有一个固定的META-INF/MANIFEST.MF文件,这个文件是 jar 元数据信息的重要载体。
2.2 MANIFEST.MF 与 Main-Class
META-INF/MANIFEST.MF文件用键值对的方式描述这个 jar 的元信息。一个最简单的例子如下:
Manifest-Version: 1.0 Created-By: Maven JAR Plugin 3.3.0 Main-Class: com.example.App其中Main-Class属性非常关键。当我们执行:
java -jar ordinary.jarJVM 的启动器会做这样几件事:
- 读取 jar 中的
META-INF/MANIFEST.MF; - 检查是否存在
Main-Class属性; - 如果不接在,直接报错
no main manifest attribute; - 如果存在,则加载并执行
Main-Class指定的类的main(String[] args)方法。
这里有一个很容易被忽略的点:Main-Class指定的类,只能使用“当前应用 classpath”中可见的类。所谓当前应用 classpath,在java -jar的场景下,主要由两部分构成:
- 这个 jar 文件本身解包后的类;
- MANIFEST 中通过
Class-Path属性声明的外部 jar 或目录。
下面是一个使用Class-Path的例子:
Manifest-Version: 1.0 Main-Class: com.example.App Class-Path: lib/a.jar lib/b.jar这种方案的确可以让一个普通的 Java 应用通过java -jar启动,但它的局限性也很明显:Class-Path指向的依赖必须位于相对固定的外部目录中。也就是说,仅仅分发一个 jar 是不够的,你还要把lib目录一起分发出去。这正是传统 “fat jar 之外挂 lib” 的部署方式,并不是 Spring Boot 所追求的“单个可执行 jar”。
2.3 普通 jar 为什么不能“自带所有依赖运行”
也许你会想:既然 jar 就是一个 ZIP,那把依赖 jar 直接塞到主 jar 内部不就行了吗?例如下面这种结构:
com/example/App.class lib/spring-core.jar lib/spring-web.jar lib/tomcat-embed-core.jar想法很直观,但实际上标准 JVM 并不会这样做。原因有两点:
第一,JVM 的标准 AppClassLoader 不认识“jar 中的 jar”。
Java 标准类加载器中的URLClassLoader可以对一个 jar 文件建立JarURLConnection,并从其中读取 class。但它理解的最小可加载单元是“jar 文件本身”,而不支持直接打开位于另一个 jar 内部的spring-core.jar这样的嵌套 jar。即使外层 jar 的某个目录里放着lib/spring-core.jar,标准 JVM 也不会把它当作 classpath 条目去遍历。
第二,jar 内的嵌套 jar 没有对应的标准 URL 协议可以直接访问。
URLClassLoader通常接受的 URL 形如file:/path/to/lib/a.jar或jar:file:/path/to/app.jar!/。如果要访问“嵌套在外层 jar 内部的另一个 jar”,意味着 URL 需要表达出两层甚至多层 jar 嵌套关系,而标准 JDK 并没有为这种场景提供官方支持。
因此,要让“一个 jar 既能包含应用代码,又能包含全部依赖,还能通过 java -jar 直接启动”,仅仅靠修改MANIFEST.MF是不够的,必须对 jar 的布局和启动逻辑做更深层次的设计。这正是 Spring Boot 做的事情。
3. Spring Boot 可执行 jar 的整体布局
3.1 一个典型的 Spring Boot 可执行 jar
我们以一个标准的 Spring Boot 项目为例,执行打包命令:
mvn clean package完成后,在target目录下会生成两个 jar 文件:
demo-0.0.1-SNAPSHOT.jar:可执行 jar;demo-0.0.1-SNAPSHOT.jar.original:打包插件执行之前的原始 jar。
可执行 jar 的内部结构大致如下:
BOOT-INF/ BOOT-INF/classes/ BOOT-INF/classes/com/example/DemoApplication.class BOOT-INF/classes/application.properties BOOT-INF/lib/ BOOT-INF/lib/spring-boot-3.2.0.jar BOOT-INF/lib/spring-context-6.1.1.jar BOOT-INF/lib/tomcat-embed-core-10.1.16.jar META-INF/ META-INF/MANIFEST.MF org/springframework/boot/loader/JarLauncher.class org/springframework/boot/loader/LaunchedURLClassLoader.class org/springframework/boot/loader/jar/JarFile.class这个结构与普通 jar 相比,有三个非常明显的变化:
- 应用自身的 class 和资源不再位于 jar 根目录的
com/xxx下,而是被移动到了BOOT-INF/classes/目录; - 全部第三方依赖被统一平铺放置到了
BOOT-INF/lib/目录; - jar 根目录下多出了
org/springframework/boot/loader/这一整套启动器类。
这种布局是 Spring Boot 可执行 jar 的核心特征。下面逐个解释这些目录和设计意图。
3.2 BOOT-INF/classes:应用字节码的“新家”
普通 jar 中,应用自身编译产物通常直接位于 jar 根目录的对应包路径下,比如com/example/DemoApplication.class。而 Spring Boot 可执行 jar 将它们整体放到了BOOT-INF/classes/目录里。
这么做的原因是:应用自己的代码不能继续由 JVM 标准类加载器直接放到“常规根路径”加载,而要交给 Spring Boot 自定义的类加载器从嵌套结构中加载。统一放进BOOT-INF/classes,可以让启动器以一致的方式处理“应用代码”和“依赖 jar”,也避免应用类和启动器类混在根目录造成管理混乱。
3.3 BOOT-INF/lib:依赖 jar 的存放区
所有运行时依赖,比如 Spring Framework、Spring Boot 自动配置模块、Tomcat 嵌入式容器、Jackson、数据库驱动等,都被打包到BOOT-INF/lib/目录。这些依赖 jar 不再解压,而是以完整的嵌套 jar 形式保存在外层 jar 内部。
这里再次强调:这些 jar 是嵌套在外层 jar 内部的,并不是解压以后的 class 文件。JVM 默认类加载器无法直接读取它们,因此 Spring Boot 必须提供一套能够读取“jar 中的 jar”的机制,后文会重点分析。
3.4 根目录下的 loader 类
可执行 jar 根目录下的org/springframework/boot/loader/目录非常特殊。它存放的是 Spring Boot 自己实现的一套启动器代码,包括JarLauncher、LaunchedURLClassLoader、JarFile、JarEntry等。这些类并不在BOOT-INF目录里,而是留在 jar 的“标准区域”。
为什么要这样放置?因为 JVM 在读取Main-Class并初始化主类时,使用的是标准类加载器。标准类加载器只能访问 jar 根目录下的普通条目,无法像 Spring Boot 自定义类加载器那样处理BOOT-INF/lib中的嵌套 jar。所以,启动入口JarLauncher以及它直接依赖的基础类,必须放在 JVM 标准类加载器可见的位置,也就是 jar 根目录的标准包路径下。
这个“标准区域 vs 嵌套区域”的划分,是理解整个启动机制的第一把钥匙。
4. 从 MANIFEST 看启动入口:Main-Class 与 Start-Class
4.1 Spring Boot 可执行 jar 的 MANIFEST.MF
打开 Spring Boot 打出的可执行 jar,查看META-INF/MANIFEST.MF,通常会看到类似下面的内容:
Manifest-Version: 1.0 Created-By: Spring Boot Maven Plugin 3.2.0 Main-Class: org.springframework.boot.loader.launch.JarLauncher Start-Class: com.example.demo.DemoApplication 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这里出现了几个非常关键,也非常容易被面试官追问的属性:
4.2 为什么要有两个“启动类”
这是面试中非常常见的一个追问。总结起来就是一句话:Main-Class是 JVM 认识的入口,Start-Class是应用真正的入口。
JVM 启动一个 jar 时,只能根据标准的Main-Class属性找到main方法。如果直接把Main-Class写成应用类com.example.demo.DemoApplication,那么 JVM 会尝试用标准类加载器加载它。但由于应用类已经被移动到了BOOT-INF/classes/中,标准类加载器无法从嵌套区域直接读取该类,启动就会失败。
Spring Boot 的解决方案是:
所以,Main-Class负责“先把启动器带起来”,Start-Class负责“把真正的业务应用带起来”。二者分工明确,缺一不可。
4.3 版本差异:老版本 JarLauncher 的位置
需要特别说明的是,Spring Boot 1.x 和 2.x 中,JarLauncher的包名通常是:
org.springframework.boot.loader.JarLauncher而从 Spring Boot 3.2 开始,loader 模块进行了重构,启动类被移动到:
org.springframework.boot.loader.launch.JarLauncher不同版本的包路径可能略有变化,但整体设计思想一致。本文后续的源码分析会以常见实现为线索,重点解释流程,不会死磕某一个具体版本的包名。
5. Spring Boot Maven/Gradle 插件在打包时做了什么
5.1 打包插件的本质:repackage
Spring Boot 项目的pom.xml中,通常会配置如下插件:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>3.2.0</version> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin>这个配置的核心并不是重新发明一个打包动作,而是复用 Maven 的package生命周期。实际执行mvn clean package时,大致会经历两步:
- 先由标准的
maven-jar-plugin打出一个普通 jar,也就是前面看到的demo-0.0.1-SNAPSHOT.jar.original; - 随后
spring-boot-maven-plugin的repackage目标会读取这个原始 jar,并基于它重新生成一个可执行 jar,覆盖默认产物demo-0.0.1-SNAPSHOT.jar。
所以repackage这个名字非常贴切:它不是从零开始打包,而是在标准 jar 的基础上做一次“再打包”,把依赖、布局和启动元数据全部补齐。
5.2 repackage 到底改动了哪些内容
相比于原始 jar,repackage后的可执行 jar 主要发生了下面几项变化:
- 重新组织应用字节码:把原 jar 根目录下的
com/xxx等包路径整体移动到BOOT-INF/classes/目录。 - 收集运行时依赖:把工程的所有编译和运行时依赖 jar 平铺复制到
BOOT-INF/lib/目录,保持原始 jar 形式,不进行解压或合并。 - 写入 loader 启动器类:把
JarLauncher、LaunchedURLClassLoader、JarFile等启动器代码放置到 jar 根目录的标准包路径下。 - 改写 MANIFEST.MF:写入可执行 jar 所需的启动属性。
其中 MANIFEST.MF 的变化最直观,通常会写入这些属性:
Main-Class:不再是应用自己的启动类,而是org.springframework.boot.loader.launch.JarLauncher;Start-Class:Spring Boot 自定义的属性,指向真正的应用启动类,例如com.example.demo.DemoApplication;Spring-Boot-Classes:应用 class 和资源所在的目录,通常是BOOT-INF/classes/;Spring-Boot-Lib:依赖 jar 所在的目录,通常是BOOT-INF/lib/;Spring-Boot-Classpath-Index:可选,用于记录需要放到类路径中的嵌套 jar 列表,优化启动速度;Spring-Boot-Layers-Index:可选,用于 Docker 分层构建场景,记录 jar 的分层信息。
Gradle 项目对应的机制也相同:spring-boot-gradle-plugin提供bootJar任务生成可执行 jar,而原来的jar任务产物通常会被命名为xxx-plain.jar。无论使用哪个构建工具,本质都是在普通产物基础上完成同样的repackage改造。
6. 启动流程源码解析:从 JarLauncher 到应用 main 方法
前面已经把结构和配置部分讲清楚了,接下来进入最核心的环节:当执行java -jar demo.jar后,Spring Boot 内部究竟发生了什么。下面以常见的 loader 实现为线索,梳理四条关键链路。
6.1 JarLauncher 的 main 方法
JVM 根据 MANIFEST 中的Main-Class找到JarLauncher,并调用它的main方法。简化后的入口大致如下:
public class JarLauncher extends ExecutableArchiveLauncher { public static void main(String[] args) throws Exception { new JarLauncher().launch(args); } }这里的launch方法通常定义在父类中,核心流程可以概括为:
protected void launch(String[] args) throws Exception { JarFile.registerUrlProtocolHandler(); ClassLoader classLoader = createClassLoader(getClassPathArchives()); launch(args, getMainClass(), classLoader); }这三步分别对应:注册 Spring Boot 自定义的 URL 协议处理器、创建能够读取嵌套区域的ClassLoader,以及根据Start-Class真正启动应用。
6.2 LaunchedURLClassLoader 如何装载 BOOT-INF
LaunchedURLClassLoader继承自URLClassLoader,它和标准类加载器最大的区别,不是加载逻辑有多特殊,而是它手里拿到的 URL 已经能够表达嵌套 jar。
创建类加载器时,Spring Boot 会把BOOT-INF/classes/以及BOOT-INF/lib/下的每一个嵌套 jar,都包装成自定义协议可以理解的 URL。可以简单理解为:
BOOT-INF/classes/被当作一个类路径根目录;BOOT-INF/lib/a.jar、BOOT-INF/lib/b.jar等则被表达成“外层 jar 内部某个嵌套 jar”的 URL,而不是需要先解压到磁盘的临时文件。
这样一来,应用类依赖的 Spring、Tomcat、Jackson 等类型,都会由这个自定义类加载器依次从BOOT-INF/classes和BOOT-INF/lib中找到,解决了标准类加载器读不到“jar 中的 jar”的问题。
6.3 JarFile 与嵌套 jar 的读取机制
要真正读懂嵌套 jar,Spring Boot 的核心是自定义的JarFile。标准 JDK 的JarFile只适合处理磁盘上独立的 jar 文件,而 Spring Boot 的JarFile能够定位并读取位于外层归档中的嵌套条目。
它的关键思路是:外层 jar 本身也是一个 ZIP 文件,因此每个嵌套 jar 在 ZIP 的中央目录里都有自己的偏移量和压缩大小。JarFile通过自定义的URLStreamHandler解析类似nested的协议,定位到嵌套 jar 的起始位置和长度,然后按需读取其中的 class 和资源。
这样做的好处主要有两点:一是全程不需要把BOOT-INF/lib中的几十个依赖 jar 解压到临时目录;二是可以配合缓存避免重复解压同一段数据,从而提升启动性能。
6.4 反射调用真正的应用 main 方法
类加载器准备好之后,最后一步就是根据Start-Class调用应用入口。Spring Boot 通常通过一个MainMethodRunner来完成,简化逻辑如下:
public void run() throws Exception { Class<?> mainClass = Class.forName( this.mainClassName, false, Thread.currentThread().getContextClassLoader()); Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); mainMethod.setAccessible(true); mainMethod.invoke(null, new Object[] { this.args }); }这里非常关键的一点是:加载Start-Class使用的不是 JVM 启动时的标准类加载器,而是已经设置到当前线程上下文中的LaunchedURLClassLoader。正因为如此,位于BOOT-INF/classes中的应用类和位于BOOT-INF/lib中的依赖类才能被正确解析。
当反射调用成功之后,DemoApplication.main里的SpringApplication.run(DemoApplication.class, args)才正式开始执行,后面的流程就进入 Spring 应用初始化阶段了。
7. 总结:一张图串起整个启动流程
到这里,整个链路已经可以完整串起来了。执行java -jar demo-0.0.1-SNAPSHOT.jar后,大致遵循以下步骤:
- JVM 读取外层 jar 的
META-INF/MANIFEST.MF,找到Main-Class,也就是根目录下标准类加载器可直接加载的JarLauncher。 JarLauncher.main被 JVM 标准类加载器执行,进入 Spring Boot 启动器逻辑。JarLauncher注册自定义 URL 协议处理器,并把BOOT-INF/classes/和BOOT-INF/lib/构造为类加载器可识别的 URL。- 创建
LaunchedURLClassLoader,使其能够读取嵌套区域中的应用类和依赖类。 - 读取 MANIFEST 中的
Start-Class,并把它设置为真正的应用入口。 - 通过反射调用
Start-Class.main(String[] args),进而执行SpringApplication.run。
理解这个流程之后,再回头看那些看似神奇的现象,就会发现它们都源于 Spring Boot 的几条底层设计:
- 标准区域与嵌套区域的分离:让 JVM 能先启动
JarLauncher,再由自定义类加载器接管后续加载。 - 自定义 JarFile 与 URL 协议:解决嵌套 jar 的直接读取问题,避免解压依赖。
- Main-Class 与 Start-Class 分工:一个负责引导启动器,一个负责指向真实业务入口。
- repackage 统一收口:把上述布局通过 Maven/Gradle 插件在打包阶段自动完成。
这也解释了为什么一旦BOOT-INF目录被破坏、Start-Class配置错误,或者自定义类加载器无法读取嵌套依赖时,启动就会报出各种看似难以定位的错误。排查这类问题时,只要沿着“MANIFEST → JarLauncher → 类加载器 → Start-Class”这条链路逐步检查,往往就能快速找到原因。
如果继续深入,还可以进一步研究JarFile的缓存策略、classpath.idx如何优化启动速度,以及 Docker 分层构建中layers.idx的具体使用方式。这些内容都建立在本篇讲解的基础之上。