打包与运行:可执行 Jar 的秘密与优雅停机

bee2026-10-0865 分钟0 次阅读
一个 jar 为什么能独立运行?拆开 fat jar 的内部结构,看 Spring Boot 的 LaunchedURLClassLoader;再配启动参数、优雅停机与 systemd 守护的完整部署脚本。
1 / 153
小节
〇、30 秒看懂
2 / 153

以前部署一个 Java Web 应用,你要做四件事:装 JDK、装 Tomcat、把 war 丢进 webapps、再祈祷服务器上的版本和你本地一致。Spring Boot 把这四件事压成一句:java -jar app.jar。这一篇讲的就是这句话背后三块内容——这个 jar 里到底装了什么、它是怎么把自己启动起来的、以及怎么让它体面地停下来。

3 / 153

先把六个词一句话解释(全文都会用到):

4 / 153
  • fat jar(可执行 jar):把所有依赖一起打进去的那个 jar,也叫 uber-jar;Boot 的 repackage 目标负责生成它
  • MANIFEST.MF:jar 里的「说明书」文件,Main-Class 告诉 JVM 该运行哪个类的 main
  • BOOT-INF/:fat jar 内部放业务代码与依赖的目录,标准 JVM 看不见它,得靠 Boot 自己的加载器
  • 类加载器(ClassLoader):负责「按名字找到字节码并变成 Class 对象」的那层,Java 允许你自己写
  • SIGTERM / SIGKILL: Unix 的两个停止信号,前者「可以收拾行李再走」,后者「当场断电」
  • profile / 外部配置:同一份 jar 在不同环境读不同配置文件的机制,运维改配置不该重新打包
5 / 153
类比

fat jar 就是自热饭盒。米、菜、水、发热包全在一个盒子里,你不需要厨房(Tomcat)、不需要煤气(外部依赖),撕开拉环它就自己加热——这就是"自带运行时"。代价也真实:盒子很重(几十 MB),而且发热包一旦拉环就没法中途停手,所以「什么时候撕」(停机时机)必须自己安排好。至于换口味,你只要换盒子,不用换整套厨具——这正是交付物只有一个 jar 的好处。

6 / 153
架构图
图 · 本篇地图:一个 jar 从打包到停机的六条支线
图 · 本篇地图:一个 jar 从打包到停机的六条支线
7 / 153

学完这一篇,你应该能回答三个问题:

8 / 153
  • 为什么 java -cp app.jar com.example.BeeApplication 一定失败,而 java -jar app.jar 可以?
  • -Xmx 在容器里为什么要改成 -XX:MaxRAMPercentage?
  • 生产上想重启一次服务,怎样做到用户零感知?(提示:两行配置 + 一个信号)
9 / 153
小节
一、两种打包形态:为什么现在推荐 fat jar
10 / 153

第一次用 Spring Boot 的人常有一个疑问:凭什么一个 java -jar app.jar 就能把整个 Web 服务跑起来? 以前用 Spring MVC,你得先打一个 war,再部署到外部 Tomcat 的 webapps 目录下,还得保证服务器上装了正确版本的 Tomcat。Spring Boot 把这一切塞进了一个 jar 里。

11 / 153
对照表
维度可执行 jar(fat jar)war + 外置容器
载体内嵌 Tomcat/Jetty/Undertow外部 Tomcat/WebLogic
启动方式java -jar app.jar丢进 webapps 由容器启动
依赖管理依赖全打进 jar,自包含容器提供 Servlet 容器与部分类库
多实例拷 jar 就能跑需保证每台机器容器版本一致
云原生适配天然适合容器与 K8s与镜像理念冲突
现状推荐遗留系统、必须交付 war 的场景
12 / 153

war 并非没用,它服务于「公司统一维护一批 Tomcat、应用必须灌进去」的历史场景。但对新项目,fat jar 的优势是压倒性的:应用与运行时成为一个不可分割的交付物,拷过去就能跑,天然契合一容器一进程的部署模型。

13 / 153
说明

Spring Boot 也支持打 war,只需把 packaging 改成 war 并让启动类继承 SpringBootServletInitializer。但除非有明确的「必须部署到外部容器」需求,否则没有理由放弃 jar。

14 / 153
小节
二、拆开 fat jar:它到底长什么样
15 / 153

最直接的认识方式,就是把 jar 解开看。执行 unzip -l(或 jar tf)列出目录:

16 / 153
text
$ unzip -l bee-app-1.0.0.jar  Length      Date    Time    Name---------  ---------- -----   ----      985  2026-10-01 10:20   META-INF/MANIFEST.MF        0  2026-10-01 10:20   BOOT-INF/        0  2026-10-01 10:20   BOOT-INF/classes/     3421  2026-10-01 10:20   BOOT-INF/classes/com/example/BeeApplication.class       89  2026-10-01 10:20   BOOT-INF/classes/application.yml        0  2026-10-01 10:20   BOOT-INF/lib/  1246720  2026-10-01 10:20   BOOT-INF/lib/spring-boot-3.2.0.jar   698112  2026-10-01 10:20   BOOT-INF/lib/spring-core-6.1.0.jar       0  2026-10-01 10:20   org/springframework/boot/loader/    28456  2026-10-01 10:20   org/springframework/boot/loader/JarLauncher.class
17 / 153

三个关键区域:

18 / 153
  • BOOT-INF/classes/:你自己的字节码与配置文件(application.yml、static/)都在这里
  • BOOT-INF/lib/:所有第三方依赖,以嵌套 jar 的形式原样存放——不是解压后的 class
  • org/springframework/boot/loader/:Spring Boot 自带的加载器与启动器代码
