Java 后端开发里,java -jar app.jar大概是出现频率最高的一行命令。不管是本地自测、部署到测试环境,还是交给运维上生产,Spring Boot 应用最终都以这样一个“可执行 Jar”的形式交付。很多人会用这行命令,但问到它背后发生了什么,能讲清楚的人其实不多。比如:为什么 Spring Boot 的 Jar 能直接用java -jar拉起?为什么它不像传统 Jar 那样依赖外部 Tomcat?为什么 IDEA 里跑得好好的,一到命令行就报ClassNotFoundException?
这篇内容,我想把java -jar背后那套打包机制完整剥开讲一遍。先说清楚 Spring Boot 可执行 Jar 的特殊结构,再拆解构建插件到底做了什么,最后把命令行运行时的参数传递、后台部署、常见启动故障这些实操细节一起整理出来。适合三类人看:刚接触 Spring Boot 想搞清楚原理的新人、用 IDEA 社区版手动打包时遇到问题的人、以及上线部署时经常被启动报错折腾的开发者。
1. 打包方式与可执行 Jar 的诞生
1.1 为什么 Spring Boot 非要做一个“可执行 Jar”
回到早期的 Java Web 开发场景。传统 SSM 或 Servlet 项目写完之后,交付物是一个 War 包,需要部署到独立的 Tomcat 或 Jetty 容器里,容器负责启动、监听端口、加载 Servlet。这个模式的痛点很明显:环境不一致,本地 Tomcat 版本和测试环境不一样,就可能出现各种诡异问题;运维要额外维护中间件;团队里新成员搭环境就要折腾一整天。
Spring Boot 做了两个关键设计来改变这种局面:第一是内嵌 Servlet 容器,把 Tomcat 直接作为依赖塞进应用里;第二就是这次要聊的,把所有内容(应用 class、依赖 Jar、内嵌容器)整合成一个独立的可执行 Jar。这样一来,只要有 JRE 就能启动,一条java -jar就把整个应用拉起来了。
但是没有一套特殊的打包机制,这个目标是无法实现的。原因在于 Java 原本对java -jar的支持非常简单:JVM 读取META-INF/MANIFEST.MF里的Main-Class,找到入口类的main方法,然后通过默认的AppClassLoader来加载 classpath 中的类。默认类加载器只能处理文件系统中的目录和单个 Jar 文件,它不理解“Jar 里面嵌着另一个 Jar”这种结构。所以如果只是简单地把所有依赖 Jar 解压再合到一起,会出现两个问题:解压出来的文件容易互相覆盖(不同 Jar 里可能出现同名文件),而且资源文件的路径处理会变得混乱;如果不解压,JVM 默认根本加载不了嵌套在 Jar 里的依赖。Spring Boot 的解决方案是自己写了一个启动器,配合自定义类加载器,专门识别和处理这种嵌套 Jar 结构。
1.2 可执行 Jar、普通 Fat Jar、War 的对比
在实操中,我们经常在“可执行 Jar”“普通 Fat Jar”“War”这几个形态之间纠结。先弄明白它们的差异,后面选型就有依据了。
| 打包产物 | 是否含依赖 | 启动方式 | 适用场景 |
|---|---|---|---|
| 普通 Jar | 不含依赖,需要外部 classpath | java -cp lib/*:app.jar com.example.Main | 内部工具、被其他项目引用的库 |
| 普通 Fat Jar | 依赖打进 Jar,但嵌套依赖无法加载 | java -jar app.jar大概率报错 | 几乎不用 |
| Spring Boot 可执行 Jar | 依赖打在BOOT-INF/lib,自定义类加载器加载 | java -jar app.jar | 微服务、独立部署 |
| War | 依赖在WEB-INF/lib | 丢进外部 Tomcat | 老项目迁移、容器强制要求 |
我见过有人手动拼 Fat Jar 然后直接java -jar,结果启动时报NoClassDefFoundError,这就是因为嵌套 Jar 没有被类加载器识别。Spring Boot 这套机制解决的不只是“有没有依赖”的问题,而是“依赖能不能被正确加载”的问题。理解这点,很多启动异常就能猜出大概方向了。
2. Jar 包内部结构与启动原理
2.1 解剖一个 Spring Boot 可执行 Jar
先用解压工具或者命令行打开一个 Spring Boot 构建出来的 Jar,看看它的真实结构。假设项目叫demo.jar,用jar tf demo.jar查看内部条目,核心内容如下:
demo.jar ├── META-INF/ │ ├── MANIFEST.MF │ └── maven/...(pom 属性信息) ├── BOOT-INF/ │ ├── classes/ │ │ └── com/example/DemoApplication.class │ └── lib/ │ ├── spring-boot-2.7.18.jar │ ├── spring-boot-autoconfigure-2.7.18.jar │ ├── spring-core-5.3.31.jar │ └── ...(所有第三方依赖) └── org/ └── springframework/ └── boot/ └── loader/ ├── JarLauncher.class ├── LaunchedURLClassLoader.class └── ...三个关键目录各司其职:BOOT-INF/classes存放项目自己编译出来的类和资源文件(application.yml、Mapper.xml、静态资源等);BOOT-INF/lib存放所有第三方依赖 Jar;org/springframework/boot/loader存放 Spring Boot 自带的启动引导类,它们是整个加载机制的起点。
细心的读者可能注意到,这套启动类在 Spring Boot 2.x 和 3.x 里的包路径不一样。2.x 是org.springframework.boot.loader.JarLauncher,3.x 变成了org.springframework.boot.loader.launch.JarLauncher。如果你经常在 IDEA 社区版里自己配 Maven 构建,或者排查启动日志时看到这些类名,注意区分版本差异。
2.2 MANIFEST.MF 里藏着的启动线索
java -jar启动时,JVM 首先读取 Jar 包里的META-INF/MANIFEST.MF。Spring Boot 可执行 Jar 的 Manifest 文件有两个核心字段,其中一个容易让人困惑:
Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: com.example.DemoApplication关键点在于Main-Class并不是我们自己写的那个启动类,而是 Spring Boot 的JarLauncher。为什么这么设计?因为 JVM 通过Main-Class找到入口后,只会调用该类的main方法。JVM 默认的类加载器不认识BOOT-INF/lib里那些嵌套 Jar,如果直接把Main-Class设为com.example.DemoApplication,应用类的加载方式没问题,但依赖类的加载会失败——它们被锁在“Jar 中的 Jar”里,默认类加载器根本看不到。
Spring Boot 的做法是:先让JarLauncher接管启动流程,它会创建一个自定义的LaunchedURLClassLoader,这个类加载器认识嵌套 Jar 结构,能正确读取BOOT-INF/lib里的所有依赖。等类加载器初始化完毕后,JarLauncher再通过反射调用Start-Class(也就是你自己的启动类)的main方法。至于Start-Class这个名字为什么不是Application-Class,可以理解为 Spring Boot 内部约定的命名,它就代表应用真正的业务启动入口。
2.3 嵌套 Jar 的类加载器设计
顺着上面的思路,LaunchedURLClassLoader是整个机制的灵魂。它的核心工作是:让BOOT-INF/lib下的每个 Jar 都能被正常加载,同时不破坏原来的包名和资源路径。
具体来说,这个类加载器会把BOOT-INF/lib下的每个嵌套 Jar 解析为一个个JarURL,例如jar:file:/path/to/app.jar!/BOOT-INF/lib/spring-core-5.3.31.jar!/,当加载一个具体类时,它能在这些嵌套 Jar 里一层层查找。所以从外面看,你执行的是java -jar demo.jar,但在类加载器内部,它已经把demo.jar当成了一个“容器”,里面的每个嵌套 Jar 都是一个可以被检索的 URL。
这也是为什么 Spring Boot 可执行 Jar 的解压运行(java -cp配合解压目录)与直接java -jar运行行为不一样。如果你把BOOT-INF/lib里的依赖手动解压到外部目录,然后用java -cp去跑,没问题;但如果你以为“直接 java -jar 就是一次普通 Jar 执行”,那就错了,它走的是一套专门的处理流程。想验证这一点,可以加-verbose:class启动参数,能看到启动时先加载了哪些Launcher相关类,再加载你的业务类。
3. 构建:Maven 插件到底做了什么
3.1 spring-boot-maven-plugin 的 repackage 任务
Spring Boot 的可执行 Jar 不是 Java 原生编译出来的,而是构建阶段加工出来的产物。整个过程依赖spring-boot-maven-plugin的repackage目标。一个典型的构建配置如下:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>2.7.18</version> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>执行mvn clean package时,实际的构建链是:Maven 先按普通 Jar 的方式编译项目,生成一个“瘦 Jar”(只含项目自身的类,不含依赖,Main-Class 指向自己的主类);然后repackage对瘦 Jar 做二次加工:把BOOT-INF/classes目录整理好;把所有运行时依赖拷贝到BOOT-INF/lib;改写MANIFEST.MF,把Main-Class替换成JarLauncher,把Start-Class设成应用主类。
如果你打开target目录,会看到有两个文件名相关的文件:demo.jar和demo.jar.original。demo.jar是打包插件处理完的可执行 Jar;demo.jar.original是 Maven 在 repackage 之前生成的那个瘦 Jar。瘦 Jar 不是没用的东西——排除可执行 Jar 后,把它改名扔给别的项目做依赖引用,或者自己研究内部结构,都是很好的参考。
3.2 Gradle 构建的对应关系
用 Gradle 构建时,对应的是bootJar任务,它在标准的jar任务之后执行,负责生成可执行 Jar。build.gradle 里的典型配置:
plugins { id 'java' id 'org.springframework.boot' version '2.7.18' } jar { enabled = true } bootJar { mainClass = 'com.example.DemoApplication' }Gradle 里的默认行为和 Maven 略有不同:只要应用了 Spring Boot 插件,bootJar默认会被执行,而普通jar任务有时会被跳过;如果你需要同时保留普通 Jar 和可执行 Jar,需要显式把jar启用。Gradle 产物的目录结构与 Maven 类似,build/libs下能看到最终的可执行 Jar。
3.3 依赖范围与打包边界
打包时另一个经常踩的问题是:哪些依赖会进BOOT-INF/lib,哪些不会。默认规则很简单:compile和runtime范围的依赖会打进去;provided范围(比如spring-boot-devtools、某些 Servlet API、Lombok)不会打进去;test范围当然也不会。
举个实际例子:如果你在 pom 里引入了spring-boot-devtools,这个依赖默认是不会进入生产 Jar 的,因为它的作用是热重启和本地开发辅助。但如果你用了provided范围的注解处理器(比如 Lombok),要意识到它在服务器上是不存在的——这类工具只在编译阶段生效,不影响运行。
还有一个容易忽略的点:依赖冲突。两个 Jar 里如果存在同名类,先声明的依赖优先;如果冲突严重,运行时会出现各种匪夷所思的NoSuchMethodError、ClassCastException。我建议用mvn dependency:tree提前检查依赖树,尤其注意重复传递的框架类。这个问题在可执行 Jar 模式下更隐蔽,因为所有依赖塞进了一个文件,肉眼排查很难,最好在 CI 阶段就加上依赖树检查。
4. 命令行运行实操与参数传递
4.1 标准启动命令与运行日志解读
打包完成后的启动命令很简单,基础形式就是:
java -jar demo.jar如果你想指定端口和激活的配置文件,最常用的是命令行参数方式:
java -jar demo.jar --server.port=8081 --spring.profiles.active=prod启动过程中会看到一段熟悉的日志。看 Spring Boot 启动日志时,我一般重点核对三个信息:启动类是否正确(对应Start-Class)、Tomcat 初始化的端口是否是预期值、有没有ERROR级别的异常。有一次我本地启动一直报端口占用,结果发现是前一个进程还没退出,日志里清清楚楚写着Port 8080 was already in use,看一眼日志就省得瞎猜。
4.2 JVM 参数、应用参数、环境变量的传递方式
java -jar命令行的参数其实分三类,很多人容易混淆:
第一类是 JVM 参数,必须放在-jar之前,用来调整虚拟机行为,例如:
java -Xms256m -Xmx512m -XX:MaxMetaspaceSize=256m -jar demo.jar这类参数一般写在启动脚本里,用来限制内存和 GC 行为。生产环境一定不要只用默认堆大小,否则高峰流量下极容易出现 Full GC 频繁和 OOM。
第二类是应用参数,放在-jar之后,Spring Boot 会把它解析到Environment中。--server.port=8081和--spring.profiles.active=prod就是这样。所有在application.yml里能配置的项,几乎都能用命令行参数覆盖。
第三类是环境变量,在启动命令前设置。Spring Boot 里有个规则的:--参数优先于环境变量,环境变量优先于配置文件。比如:
SERVER_PORT=8081 java -jar demo.jar --spring.profiles.active=prod这里端口来自环境变量SERVER_PORT,profile 来自命令行参数。在 Docker 部署场景下,这个优先级关系非常重要,因为容器里通常用环境变量传配置,如果写在代码里强行覆盖了环境变量,会很难排查。
4.3 后台运行与进程管理
本地开发时,java -jar demo.jar前台跑着没问题,Ctrl+C 结束。但真正部署到服务器,没人愿意开一个终端挂着。Linux 下最常见的做法是nohup加日志重定向:
nohup java -jar demo.jar --spring.profiles.active=prod > app.log 2>&1 &nohup的作用是让进程忽略挂断信号,&是放到后台执行,> app.log把标准输出和错误输出都写到日志文件。但这样管理进程比较原始,如果机器重启,进程不会自动恢复。规范的部署应该用 systemd 服务,配置一个demo.service:
[Unit] Description=Demo Application After=network.target [Service] ExecStart=/usr/bin/java -Xms256m -Xmx512m -jar /opt/app/demo.jar Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target这样能用systemctl start demo和systemctl status demo管理进程,异常退出还能自动拉起,比裸 nohup 靠谱很多。
Windows 环境也有自己的问题。搜索热词里有“运行 bat + 命令行 + 隐藏窗口”,确实是很多人在 Windows 服务器上遇到的真实需求。用 javaw(无控制台窗口的 Java 启动器)来规避黑窗口:
@echo off start "" javaw -jar "D:\app\demo.jar" --spring.profiles.active=prod exit如果你希望窗口最小化而不是完全隐藏,可以用:
@echo off start /min java -jar "D:\app\demo.jar" exitWindows 上的进程管理就麻烦多了,推荐用 NSSM 把 Java 进程注册成 Windows 服务,这样能开机自启、崩溃自动重启,还能统一看日志。反正我在 Windows 测试环境被黑窗口弹烦了之后,就再也没裸跑过 Java 进程。
5. 常见问题排查与避坑记录
5.1 端口被占用怎么办
java -jar启动时最经典的问题就是端口被占用。报错长这样:
Web server failed to start. Port 8080 was already in use.排查命令,Linux 和 Windows 惯用方式:
# Linux netstat -tlnp | grep 8080 lsof -i :8080 # Windows netstat -ano | findstr 8080找到占用进程之后,确认是可以杀的再处理。如果项目里有多套环境互相冲突,更推荐在启动脚本里固定传端口,别依赖默认值。命令行参数的方式最简单:
java -jar demo.jar --server.port=8081如果端口是动态分配的,Spring Boot 还支持server.port=0,会随机选一个可用端口,适合在 CI 里做服务间测试。日志里会打印实际端口,以 “Tomcat started on port(s): 8081” 的行为准。
5.2 Jar 包过大与瘦身方案
一个 Spring Boot 可执行 Jar 动辄几十上百 MB,因为所有依赖都在里面。改进思路一般是“外层依赖和内部依赖分离”或者“构建时裁剪不需要的模块”。
最常见的做法是把依赖放到外部lib目录,启动时指定加载路径。Spring Boot 2.x 支持通过-Dloader.path指定外部依赖目录:
java -Dloader.path=lib/ -jar demo.jar这样BOOT-INF/lib里的依赖可以裁剪掉,Jar 本体只剩项目代码,启动时从外部lib加载依赖。好处是 Jar 体积大幅缩小,坏处是部署时必须带上外部依赖目录,否则直接ClassNotFoundException。
另一种瘦身思路是排除不会用到的自动配置。比如用不到 Redis、Elasticsearch,就在application.yml里关闭相关自动配置,或者exclude掉对应依赖。这个方法对 Jar 体积影响不大,但对启动速度有帮助。Spring Boot 2.3+ 还支持spring-boot-maven-plugin中requiresUnpack等选项,用于对特定依赖做解压处理(比如需要一个本地库文件的第三方包),不过大多数项目用不到。
5.3 外部化配置文件与多环境切换
可执行 Jar 的一个麻烦点是:配置都在 Jar 内部,修改application.yml还得重新打一次包。Spring Boot 提供了外部化配置机制,启动时用--spring.config.location指定外部配置文件所在目录:
java -jar demo.jar --spring.config.location=/etc/demo/application.yml也可以指定整个目录:
java -jar demo.jar --spring.config.additional-location=/etc/demo/spring.config.location会完全替换掉 Jar 内部配置,spring.config.additional-location是追加补充。多环境切换用--spring.profiles.active=dev/prod是标准做法,配合application-dev.yml、application-prod.yml这样命名即可。实际经验中,我建议把密码、密钥、第三方接口地址这类敏感配置放到环境变量或配置中心,而不是写进 Jar 内的 yml。否则运维拿到 Jar 包就能看到数据库账号,这在生产环境是大忌。
5.4 启动后的监控与健康检查
服务启动之后,怎么确认它真的“活着”?看日志当然不直观,最可靠的方案是引入 Spring Boot Actuator。在 pom 里加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>然后在配置文件中添加:
management: endpoints: web: exposure: include: health,info endpoint: health: show-details: always重启之后,直接访问http://localhost:8080/actuator/health,返回 JSON 内容:
{"status":"UP"}健康检查在容器编排里尤其关键,Docker/K8s 的探针、云平台的健康检查接口都依赖这个 Endpoint。如果你部署的是一个集群,还需要在每条节点上保证健康检查都能通过,否则流量打过去直接 504。
5.5 启动后立刻退出:异常排查思路
还有一种常见情况:java -jar demo.jar执行后,日志打了一摞,但进程马上就没了。我遇到过几种原因:
- 端口被占用,
Tomcat初始化失败,ApplicationContext 启动失败。 - 应用代码内部抛异常,
main方法结束,进程退出。 - 数据库连接、Redis 连接失败,导致启动阶段的自动化检查失败(比如
@PostConstruct里调了第三方接口)。 - 启动类找不到对应的配置文件,比如
application.yml里指定了spring.config.location指向一个不存在的文件。
排查时别急着追代码,先把日志看透。Spring Boot 的启动日志里如果出现 “APPLICATION FAILED TO START” 这种大标题,直接看它后面的 Description 和建议的 Action。有一次我把数据库配置类写活了,启动时还是报连接失败,排查后发现是spring.datasource.hikari.initialization-fail-timeout设成了 0,HikariCP 不阻塞启动,看起来是“启动成功”,实际是“带病启动”。这类隐性故障在测试环境很坑,我最后是把健康检查里数据库自动配置全开,再配合启动日志排查才定位到。
最后再分享一个压箱底的操作
回到开头那个问题:为什么 Spring Boot 的 Jar 可以直接java -jar?因为它不是传统意义上的“可运行 Jar”,而是一个自包含的“应用程序包”——自带类加载器、自带依赖、自带内嵌容器。理解这个机制,最大的帮助不是你炫耀时能讲出LaunchedURLClassLoader这几个词,而是以后遇到启动问题,你能顺着“Main-Class → JarLauncher → Start-Class → 依赖加载 → 配置加载 → 容器启动”这条链路去定位,不会像无头苍蝇一样乱猜。
最后说一个个人习惯。我每次本地开发时都会保留一个快速验证入口:mvn clean package -DskipTests之后,建议手动看一眼MANIFEST.MF的内容,确认Main-Class和Start-Class没有被搞乱。如果改了启动类名、包结构,但忘了更新配置,这里多半就能看出来。Spring Boot 的打包机制自带了一定的容错和高兼容性,但那不意味着我们可以完全无视它的内部约定。对这个机制理解越深,踩坑越少,甚至能在部署方案上发现很多“原来还能这么玩”的优化空间。