打包与运行:可执行 Jar 的秘密与优雅停机
以前部署一个 Java Web 应用,你要做四件事:装 JDK、装 Tomcat、把 war 丢进 webapps、再祈祷服务器上的版本和你本地一致。Spring Boot 把这四件事压成一句:java -jar app.jar。这一篇讲的就是这句话背后三块内容——这个 jar 里到底装了什么、它是怎么把自己启动起来的、以及怎么让它体面地停下来。
先把六个词一句话解释(全文都会用到):
- 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 在不同环境读不同配置文件的机制,运维改配置不该重新打包
fat jar 就是自热饭盒。米、菜、水、发热包全在一个盒子里,你不需要厨房(Tomcat)、不需要煤气(外部依赖),撕开拉环它就自己加热——这就是"自带运行时"。代价也真实:盒子很重(几十 MB),而且发热包一旦拉环就没法中途停手,所以「什么时候撕」(停机时机)必须自己安排好。至于换口味,你只要换盒子,不用换整套厨具——这正是交付物只有一个 jar 的好处。

学完这一篇,你应该能回答三个问题:
- 为什么
java -cp app.jar com.example.BeeApplication一定失败,而java -jar app.jar可以? -Xmx在容器里为什么要改成-XX:MaxRAMPercentage?- 生产上想重启一次服务,怎样做到用户零感知?(提示:两行配置 + 一个信号)
第一次用 Spring Boot 的人常有一个疑问:凭什么一个 java -jar app.jar 就能把整个 Web 服务跑起来? 以前用 Spring MVC,你得先打一个 war,再部署到外部 Tomcat 的 webapps 目录下,还得保证服务器上装了正确版本的 Tomcat。Spring Boot 把这一切塞进了一个 jar 里。
| 维度 | 可执行 jar(fat jar) | war + 外置容器 |
|---|---|---|
| 载体 | 内嵌 Tomcat/Jetty/Undertow | 外部 Tomcat/WebLogic |
| 启动方式 | java -jar app.jar | 丢进 webapps 由容器启动 |
| 依赖管理 | 依赖全打进 jar,自包含 | 容器提供 Servlet 容器与部分类库 |
| 多实例 | 拷 jar 就能跑 | 需保证每台机器容器版本一致 |
| 云原生适配 | 天然适合容器与 K8s | 与镜像理念冲突 |
| 现状 | 推荐 | 遗留系统、必须交付 war 的场景 |
war 并非没用,它服务于「公司统一维护一批 Tomcat、应用必须灌进去」的历史场景。但对新项目,fat jar 的优势是压倒性的:应用与运行时成为一个不可分割的交付物,拷过去就能跑,天然契合一容器一进程的部署模型。
Spring Boot 也支持打 war,只需把 packaging 改成 war 并让启动类继承 SpringBootServletInitializer。但除非有明确的「必须部署到外部容器」需求,否则没有理由放弃 jar。
最直接的认识方式,就是把 jar 解开看。执行 unzip -l(或 jar tf)列出目录:
$ 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三个关键区域:
BOOT-INF/classes/:你自己的字节码与配置文件(application.yml、static/)都在这里BOOT-INF/lib/:所有第三方依赖,以嵌套 jar 的形式原样存放——不是解压后的 classorg/springframework/boot/loader/:Spring Boot 自带的加载器与启动器代码
再看主清单文件:
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/
正因为 BOOT-INF/classes 不在 jar 根目录,java -cp app.jar com.example.BeeApplication 一定失败——JVM 的标准类加载器只认识根目录下的 com/example/...,找不到 BOOT-INF/classes 里的东西。这不是配置错误,是 fat jar 的固有结构,必须靠 -jar 触发它自带的加载器。
看到 Main-Class: JarLauncher 就明白了:java -jar 启动的其实是 Spring Boot 的 JarLauncher,不是你写的 BeeApplication。完整链路是这样的:
- 用户执行
java -jar app.jar - JVM 读 MANIFEST,看到
Main-Class: JarLauncher,反射调用它的main JarLauncher读同一个 MANIFEST 里的Start-Class,得知真正的启动类是BeeApplication- 它创建一个特殊的
LaunchedURLClassLoader,**把BOOT-INF/lib/.jar和BOOT-INF/classes/都挂到这个加载器的搜索路径上* - 用这个类加载器加载并反射调用
BeeApplication.main
把这条链再往上游接一格,就是完整的一生——很多人只盯着 java -jar,却不知道真正让 jar 「能自运行」的是打包时那一步 repackage:

