java -jar app.jar这行命令我一天要敲很多次回车之后服务起来接口通了整套系统活了。但如果你真的顺着这行命令往深处看一眼会发现很多有意思的东西为什么 Spring Boot 打完包之后的 jar 文件动不动就四五十兆为什么 IDE 里跑得好好的复制到服务器上直接java -jar却报no main manifest attribute为什么打成普通 jar 就启动不了这些都指向同一件事——Spring Boot 命令行运行背后那套打包机制。这篇内容我就围绕java -jar和 Spring Boot 的打包机制展开把这个过程从入口到原理到实战拆成明明白白几条线。适合刚接触 Spring Boot 打包部署、被各种启动错误磨得头疼的新人也适合做过一段时间部署、但没认真研究过 fat jar 内部结构的中级开发者。我第一次在 Linux 服务器上敲完java -jar看到那串刺眼的红色异常时整整排查了一下午后来才意识到问题根本不在命令而在于我打出来的 jar 压根不是 Spring Boot 可执行格式。搞清楚这套机制之后你后续处理配置覆盖、多环境切换、jar 包瘦身、Docker 分层构建都会顺手很多。1. java -jar 一行命令背后发生了什么1.1 普通 jar 为什么跑不起来先回到 JDK 本身。java 命令执行-jar参数时并不会像 IDE 那样给你算好 classpath它会直接去 jar 包里的META-INF/MANIFEST.MF文件找Main-Class这个属性然后把这个类加载到 JVM 里作为入口。对一个普通 jar 来说Main-Class写的是你自己的启动类比如com.example.demo.DemoApplication这一步本身没问题。问题出在依赖上。一个 Spring Boot 项目maven 里引入的依赖少说十几个多则几十个。你打包成普通 jar 的时候这些依赖不会自动塞进 jar 里MANIFEST.MF里的Class-Path只能写相对路径指望部署环境里正好有这些 jar而且目录结构还完全一致。这在现实中根本不现实。所以你直接拿普通 jar 去java -jar最常见的报错就两个一个是no main manifest attribute说明连入口都没声明对另一个是跑到某个类时抛ClassNotFoundException说明依赖缺失或 Class-Path 指向错误。1.2 Spring Boot 可执行 jar 的特殊结构Spring Boot 的做法是让插件在打包阶段做一次 re-package把原本那个瘦的普通 jar 重新打包成所谓的 fat jar也叫可执行 jar。你可以把它理解成一个自带依赖仓库的单体文件。我随便打开一个典型的 Spring Boot 可执行 jar结构大致长这样app.jar ├── META-INF │ └── MANIFEST.MF ├── BOOT-INF │ ├── classes │ │ └── com/example/demo/DemoApplication.class │ └── lib │ ├── spring-boot-3.2.5.jar │ ├── spring-boot-starter-web-3.2.5.jar │ └── ... └── org └── springframework └── boot └── loader这里的关键不是 BOOT-INF 里塞了多少 jar而是MANIFEST.MF里的入口变了。正常情况下读者可能以为Main-Class会指向你的DemoApplication但实际构建出来的文件里Main-Class指向的是 Spring Boot 的 JarLauncher你自己的启动类被记录在另一个属性Start-Class里。Manifest-Version: 1.0 Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: com.example.demo.DemoApplication Spring-Boot-Version: 3.2.5 Spring-Boot-Classes: BOOT-INF/classes/ Spring-Boot-Lib: BOOT-INF/lib/为什么绕这么一圈因为 JDK 自带的类加载器读不了 BOOT-INF/lib 下那些嵌套的 jar你就算设置了 Class-Path 也没辙。Spring Boot 于是先让 JarLauncher 启动由它构造一个专用的类加载器把 BOOT-INF/lib 里所有依赖识别出来再回过头加载真正的 Start-Class。这一层桥是整个打包机制里最核心的设计。提示判断一个 jar 是不是 Spring Boot 可执行格式不要只看大小直接在命令行执行jar tf app.jar看有没有org/springframework/boot/loader和BOOT-INF/classes。这两个目录同时存在基本就能确定是可执行 fat jar。1.3 命令行运行和 IDE 运行的差别在 IntelliJ IDEA 里点那个绿色运行按钮时IDEA 会根据模块的 classpath从你本地 Maven 仓库把所有依赖找齐再直接调DemoApplication.main。社区版也一样只要你是普通 Maven 项目加了 Spring Boot 依赖运行逻辑没有任何差别。这个方式的优点是快、方便断点调试但它不是真正在生产环境运行的形态。生产环境用的命令是java -jar target/app.jar它启动的是打包产物依赖全部来自 BOOT-INF/lib两者依赖来源完全不同。所以经常会出现本地运行正常、一部署就缺这个缺那个的情况。碰到这种情况我的排查顺序永远是先验证 jar 包本身而不是先怀疑代码。对比项IDE 运行java -jar 运行依赖来源本地 Maven 仓库拼出的 classpathBOOT-INF/lib 嵌套 jar配置文件来源开发环境目录jar 内 外部覆盖启动速度快相对慢加载依赖多适合场景开发调试部署、测试、生产2. 打包机制拆解spring-boot-maven-plugin 的 repackage 魔法2.1 package 和 repackage 的区别Maven 本身的生命周期里package这个阶段默认打的只是一个普通 jar把项目自己编译出来的 class 和 resources 打包到一起就完事。Spring Boot 的打包机制是在这个基础之上加了一步spring-boot-maven-plugin 在 package 阶段执行repackage目标把刚生成的普通 jar 拿过来重新整理拷贝业务类到 BOOT-INF/classes把项目所有依赖放到 BOOT-INF/lib再写入新的 manifest 和 loader 相关类最后覆盖掉原来的文件。这一步非常关键。很多人以为在 pom 里配上 spring-boot-maven-plugin 就万事大吉了其实还得保证repackagegoal 在 package 阶段被正确触发。如果使用的父工程是spring-boot-starter-parent插件默认就帮我们绑定好了但如果是独立管理父工程或者插件配置里没有显式声明goalrepackage/goal那打出来的很可能只是一个普通 jar你抱着它去部署就会踩到前面说的no main manifest attribute。2.2 pom 里的关键配置一个最小可用的配置大概是这个模样build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId version3.2.5/version configuration mainClasscom.example.demo.DemoApplication/mainClass finalNameapp/finalName /configuration executions execution goals goalrepackage/goal /goals /execution /executions /plugin /plugins /build如果你用的是spring-boot-starter-parent作为 parentversion可以直接省略repackage 也默认绑定在 package 生命周期里不需要额外写 executions。我自己项目里就踩过这种重复绑定的坑有段时间为了统一构建配置我把所有插件都挪到公司公共 parent 里结果 spring-boot-maven-plugin 的 repackage 和默认行为撞在一起打出来的 jar 时好时坏。后来统一只在一个地方控制插件版本和执行时机问题才消停。mainClass配置项在单模块项目里通常可以自动识别但多模块项目建议还是显式写上。我习惯把finalName也固定下来这样不管是构建脚本还是部署脚本引用文件名都不会因为版本号变化而要改来改去。2.3 三种常见的启动姿势对比打包出来的 fat jar 通常有几种启动姿势但不是每种都好使# 推荐直接跑可执行 jar java -jar app.jar # 不推荐直接指定业务主类 java -cp app.jar com.example.demo.DemoApplication # 开发用Maven 插件直接运行 mvn spring-boot:run第二行这个写法很有意思。-cp确实把 app.jar 放进 classpath 了但你的应用启动后一旦遇到 Spring 框架、Tomcat 容器这些第三方类普通的 AppClassLoader 并不知道 BOOT-INF/lib 下面躺着哪些 jar于是立刻ClassNotFoundException。第三行spring-boot:run是开发期工具它不会走 fat jar 的 loader而是像 IDE 一样把依赖拼起来运行所以很适合本地调。2.4 从 2.x 到 3.x 的 loader 变化我最早用的是 Spring Boot 2.3.x后来接手 2.6.x 的项目再后来升到 3.2.x。这几次升级体验下来java -jar的用法始终没变但 loader 这个模块本身一直在演进。Spring Boot 3 里整个 loader 做了重构包名也有调整3.2 之后你如果再自己写定制 ClassLoader 或者改 manifest需要对照官方文档重新验证因为底层实现跟 2.x 已经不完全一样了。对绝大多数只做常规部署的同学来说记住一句就行java -jar启动兼容性很好升级版本后重新跑一次mvn clean package再确认一下 BOOT-INF 结构和先前保持一致即可。3. 构建到运行从 mvn package 到第一个启动日志3.1 构建命令与产物检查完整的落地流程一般是这样mvn clean package -DskipTests ls -lh target/app.jar java -jar target/app.jar第一条命令里的-DskipTests是为了跳过测试减少打包时间。如果你的单元测试有完整的环境依赖打包时跑测试可能直接卡住很久这种情况在 CI 流水线里尤其烦人。理想做法是让独立的测试任务负责测试package这一步专注出产物。3.2 怎么快速判断一个 jar 是不是 fat jar对着服务器上几十兆的文件想确认它是不是 Spring Boot 可执行格式不需要先启动试错。直接jar tf app.jar | grep BOOT-INF unzip -p app.jar META-INF/MANIFEST.MFWindows 环境没装 unzip 也无所谓JDK 自带jar命令足够用。我看到 BOOT-INF/lib 和 BOOT-INF/classes 都正常出现心里就有底了。如果不放心再看一眼 manifest 里的Main-Class是不是 JarLauncher这基本上就是打包机制正确触发的铁证。3.3 落地脚本Windows 和 Linux 都要会很多项目开发机是 Windows测试环境是 Linux。两边的启动脚本写法不一样但核心命令一致。Linux 下我一般写一个start.sh#!/bin/bash APP_NAMEapp.jar JAVA_OPTS-Xms512m -Xmx1024m -Djava.security.egdfile:/dev/urandom nohup java $JAVA_OPTS -jar $APP_NAME --spring.profiles.activeprod /opt/logs/app.log 21 echo $! app.pid停止脚本stop.sh就简单很多#!/bin/bash kill $(cat app.pid)Windows 下可以写一个简单的start.batecho off start app /b java -Xms512m -Xmx1024m -jar app.jar --spring.profiles.activeprod --server.port8080 app.log 21如果你是在 Windows 本机开发不想每次启动都弹一个黑漆漆的 CMD 窗口还有个老办法再用一个 VBS 文件去包住 batSet ws CreateObject(Wscript.Shell) ws.Run cmd /c start.bat, 0, False把 start.bat 放到和 run.vbs 同目录双击 run.vbs进程自动后台启动而且不会显示命令行窗口。这个技巧网上折腾过的人应该都熟配合app.log查看日志也够用。正式环境如果追求更规范的服务管理可以考虑用 WinSW 注册成 Windows 服务或者直接用 Linux 的 systemd这些以后有机会再单独聊。注意-Xms、-Xmx这类 JVM 参数必须写在-jar之前。而--server.port、--spring.profiles.active这些应用参数必须写在-jar后面。位置写反了轻则参数不生效重则直接启动失败这是我见过最频繁的低级错误之一。4. 命令行运行的高级玩法外部配置、多环境与启动参数4.1 不要让配置死在 jar 里jar 包一旦构建完成里面的 application.yml 就是固定的。如果你每次改配置都要重新打包说明还没用对 Spring Boot 命令行运行的精髓。Spring Boot 对配置文件有明确的优先级从高到低大体是命令行参数、Java 系统属性、OS 环境变量、jar 包外的配置文件、jar 包内的配置文件。这意味着你在启动命令里写一个参数它的优先级比 application.yml 更高可以覆盖里面的值。最常用的几个例子# 直接改端口 java -jar app.jar --server.port8081 # 指定环境 java -jar app.jar --spring.profiles.activeprod # 指定外部配置文件目录 java -jar app.jar --spring.config.locationfile:/opt/conf/管理多个环境时我习惯把共性的东西放到 jar 内配置把环境差异放到部署脚本的命令行参数里。比如数据库地址、Redis 地址这些全部通过--spring.config.additional-locationfile:/opt/app/conf/指向服务器上的外部目录。这样同一个 jar 包在测试机、预发机、生产机上跑起来的行为可以完全不同换环境的时候不用重新构建只需要写好各自的外部配置文件。4.2 -D 参数和 -- 参数到底什么区别这个困惑了很多人。-D是 JVM 系统属性属于 Java 层面的而--后面的参数是 Spring Boot 应用参数。实际效果上大部分 Spring 配置项这两者都能生效因为 Spring Environment 会把系统属性也纳入属性源。但建议优先用--因为语义更清晰不易混淆。java -jar -Dserver.port8082 app.jar --server.port8081如果同时出现这种情况真正的生效值是谁答案还是命令行参数--server.port8081。我测试过多次Spring Boot 内部对命令行属性源的优先级比 Java 系统属性更高。这种细节你不知道排查怪异问题时可能浪费很多时间。4.3 多环境 profile 的实战组合最典型的一套组合就是运行时指定 profile 和环境参数java -jar -Xms512m -Xmx1024m app.jar \ --spring.profiles.activeprod \ --spring.config.additional-location/opt/app/conf/ \ --logging.file/var/log/app.log还有一个容易被忽略的工具环境变量SPRING_APPLICATION_JSON。你可以把 JSON 格式的配置直接塞进去Spring Boot 会解析成配置项。比如SPRING_APPLICATION_JSON{server.port:8090} java -jar app.jar这个方式在容器化部署、Kubernetes 环境里尤其好用很多配置注入工具最终都会落到环境变量。不过日常排查时它也是配置怎么没生效的高发区因为你看不到 jar 外还有环境变量在覆盖。4.4 部署起来之后怎么观测jar 包跑起来之后传统看日志的方式只能确保活着想看 JVM 内存、线程、健康状态可以在工程里加一个 spring-boot-starter-actuator 依赖然后通过management.endpoints.web.exposure.includehealth,info,metrics这类配置暴露端点。如果你有多个服务再配一个 Spring Boot Admin把各个服务的监控页面统一起来内存、线程、请求指标一目了然。这个话题说起来可以单独写一篇这里只想提醒你打包机制保证的是能启动监控体系保证的是持续稳定这两件事在部署体系里缺一不可。5. 常见问题与排查技巧实录5.1 构建产物相关的坑症状原因排查与解法no main manifest attribute打出来的是普通 jarrepackage 没生效检查 pom 是否有 spring-boot-maven-plugin以及是否执行了 repackageUnable to find main class多模块下主类识别失败在插件 configuration 里显式指定mainClassjar 只有几百 KB被打包成普通 jar 了用jar tf看结构确认有 BOOT-INF 和 loader启动报ClassNotFoundException依赖没有正确进入 BOOT-INF/lib解压 jar 看 lib 目录比对 mvn dependency:treeno main manifest attribute这一个错误我前前后后遇到过好几次几乎都是同一个原因pom 里根本就没加 spring-boot-maven-plugin。后来我总结出一个速查习惯——打完包先看 jar 体积Spring Boot 项目只要连 Web 依赖至少二十兆朝上如果只有几百 KB肯定不是可执行格式继续查插件配置准没错。5.2 启动期异常症状原因排查与解法BindException: Address already in use端口被占用查进程换端口或清理旧进程内存不足直接退出-Xmx设置过高或机器内存不足调整 JVM 参数看启动日志确定 OOM 类型启动极慢卡在随机数初始化JVM 熵源问题加-Djava.security.egdfile:/dev/urandom中文乱码Windows 控制台默认编码与 UTF-8 不一致在 cmd 里执行chcp 65001或加-Dfile.encodingUTF-8端口占用是最好排查的。Linux 下lsof -i:8080Windows 下netstat -ano | findstr 8080找到 PID 之后要么 kill 进程要么换个端口启动。启动慢那个urandom的坑老服务器上尤其明显有些环境启动时要等几十秒甚至更久就会让人误以为卡死了。在 JVM 参数里加上-Djava.security.egdfile:/dev/urandom大多数情况下能明显改善。5.3 配置与依赖类的疑难杂症症状原因排查与解法配置改了但没生效参数优先级搞错或配置文件位置不对确认命令行参数、环境变量、外部配置文件、jar 内配置的层级远程环境和本地行为不一致环境变量里残留旧配置检查 SPRING_APPLICATION_JSON、系统环境变量依赖冲突导致 NoSuchMethodError传递依赖版本被覆盖mvn dependency:tree定位排除多余依赖启动后马上退出且没有日志可能是 ForkJoinPool 或非 Web 项目确认是否引入了 Web 容器依赖或者检查启动类是否抛了未捕获异常配置未生效这个我推荐一个百试不爽的排查手段启动时加上--debug参数Spring Boot 会把加载的配置源和最终装配结果打出来能看到某个属性到底被哪个来源覆盖。比盲改配置高效得多。至于依赖冲突有一次我们升级 Spring Boot 2.6.x 到 3.x 后某个老第三方库里的方法签名变了日志里一堆NoSuchMethodError。用依赖树排查了很久最后定位是某个传递依赖被顶到了旧版本排除掉旧版本、强制使用新版本后才解决。6. 打包机制的延伸分层 jar 与云原生部署6.1 分层 jar 解决 docker 镜像体积痛点搞清楚了 fat jar 内部结构再看 Docker 部署就会容易很多。传统 Docker 部署是把整个 fat jar 拷贝进镜像然后启动。但这带来一个问题每次只改了一行业务代码重新构建镜像时依赖层也得跟着重新传输。Spring Boot 2.3 版本之后引入了分层 jar 特性也就是把 BOOT-INF 里的内容按稳定程度分成 dependencies、spring-boot-loader、snapshot-dependencies、application 等层Docker 构建时可以分别缓存。用起来也很简单命令行有一个专门的 modejava -Djarmodelayertools -jar app.jar extract执行完会在当前目录下生成类似 dependencies、spring-boot-loader、application 这样几个文件夹。对应的 Dockerfile 可以这样写FROM eclipse-temurin:17-jre AS builder WORKDIR /app COPY app.jar . RUN java -Djarmodelayertools -jar app.jar extract FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuilder /app/dependencies/ ./ COPY --frombuilder /app/spring-boot-loader/ ./ COPY --frombuilder /app/snapshot-dependencies/ ./ COPY --frombuilder /app/application/ ./ ENTRYPOINT [java, org.springframework.boot.loader.launch.JarLauncher]这样改一次业务代码重新构建时只需要重新传输 application 层依赖层直接命中缓存构建和推送速度都会有质的提升。不同 Spring Boot 版本对层名和入口类名可能有调整我用 3.x 时打印出来的目录和官方文档略有出入这时候一切以你实际extract的结果为准别照抄旧版 Dockerfile。6.2 什么时候别用 fat jar可执行 fat jar 解决了部署便捷性的问题但它不是银弹。如果你的微服务拆得很细几十个服务每个都自带完整 Tomcat 和 Spring 框架那磁盘、启动内存的浪费就很明显。这时候有几个选择用 Spring Boot 3 搭配 Gradle/Maven 做 GraalVM Native Image把应用编译成原生可执行文件启动时间能压到毫秒级或者退一步用 Spring Boot 的 war 包部署到外部 Tomcat多个应用共享一个容器进程。我的建议是单体和中小型微服务还是踏踏实实用标准 fat jar 最省心架构到了一定规模性能瓶颈明确指向启动速度和内存占用再考虑 Native Image 路线。打包机制是基础基础理解了后面无论换哪种部署形态都能很快上手。最后再分享一个我踩过好多次坑之后养成的工作习惯每次构建完成不要急着把 jar 扔上服务器先在本地跑一遍jar tf看看 BOOT-INF 和 loader 是否都在再瞄一眼 manifest 里的 Start-Class确认没有因为多模块或代码迁移导致主类飘了。这套检查总共花不了半分钟但能帮你避开一整个晚上的排错时间。打包机制听起来是个基础话题可真把它吃透了你在命令行面前会凭空多出很多底气。