news 2026/10/7 10:11:01

为什么 Spring Boot 的 jar 可以直接运行?从 MANIFEST 到 JarLauncher 的完整启动原理详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
为什么 Spring Boot 的 jar 可以直接运行?从 MANIFEST 到 JarLauncher 的完整启动原理详解

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.jar

JVM 的启动器会做这样几件事:

  1. 读取 jar 中的META-INF/MANIFEST.MF;
  2. 检查是否存在Main-Class属性;
  3. 如果不接在,直接报错no main manifest attribute;
  4. 如果存在,则加载并执行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 相比,有三个非常明显的变化:

  1. 应用自身的 class 和资源不再位于 jar 根目录的com/xxx下,而是被移动到了BOOT-INF/classes/目录;
  2. 全部第三方依赖被统一平铺放置到了BOOT-INF/lib/目录;
  3. 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时,大致会经历两步:

  1. 先由标准的maven-jar-plugin打出一个普通 jar,也就是前面看到的demo-0.0.1-SNAPSHOT.jar.original;
  2. 随后spring-boot-maven-plugin的repackage目标会读取这个原始 jar,并基于它重新生成一个可执行 jar,覆盖默认产物demo-0.0.1-SNAPSHOT.jar。

所以repackage这个名字非常贴切:它不是从零开始打包,而是在标准 jar 的基础上做一次“再打包”,把依赖、布局和启动元数据全部补齐。

5.2 repackage 到底改动了哪些内容

相比于原始 jar,repackage后的可执行 jar 主要发生了下面几项变化:

  1. 重新组织应用字节码:把原 jar 根目录下的com/xxx等包路径整体移动到BOOT-INF/classes/目录。
  2. 收集运行时依赖:把工程的所有编译和运行时依赖 jar 平铺复制到BOOT-INF/lib/目录,保持原始 jar 形式,不进行解压或合并。
  3. 写入 loader 启动器类:把JarLauncher、LaunchedURLClassLoader、JarFile等启动器代码放置到 jar 根目录的标准包路径下。
  4. 改写 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后,大致遵循以下步骤:

  1. JVM 读取外层 jar 的META-INF/MANIFEST.MF,找到Main-Class,也就是根目录下标准类加载器可直接加载的JarLauncher。
  2. JarLauncher.main被 JVM 标准类加载器执行,进入 Spring Boot 启动器逻辑。
  3. JarLauncher注册自定义 URL 协议处理器,并把BOOT-INF/classes/和BOOT-INF/lib/构造为类加载器可识别的 URL。
  4. 创建LaunchedURLClassLoader,使其能够读取嵌套区域中的应用类和依赖类。
  5. 读取 MANIFEST 中的Start-Class,并把它设置为真正的应用入口。
  6. 通过反射调用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的具体使用方式。这些内容都建立在本篇讲解的基础之上。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 10:10:38

产品只是船票,生态才是航线

《卖机器的时代已经过去》 ——真正的竞争力&#xff0c;不是把货送到港口&#xff0c;而是让客户离不开你一台设备装船&#xff0c;一笔货款到账&#xff0c;放在二十年前&#xff0c;这就叫出海成功了。放在今天&#xff0c;客户会追着你问四个问题&#xff1a;谁来安装&…

作者头像 李华
网站建设 2026/10/7 10:10:16

上海整木定制工厂亲测推荐:环保健康,真实案例分享

行业痛点分析在健康环保整木定制领域&#xff0c;核心挑战主要集中在材料选择、生产工艺、安装精度以及最终产品对居住环境的影响上。传统整木定制过程中&#xff0c;甲醛释放量高、TVOC排放超标等问题较为普遍&#xff0c;这些有害物质严重影响了室内空气质量&#xff0c;威胁…

作者头像 李华
网站建设 2026/10/7 10:09:54

2026 企业 AI 办公工具选型指南:国内平台全景评估

不少企业在调研AI办公工具的初期&#xff0c;很容易陷入几个典型的选型误区&#xff1a;有人把不同产品的功能列表拉出来逐一计数&#xff0c;选功能点最多的版本采购&#xff0c;上线之后才发现80%的功能团队根本用不上&#xff1b;有人只盯着采购成本做对比&#xff0c;选报价…

作者头像 李华
网站建设 2026/10/7 10:09:35

Sandsea Notes: 笔记软件最该有的功能,是让你能随时离开它

笔记软件用久了会有一种隐隐的焦虑&#xff1a;写得越多&#xff0c;越走不掉。格式是它自己的&#xff0c;附件存在它的库里&#xff0c;版本历史是它的一个功能开关。软件一升级、一收费、一停更&#xff0c;你手里最后还能剩下什么&#xff1f; Sandsea Notes 想试一下反过来…

作者头像 李华
网站建设 2026/10/7 10:09:33

2026深度体验:我实测豆包工作的真实办公感受

最近一直在找能帮自己分担重复办公任务的AI工具&#xff0c;之前试过不少单功能的生成类工具&#xff0c;每次生成完内容还要自己导到飞书里排版、同步给团队&#xff0c;来回折腾要花不少额外时间&#xff0c;上周和同部门的运营朋友吃饭&#xff0c;看她用新的智能体平台做竞…

作者头像 李华