这里要解决的核心问题是 "jar in jar":Java 诞生时根本没考虑过「一个 jar 里再放 jar」。标准 JVM 只能读扁平结构的 jar,无法直接打开嵌套的依赖。Spring Boot 的解法是自定义 URL 协议与 JarFile 扩展:它把每个嵌套 jar 的入口映射成一个特殊 URL,LaunchedURLClassLoader 按 URL 逐个打开,于是 JVM 的类加载机制就能"看见"这些嵌套依赖了。
LaunchedURLClassLoader 的父加载器是应用类加载器(AppClassLoader)。它额外接管了 BOOT-INF 下的资源查找,这也是为什么加载器代码必须放在外层 jar 根目录——它得先于业务类被标准加载器加载。
「两套加载器各看得见什么」这件事,用图的不如用一次交接。下面这段动画把这次交接演了六帧,注意第 ② 帧——标准加载器此刻还不知道你的类存在:

把上面那五步链路摊成单步执行。左边是这台 JVM 依次做的事,右边同步刷新「此刻的 Main-Class」「谁能看见谁」——重点在第 ③ 拍(路是这时候铺的)和第 ⑤ 拍(方向盘是这时候交出去的):
java -jar target/bee-app-1.0.0.jar// ① JVM 打开 zip,只读 META-INF/MANIFEST.MF 里的 Main-Class// ② Main-Class = org.springframework.boot.loader.launch.JarLauncher// ③ JarLauncher 新建 LaunchedClassLoader,把 BOOT-INF/classes 与 lib 挂上去// ④ 用新加载器按 Start-Class 的名字找到 BeeApplicationSpringApplication.run(BeeApplication.class, args); // 从这里起才是你熟悉的世界| 命令 | java -jar |
| JVM 此刻知道的 | 只有 zip 的中央目录 |
| 搜索路径 | app.jar 自己 |
| 线程 | main |
java.exe → JLI_LaunchLauncherHelper同一个知识点换个角度看,就是「谁看得见谁」这张分层图。从 ① 点到 ⑤,第 ② 格最容易记反:
打包本身不复杂,难在打包时该跳过什么、激活哪个环境。
| 命令 / 参数 | 作用 | 使用建议 |
|---|---|---|
mvn clean package | 清理并打包 | 本地日常 |
-DskipTests | 编译测试类但不执行测试 | CI 中最常用 |
-Dmaven.test.skip=true | 连测试类都不编译 | 测试代码有编译错误时的应急 |
-Pprod | 激活 prod profile | 按环境切换配置 |
-U | 强制更新 SNAPSHOT 依赖 | 依赖更新不及时时 |
-Dspring.profiles.active=prod | 指定运行 profile | 打包后运行时用 |
上面这一串开关都建立在一个前提上:pom 里有 spring-boot-maven-plugin。那一行才是 repackage 的开关,也是「能自运行的 jar」与「几百 KB 的普通 jar」的分水岭。勾一遍,顺便看依赖该带哪几件——注意 test 那件的 scope,它正是 -DskipTests 讨论的那个东西:
<?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>上面那一串开关只是一层「加料」,生命周期才是流水线本身。在动手改 pom 之前,先把一条 mvn package 到底按什么顺序跑了什么看清楚——四个参数依次点一遍:
看完再回看上面那张表:-Pprod 换的是 profile,-DskipTests 动的是 surefire 的开关,而 package 阶段本身,永远会把绑在它上面的每一个插件目标一起带走。
多环境 profile 打包,pom.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># 打生产包:profile 值会被过滤进 application.ymlmvn clean package -Pprod -DskipTests-DskipTests与-Dmaven.test.skip=true的区别:前者仍然编译测试类(能发现测试代码写错),后者直接跳过编译,更快但可能掩盖问题filtering会把application.yml里的@profiles.active@(或${profiles.active})替换成当前 profile 的值- 打包与运行 profile 是两回事:打包决定「哪个配置进了 jar」,运行决定「运行时读哪份配置」,别混淆
java -jar 并不只有 jar 路径一个参数,JVM 参数与应用参数要分开写:
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覆盖配置项,优先级最高
| 参数 | 含义 | 入门建议 |
|---|---|---|
-Xms | 初始堆大小 | 与 -Xmx 设成相同值,避免运行期反复扩堆 |
-Xmx | 最大堆大小 | 容器内按限额的 50%~75% 设,留出元空间与直接内存 |
-XX:+UseG1GC | 使用 G1 回收器 | JDK 9+ 默认,大堆更友好 |
-XX:MaxRAMPercentage | 堆占物理内存比例 | 容器环境首选,如 75.0 |
-Xss | 单线程栈大小 | 一般不动,线程多时才需要调小 |
-Xmx 设成「越大越好」是经典误区。堆越大,Full GC 的单次停顿越长,而且堆内存 + 元空间 + 线程栈 + 直接内存 + JVM 自身开销才是一台机器真正占用的内存。在生产容器里,最稳妥的写法是 -XX:MaxRAMPercentage=75.0 —— 让 JVM 根据 cgroup 限额自动计算堆大小,而不是写死一个忽略容器限制的 -Xmx。
这个百分比是唯一真正该拖的滑块,因为它的两个失败方向都很响:调小了频繁 Full GC,调大了被内核直接杀。按 512MiB 的容器限额拖一遍:
- 384MiB 堆,非堆留 128MiB,够多数中小服务
- 这是把百分比交给 cgroup 的写法,改限额不用改启动参数
- 上线前用 Runtime.getRuntime().maxMemory() 核一次实际值
- GC 日志里出现连续 to-space exhausted,说明这一档对你偏小了——但先去看是不是泄漏
这是本篇最重要的部分。很多线上"数据不一致"的锅,不是业务代码的错,而是停机姿势不对。
想想 kill -9 那一刻发生了什么:JVM 收到 SIGKILL,没有任何回调会被执行。此刻正在处理的一个「扣款 + 加积分」事务可能扣了款还没加积分;一个已经读到内存、还没返回的请求被直接掐断,客户端收到连接重置;连接池里的连接被操作系统粗暴回收,数据库侧还挂着一个"未提交事务"。
Spring Boot 提供了优雅停机,只需两行配置:
server: shutdown: graceful # 默认是 immediate(立即断)spring: lifecycle: timeout-per-shutdown-phase: 30s # 每个停机阶段最多等多久server.shutdown: graceful:把 Web 服务器的停机模式从「立即」改成「优雅」timeout-per-shutdown-phase: 30s:给「等待处理中请求」设一个上限,超时才强制中断;不给上限就可能一直等下去

