Spring Boot 可执行 JAR 文件直接启动的原理与实践

1. Spring Boot 可执行 JAR 的背景与意义

1.1 传统 Java 应用的部署痛点

在 Spring Boot 出现之前,Java Web 应用的主流部署方式是 WAR 包 + 外部 Servlet 容器,典型流程如下:

  1. 构建应用 :使用 Maven/Gradle 编译并打包生成 .war 文件。

  2. 准备容器:运维人员在服务器上安装、配置 Tomcat/Jetty。

  3. 部署应用 :将 WAR 包放入 Tomcat 的 webapps/ 目录。

  4. 重启容器:容器加载新部署的 WAR 包并运行。

痛点

  • 依赖外部环境:容器版本、配置可能不一致,带来兼容问题。

  • 部署流程繁琐:需要先拷贝到指定目录,再重启容器。

  • 升级风险高:容器升级可能影响多个应用。

  • 可移植性差:同一 WAR 包在不同服务器运行可能出现差异。

想象一个场景:

你需要在三台生产服务器上部署同一个应用。传统方式下,你不仅要保证这三台服务器上 Tomcat 版本一致,还要配置相同的 server.xmlweb.xml 等文件。一旦版本不一致,就可能出现类加载冲突或依赖缺失。


1.2 Spring Boot 的革新:自包含部署的诞生

Spring Boot 从设计之初,就提出了一个目标:"Just run it" ------ 构建出来的包应该直接运行,而不需要额外的安装步骤。

它的解决方案是:

  • 引入 Fat JAR(胖包) 概念,将应用代码、所有依赖、以及一个嵌入式的 Servlet 容器打包进同一个 JAR 文件。

  • 让这个 JAR 文件具备可执行性 ,通过 java -jar 命令即可启动。

优点

  1. 自包含:所有运行所需的依赖和容器都在 JAR 内。

  2. 跨平台一致性:开发、测试、生产环境都运行相同的 JAR,减少环境差异。

  3. 部署简化:无需在服务器上预装 Tomcat,只需安装 JDK。

  4. 升级方便:直接替换 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 文件和资源文件。

  • 依赖 :需要在运行时通过 -cpCLASSPATH 引用外部依赖 JAR。

  • 启动方式

    复制代码
    java -cp myapp.jar:lib/* com.example.Main

    依赖必须解压或平铺在文件系统可访问的位置。

Fat JAR(胖包)
  • 内容

    1. 应用自身的 .class 文件

    2. 所有第三方依赖 JAR(以嵌套形式放在 JAR 内部)

    3. 嵌入式 Servlet 容器(Tomcat/Jetty/Undertow)

    4. 特殊的启动器类(如 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/ # 启动器类
目录说明:
  1. META-INF/MANIFEST.MF

    描述 JAR 元信息,包括入口类:

    复制代码
    Main-Class: org.springframework.boot.loader.JarLauncher
    Start-Class: com.example.demo.DemoApplication
  2. BOOT-INF/classes/

    存放编译生成的应用主代码。

  3. BOOT-INF/lib/

    存放所有依赖的第三方 JAR,嵌套在主 JAR 内。

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

  • 作用

    1. 解析 JAR 内部结构(BOOT-INF/classes、BOOT-INF/lib)。

    2. 构建自定义类加载器加载嵌套依赖。

    3. 再去调用 Start-Class

Start-Class
  • 你的应用主类(通常标有 @SpringBootApplication)。

  • JarLauncher 会读取该属性,然后通过反射执行它的 main() 方法。

流程图(文字版)

复制代码
JVM 启动
   ↓
Main-Class: JarLauncher
   ↓
解析 Start-Class
   ↓
加载类 & 创建 Spring 应用上下文
   ↓
调用 main() 方法

3.2 JarLauncher 的启动逻辑

JarLauncher 是 Spring Boot Loader 模块中的一个核心类。它的职责可以概括为:

  1. 定位 :找到 Fat JAR 内的 BOOT-INF/classesBOOT-INF/lib

  2. 构造类加载器 :使用自定义 LaunchedURLClassLoader 支持嵌套 JAR 直接加载。

  3. 启动应用

    • 读取 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 特点

  1. 嵌套 JAR 加载:直接从主 JAR 的 Zip 流中读取依赖。

  2. 类加载隔离

    • 应用依赖与 JDK 类隔离。

    • 不会污染系统 classpath。

  3. 无需解压:减少 I/O 操作,加快启动。

加载路径示例(文字说明):

复制代码
1. 从 BOOT-INF/classes/ 查找应用类。
2. 从 BOOT-INF/lib/ 查找第三方依赖。
3. 如果都找不到,委托父类加载器(Bootstrap 或 Ext)。

3.4 启动线程与主类绑定过程

JarLauncher 创建了类加载器并设置到 TCCL(Thread Context ClassLoader) 后:

  1. JVM 当前线程的类查找会优先使用这个类加载器。

  2. Spring Boot 主类及依赖全部从 Fat JAR 内部加载。

  3. 应用的 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

  1. 判断应用类型

    • 如果是 Servlet Web 应用,自动创建 ServletWebServerFactory 的 Bean。

    • 默认实现为 TomcatServletWebServerFactory

  2. 构建 WebServer

    • TomcatServletWebServerFactory 会创建 Tomcat 实例。

    • 配置端口、连接数、编码、压缩等参数。

  3. 注册 DispatcherServlet

    • 自动注册 Spring MVC 的 DispatcherServlet 作为默认请求分发器。

    • 所有 HTTP 请求会先经过它。

  4. 调用容器的 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)来分发请求。

  • 避免直接暴露应用端口到公网,增加安全性。