19 / 153

再看主清单文件:

20 / 153
text
Manifest-Version: 1.0Main-Class: org.springframework.boot.loader.launch.JarLauncherStart-Class: com.example.BeeApplicationSpring-Boot-Version: 3.2.0Spring-Boot-Classes: BOOT-INF/classes/Spring-Boot-Lib: BOOT-INF/lib/
21 / 153
架构图
图 1 · Fat Jar 的内部结构
图 1 · Fat Jar 的内部结构
22 / 153
坑

正因为 BOOT-INF/classes 不在 jar 根目录,java -cp app.jar com.example.BeeApplication 一定失败——JVM 的标准类加载器只认识根目录下的 com/example/...,找不到 BOOT-INF/classes 里的东西。这不是配置错误,是 fat jar 的固有结构,必须靠 -jar 触发它自带的加载器。

23 / 153
小节
三、启动原理:JarLauncher 与 LaunchedURLClassLoader
24 / 153

看到 Main-Class: JarLauncher 就明白了:java -jar 启动的其实是 Spring Boot 的 JarLauncher,不是你写的 BeeApplication。完整链路是这样的:

25 / 153
  1. 用户执行 java -jar app.jar
  2. JVM 读 MANIFEST,看到 Main-Class: JarLauncher,反射调用它的 main
  3. JarLauncher 读同一个 MANIFEST 里的 Start-Class,得知真正的启动类是 BeeApplication
  4. 它创建一个特殊的 LaunchedURLClassLoader,**把 BOOT-INF/lib/.jar 和 BOOT-INF/classes/ 都挂到这个加载器的搜索路径上*
  5. 用这个类加载器加载并反射调用 BeeApplication.main
26 / 153

把这条链再往上游接一格,就是完整的一生——很多人只盯着 java -jar,却不知道真正让 jar 「能自运行」的是打包时那一步 repackage:

27 / 153
架构图
图 · 从 mvn package 到 main 被执行
图 · 从 mvn package 到 main 被执行
28 / 153

这里要解决的核心问题是 "jar in jar":Java 诞生时根本没考虑过「一个 jar 里再放 jar」。标准 JVM 只能读扁平结构的 jar,无法直接打开嵌套的依赖。Spring Boot 的解法是自定义 URL 协议与 JarFile 扩展:它把每个嵌套 jar 的入口映射成一个特殊 URL,LaunchedURLClassLoader 按 URL 逐个打开,于是 JVM 的类加载机制就能"看见"这些嵌套依赖了。

29 / 153
要点

LaunchedURLClassLoader 的父加载器是应用类加载器(AppClassLoader)。它额外接管了 BOOT-INF 下的资源查找,这也是为什么加载器代码必须放在外层 jar 根目录——它得先于业务类被标准加载器加载。

30 / 153

「两套加载器各看得见什么」这件事,用图的不如用一次交接。下面这段动画把这次交接演了六帧,注意第 ② 帧——标准加载器此刻还不知道你的类存在:

31 / 153
原理动画
动图 · 两套类加载器的一次交接
动图 · 两套类加载器的一次交接
32 / 153

把上面那五步链路摊成单步执行。左边是这台 JVM 依次做的事,右边同步刷新「此刻的 Main-Class」「谁能看见谁」——重点在第 ③ 拍(路是这时候铺的)和第 ⑤ 拍(方向盘是这时候交出去的):

33 / 153
单步调试台
单步台跟着调试器走一遍:java -jar 之后那五拍1 / 6
六拍。第 ③ 拍之前,你的业务类对 JVM 完全不存在;第 ⑤ 拍之后,才终于接上第十七篇讲的那条启动主线
被调试的代码
1java -jar target/bee-app-1.0.0.jar
2// ① JVM 打开 zip,只读 META-INF/MANIFEST.MF 里的 Main-Class
3// ② Main-Class = org.springframework.boot.loader.launch.JarLauncher
4// ③ JarLauncher 新建 LaunchedClassLoader,把 BOOT-INF/classes 与 lib 挂上去
5// ④ 用新加载器按 Start-Class 的名字找到 BeeApplication
6SpringApplication.run(BeeApplication.class, args); // 从这里起才是你熟悉的世界
此刻的变量
命令java -jar
JVM 此刻知道的只有 zip 的中央目录
搜索路径app.jar 自己
线程main
调用栈
1java.exe → JLI_Launch
2LauncherHelper
1-jar 与 -cp 的区别在这第一拍就定了:-jar 让 JVM 去 MANIFEST 里找入口,-cp 要求你自己把入口类名说清楚。后者在这份布局上必然失败,因为下一条路径规则根本不包括 BOOT-INF。
34 / 153

同一个知识点换个角度看,就是「谁看得见谁」这张分层图。从 ① 点到 ⑤,第 ② 格最容易记反:

