作为一个常年跟 Spring Boot 打包、部署打过无数次交道的人,我太清楚java -jar之后屏幕上弹出各种报错时的那种感受了。明明本地 IDEA 里跑得好好的,一打成 jar 包换个环境就起不来;明明打包成功了,扔到服务器上却提示“找不到主清单属性”或者直接ClassNotFoundException。这些问题的根源,绝大多数不是代码本身,而是对 Spring Boot 那个“可执行 jar”的打包和运行机制理解得不够透。
这篇东西不打算给你铺一堆理论,就围绕 Spring Boot 打包和运行 jar 包这件事,把从 Maven 配置、插件原理、常见坑到服务器上正确启动方式、甚至 Docker 化的整套链路捋一遍。里面有我实际项目中踩过、填过的坑,也有命令行和配置层面的实操细节,希望能帮你省下几个晚上的排查时间。
1. 先搞清楚:Spring Boot 的 jar 为什么能“一键运行”
很多人直接用java -jar xxx.jar启动成功了,就以为 Spring Boot 打包就是把 class 文件和依赖塞进同一个包,其实没那么简单。你要是去解压这个 jar 看一眼,会发现它的目录结构和普通 jar 完全不一样,有BOOT-INF/classes、BOOT-INF/lib,还有org/springframework/boot/loader。这套结构的核心目的,就是让一个普通 jar 可以直接通过java -jar跑起来,而不用管 classpath 里到底有没有依赖。
关键点在于 Spring Boot 官方提供的spring-boot-maven-plugin插件。它在执行mvn package时,不只是把项目编译打包,还额外做了两件事:一是把项目自身的字节码和 resources 放到BOOT-INF/classes,把所有第三方依赖 jar 放到BOOT-INF/lib,这样你的项目里就算有上百个依赖,也全都被“卷”进这一个文件里了;二是生成一个特殊的启动器,默认是JarLauncher,它在MANIFEST.MF里写入Main-Class: org.springframework.boot.loader.JarLauncher,而你项目里真正的SpringApplication入口类则被写进Start-Class属性。
启动时,JarLauncher会先接管 JVM 的类加载过程,创建LaunchedURLClassLoader,把BOOT-INF/lib下的所有 jar 都注册进这个自定义类加载器的搜索范围,然后才去加载Start-Class指定的主类。这一步很关键,因为 JVM 默认的ClassLoader根本不认识BOOT-INF/lib这种嵌套 jar 结构,如果按普通 jar 的方式直接运行,ClassNotFoundException就是必然结果。
理解了这点后,你就能解释很多现象:比如为什么有时候项目用mvn package打出来的 jar 只有十几 KB,因为那只是 maven 默认的maven-jar-plugin打的普通 jar,压根没经过 Spring Boot 插件的 repackage;再比如为什么你直接双击 jar 也好、命令行运行也好,main方法报错“找不到主类”,多半是MANIFEST.MF里的Start-Class配错了或者没配置。这个机制不是玄学,搞明白后再排查问题,方向会清晰很多。
2. Maven 配置的完整落地:从pom.xml到命令行打包
2.1 父工程和插件版本怎么选
如果你是用 Spring Initializr 生成的工程,spring-boot-starter-parent已经帮你管理好了所有插件版本,不需要额外指定。但如果你是基于公司内部的基础 pom 搭建的,或者自己维护一个多模块工程,一定要手动加上spring-boot-maven-plugin并指定版本。我见过最典型的问题是:只加了spring-boot-starter-web依赖,却忘了在build节点里配插件,结果mvn package出来的 jar 根本没有BOOT-INF/lib,运行的时候一路ClassNotFoundException。
版本选择上,Spring Boot 3.x 对应插件版本 3.x,Spring Boot 2.x 对应插件版本 2.x,不要跨大版本混用。如果你在pom.xml里已经继承了spring-boot-starter-parent,那么直接用下面的配置就够了:
<build> <finalName>myapp</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.MyApplication</mainClass> </configuration> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>mainClass不写也能找到,因为插件会去扫描spring-boot-starter相关配置里的启动类;但如果你的模块结构比较特殊,比如有多个带main方法的类,或者启动类不在默认扫描路径下,建议还是显式指定,避免打包时提示找不到主类。
2.2mvn package的几种正确姿势
我平时最常用的几个命令:
# 跳过单元测试打包 mvn clean package -DskipTests # 跳过测试编译和测试运行(连测试代码都不编译) mvn clean package -Dmaven.test.skip=true # 指定 profile 打包(比如 dev/prod 环境) mvn clean package -DskipTests -Pprod # 只打某个模块及其依赖模块 mvn clean package -pl common,service -am -DskipTests-DskipTests和-Dmaven.test.skip=true的区别很微妙。前者只跳过测试执行,但测试代码仍然会编译;后者连测试代码的编译都跳过。在公司 CI 上如果构建时间很紧张,用-Dmaven.test.skip=true会快一些;但如果你希望保留测试代码的语法检查,那用-DskipTests更稳妥。这个细节在长时间跑构建的时候能感受到差距。
如果项目用了 Spring Cloud 多模块,特别是模块之间互相依赖的场景,-pl xxx -am一定要学会。它会在构建目标模块前先把依赖的模块一并构建,避免“找不到依赖模块 jar”的尴尬。
2.3 配置文件如何打进 jar 并区分环境
Spring Boot 的特性之一就是配置外置。你可以把application.yml放在 jar 包外部,然后通过--spring.config.location指定外部配置文件路径,这样修改配置就不用重新打包。但如果不做任何特殊处理,src/main/resources下的application.yml会被插件自动打包进BOOT-INF/classes,运行时优先使用classpath:下的默认配置,外部配置需要显式指定或者靠优先级覆盖。
区分环境通常用多 profile 文件,比如application-dev.yml、application-prod.yml,打包时通过-Pprod触发 Maven profile,同时配合spring.profiles.active=prod激活对应的 Spring profile。一个可靠的推荐做法是在启动脚本里通过SPRING_PROFILES_ACTIVE=prod环境变量指定,而不是把环境写死在配置文件里,这样同一个 jar 在不同环境之间迁移特别方便。
3.spring-boot:repackage到底做了什么,以及它引发的“原 jar 消失”困惑
3.1 repackage 与普通 jar 的本质区别
如果你没用 Spring Boot 插件而是只用了maven-jar-plugin,那打出来的 jar 就是普通 jar——MANIFEST.MF里Main-Class写的可能是你自己的启动类,但依赖的 jar 根本不会被打进去,运行时必须靠-classpath手动指定一大堆 jar。这在实际部署中几乎不可维护。
Spring Boot 插件的repackagegoal 做的事情,其实是在 Maven 默认的package阶段之后,把刚刚生成的那个普通 jar 再次进行“改造”:把原有的普通 jar 重命名并移动到BOOT-INF/lib作为依赖之一(实际上是放进了BOOT-INF/lib,但产生了一个小技巧),然后把 Spring Boot 自己的启动器类加进去,重新生成一个完整可执行的 jar。
这里有个经常让人懵的点:执行mvn package之后,target/目录下会同时出现app.jar和app.jar.original。那个app.jar.original就是 Maven 原本生成的、未经过 Spring Boot 改造的普通 jar。如果你只是想看看项目源码编译后的产物,或者要把它作为其他模块的依赖引用,打开app.jar.original就能找到你熟悉的com/xxx/...类路径;如果你要部署、启动,那必须用app.jar。很多人第一次看到.original后缀不知道是干嘛的,其实这是插件故意保留的副本。
3.2 为什么多模块场景下会“依赖传不过去”
多模块工程里,A 模块依赖 B 模块,但 B 模块被打成了 Spring Boot 可执行 jar,A 编译时引用 B 就会失败。原因很简单:可执行 jar 里的类全被塞到BOOT-INF/classes下了,普通ClassLoader直接引用这个 jar 时找不到com/xxx/XXX.class的路径。
解决办法有两个方向:一是在 B 模块的插件配置里单独关掉repackage或使用classifier,让它同时生成一个普通 jar 供其他模块依赖;二是把依赖关系调整成provided作用域,这样 B 模块打包时不会把依赖打进去,而最终的可执行 jar 由聚合方统一处理。
经验之谈:多模块工程里,最稳定的结构是“各个底层模块作为普通 jar 被依赖,只在最上层的启动模块上配置spring-boot-maven-plugin的repackage”。这样既避免了每个模块都变成一个臃肿的可执行 jar,也避免了模块间依赖时出现的类路径找不到问题。
3.3 依赖 jar 的分包与外置优化
Spring Boot 默认把所有依赖都打进一个 jar,好处是部署简单,坏处很明显:jar 体积动辄几十一两百 MB,每次发版哪怕只改一行代码,整个包都要重新传。于是项目体量上来之后,很多团队会选择把依赖外置。
插件提供了requiresUnpack,还能配合配置项实现类似“瘦包”的效果。但更常见的做法是在运行时通过loader.path指定外部依赖目录。比如你的启动命令写成:
java -Dloader.path=lib/ -jar app.jar那么 Spring Boot 启动器在加载类时,除了BOOT-INF/lib里的依赖,还会去lib/目录下找 jar。于是你可以把BOOT-INF/lib里的依赖全部解压到服务器某个固定目录,线上只更新那个瘦小的业务 jar。配合 CI 的缓存机制,能大幅提升发布效率。但要注意,Spring Boot 3.x 默认使用PropertiesLauncher时需要单独配置,不能直接默认支持loader.path,这一点踩坑的人不少,我用下面这种相对保守的写法:
<configuration> <mainClass>com.example.MyApplication</mainClass> <layout>ZIP</layout> </configuration>这样打包后loader.path才会生效,因为ZIP布局对应的启动器是PropertiesLauncher,它会解析loader.path系统属性。
4. 运行 jar 包的正确姿势:前台、后台、参数、日志和退出码
4.1 最简单的启动和它的问题
java -jar myapp.jar这种前台运行方式适合本地调试,窗口一关进程就没了。在服务器上一般不会这么用,而是用nohup让它脱离终端会话:
nohup java -jar myapp.jar > myapp.log 2>&1 & echo $! > myapp.pidnohup的意义是让进程忽略SIGHUP信号,也就是即使你退出 SSH 会话,Java 进程也不会被挂断。2>&1是把标准错误输出也重定向到同一个日志文件,不然报错信息会直接打到终端上,你在日志里看不到任何线索。把 PID 写到文件里是为了后续脚本能方便地停止、重启。
4.2 JVM 参数、启动参数应该如何给
日志里经常能看到no main manifest attribute,但如果你已经正确配置了插件,更多时候需要关注的是 JVM 参数传递方式。特别注意java -jar后面跟的参数分为两类:-D开头的系统属性和--开头的应用参数。
java -Xms512m -Xmx1024m \ -Dspring.profiles.active=prod \ -Dserver.port=8080 \ -jar myapp.jar \ --logging.level.root=INFO-Xms、-Xmx是 JVM 内存参数,必须放在-jar前面,否则会被当成应用参数传给main方法,起不到调整 JVM 内存的作用。--server.port=8080是 Spring Boot 的应用级配置,它的优先级比application.yml里的配置高,适合启动时临时覆盖端口;-D开头的系统属性也能被 Spring Boot 读取到,常用于指定环境、JVM 调试端口等。
一个我常用来快速排查问题的启动方式:先前台跑,参数里加上--debug,Spring Boot 会把自动配置的匹配、不匹配决策全部打到日志里,定位配置项为什么没生效非常管用。
4.3 端口被占用、进程杀不死的处理
这个问题几乎每个部署过 jar 的人都遇到过。java -jar启动后如果端口被占,Spring Boot 会直接报Port already in use并退出。很多人第一反应是找 IDEA 里之前的进程,其实先执行:
netstat -tlnp | grep 8080看看到底是哪个进程占了端口。如果是你自己之前起的 Java 进程,用kill -9 PID杀掉即可。但有个更稳妥的习惯:不要直接kill -9,条件允许时先用kill PID(即默认发SIGTERM),让 Spring Boot 有机会执行优雅停机;如果卡住再上kill -9。毕竟在一个有数据写入的服务里,直接强杀可能会导致数据不一致。
如果你手里有之前的myapp.pid文件,那重启脚本就简单了:
kill $(cat myapp.pid)为了省事,我在生产服务器上的启停脚本都统一封装了start.sh、stop.sh,核心逻辑就是“先读 PID → 优雅停止 → 等待几秒 → 确认进程退出 → 重启”。这不复杂,但能让日常发布少很多“老子又忘了几号端口”的琐碎。
4.4 日志拆分与保留策略
nohup输出到myapp.log是方便,但单文件无限增长迟早撑爆磁盘。如果项目没有引入日志框架的滚动策略,或者只想简单控制运维风险,我推荐在启动脚本里结合logrotate或直接让 Spring Boot 输出到按天滚动的文件。Spring Boot 2.x 后对logback-spring.xml的滚动支持很完善,下面是一个很常见的配置片段:
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/myapp.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/myapp.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxHistory>15</maxHistory> </rollingPolicy> </appender>这样每天一个日志文件,保留 15 天,配合服务器上的系统盘监控,基本不会出现“日志把磁盘撑爆导致服务悄悄挂掉”的情况。
5. 从“能打包”到“稳定运行”:几个高频故障的排查链路
5.1 找不到或无法加载主类
这个问题出现时,控制台会输出类似Error: Could not find or load main class com.example.MyApplication。先别急着怀疑代码,按这个顺序排查:
确认 jar 里
MANIFEST.MF的内容,用命令查看:jar xf myapp.jar META-INF/MANIFEST.MF cat META-INF/MANIFEST.MF重点看
Main-Class是否是org.springframework.boot.loader.JarLauncher,以及有没有Start-Class。如果Main-Class直接是你自己的类,说明这个 jar 根本没经过 Spring Boot 插件重打包。确认 pom 里
spring-boot-maven-plugin确实存在,且repackagegoal 在 package 阶段执行。如果只是引用了插件但没有配置executions,默认情况下插件不会自动执行 repackage(部分版本需要显式声明)。如果你在启动时手动指定了
-Dloader.main或者换过mainClass,确认值和实际类全限定名一致,并且编译后的 class 确实存在于BOOT-INF/classes。
5.2 类找不到但打包明明成功了
ClassNotFoundException在运行时出现,多半不是缺spring-boot-maven-plugin,而是你的代码里用了某个依赖中的类,但这个依赖被打包时因 scope 原因没进BOOT-INF/lib。最典型的就是把<scope>provided</scope>加到了不该加的地方。比如 Lombok 用provided没问题,它只在编译期需要;但如果某个业务库也标了provided,运行时它不在 classpath 里,就会报错。
排查方法很直接,解压 jar 查看BOOT-INF/lib里有没有那个依赖:
unzip -l myapp.jar | grep 'BOOT-INF/lib/xxx'没有就回 pom 里检查依赖 scope。另外一个很常见的原因是 Maven 依赖仲裁——A 传递依赖了 B 的 1.0 版本,你的代码里手动引进了 B 的 2.0,但最终解析到的是 1.0,两个版本 API 差异导致方法找不到。这种情况用mvn dependency:tree看依赖树,基本一眼能定位。
5.3 Spring Boot 3.x 打包后启动提示版本不对或无法运行
首页热搜词里很多人吐槽“spring boot版本太高”导致各种问题。这里要区分两种场景:一是本地 JDK 版本太低,Spring Boot 3.x 要求 JDK 17 及以上,你拿 JDK 8 启动 3.x 的 jar 会直接报UnsupportedClassVersionError;二是打包时用的环境到底是 JDK 8 还是 17,建议 pom 里显式指定java.version:
<properties> <java.version>17</java.version> </properties>同时最好在打包机器上配置一致的 JAVA_HOME,避免“本地能跑、CI 上打出来的包跑不了”的诡异情况。如果项目确实还停留在 JDK 8,就老实选 Spring Boot 2.7 或者 2.6 的版本线,没必要为了追新而给自己找麻烦。
5.4 配置文件不生效的排查链路
jar 启动后日志显示端口不是你在application.yml里写的端口,或者某个配置项看起来完全没被读取。这类问题基本都出在“配置加载优先级”上。Spring Boot 的配置优先级大致是:命令行参数 >SPRING_APPLICATION_JSON> 系统属性 >application-{profile}.yml>application.yml。如果你之前设置过环境变量SERVER_PORT或者SPRING_PROFILES_ACTIVE,它很可能覆盖了配置文件里的值。
排查手段是在启动时加--debug,日志会明确输出“环境变量、命令行参数、配置文件”的加载来源。我最常遇到的情况其实是:修改了application.yml,但忘了重新打包,因为配置文件已经被打进BOOT-INF/classes,如果依赖本地 IDE 的缓存目录启动,用的还是旧配置。这种低级错误,换一个“配置文件外置”的习惯就能彻底避免。
6. 进阶玩法:把可执行 jar 交给 Docker 和 Jenkins 去跑
6.1 一个简洁可靠的 Dockerfile 模板
打包和运行 jar 的终点通常不是手动执行命令,而是进入容器化部署。下面是一个我长期在用的 Dockerfile 模板,特点是小、稳、易维护:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/myapp.jar /app/myapp.jar ENV SPRING_PROFILES_ACTIVE=prod EXPOSE 8080 ENTRYPOINT ["java", "-XX:+UseG1GC", "-Xms256m", "-Xmx512m", "-jar", "/app/myapp.jar"]强调几点:第一,使用 JRE 基础镜像而不是 JDK,因为编译发生在 Maven 阶段,运行只需要 JRE,镜像体积能小一截;第二,ENV SPRING_PROFILES_ACTIVE=prod是环境变量指定 profile 的典型用法,后续部署不同环境可以覆盖;第三,启动命令建议写成ENTRYPOINT数组形式,避免 shell 解析带来的坑。
如果你用的是 Spring Boot 3.x,JDK 版本自然得跟着 17;如果还是 JDK 8,就把基础镜像换成eclipse-temurin:8-jre。热搜里提到的“springboot jdk1.8打包到docker desktop”的问题,多半是在本地 Docker 环境里因为 JDK 版本不匹配,或者 Docker Desktop 的文件挂载权限导致的,注意把基础镜像版本和 pom 里的java.version对齐就能解决。
构建命令很简单:
docker build -t myapp:latest .如果构建过程中依赖下载慢,可以给 Maven 配置国内镜像源,或者用多阶段构建把 Maven 构建和运行镜像分开。多阶段构建的优点是不需要本地装 Maven,而且最终镜像不会把 Maven 和源码带进去。
6.2 Jenkins 流水线里的打包与发布
在 CI 环境里,打包最忌讳的是依赖开发人员本地环境。Jenkins 上构建时,先保证工作区干净,再执行:
stage('Build') { steps { sh 'mvn clean package -Dmaven.test.skip=true -P prod' } } stage('Archive') { steps { archiveArtifacts artifacts: 'target/myapp.jar' } }如果用 Docker 方式,就在构建阶段生成镜像命名并推送到镜像仓库:
stage('Docker Build & Push') { steps { sh 'docker build -t registry.example.com/myapp:${BUILD_NUMBER} .' sh 'docker push registry.example.com/myapp:${BUILD_NUMBER}' } }BUILD_NUMBER是 Jenkins 环境变量,用构建号做镜像 tag 是最简单、可回滚的方案。很多团队还会在这个基础上加“按分支打 tag”“自动更新 deploy.yaml”的步骤,核心目的都一样:保证发布的可追踪性。如果只是本地配合 Docker 调试,docker run -p 8080:8080 --env SPRING_PROFILES_ACTIVE=prod myapp:latest就够了,注意-p的容器端口和 jar 里配置的server.port要一致,否则会出现从宿主机访问不进去的情况。
6.3 关于“依赖 jar 外置”在容器场景里的陷阱
上面提到过loader.path可以把依赖外置,但这个方案在 Docker 里要慎重。因为容器文件系统是分层的,如果你把依赖放在镜像层里,每次修改依赖库都需要重新构建镜像,失去了外置减少体量的意义;如果用挂载卷把宿主机目录挂进容器,那就要保证宿主机上确实有这些 jar,否则容器里照样ClassNotFoundException。
相比之下,容器化之后更推荐直接使用胖 jar,镜像体积大一点,但换来的是部署时的极简和确定性。你要真在意镜像体积,也应该从基础镜像、构建缓存上去优化,而不是搞loader.path这种相对小众的玩法——毕竟在容器里你本来就可以随时docker pull和docker run,并不缺那几 MB 传输成本。
7. 两条尽量养成的好习惯
最后说两个我因为偷懒吃过亏、后来一直坚持的习惯。第一个是每次打完包之后,顺手执行一下java -jar myapp.jar --help或者java -jar myapp.jar --version。Spring Boot 3.x 默认支持这些参数,如果 jar 能正常响应,说明至少启动链路是通的,遇到问题也不会是打包阶段的问题。很多部署事故,其实在本地打包完那一刻就已经注定了。
第二个习惯是启动脚本里一定加上“启动后探活”逻辑,不能nohup ... &完就万事大吉。简单的探活可以靠轮询端口,比如等curl -f http://127.0.0.1:8080/actuator/health返回 200,再告诉发布系统“启动成功”。如果依赖 Spring Boot 默认没有引入 actuator,也可以用nc -z 127.0.0.1 8080这种基础手段。写一个受控的启动等待循环,比发布完成后才发现服务根本没起来要舒服得多。
顺带一提,Spring Boot 3.x 默认把server.port的替换逻辑做了调整,如果你用-Dserver.port=8081这种写法,新版本会警告并建议使用SERVER_PORT环境变量或--server.port参数。这个变化不致命,但遇到“端口怎么不变”的问题时,有可能就是版本差异引起的——建议在项目的启动文档里统一成环境变量方式,跨版本兼容性更好。