1. Spring Boot 可执行 JAR 的背景与意义
1.1 传统 Java 应用的部署痛点
在 Spring Boot 出现之前,Java Web 应用的主流部署方式是 WAR 包 + 外部 Servlet 容器,典型流程如下:
-
构建应用 :使用 Maven/Gradle 编译并打包生成
.war
文件。 -
准备容器:运维人员在服务器上安装、配置 Tomcat/Jetty。
-
部署应用 :将 WAR 包放入 Tomcat 的
webapps/
目录。 -
重启容器:容器加载新部署的 WAR 包并运行。
痛点:
-
依赖外部环境:容器版本、配置可能不一致,带来兼容问题。
-
部署流程繁琐:需要先拷贝到指定目录,再重启容器。
-
升级风险高:容器升级可能影响多个应用。
-
可移植性差:同一 WAR 包在不同服务器运行可能出现差异。
想象一个场景:
你需要在三台生产服务器上部署同一个应用。传统方式下,你不仅要保证这三台服务器上 Tomcat 版本一致,还要配置相同的
server.xml
、web.xml
等文件。一旦版本不一致,就可能出现类加载冲突或依赖缺失。
1.2 Spring Boot 的革新:自包含部署的诞生
Spring Boot 从设计之初,就提出了一个目标:"Just run it" ------ 构建出来的包应该直接运行,而不需要额外的安装步骤。
它的解决方案是:
-
引入 Fat JAR(胖包) 概念,将应用代码、所有依赖、以及一个嵌入式的 Servlet 容器打包进同一个 JAR 文件。
-
让这个 JAR 文件具备可执行性 ,通过
java -jar
命令即可启动。
优点:
-
自包含:所有运行所需的依赖和容器都在 JAR 内。
-
跨平台一致性:开发、测试、生产环境都运行相同的 JAR,减少环境差异。
-
部署简化:无需在服务器上预装 Tomcat,只需安装 JDK。
-
升级方便:直接替换 JAR 文件即可。
部署时的命令可能就是这样:
java -jar myapp-1.0.jar
与传统方式相比,这一步已经省略了容器安装、配置、拷贝 WAR 包等步骤。
2. Fat JAR 的生成机制与结构解析
2.1 Fat JAR 的核心特征与传统 JAR 的区别
在 Java 世界里,JAR 文件 (Java Archive)本质上是一个压缩包,里面存放了字节码文件、资源文件,以及一个 META-INF/MANIFEST.MF
描述文件。
传统 JAR
-
内容 :仅包含当前项目编译出来的
.class
文件和资源文件。 -
依赖 :需要在运行时通过
-cp
或CLASSPATH
引用外部依赖 JAR。 -
启动方式:
java -cp myapp.jar:lib/* com.example.Main
依赖必须解压或平铺在文件系统可访问的位置。
Fat JAR(胖包)
-
内容:
-
应用自身的
.class
文件 -
所有第三方依赖 JAR(以嵌套形式放在 JAR 内部)
-
嵌入式 Servlet 容器(Tomcat/Jetty/Undertow)
-
特殊的启动器类(如
JarLauncher
)
-
-
启动方式:
java -jar myapp-1.0.jar
2.2 Maven 插件的打包流程详解
Spring Boot 通过 spring-boot-maven-plugin
插件的 repackage
目标,将一个普通 JAR 重新打包成 Fat JAR。
POM 配置示例:
<build>
<plugins>
<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>
</plugins>
</build>
关键点:
-
repackage
:将编译产物的 JAR "改造"成可执行 JAR。 -
自动检测
@SpringBootApplication
标注的主类作为Start-Class
,或通过<mainClass>
显式指定。 -
原始构建产物会被重命名为
xxx.jar.original
以保留。
打包命令:
mvn clean package
执行后会得到:
target/
├─ myapp-1.0.jar # 可执行 Fat JAR
└─ myapp-1.0.jar.original # 原始 JAR(无启动器)
2.3 Fat JAR 的内部结构解析
一个典型的 Spring Boot Fat JAR 内部目录结构如下:
myapp-1.0.jar
├─ META-INF/
│ └─ MANIFEST.MF
├─ BOOT-INF/
│ ├─ classes/ # 应用自己的类文件
│ ├─ lib/ # 所有依赖 JAR
│ └─ classpath.idx # 类路径索引
└─ org/springframework/boot/loader/ # 启动器类
目录说明:
-
META-INF/MANIFEST.MF
描述 JAR 元信息,包括入口类:
Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: com.example.demo.DemoApplication
-
BOOT-INF/classes/
存放编译生成的应用主代码。
-
BOOT-INF/lib/
存放所有依赖的第三方 JAR,嵌套在主 JAR 内。
-
spring-boot-loader
启动器代码,负责加载嵌套 JAR 并启动应用。
2.4 MANIFEST.MF 的关键属性解析
Spring Boot 会在打包时生成特殊的 META-INF/MANIFEST.MF
:
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.demo.DemoApplication
Spring-Boot-Version: 3.2.0
-
Main-Class
JVM 入口类,Spring Boot 固定为
JarLauncher
,它负责解析 Fat JAR 并启动应用。 -
Start-Class
应用的主类,最终会由
JarLauncher
反射调用main()
方法。 -
Spring-Boot-Version
标记构建时使用的 Spring Boot 版本。
3. JarLauncher 与类加载器的协作原理
3.1 Main-Class 与 Start-Class 的作用
在一个可执行 JAR 中,MANIFEST.MF
文件里会有两个非常关键的属性:
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.demo.DemoApplication
Main-Class
-
JVM 启动 JAR 时首先执行的类。
-
在 Spring Boot 中,这个类并不是你的应用主类,而是
JarLauncher
。 -
作用:
-
解析 JAR 内部结构(BOOT-INF/classes、BOOT-INF/lib)。
-
构建自定义类加载器加载嵌套依赖。
-
再去调用
Start-Class
。
-
Start-Class
-
你的应用主类(通常标有
@SpringBootApplication
)。 -
JarLauncher
会读取该属性,然后通过反射执行它的main()
方法。
流程图(文字版):
JVM 启动
↓
Main-Class: JarLauncher
↓
解析 Start-Class
↓
加载类 & 创建 Spring 应用上下文
↓
调用 main() 方法
3.2 JarLauncher 的启动逻辑
JarLauncher
是 Spring Boot Loader 模块中的一个核心类。它的职责可以概括为:
-
定位 :找到 Fat JAR 内的
BOOT-INF/classes
和BOOT-INF/lib
。 -
构造类加载器 :使用自定义
LaunchedURLClassLoader
支持嵌套 JAR 直接加载。 -
启动应用:
-
读取
Start-Class
的类名。 -
通过反射调用该类的
main()
方法。
-
简化版伪代码(非源码,仅逻辑示例):
public class JarLauncher {
public static void main(String[] args) {
// 1. 构造类加载器
ClassLoader loader = createLaunchedClassLoader();
// 2. 设置线程上下文类加载器
Thread.currentThread().setContextClassLoader(loader);
// 3. 读取 Start-Class
String mainClassName = readStartClassFromManifest();
// 4. 反射调用 main()
Method main = loader.loadClass(mainClassName).getMethod("main", String[].class);
main.invoke(null, (Object) args);
}
}
3.3 LaunchedURLClassLoader 的加载机制
为什么 Spring Boot 不能直接用 URLClassLoader
?
-
普通类加载器无法直接加载"嵌套"在 JAR 内部的依赖包(BOOT-INF/lib/*.jar)。
-
如果先解压到文件系统再加载,会降低启动速度、增加磁盘占用。
LaunchedURLClassLoader 特点:
-
嵌套 JAR 加载:直接从主 JAR 的 Zip 流中读取依赖。
-
类加载隔离:
-
应用依赖与 JDK 类隔离。
-
不会污染系统 classpath。
-
-
无需解压:减少 I/O 操作,加快启动。
加载路径示例(文字说明):
1. 从 BOOT-INF/classes/ 查找应用类。
2. 从 BOOT-INF/lib/ 查找第三方依赖。
3. 如果都找不到,委托父类加载器(Bootstrap 或 Ext)。
3.4 启动线程与主类绑定过程
当 JarLauncher
创建了类加载器并设置到 TCCL(Thread Context ClassLoader) 后:
-
JVM 当前线程的类查找会优先使用这个类加载器。
-
Spring Boot 主类及依赖全部从 Fat JAR 内部加载。
-
应用的
main()
方法启动SpringApplication
,进入 Spring Boot 生命周期。
这种绑定方式确保了:
-
即使系统 classpath 中存在同名类,也会优先加载 JAR 内部版本(避免依赖冲突)。
-
在不同服务器、不同环境中启动的结果完全一致。
4. 嵌入式 Servlet 容器的集成与启动流程
4.1 嵌入式容器的选型与配置
Spring Boot 的 Web 项目在构建时默认依赖 spring-boot-starter-web ,而这个 starter 默认集成的是 Tomcat 。
不同容器的选择取决于你的依赖:
容器类型 | Maven 依赖 |
---|---|
Tomcat(默认) | spring-boot-starter-web |
Jetty | spring-boot-starter-jetty |
Undertow | spring-boot-starter-undertow |
替换容器示例(使用 Jetty):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
这样,Spring Boot 会自动用 Jetty 代替 Tomcat 作为嵌入式容器。
4.2 容器启动的底层实现
当 DemoApplication.main()
调用了 SpringApplication.run()
后,Spring Boot 会进入 自动配置阶段 ,这里的关键是 ServletWebServerFactoryAutoConfiguration:
-
判断应用类型
-
如果是 Servlet Web 应用,自动创建
ServletWebServerFactory
的 Bean。 -
默认实现为
TomcatServletWebServerFactory
。
-
-
构建 WebServer
-
TomcatServletWebServerFactory
会创建Tomcat
实例。 -
配置端口、连接数、编码、压缩等参数。
-
-
注册 DispatcherServlet
-
自动注册 Spring MVC 的
DispatcherServlet
作为默认请求分发器。 -
所有 HTTP 请求会先经过它。
-
-
调用容器的 start() 方法
-
绑定网络端口(默认 8080)。
-
开启主线程监听请求。
-
文字流程图:
SpringApplication.run()
↓
创建 ApplicationContext
↓
自动配置 ServletWebServerFactory
↓
new Tomcat()
↓
Tomcat.start()
↓
开始监听端口,接受请求
4.3 端口冲突与自定义配置
默认情况下,Spring Boot 使用 8080 端口,如果该端口被占用,会抛出 PortInUseException
并启动失败。
修改端口:
server.port=9090
绑定 IP 地址(限制只监听某个网卡):
server.address=192.168.1.100
关闭端口自动重试(Spring Boot 2.6+):
server.port=8080
server.port.retry=false
生产环境技巧:
-
在部署多实例应用时,使用不同端口或反向代理(如 Nginx)来分发请求。
-
避免直接暴露应用端口到公网,增加安全性。