35 / 153
交互图解
分层两套类加载器,两份可见性1 / 5
从 ① 点到 ⑤。第 ② 格那个「JVM 根本不读 Start-Class」是本页最多人在面试里答错的一格
→
→
→
→
① 外层根目录:只有壳
标准 AppClassLoader 只看得到 jar 根目录下的东西,而根目录里只有 MANIFEST 和 org/springframework/boot/loader/** 那几个引导类。你的任何一行业务代码都不在这里。
全部看懂了一句话记法:Main-Class 给 JVM 看,Start-Class 给 Boot 看,BOOT-INF 给新加载器看。
36 / 153
小节
四、打包命令全家桶
37 / 153

打包本身不复杂,难在打包时该跳过什么、激活哪个环境。

38 / 153
对照表
命令 / 参数作用使用建议
mvn clean package清理并打包本地日常
-DskipTests编译测试类但不执行测试CI 中最常用
-Dmaven.test.skip=true连测试类都不编译测试代码有编译错误时的应急
-Pprod激活 prod profile按环境切换配置
-U强制更新 SNAPSHOT 依赖依赖更新不及时时
-Dspring.profiles.active=prod指定运行 profile打包后运行时用
39 / 153

上面这一串开关都建立在一个前提上:pom 里有 spring-boot-maven-plugin。那一行才是 repackage 的开关,也是「能自运行的 jar」与「几百 KB 的普通 jar」的分水岭。勾一遍,顺便看依赖该带哪几件——注意 test 那件的 scope,它正是 -DskipTests 讨论的那个东西:

40 / 153
生成器
生成器可执行 jar 的那三行 pompom.xml2 / 6
勾 Web 与 Actuator,看 parent 接管插件后产出什么;再加 Test,注意它的 scope=test;然后对照第三节那张时间线图——把插件从 pom 里去掉,产物就退化成 no main manifest attribute 的那个普通包
产物
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>demo-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
Actuatorhealth/metrics/info 等端点;暴露面记得走白名单,别写 *。
41 / 153

上面那一串开关只是一层「加料」,生命周期才是流水线本身。在动手改 pom 之前,先把一条 mvn package 到底按什么顺序跑了什么看清楚——四个参数依次点一遍:

42 / 153
内核实验
TeaVM一条 mvn package 跑了什么未启动
重点是 bind 那一档:repackage 不是你需要手敲的命令,而是一个被「绑到 package 阶段」的插件目标——这正是「pom 里那三行才是分水岭」的机制版解释
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
43 / 153

看完再回看上面那张表:-Pprod 换的是 profile,-DskipTests 动的是 surefire 的开关,而 package 阶段本身,永远会把绑在它上面的每一个插件目标一起带走。

44 / 153

多环境 profile 打包,pom.xml 里这样声明:

45 / 153
xml
<profiles>    <profile>        <id>dev</id>        <activation><activeByDefault>true</activeByDefault></activation>        <properties>            <profiles.active>dev</profiles.active>        </properties>    </profile>    <profile>        <id>prod</id>        <properties>            <profiles.active>prod</profiles.active>        </properties>    </profile></profiles><build>    <resources>        <resource>            <directory>src/main/resources</directory>            <filtering>true</filtering>   <!-- 让 ${profiles.active} 被替换 -->        </resource>    </resources></build>
46 / 153
代码对照
代码bash
# 打生产包:profile 值会被过滤进 application.ymlmvn clean package -Pprod -DskipTests
解读
  • -DskipTests 与 -Dmaven.test.skip=true 的区别:前者仍然编译测试类(能发现测试代码写错),后者直接跳过编译,更快但可能掩盖问题
  • filtering 会把 application.yml 里的 @profiles.active@(或 ${profiles.active})替换成当前 profile 的值
  • 打包与运行 profile 是两回事:打包决定「哪个配置进了 jar」,运行决定「运行时读哪份配置」,别混淆
47 / 153
小节
五、启动参数全解:从 -Xmx 到 profile
48 / 153

java -jar 并不只有 jar 路径一个参数,JVM 参数与应用参数要分开写:

49 / 153
代码对照
代码bash
java -Xms512m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \     -Dspring.profiles.active=prod -Dserver.port=8080 \     -jar bee-app-1.0.0.jar \     --logging.level.com.example=DEBUG
解读
  • JVM 参数放在 -jar 之前,由 JVM 解析:-Xms/-Xmx(堆)、-XX:+UseG1GC(垃圾回收器)、-XX:MaxGCPauseMillis(暂停目标)
  • 系统属性用 -D:-Dspring.profiles.active=prod 把 profile 作为系统属性传入
  • 应用参数放在 jar 之后,用 --:--logging.level.com.example=DEBUG 覆盖配置项,优先级最高
50 / 153
小节
5.1 JVM 调优入门与常见误区
51 / 153
对照表
参数含义入门建议
-Xms初始堆大小与 -Xmx 设成相同值,避免运行期反复扩堆
-Xmx最大堆大小容器内按限额的 50%~75% 设,留出元空间与直接内存
-XX:+UseG1GC使用 G1 回收器JDK 9+ 默认,大堆更友好
-XX:MaxRAMPercentage堆占物理内存比例容器环境首选,如 75.0
-Xss单线程栈大小一般不动,线程多时才需要调小
52 / 153
坑

-Xmx 设成「越大越好」是经典误区。堆越大,Full GC 的单次停顿越长,而且堆内存 + 元空间 + 线程栈 + 直接内存 + JVM 自身开销才是一台机器真正占用的内存。在生产容器里,最稳妥的写法是 -XX:MaxRAMPercentage=75.0 —— 让 JVM 根据 cgroup 限额自动计算堆大小,而不是写死一个忽略容器限制的 -Xmx。

53 / 153

这个百分比是唯一真正该拖的滑块,因为它的两个失败方向都很响:调小了频繁 Full GC,调大了被内核直接杀。按 512MiB 的容器限额拖一遍:

54 / 153
参数调节台
调节台容器限额 512MiB:堆该拿几成
-XX:MaxRAMPercentage
75% 限额当前 10 – 95
常用推荐档:先按 75 起步
  • 384MiB 堆,非堆留 128MiB,够多数中小服务
  • 这是把百分比交给 cgroup 的写法,改限额不用改启动参数
  • 上线前用 Runtime.getRuntime().maxMemory() 核一次实际值
  • GC 日志里出现连续 to-space exhausted,说明这一档对你偏小了——但先去看是不是泄漏
堆可用75%
非堆余量25%
被 OOMKill 风险20%
限额不是堆的预算,是整进程的预算——先给非堆留账,剩下的才是堆。
55 / 153
小节
六、优雅停机:别让 kill -9 腰斩请求
56 / 153

这是本篇最重要的部分。很多线上"数据不一致"的锅,不是业务代码的错,而是停机姿势不对。

57 / 153

想想 kill -9 那一刻发生了什么:JVM 收到 SIGKILL,没有任何回调会被执行。此刻正在处理的一个「扣款 + 加积分」事务可能扣了款还没加积分;一个已经读到内存、还没返回的请求被直接掐断,客户端收到连接重置;连接池里的连接被操作系统粗暴回收,数据库侧还挂着一个"未提交事务"。

58 / 153

Spring Boot 提供了优雅停机,只需两行配置:

59 / 153
代码对照
代码yaml
server:  shutdown: graceful                 # 默认是 immediate(立即断)spring:  lifecycle:    timeout-per-shutdown-phase: 30s  # 每个停机阶段最多等多久
解读
  • server.shutdown: graceful:把 Web 服务器的停机模式从「立即」改成「优雅」
  • timeout-per-shutdown-phase: 30s:给「等待处理中请求」设一个上限,超时才强制中断;不给上限就可能一直等下去
60 / 153
原理动画
动图 · 优雅停机的六步
动图 · 优雅停机的六步
61 / 153

开启后,正常 kill <pid>(不带 -9)会走下面这条完整路径:

62 / 153
  1. 收到 SIGTERM 信号:kill、systemd stop、docker stop、K8s 删除 Pod 都是发这个信号,不是 -9
  2. 停止接收新请求:Web 连接器关闭,不再建立新连接,负载均衡探测到不健康后把流量摘走
  3. 等待处理中请求完成:限时等待,timeout-per-shutdown-phase 到期或请求清空为止
  4. 触发容器 close 与 Bean 销毁回调:@PreDestroy、DisposableBean#destroy、@Bean(destroyMethod) 依次执行
  5. 关闭线程池与资源:线程池、连接池、消息客户端做收尾
  6. JVM 退出
63 / 153
警告

优雅停机只对 SIGTERM 生效,kill -9 会跳过以上全部。所以线上运维的第一条纪律是:永远先 kill <pid>,观察日志确认「已完成优雅停机」,再考虑是否需要 -9。给停机脚本预留足够超时时间(如 systemd 的 TimeoutStopSec),比事后追查数据不一致便宜得多。

64 / 153
小节
七、生产运行脚本:从 shell 到 systemd
65 / 153
小节
7.1 启动 / 停止 / 重启脚本
66 / 153

直接 nohup java -jar ... & 有两个问题:拿不到 PID 不好停、不知道服务到底起没起来。一个实用的脚本应该管好 PID 并等待健康检查:

67 / 153
代码对照
代码bash
#!/usr/bin/env bashset -euo pipefailAPP_NAME="bee-app"JAR="bee-app-1.0.0.jar"PID_FILE="${APP_NAME}.pid"LOG_FILE="logs/startup.log"JVM_OPTS="-Xms512m -Xmx512m -XX:+UseG1GC -XX:MaxRAMPercentage=75.0"start() {  if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then    echo "$APP_NAME already running (pid $(cat "$PID_FILE"))"; exit 0  fi  mkdir -p logs  nohup java $JVM_OPTS -jar "$JAR" --spring.profiles.active=prod \    >> "$LOG_FILE" 2>&1 &  echo $! > "$PID_FILE"  printf 'waiting for health check'  for _ in $(seq 1 30); do    if curl -sf http://localhost:8080/actuator/health | grep -q '"status":"UP"'; then      echo ' UP'; return 0    fi    printf '.'; sleep 1  done  echo ' FAILED'; exit 1}stop() {  [ -f "$PID_FILE" ] || { echo 'not running'; exit 0; }  PID="$(cat "$PID_FILE")"  kill "$PID"                     # 发 SIGTERM,触发优雅停机  for _ in $(seq 1 30); do    kill -0 "$PID" 2>/dev/null || { rm -f "$PID_FILE"; echo 'stopped'; return 0; }    sleep 1  done  echo 'timeout, force kill'; kill -9 "$PID"; rm -f "$PID_FILE"}case "${1:-}" in  start)   start ;;  stop)    stop ;;  restart) stop; start ;;  *) echo "usage: $0 {start|stop|restart}"; exit 1 ;;esac
解读
  • nohup ... & 后台启动,echo $! > PID_FILE 记下子进程号,停止时才有据可依
  • 启动后轮询 /actuator/health,等到 UP 才算成功,避免"启动了但没就绪"的假象
  • stop 先发 SIGTERM,最多等 30 秒,实在不退才 -9——给优雅停机留出生路
68 / 153
小节
7.2 systemd:让系统接管守护
69 / 153

生产机上更规范的做法是交给 systemd,它天然支持优雅停机(默认发 SIGTERM)与崩溃重启:

70 / 153
代码对照
代码ini
[Unit]Description=Bee Spring Boot ApplicationAfter=network.target mysql.serviceWants=mysql.service[Service]Type=simpleUser=appWorkingDirectory=/opt/beeEnvironment="SPRING_PROFILES_ACTIVE=prod"EnvironmentFile=/opt/bee/app.env          # DB_PASSWORD 等敏感项写这里,不入库ExecStart=/usr/bin/java -Xms512m -Xmx512m -XX:+UseG1GC \  -XX:MaxRAMPercentage=75.0 -jar /opt/bee/bee-app.jarSuccessExitStatus=143                      # JVM 优雅停机以 143 退出,视为正常TimeoutStopSec=30                          # 给优雅停机足额时间,超时才 SIGKILLRestart=on-failureRestartSec=5[Install]WantedBy=multi-user.target
解读
  • SuccessExitStatus=143:收到 SIGTERM 后 JVM 正常退出码是 143(128+15),不声明的话 systemd 会误判为失败
  • TimeoutStopSec=30:与应用的 timeout-per-shutdown-phase 呼应,systemd 只在超时后才发 SIGKILL
  • Restart=on-failure:进程异常退出时自动拉起,简单场景能顶替一部分进程守护工具
  • EnvironmentFile:把密码等敏感项从 unit 文件里剥离,避免业务配置泄露
71 / 153
小节
八、健康检查与滚动发布
72 / 153

部署到 K8s 或多实例环境时,/actuator/health 是编排系统判断"这个实例能不能接流量"的依据。它区分两个探针:readiness(就绪探针)决定是否加入负载均衡,liveness(存活探针)决定是否重启实例。优雅停机配合就绪探针,才能做到「先摘流量、再停进程」,用户零感知。

73 / 153
yaml
management:  endpoint:    health:      probes:        enabled: true  endpoints:    web:      exposure:        include: health,info
74 / 153

打开 probes 后,就有了 /actuator/health/readiness 与 /actuator/health/liveness 两个专用端点——下一篇《Docker 容器化部署》会把它们接进编排文件。

75 / 153
小节
九、三个高频坑
76 / 153
小节
9.1 读 jar 内资源别用 File
77 / 153

打包后,application.yml、模板、证书都在 jar 内部,它们不是文件系统里的路径:

78 / 153
代码对照
代码java
// 反例:jar 内资源没有真实文件路径,打包后会抛 FileNotFoundExceptionFile file = new File("classpath:config/rules.json");// 正例:始终用 ClassPathResource 或 classpath: 前缀Resource resource = new ClassPathResource("config/rules.json");InputStream in = resource.getInputStream();
解读

坑:new File("src/main/resources/xxx") 在 IDE 里能跑,一打成 jar 就报 FileNotFoundException。因为 src/main/resources 在打包后进入了 BOOT-INF/classes,根本不存在那个目录。凡是读取打包进 jar 的资源,一律走 ClassPathResource 或 getResourceAsStream。

79 / 153
小节
9.2 改 jar 内配置要重新打包
80 / 153

想改个数据库地址,很多人会 unzip 出 jar、改配置、再压回去——极容易破坏 zip 结构导致 jar 损坏。正确做法是用外部配置覆盖:Spring Boot 会优先读取 jar 同级目录或 config/ 子目录下的 application.yml。

81 / 153
代码对照
代码text
/opt/bee/├── bee-app.jar├── application.yml          # 同目录,优先级高于 jar 内└── config/    └── application-prod.yml # config/ 子目录,优先级更高
解读

提示:外部配置的优先级顺序是:命令行参数 > jar 同级 config/ 目录 > jar 同级目录 > jar 内 classpath:/config/ > jar 内 classpath:/。运维改配置只碰 config/ 目录,不打 jar,这才是可持续的方式。

82 / 153

这条「从改文件到不用重新打包」的链路值得单独走一遍,因为线上改一个数据库密码要发一次版,几乎每支团队都为此交过学费:

83 / 153
原理动画
动图 · 同一个 jar,五层配置怎么被盖住
动图 · 同一个 jar,五层配置怎么被盖住
84 / 153
小节
9.3 启动慢别只怪代码
85 / 153

Started BeeApplication in 8.3 seconds 这类日志如果越来越慢,多半是启动时做了重活:扫描大量 Bean、初始化连接池、预热缓存、拉取远程配置。用 --debug 或引入 spring-boot-starter-actuator 看启动指标,定位到底是哪段慢,而不是盲目优化。

86 / 153
小节
十、交互演示:启动到就绪,容器内部在忙什么
87 / 153

一个 Spring Boot 应用从 main 被调用,到真正"可服务",中间发生了大量 Bean 的创建与装配。这个演示把容器的装配过程跑一遍——理解 Bean 的创建顺序,也就理解了启动耗时的来源。

88 / 153
内核实验
89 / 153
  • 切换条件开关(@ConditionalOn*),观察同一份代码在不同条件下装配出的 Bean 不同
  • Bean 越多、依赖链越深,启动越慢——这就是"大应用启动要几十秒"的根因
  • 想缩短启动:减少不必要的自动配置、用 lazy-initialization、把重活挪到启动后异步做
90 / 153
小节
十一、生产思辨:java -jar 还是容器内运行
91 / 153
决策
决策你要把 Spring Boot 应用部署到生产,团队既有裸机也有 K8s 集群。打包产物和运行方式该怎么定?
92 / 153
小节
十二、把 jar 拆开看:四个参数对应四段路
93 / 153

第〇节那张地图的「结构」和「启动」两条支线,可以合成一个实验走完。先看清盒子内部怎么分层:

94 / 153
内核实验
TeaVM自热饭盒里到底分了几个隔层未启动
选「jar 内部结构」,逐格对上第二节 unzip -l 的输出:MANIFEST / BOOT-INF/classes / BOOT-INF/lib / loader
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
95 / 153

然后是那句「为什么 -cp 不行而 -jar 行」——答案全在加载器这一格:

96 / 153
内核实验
TeaVM嵌套 jar 是怎么被 JVM 看见的未启动
选「LaunchedURLClassLoader」,看它如何把 BOOT-INF 挂上搜索路径;这正是第三节那条 URL 协议链路的动画版
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
97 / 153

启动序列单独再看一遍,重点盯住「谁先被标准加载器加载」:

98 / 153
内核实验
TeaVM从 java -jar 到你的 main未启动
选「启动序列」,对照第三节的五步链路,注意 JarLauncher 必须先于业务类被加载,所以它只能待在外层根目录
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
99 / 153

最后一个参数回答第一节那个「war 还有没有用」:

100 / 153
内核实验
TeaVM换成 war 会少了什么未启动
选「war 部署」,比较加载器由谁提供、依赖放在哪,理解为什么外置容器时代要额外装 Tomcat
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
101 / 153
类比

java -cp app.jar com.example.BeeApplication 之所以失败,就像拿着自热饭盒却只给它插了个充电口——你没拉那个发热包(JarLauncher),盒里的水不会自己开。-jar 才是那根拉环:JVM 读 MANIFEST、找到 JarLauncher、由它去铺 BOOT-INF 这条路。IDE 里能跑也是同理——IDE 根本没用 fat jar,它直接拿 target/classes 加上一堆平铺的依赖 jar 拼了个 classpath,等于把食材倒进自家锅里煮。

102 / 153
小节
十三、上了容器之后,谁替你决定内存与停机
103 / 153

同一份 jar 进了 Docker,事情会变:JVM 看到的不再是宿主机配置,而是 cgroup 的限额。这个实验专门讲这两件事。

104 / 153
内核实验
TeaVM镜像分层:为什么改一行代码要重传 200MB未启动
先选「分层与缓存」看指令顺序如何决定缓存命中,再选「构建顺序」把依赖层与代码层调换一次,体会差异
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
105 / 153
内核实验
TeaVM容器里的 JVM 该怎么认这份内存未启动
选「容器内存感知」对比写死 -Xmx 与 MaxRAMPercentage 的结果;再用「停机信号」看 docker stop 默认给的宽限期发生了什么
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
106 / 153

配置与端点这两个外围机制也各有实验。第一个解释「为什么外部 application.yml 有时生效有时不生效」:

107 / 153
内核实验
TeaVM配置文件到底按什么顺序被读未启动
选「谁覆盖谁」把 jar 内、jar 同级、config/ 子目录三种来源排一遍序;再用「${} 占位符」看变量替换发生在哪一步
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
108 / 153

第二个解释第八节那两个探针从哪来、暴露多了有什么后果:

109 / 153
内核实验
TeaVMhealth / readiness / liveness 该露给谁未启动
选「健康指示器聚合」看 UP 是怎么算出来的;再用「配合上线生命周期」对齐「摘流量 → 等存量 → 停进程」的顺序;最后看「全暴露的风险」为什么 env 端点不能公网可达
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
110 / 153

第八节那两个探针和第六节的停机是同一件事的两端:探针决定「什么时候不再给你新请求」,信号决定「你已经接下的请求怎么收尾」。按 ready → drain → kill → gray 点一遍,第六节那条时间预算链就有了落点:

111 / 153
内核实验
TeaVM停机这条路:SIGTERM、排空、然后才是强杀未启动
重点看 drain 这一档:负载均衡先摘流量、应用再等存量,两步顺序颠倒就会丢请求;gray 那一档解释为什么滚动发布时新旧实例要同时活着
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
112 / 153

实验按完了,换成命令行自己敲。这台控制台连着浏览器里的同一个内核,回显全部由内核算出来——先 whoami 看当前跑的是哪套加载器,再 curl /actuator/health 探一下,然后逐条 lab:

113 / 153
内核控制台
114 / 153
说明

boot 之后紧跟着敲 lab fatjar loader,两个视图对起来看——前者是容器在装 Bean,后者是加载器在铺路。第八节那个 readiness 探针要等第二条线走完才会 UP,这也是第七节脚本里为什么要轮询健康端点而不是 sleep 10。

115 / 153
小节
十四、沙盘:容器限额 512MiB,堆该怎么给
116 / 153

新手最容易写的一句是 -Xmx512m,然后被 OOMKilled 反复打脸。这个沙盘固定容器限额为 512MiB,只切换启动参数,同屏看堆大小、进程 RSS 与结局:

117 / 153
沙盘
沙盘容器内存:512MiB 限额下怎么写启动参数
运行结果
Container memory limit: 512MiB
# JVM 读取 cgroup 限额而不是物理内存
Heap max = 512 × 75% ≈ 384MB
Metaspace + 线程栈 + Direct + 自身开销 ≈ 110MB
进程 RSS ≈ 494MB < 限额 512MiB
结果:Running,且有余量应对突发
推荐姿势:把百分比留给非堆开销。若线程很多,再降到 60~70。
118 / 153
说明

数字是示意,但那条 RSS 曲线是真的——Java 进程占用 ≠ 堆大小。判断口诀是「先看 limit,再按比例给堆,剩下的留给 Metaspace、线程栈与直接内存」。另外记得配上 -XX:+UseContainerSupport(JDK 8u191+ / JDK 11+ 默认开启)。

119 / 153
小节
十五、随堂自测
120 / 153

先来一道热身题,直接对应第十二节那个拉环类比:

121 / 153
随堂自测
随堂自测同事在服务器上执行 `java -cp bee-app-1.0.0.jar com.example.BeeApplication`,报 ClassNotFoundException。他坚持认为「jar 里明明有这个 class,unzip -l 能看到」。最准确的解释是?
先自己选一个,选中立刻告诉你对不对
122 / 153

再来一道综合题,把第六节的优雅停机与第七节的 systemd 串起来:

123 / 153
随堂自测
随堂自测线上用 systemd 托管,应用配了 server.shutdown: graceful 和 timeout-per-shutdown-phase: 30s。执行 systemctl stop 后进程仍然丢了一批正在处理的请求。下列哪项最可能是原因?
先自己选一个,选中立刻告诉你对不对
124 / 153
小节
十六、常见报错速查
125 / 153
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
no main manifest attribute, in bee-app-1.0.0.jar这个 jar 是普通 jar:Maven 只跑了 package,没跑 spring-boot-maven-plugin 的 repackage,MANIFEST 里没有 Main-Class检查 pom 是否引入 spring-boot-maven-plugin;unzip -p app.jar META-INF/MANIFEST.MF 看有没有 Main-Class: ...JarLauncher本篇第二、三节
IDE 里能跑,java -jar 起不来,报 ClassNotFoundException / NoClassDefFoundError用了 -cp 直启主类(BOOT-INF 不可见),或者你跑的是 original-*.jar 那个普通包只用 java -jar xxx.jar;target/ 下带 original- 前缀的那个不是可执行包本篇第三、十二节
UnsupportedClassVersionError: com/example/BeeApplication has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version only recognizes up to 52.0编译用的 JDK 比运行用的新(61.0=JDK17,52.0=JDK8)java -version 与 mvn -v 都看一遍;服务器换 JDK,或 pom 里降 maven.compiler.release环境篇(多版本共存)
Error: Could not find or load main class com.example.BeeApplication类名拼错、包名不符,或 jar 不是可执行包(同第一行);也可能是工作目录下有同名残缺 jar`unzip -l app.jar \grep BeeApplication` 确认真实全限定名;确认用的是 repackage 后的 jar本篇第二节
改了 jar 同级的 application.yml 却不生效放错层级:只有 jar 同级目录或其 config/ 子目录会被自动读;src/main/resources 那份已被打进 jar,改源文件不改产物把文件放到 /opt/bee/config/application.yml;用 --spring.config.location= 显式指定并用启动日志确认加载了哪些位置本篇第九.2 节
启动时 Port 8080 was already in use 后进程退出,systemd 反复拉起端口被旧实例占用(PID 文件丢了导致没停干净),或多实例部署忘配端口`ss -lntp \grep 8080 找到旧进程;脚本严格按 PID 停;多实例用不同 server.port` 或交给容器网络本篇第七节
Pod 状态 OOMKilled,但日志里没有 OutOfMemoryError进程总占用(堆+元空间+线程栈+直接内存)超过 cgroup 限额,是内核先动的手用 -XX:MaxRAMPercentage=75.0 替代写死的 -Xmx,并按第十四节沙盘核算 RSS本篇第五.1 节
停机后仍有用户报「请求提交成功但没落库」kill -9 跳过了全部收尾;或异步线程池没配 waitForTasksToCompleteOnShutdown永远先 kill <pid>;线程池显式设置优雅关闭与 awaitTerminationSeconds本篇第六节
126 / 153
提示

这张表的第一行和第四行其实是同一个病:你手上的 jar 不是可执行 jar。分不清就看 MANIFEST——有没有 Main-Class: org.springframework.boot.loader.launch.JarLauncher,一眼定生死。

127 / 153

第三行那句「IDE 能跑、java -jar 起不来」还有一个更阴的变体:应用能起来,是某个 Bean 在初始化时读了一份打不进文件系统的资源。下面这段就是那条现场,先别看解析——点出你认为的凶手行:

128 / 153
报错急救
报错急救IllegalStateException: class path resource [certs/apiclient_cert.pem] cannot be resolved to absolute file path
IDE 里好好的,打成 jar 就读不到证书文件

微信支付要求把商户证书当 File 传给 SDK。开发机上一切正常,交付到服务器上启动即失败;运维照着报错去 /opt/bee 下建了一个 certs 目录,还是不行。

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'wxPayInitializer': Invocation of init method failed
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1853)
at com.example.pay.WxPayInitializer.<init>(WxPayInitializer.java:31)
Caused by: java.lang.IllegalStateException: class path resource [certs/apiclient_cert.pem] cannot be resolved to absolute file path because it does not reside in the file system: jar:nested:/opt/bee/bee-app.jar/!BOOT-INF/classes/!/certs/apiclient_cert.pem
at org.springframework.util.ClassLoaderUtils.getFile(ClassLoaderUtils.java:107)
at org.springframework.core.io.AbstractResource.getFile(AbstractResource.java:129)
at org.springframework.core.io.ClassPathResource.getFile(ClassPathResource.java:189)
at com.example.pay.WxPayInitializer.loadCertificate(WxPayInitializer.java:47)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
129 / 153
小节
十七、动手练习
130 / 153
小节
第一档 · 照做
131 / 153

目标:亲手产出一个可执行 jar,验证它的内部结构,并用两种方式把它跑起来与停下来。

132 / 153

第一步,pom.xml 只需要 parent 与 web starter,插件由 parent 接管:

133 / 153
xml
<parent>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-parent</artifactId>    <version>3.2.0</version>    <relativePath/></parent><dependencies>    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-web</artifactId>    </dependency>    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-actuator</artifactId>    </dependency></dependencies><build>    <plugins>        <plugin>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-maven-plugin</artifactId>        </plugin>    </plugins></build>
134 / 153

第二步,src/main/resources/application.yml 打开优雅停机与两个探针:

135 / 153
yaml
server:  shutdown: gracefulspring:  application:    name: bee-app  lifecycle:    timeout-per-shutdown-phase: 20smanagement:  endpoint:    health:      probes:        enabled: true  endpoints:    web:      exposure:        include: health,info
136 / 153

第三步,命令与预期输出:

137 / 153
bash
$ mvn clean package -DskipTests[INFO] Building jar: .../target/bee-app-1.0.0.jar[INFO] --- spring-boot-maven-plugin:3.2.0:repackage (repackage) @ bee-app ---[INFO] Replacing main artifact with repackaged archive[INFO] BUILD SUCCESS$ ls target/*.jartarget/bee-app-1.0.0.jar            # 可执行的那个(几十 MB)target/bee-app-1.0.0.jar.original   # repackage 前的普通 jar(几百 KB)$ unzip -p target/bee-app-1.0.0.jar META-INF/MANIFEST.MFManifest-Version: 1.0Main-Class: org.springframework.boot.loader.launch.JarLauncherStart-Class: com.example.BeeApplicationSpring-Boot-Classes: BOOT-INF/classes/Spring-Boot-Lib: BOOT-INF/lib/$ java -jar target/bee-app-1.0.0.jar  .   ____          _            __ _ _ /\\ / ___' | |_) | |_) | |_' | (_)|_|   :: Spring Boot ::                (v3.2.0)... Started BeeApplication in 3.18 seconds (process running for 3.512)... Tomcat started on port 8080 (http) with context path ''$ curl -s localhost:8080/actuator/health/readiness{"status":"UP"}
138 / 153

第四步,停它,并且只看停机日志:

139 / 153
bash
$ kill $(jps -l | grep bee-app | awk '{print $1}')2026-10-07T10:20:31.402  INFO ... : Commencing graceful shutdown. Waiting for active requests to complete2026-10-07T10:20:31.418  INFO ... : Graceful shutdown complete
140 / 153

看到 Commencing graceful shutdown 与 Graceful shutdown complete 两行,就算过关。如果只写了 mvn package 而没有 repackage 那一行输出,你会得到 no main manifest attribute——那就是第十六节第一行的现场。

141 / 153
小节
第二档 · 变体
142 / 153
  1. 故意用 java -cp target/bee-app-1.0.0.jar com.example.BeeApplication。你会观察到:ClassNotFoundException —— 第十二节那个拉环类比的实证。
  2. 把 timeout-per-shutdown-phase 改成 2s,另开一个终端持续打请求,再 kill 进程。你会观察到:日志出现 Shutdown triggered but active requests did not finish within timeout,部分请求返回 5xx。这就是第七节脚本里「最多等 30 秒」的意义。
  3. 把 target/bee-app-1.0.0.jar.original 改名成 .jar 后 java -jar。你会观察到:no main manifest attribute——它是 repackage 之前的普通包,不含加载器。顺手记住:.original 永远不要交付。
143 / 153
小节
第三档 · 造一个
144 / 153

做一个「一条命令交付」的最小部署包,让别人拿到它能独立上线。验收清单:

145 / 153
  • [ ] mvn clean package -Pprod -DskipTests 一次产出可执行 jar,且 target/ 下能指出哪个是 .original
  • [ ] 交付目录形如 /opt/bee/{bee-app.jar, config/application-prod.yml, bin/app.sh},改配置不需要重新打包
  • [ ] app.sh start|stop|restart|status 四个子命令都能用:start 轮询 /actuator/health/readiness 才算成功,stop 先发 SIGTERM 并等待,超时才 -9
  • [ ] 同时提供一份 systemd unit,其中 SuccessExitStatus=143、TimeoutStopSec 不小于应用的阶段超时
  • [ ] 内存参数写成 -XX:MaxRAMPercentage=75.0,并在 README 里说明为什么不写死 -Xmx
  • [ ] 演示一次「零感知重启」:压测中重启进程,请求成功率不掉
  • [ ] README 里贴出三条你最常踩的报错原文与自查命令(对照第十六节)
146 / 153
小节
十八、要点自查
147 / 153
自检

我能画出 fat jar 的四块内部结构,并说清每块由谁加载吗?

148 / 153
自检

我能解释为什么 Main-Class 必须是 JarLauncher,以及为什么它只能待在外层根目录?

149 / 153
自检

我知道 -DskipTests 与 -Dmaven.test.skip=true 的区别是「测不测」而非「编不编」,并能说出各自风险?

150 / 153
自检

我能背出优雅停机那条时间预算链(LB 宽限 ≥ systemd TimeoutStopSec ≥ 应用阶段超时 ≥ 最长请求)?

151 / 153
自检

容器里遇到 OOMKilled,我会先算 RSS 而不是先加内存吗?

152 / 153
口诀

打包管「什么进盒子」,启动参数管「盒子怎么烧」,信号管「什么时候熄火」。

153 / 153
总结

这一节要带走三句话——fat jar 的 Main-Class 是 JarLauncher 而不是你的启动类,LaunchedURLClassLoader 解决了 jar in jar 的加载难题;打包参数决定"什么进了 jar",运行参数决定"启动时读什么",两者别混;优雅停机是生产纪律:先 kill <pid> 发 SIGTERM,等它走完"停新请求 → 等存量请求 → 销毁 Bean → 关资源",实在不退再 -9。把这三点做对,交付就稳了一大半。