Spring Boot 的可执行 jar 是怎么运行的?为什么能 java -jar 直接启动?
简化版
Spring Boot 打出的是一个 fat jar(胖 jar / 可执行 jar)——它把你的代码、所有依赖 jar、以及一个「启动引导器」全打进一个 jar,所以 java -jar app.jar 就能直接跑,不用配 classpath、不用外部 Tomcat。它和普通 jar 不同:内部有特殊结构(BOOT-INF/classes 放你的类、BOOT-INF/lib 放依赖 jar),且 MANIFEST.MF 里的 Main-Class 不是你的启动类,而是 Spring Boot 的 JarLauncher。JarLauncher 用一个自定义类加载器(能加载「jar 里嵌套的 jar」,标准 Java 做不到),把依赖都加载进来,再反射调用你真正的 main 方法。这就是 fat jar「一个包搞定一切」的原理。
详细版
fat jar 的内部结构:
app.jar
├── META-INF/
│ └── MANIFEST.MF # Main-Class: org.springframework.boot.loader.JarLauncher
│ # Start-Class: com.example.Application(你真正的启动类)
├── org/springframework/boot/loader/ # Spring Boot 的启动引导器(JarLauncher 等)
├── BOOT-INF/
│ ├── classes/ # ★ 你自己的代码(编译后的 .class)和资源
│ │ └── com/example/...
│ └── lib/ # ★ 所有依赖 jar(spring-core.jar、mysql.jar…)
│ └── *.jar # 一堆嵌套的 jar
启动流程:
java -jar app.jar
→ JVM 读 MANIFEST.MF 的 Main-Class → 是 JarLauncher(不是你的类!)
→ JarLauncher 运行:
1. 创建自定义类加载器 LaunchedURLClassLoader
(能加载 BOOT-INF/lib 下嵌套 jar 里的类——标准 ClassLoader 做不到)
2. 读 MANIFEST.MF 的 Start-Class → 你真正的启动类 com.example.Application
3. 用自定义类加载器加载你的启动类,反射调用它的 main 方法
→ 你的 main 里 SpringApplication.run() → 正常启动 Spring Boot
为什么需要 JarLauncher 和自定义类加载器:
标准 Java 规范:jar 里不能直接加载"嵌套 jar(jar in jar)"里的类
→ java -jar 只能加载本 jar 顶层的类,加载不了 BOOT-INF/lib/xxx.jar 里的类
Spring Boot 的解法:
→ 自定义 LaunchedURLClassLoader,让它能读取并加载嵌套 jar 里的类
→ 由 JarLauncher 先建好这个类加载器,再启动你的应用
⚠️ fat jar 是 Spring Boot Maven/Gradle 插件(
spring-boot-maven-plugin的repackage)二次打包产生的——普通mvn package先打出一个普通 jar,插件再把它「重新打包」成 fat jar(塞入依赖、加启动器、改 MANIFEST)。所以打包日志里会看到普通 jar 和一个.jar.original(原始 jar 被备份了)。没有这个插件,打出的就是普通 jar,java -jar会报「找不到主类」或缺依赖。
完整版教学
一、fat jar 解决什么:一个包搞定部署
传统 Java Web 应用部署很繁琐——打成 war 包,扔进外部 Tomcat,还要配好 Tomcat、管理依赖。Spring Boot 想让部署变简单:打成一个 jar,java -jar 就能跑:
传统方式:war 包 + 外部 Tomcat + 配 classpath + 管依赖 → 步骤多、易出错
Spring Boot:一个 fat jar,java -jar app.jar 直接启动 → 一步到位
要做到「一个包搞定」,这个 jar 必须自包含(self-contained)——包含你的代码、所有依赖、以及内嵌的 Web 服务器(Tomcat)。这就是 fat jar(也叫 uber jar、胖 jar)的含义:把运行所需的一切都塞进一个 jar。理解「fat jar = 自包含的可执行 jar」,就理解了为什么它能脱离外部容器独立运行——因为它自己就带了运行环境。
二、fat jar 和普通 jar 的结构区别
普通 jar 和 fat jar 结构完全不同,这是理解的关键:
普通 jar(依赖不在里面):
com/example/*.class # 只有你的类
META-INF/MANIFEST.MF # Main-Class 指向你的启动类
→ 运行时依赖要靠外部 classpath 提供,缺依赖就报 NoClassDefFound
Spring Boot fat jar(依赖全在里面):
org/springframework/boot/loader/* # 启动引导器
BOOT-INF/classes/ # 你的类和资源
BOOT-INF/lib/*.jar # ★ 所有依赖 jar 都打进来了!
META-INF/MANIFEST.MF # Main-Class 指向 JarLauncher(不是你的类)
两个关键差异:① 依赖 jar 被打进 BOOT-INF/lib(普通 jar 没有依赖);② Main-Class 是 Spring Boot 的 JarLauncher 而非你的启动类(你的启动类在 Start-Class 里)。为什么你的类放在 BOOT-INF/classes 而不是 jar 根目录?就是为了和启动引导器(在根目录的 org/springframework/boot/loader)分开,让 JarLauncher 能先启动、再用特殊方式加载 BOOT-INF 里的东西。
三、核心难题:jar 里的 jar 加载不了
fat jar 面临一个 Java 规范层面的难题:标准 Java 无法直接加载「嵌套 jar(jar in jar)」里的类。
BOOT-INF/lib/ 下是一堆完整的 .jar 文件(spring-core.jar 等)
标准 Java 的 URLClassLoader 只能加载:
- 文件系统上独立的 .jar
- jar 里顶层的 .class
它无法加载「一个 jar 内部嵌套的另一个 jar」里的 .class!
→ 如果直接把依赖 jar 塞进去,标准类加载器根本读不到里面的类
有两种解决思路:方案 A(Shade/uber jar)——把所有依赖 jar 解压,把里面的 .class 全平铺到一个 jar 里(不保留 jar 结构)。这样能用标准类加载器,但会丢失依赖的独立性、可能有文件冲突。方案 B(Spring Boot 的做法)——保留依赖 jar 的完整结构(BOOT-INF/lib/*.jar),自己写一个能加载嵌套 jar 的类加载器。Spring Boot 选了 B,因为它保留了依赖的完整性和可追溯性。这就引出了 JarLauncher 和自定义类加载器。
四、JarLauncher 与自定义类加载器
Spring Boot 的解法核心是两个东西:JarLauncher(启动引导器)+ LaunchedURLClassLoader(自定义类加载器):
java -jar app.jar 的完整过程:
1. JVM 看 MANIFEST.MF 的 Main-Class = JarLauncher → 先运行 JarLauncher
(JarLauncher 的类在 jar 根目录 org/springframework/boot/loader/ 下,
标准类加载器能加载它——所以它放根目录不放 BOOT-INF)
2. JarLauncher 创建 LaunchedURLClassLoader:
这个自定义类加载器"认识" BOOT-INF/lib 下的嵌套 jar,
能从「jar 里的 jar」中读取并加载 .class
3. JarLauncher 读 Start-Class = com.example.Application(你的真启动类)
4. 用 LaunchedURLClassLoader 加载你的启动类,反射调它的 main()
5. 你的 main → SpringApplication.run() → 应用启动
关键设计:JarLauncher 自己放在 jar 根目录(标准类加载器能加载它),它负责「搭好能加载嵌套 jar 的环境」,再把控制权交给你的应用。所以 Main-Class 是 JarLauncher(引导者)、Start-Class 是你的类(真正的应用入口)——两级启动,先引导再运行。这就是「为什么 java -jar 能启动一个自包含 jar」的完整答案。
五、repackage:fat jar 是怎么打出来的
fat jar 不是普通 mvn package 直接产生的,而是 Spring Boot 插件二次打包的结果:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!-- 它绑定了 repackage 目标 -->
</plugin>
打包过程:
1. mvn package 先正常编译、打出一个"普通 jar"(只含你的类)
2. spring-boot-maven-plugin 的 repackage 目标接手,把普通 jar"重新打包":
- 把普通 jar 里的类挪到 BOOT-INF/classes
- 把所有依赖 jar 塞进 BOOT-INF/lib
- 把 Spring Boot 启动引导器(loader)加进来
- 改写 MANIFEST.MF:Main-Class=JarLauncher, Start-Class=你的类
3. 产出:
- app.jar(fat jar,可执行)
- app.jar.original(原始普通 jar 的备份)
所以「没加 spring-boot-maven-plugin 就打不出可执行 jar」——普通 mvn package 只能打普通 jar(java -jar 会因缺依赖或没有正确 Main-Class 而失败)。理解「fat jar 是 repackage 二次加工的产物」,就明白了打包日志里为什么有 .jar.original(那是被替换前的普通 jar)。
六、fat jar 的取舍与相关部署方式
fat jar 好用,但也有取舍,以及几种相关部署方式要理清:
| 方式 | 说明 | 适用 |
|---|---|---|
| fat jar(默认) | 一个自包含 jar,java -jar 直接跑 | 微服务、容器化部署 |
| war 包 | 传统方式,部署到外部 Tomcat | 需要复用外部容器、传统运维 |
| 分层 jar(layered jar) | 把 fat jar 按变化频率分层(依赖层、代码层) | Docker 镜像分层缓存,加快构建 |
fat jar 的代价:① 体积大(含所有依赖,几十到上百 MB);② 依赖更新要重新打整个包。针对 Docker 场景,Spring Boot 2.3+ 支持分层 jar(layered jar)——把 fat jar 拆成「依赖层(很少变)、代码层(经常变)」等,配合 Docker 镜像分层,依赖没变时构建镜像能复用缓存层,只重传变化的代码层,大幅加快镜像构建和推送。这是 fat jar 在容器时代的优化。也可以打成 war 部署到外部 Tomcat(需要继承 SpringBootServletInitializer),适合要复用现有容器的传统环境。
记忆钩子:「fat jar 自包含(BOOT-INF/classes 你的类 + BOOT-INF/lib 依赖 jar + loader 引导器);Main-Class 是 JarLauncher 不是你的类(你的在 Start-Class);标准 Java 加载不了嵌套 jar,靠自定义 LaunchedURLClassLoader 解决;由 spring-boot-maven-plugin 的 repackage 二次打包产生;Docker 用分层 jar 优化」。
七、常见误区与追问
- 误区:Spring Boot 的可执行 jar 和普通 jar 结构一样。 完全不同——fat jar 有 BOOT-INF/classes(你的类)、BOOT-INF/lib(依赖 jar)、loader(引导器),Main-Class 是 JarLauncher 而非你的启动类。
- 误区:java -jar 直接运行的是你的启动类。 运行的是 Main-Class 指定的 JarLauncher,它搭好类加载环境后才反射调用 Start-Class(你的启动类)的 main。
- 误区:普通 mvn package 就能打出可执行 jar。 需要 spring-boot-maven-plugin 的 repackage 二次打包;普通 package 只出普通 jar,java -jar 会缺依赖或找不到主类。
- 误区:标准 Java 能加载 jar 里嵌套的 jar。 不能,标准 URLClassLoader 加载不了「jar in jar」里的类;Spring Boot 用自定义 LaunchedURLClassLoader 才实现。
- 追问:为什么 Main-Class 不直接是你的启动类? 因为你的类和依赖在 BOOT-INF 里、需要特殊类加载器才能加载;先让 JarLauncher(在 jar 根目录、标准类加载器能加载它)启动、建好 LaunchedURLClassLoader,再加载你的类。
- 追问:fat jar 体积大、依赖更新慢怎么优化? Spring Boot 2.3+ 的分层 jar(layered jar)把 jar 按变化频率分层,配合 Docker 镜像分层缓存,依赖没变时复用缓存层,只重建变化的代码层。
- 追问:Spring Boot 能打成 war 部署到外部 Tomcat 吗? 能,启动类继承 SpringBootServletInitializer、打包类型改 war、内嵌 Tomcat 依赖设为 provided,即可部署到外部 Servlet 容器。
八、加强记忆
Spring Boot 的可执行 fat jar 是「自包含」的——把你的代码、所有依赖 jar、内嵌 Tomcat、启动引导器全打进一个 jar,所以 java -jar app.jar 一步启动、无需外部容器。它的结构和普通 jar 完全不同:BOOT-INF/classes(你的类)+ BOOT-INF/lib(依赖 jar)+ 根目录的 loader(引导器),且 MANIFEST.MF 的 Main-Class 是 Spring Boot 的 JarLauncher(你的启动类在 Start-Class)。核心难题是标准 Java 加载不了「jar 里嵌套的 jar」,Spring Boot 的解法是自定义 LaunchedURLClassLoader(能读嵌套 jar),由 JarLauncher(放 jar 根目录、标准类加载器能加载它)先建好这个类加载器、再反射调用你的 main——两级启动,先引导再运行。fat jar 由 spring-boot-maven-plugin 的 repackage 二次打包产生(普通 package 只出普通 jar,还会留个 .jar.original 备份)。代价是体积大,Docker 场景用分层 jar 优化(按变化频率分层复用镜像缓存)。一句话「自包含 fat jar、Main-Class 是 JarLauncher、自定义类加载器读嵌套 jar、repackage 二次打包、分层 jar 优化 Docker」。