开启后,正常 kill <pid>(不带 -9)会走下面这条完整路径:
- 收到 SIGTERM 信号:
kill、systemd stop、docker stop、K8s 删除 Pod 都是发这个信号,不是-9 - 停止接收新请求:Web 连接器关闭,不再建立新连接,负载均衡探测到不健康后把流量摘走
- 等待处理中请求完成:限时等待,
timeout-per-shutdown-phase到期或请求清空为止 - 触发容器 close 与 Bean 销毁回调:
@PreDestroy、DisposableBean#destroy、@Bean(destroyMethod)依次执行 - 关闭线程池与资源:线程池、连接池、消息客户端做收尾
- JVM 退出
优雅停机只对 SIGTERM 生效,kill -9 会跳过以上全部。所以线上运维的第一条纪律是:永远先 kill <pid>,观察日志确认「已完成优雅停机」,再考虑是否需要 -9。给停机脚本预留足够超时时间(如 systemd 的 TimeoutStopSec),比事后追查数据不一致便宜得多。
直接 nohup java -jar ... & 有两个问题:拿不到 PID 不好停、不知道服务到底起没起来。一个实用的脚本应该管好 PID 并等待健康检查:
#!/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 ;;esacnohup ... &后台启动,echo $! > PID_FILE记下子进程号,停止时才有据可依- 启动后轮询
/actuator/health,等到UP才算成功,避免"启动了但没就绪"的假象 stop先发SIGTERM,最多等 30 秒,实在不退才-9——给优雅停机留出生路
生产机上更规范的做法是交给 systemd,它天然支持优雅停机(默认发 SIGTERM)与崩溃重启:
[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.targetSuccessExitStatus=143:收到 SIGTERM 后 JVM 正常退出码是 143(128+15),不声明的话 systemd 会误判为失败TimeoutStopSec=30:与应用的timeout-per-shutdown-phase呼应,systemd 只在超时后才发SIGKILLRestart=on-failure:进程异常退出时自动拉起,简单场景能顶替一部分进程守护工具EnvironmentFile:把密码等敏感项从 unit 文件里剥离,避免业务配置泄露
部署到 K8s 或多实例环境时,/actuator/health 是编排系统判断"这个实例能不能接流量"的依据。它区分两个探针:readiness(就绪探针)决定是否加入负载均衡,liveness(存活探针)决定是否重启实例。优雅停机配合就绪探针,才能做到「先摘流量、再停进程」,用户零感知。
management: endpoint: health: probes: enabled: true endpoints: web: exposure: include: health,info打开 probes 后,就有了 /actuator/health/readiness 与 /actuator/health/liveness 两个专用端点——下一篇《Docker 容器化部署》会把它们接进编排文件。
打包后,application.yml、模板、证书都在 jar 内部,它们不是文件系统里的路径:
// 反例: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。
想改个数据库地址,很多人会 unzip 出 jar、改配置、再压回去——极容易破坏 zip 结构导致 jar 损坏。正确做法是用外部配置覆盖:Spring Boot 会优先读取 jar 同级目录或 config/ 子目录下的 application.yml。
/opt/bee/├── bee-app.jar├── application.yml # 同目录,优先级高于 jar 内└── config/ └── application-prod.yml # config/ 子目录,优先级更高提示:外部配置的优先级顺序是:命令行参数 > jar 同级 config/ 目录 > jar 同级目录 > jar 内 classpath:/config/ > jar 内 classpath:/。运维改配置只碰 config/ 目录,不打 jar,这才是可持续的方式。
这条「从改文件到不用重新打包」的链路值得单独走一遍,因为线上改一个数据库密码要发一次版,几乎每支团队都为此交过学费:

Started BeeApplication in 8.3 seconds 这类日志如果越来越慢,多半是启动时做了重活:扫描大量 Bean、初始化连接池、预热缓存、拉取远程配置。用 --debug 或引入 spring-boot-starter-actuator 看启动指标,定位到底是哪段慢,而不是盲目优化。
一个 Spring Boot 应用从 main 被调用,到真正"可服务",中间发生了大量 Bean 的创建与装配。这个演示把容器的装配过程跑一遍——理解 Bean 的创建顺序,也就理解了启动耗时的来源。
- 切换条件开关(
@ConditionalOn*),观察同一份代码在不同条件下装配出的 Bean 不同 - Bean 越多、依赖链越深,启动越慢——这就是"大应用启动要几十秒"的根因
- 想缩短启动:减少不必要的自动配置、用
lazy-initialization、把重活挪到启动后异步做
第〇节那张地图的「结构」和「启动」两条支线,可以合成一个实验走完。先看清盒子内部怎么分层:
然后是那句「为什么 -cp 不行而 -jar 行」——答案全在加载器这一格:
启动序列单独再看一遍,重点盯住「谁先被标准加载器加载」:
最后一个参数回答第一节那个「war 还有没有用」:
java -cp app.jar com.example.BeeApplication 之所以失败,就像拿着自热饭盒却只给它插了个充电口——你没拉那个发热包(JarLauncher),盒里的水不会自己开。-jar 才是那根拉环:JVM 读 MANIFEST、找到 JarLauncher、由它去铺 BOOT-INF 这条路。IDE 里能跑也是同理——IDE 根本没用 fat jar,它直接拿 target/classes 加上一堆平铺的依赖 jar 拼了个 classpath,等于把食材倒进自家锅里煮。
同一份 jar 进了 Docker,事情会变:JVM 看到的不再是宿主机配置,而是 cgroup 的限额。这个实验专门讲这两件事。
配置与端点这两个外围机制也各有实验。第一个解释「为什么外部 application.yml 有时生效有时不生效」:
第二个解释第八节那两个探针从哪来、暴露多了有什么后果:
第八节那两个探针和第六节的停机是同一件事的两端:探针决定「什么时候不再给你新请求」,信号决定「你已经接下的请求怎么收尾」。按 ready → drain → kill → gray 点一遍,第六节那条时间预算链就有了落点:
实验按完了,换成命令行自己敲。这台控制台连着浏览器里的同一个内核,回显全部由内核算出来——先 whoami 看当前跑的是哪套加载器,再 curl /actuator/health 探一下,然后逐条 lab:
boot 之后紧跟着敲 lab fatjar loader,两个视图对起来看——前者是容器在装 Bean,后者是加载器在铺路。第八节那个 readiness 探针要等第二条线走完才会 UP,这也是第七节脚本里为什么要轮询健康端点而不是 sleep 10。
新手最容易写的一句是 -Xmx512m,然后被 OOMKilled 反复打脸。这个沙盘固定容器限额为 512MiB,只切换启动参数,同屏看堆大小、进程 RSS 与结局:
Container memory limit: 512MiB# JVM 读取 cgroup 限额而不是物理内存Heap max = 512 × 75% ≈ 384MBMetaspace + 线程栈 + Direct + 自身开销 ≈ 110MB进程 RSS ≈ 494MB < 限额 512MiB结果:Running,且有余量应对突发
数字是示意,但那条 RSS 曲线是真的——Java 进程占用 ≠ 堆大小。判断口诀是「先看 limit,再按比例给堆,剩下的留给 Metaspace、线程栈与直接内存」。另外记得配上 -XX:+UseContainerSupport(JDK 8u191+ / JDK 11+ 默认开启)。
先来一道热身题,直接对应第十二节那个拉环类比:
再来一道综合题,把第六节的优雅停机与第七节的 systemd 串起来:
| 报错原文(片段) | 真实原因 | 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 | 本篇第六节 |
这张表的第一行和第四行其实是同一个病:你手上的 jar 不是可执行 jar。分不清就看 MANIFEST——有没有 Main-Class: org.springframework.boot.loader.launch.JarLauncher,一眼定生死。
第三行那句「IDE 能跑、java -jar 起不来」还有一个更阴的变体:应用能起来,是某个 Bean 在初始化时读了一份打不进文件系统的资源。下面这段就是那条现场,先别看解析——点出你认为的凶手行:
微信支付要求把商户证书当 File 传给 SDK。开发机上一切正常,交付到服务器上启动即失败;运维照着报错去 /opt/bee 下建了一个 certs 目录,还是不行。
目标:亲手产出一个可执行 jar,验证它的内部结构,并用两种方式把它跑起来与停下来。
第一步,pom.xml 只需要 parent 与 web starter,插件由 parent 接管:
<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>第二步,src/main/resources/application.yml 打开优雅停机与两个探针:
server: shutdown: gracefulspring: application: name: bee-app lifecycle: timeout-per-shutdown-phase: 20smanagement: endpoint: health: probes: enabled: true endpoints: web: exposure: include: health,info第三步,命令与预期输出:
$ 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"}第四步,停它,并且只看停机日志:
$ 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看到 Commencing graceful shutdown 与 Graceful shutdown complete 两行,就算过关。如果只写了 mvn package 而没有 repackage 那一行输出,你会得到 no main manifest attribute——那就是第十六节第一行的现场。
- 故意用
java -cp target/bee-app-1.0.0.jar com.example.BeeApplication。你会观察到:ClassNotFoundException—— 第十二节那个拉环类比的实证。 - 把
timeout-per-shutdown-phase改成2s,另开一个终端持续打请求,再kill进程。你会观察到:日志出现Shutdown triggered but active requests did not finish within timeout,部分请求返回 5xx。这就是第七节脚本里「最多等 30 秒」的意义。 - 把
target/bee-app-1.0.0.jar.original改名成.jar后java -jar。你会观察到:no main manifest attribute——它是 repackage 之前的普通包,不含加载器。顺手记住:.original永远不要交付。
做一个「一条命令交付」的最小部署包,让别人拿到它能独立上线。验收清单:
- [ ]
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 里贴出三条你最常踩的报错原文与自查命令(对照第十六节)
我能画出 fat jar 的四块内部结构,并说清每块由谁加载吗?
我能解释为什么 Main-Class 必须是 JarLauncher,以及为什么它只能待在外层根目录?
我知道 -DskipTests 与 -Dmaven.test.skip=true 的区别是「测不测」而非「编不编」,并能说出各自风险?
我能背出优雅停机那条时间预算链(LB 宽限 ≥ systemd TimeoutStopSec ≥ 应用阶段超时 ≥ 最长请求)?
容器里遇到 OOMKilled,我会先算 RSS 而不是先加内存吗?
打包管「什么进盒子」,启动参数管「盒子怎么烧」,信号管「什么时候熄火」。
这一节要带走三句话——fat jar 的 Main-Class 是 JarLauncher 而不是你的启动类,LaunchedURLClassLoader 解决了 jar in jar 的加载难题;打包参数决定"什么进了 jar",运行参数决定"启动时读什么",两者别混;优雅停机是生产纪律:先 kill <pid> 发 SIGTERM,等它走完"停新请求 → 等存量请求 → 销毁 Bean → 关资源",实在不退再 -9。把这三点做对,交付就稳了一大半。