如果你刚把Spring Boot项目从“在IDEA里点一下Run”推进到“要打成Jar包扔到服务器上跑”这一步,多半会撞上几件让人哭笑不得的事:本地一切正常,mvn package却报错;辛苦打包成功,java -jar一跑,提示No main manifest attribute;终于跑起来了,却发现连不上数据库,配置文件改了也没生效。这些问题我基本都踩过一遍,也顺着源码和构建日志把它们琢磨清楚了。这篇内容准备把Spring Boot打包这条链路完整讲透,既讲可执行Jar的原理,也讲Maven命令和IDEA的实操、配置文件外置、Docker镜像打包、常见报错排查,最后聊一下GraalVM原生镜像和Spring Boot 3.5虚拟线程这类进阶话题。无论你是正在做毕设或企业项目、需要把系统交给别人的学生,还是刚接手部署的Java开发,应该都能找到对应的答案。
1. 先搞懂Spring Boot的可执行Jar结构,很多报错会迎刃而解
1.1 Fat Jar和传统Jar的差别
Spring Boot默认打出来的包,准确说叫可执行Jar(也被叫Fat Jar或Uber Jar),它和传统Java项目打出来的Jar有本质区别。传统Jar只包含自己编译好的class文件和资源文件,所有第三方依赖都要额外放到一个lib目录里,部署时要手工维护classpath,依赖稍微多几个就容易漏。Spring Boot的可执行Jar则是“全家桶”,把项目自身代码、所有第三方依赖、内嵌的Tomcat或Jetty服务器全部塞进一个Jar文件里。
打开一个Spring Boot可执行Jar,你会看到这样的目录结构:
demo.jar ├── BOOT-INF │ ├── classes # 项目编译后的class和resources下的配置文件 │ └── lib # 所有第三方依赖jar ├── META-INF │ ├── MANIFEST.MF # 启动入口信息 │ └── maven └── org └── springframework └── boot └── loader # Spring Boot自定义的类加载器这个结构很关键。BOOT-INF/lib里躺着所有依赖,BOOT-INF/classes里是项目自己的代码和内置的application.yml。而org/springframework/boot/loader是Spring Boot自定义的类加载器,JDK 9之后默认的应用程序类加载器不支持直接从嵌套Jar里加载class,所以Spring Boot需要用自己的JarLauncher启动。
我经常用“精装房和毛坯房”来打比方:传统Jar像毛坯房,家具和家电(依赖)需要你自己搬进去,搬家麻烦,缺一样都不行;Fat Jar是精装房,拎包入住,里面一切都配好。理解这一点,你就知道为什么Spring Boot项目部署起来这么方便了。
1.2 repackage到底干了什么,连main方法都“换了位置”
很多教程只让你在pom.xml里加一个插件,说“加上这个就能打包了”,但很少解释这个插件到底做了什么事。这东西叫spring-boot-maven-plugin,它的核心功能之一叫repackage,在package阶段执行。
它做的事情实际上是这样的:
- Maven先按普通方式把项目打成一个常规Jar,放在
target下。 spring-boot-maven-plugin读取这个刚生成的原始Jar。- 插件把原始Jar的内容挪进
BOOT-INF/classes,把所有依赖挪进BOOT-INF/lib。 - 插件在
META-INF/MANIFEST.MF里写入新的Main-Class:org.springframework.boot.loader.JarLauncher。 - 你项目里真正的
main方法对应的类,被写进Start-Class属性。 - 原始的Jar会被改名为
*.jar.original保留在target目录。
所以当你运行java -jar demo.jar时,真正被JVM启动的是JarLauncher,它先把嵌套在BOOT-INF/lib里的依赖全部加载进自定义类加载器,然后再反射调用你写在Start-Class里的那个main方法。
知道这个机制有什么用?排查问题特别有用。比如你发现target目录下没有.jar.original文件,说明repackage根本没有执行;比如你运行Jar提示找不到main方法,大概率就是Start-Class没配对或没生成。这些东西后面排错章节还会用到。
1.3 No main manifest attribute是怎么来的
“No main manifest attribute”应该是Spring Boot打包新手最常撞见的错误之一,运行java -jar时直接报错退出。它的含义是:Jar包的META-INF/MANIFEST.MF里没有Main-Class属性,JVM根本不知道该从哪个类进去。
出现这个问题的常见原因有三类:
- 项目没有继承
spring-boot-starter-parent,而是自己自定义了父POM,导致spring-boot-maven-plugin没有被Maven的插件版本管理统一控制,或者根本没有在build里配置这个插件。 - 插件配置了,但
<mainClass>写错了路径,repackage时找不到带main方法的类。 - 打包命令里显式跳过了repackage执行。
对应的解决办法也直接:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.3</version> <relativePath/> </parent>然后保证build里有这个插件:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>如果父POM不方便改,就在插件配置里显式指定入口类:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.DemoApplication</mainClass> </configuration> </plugin>排掉这个问题,通常java -jar就能正常把项目拉起来。
2. 标准打包流程与部署时的外部化配置
2.1 命令行打包最稳妥,IDEA打包只适合日常自测
打包这件事,我强烈建议你优先用命令行,而不是IDE里的按钮。不是IDE按钮不能用,而是命令行更可控、更容易复现,也方便在CI/CD流水线里复用。
最常用的一条命令是:
mvn clean package -DskipTestsclean会把target目录删掉,避免上次构建的残留文件干扰;package会执行编译、测试(这一步被跳过)、打包的完整链路。-DskipTests表示跳过测试代码的执行,但测试代码仍然会编译。
这里要区分两个经常被混用的参数,它们在很多时候坑过人:
| 参数 | 是否编译测试代码 | 是否执行测试 | 适用场景 |
|---|---|---|---|
-DskipTests | 是 | 否 | 测试代码能编译通过,只想跳过测试执行 |
-Dmaven.test.skip=true | 否 | 否 | 测试代码本身编译不过,或者完全不想碰测试 |
实际项目中,如果团队没有跑完整测试的要求,我一般直接用:
mvn clean package -Dmaven.test.skip=true -Dcheckstyle.skip=true第二个参数视项目里是否配置了代码检查插件而定,跳过检查能省不少时间,但只建议在本地打包或者确定代码没问题时这么干,有CI流水线的项目还是把检查留给流水线。
打包完成后,target目录下会出现两个文件:demo.jar和demo.jar.original。如果你用的是IDEA的Maven工具面板,点package执行的其实是同一套逻辑,但IDEA可能会用自己的Maven版本和JDK版本,偶尔出现“IDEA能打包、命令行报错”或者相反的情况。所以遇到异常,先用命令行跑一遍,往往能暴露真实问题。
2.2 配置文件外置:config目录和命令行参数的优先级
打包部署和本地开发最大的一个区别是:本地配置直接改src/main/resources/application.yml就行,但服务器上是不能随便改Jar包内部文件的。Spring Boot为此提供了一整套外部化配置机制,优先级大致是这样的:
| 优先级(高到低) | 配置来源 |
|---|---|
| 1 | 命令行参数,如--server.port=8081 |
| 2 | Java系统属性,如-Dserver.port=8081 |
| 3 | 操作系统环境变量,如SERVER_PORT=8081 |
| 4 | 外部配置文件(jar包同目录或同目录config/子目录的application.yml) |
| 5 | Jar包内部的application.yml |
实际部署时,我推荐的外部目录结构是这样:
/opt/app/ ├── demo.jar ├── config/ │ └── application-prod.yml └── logs/Jar包放在/opt/app/,配置放在config子目录里,Spring Boot启动时会自动读取config/application.yml或config/application-prod.yml,并且优先于Jar包内部的同名配置。这样改端口、改数据库地址、改日志级别,都只需要改服务器上的文件,完全不用重新打包。
还有一种更灵活的做法是启动时显式指定配置位置:
java -jar demo.jar --spring.config.location=file:/etc/app/application.yml但注意,使用--spring.config.location时,默认的配置路径会被替代掉,Jar包内部的配置就不会再被读取了。如果想保留默认配置,只追加外部目录,用--spring.config.additional-location更稳妥。
关于数据库密码、Redis密码、MinIO密钥这类敏感信息,我的经验是不要写进任何application.yml,包括外置的配置文件。直接在启动命令里用--spring.datasource.password=xxx传,或者通过环境变量注入,配合部署平台的密钥管理功能,安全性和可维护性都会好很多。现在接入MinIO、Redis、MySQL这类中间件,基本都走这套思路:连接地址和凭据全部外置,代码里只留占位符。
2.3 MyBatis-Plus的XML与Mapper同包,打包后查不到SQL
这是搜索热词里非常典型的一个问题:“Spring Boot项目使用MyBatis-Plus,XML与Mapper在同一个文件夹下应该如何配置”。开发环境跑得好好的,一打包部署就报Invalid bound statement (not found)。
问题的根源在于Maven的资源过滤规则。默认情况下,Maven把src/main/resources目录下的内容复制到target/classes,而src/main/java目录只负责编译Java类,里面的XML文件默认不会被当作资源复制过去。开发环境里IDEA会做一些额外处理,所以你能正常读到XML;但用mvn package打成Jar后,BOOT-INF/classes里根本没有对应的XML文件,运行时MyBatis自然找不到SQL语句。
解决方案有两种。第一种是改pom.xml,把src/main/java下的XML也纳入资源:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> <resource> <directory>src/main/resources</directory> </resource> </resources> </build>这样XML会被复制到classes目录,同时保留原包路径,MyBatis就能按Mapper接口的包路径找到XML了。
第二种方案是我更推荐的:不要让XML和Mapper同包,把XML统一放到src/main/resources/mapper/目录,在配置里声明扫描路径:
mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.demo.entity这种做法的好处是目录职责清楚,打包后XML天然在BOOT-INF/classes/mapper/下,不会出幺蛾子,多人协作时也更容易形成统一约定。顺带提醒一句:如果你在pom.xml里对resources做了include过滤,必须把所有需要打包的资源类型都写进去,比如**/*.yml、**/*.properties、**/*.xml,否则很容易出现“数据库配置丢失”“日志配置没生效”这种隐蔽问题。
3. 打包出的Jar怎么跑:运行参数、端口日志与常见“假死”
3.1 一条标准启动命令和常用的JVM参数
打包完成后,最直接的启动方式是这样:
java -jar /opt/app/demo.jar --spring.profiles.active=prod --server.port=8080--spring.profiles.active=prod用来激活生产环境的profile,--server.port=8080用来覆盖默认端口,这些都属于优先级最高的命令行参数。
内存参数方面,本地测试可以这么写:
java -Xms512m -Xmx1024m -jar demo.jar-Xms是JVM启动时分配的初始堆内存,-Xmx是最大堆内存。但真实部署的时候,我强烈不建议在java -jar命令里写死-Xmx,尤其是容器化部署时,固定堆上限可能导致JVM能感知到的容器内存和-Xmx不一致,出现OOM被系统杀掉,或者明明还有内存却不用的尴尬情况。容器环境里更推荐用百分比参数,这个放到Docker章节详细说。
生产环境如果不想用nohup,可以写一个简单的systemd服务单元文件:
[Unit] Description=demo-app After=network.target [Service] Type=simple User=app WorkingDirectory=/opt/app ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/app/demo.jar --spring.profiles.active=prod SuccessExitStatus=143 Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target这里SuccessExitStatus=143是因为systemd停止Java服务时,JVM会收到SIGTERM信号返回143,要标注为正常退出码,否则systemd会认为服务是异常停止的。Restart=on-failure配合RestartSec=10,服务挂了能自动拉起。
3.2 启动后不显示端口号,不代表启动失败
热词里有一条“IDEA启动Spring Boot项目不显示端口号”,这个现象在真实部署中也很常见,很多人看到控制台没有那句Tomcat started on port 8080就慌了,以为启动失败,结果Log里明明写着“Started DemoApplication in 3.2 seconds”。
不显示端口号的原因通常是这几个:
spring.main.banner-mode被设置成off,有些日志组件还会过滤掉Spring Boot启动时打印的信息,导致你只看到部分日志。- 项目里配置了自定义的
logback-spring.xml,日志级别把org.apache.catalina、org.springframework.boot.web.embedded.tomcat这些类的启动日志级别调高了(比如WARN),Tomcat的端口信息就不会出现在输出里。 - 端口被占用,Tomcat启动失败,但错误堆栈被日志配置吞了一部分,误以为“没显示端口”就是没启动。
我的排查方法是,先别管控制台输出,直接看端口监听状态:
ss -lntp | grep 8080 curl http://localhost:8080/actuator/health如果端口在监听,curl能通,那服务就是起来了,只是日志没打印而已。反过来,如果端口没监听,再去查完整日志文件里有没有Port 8080 was already in use或者Web server failed to start这种关键行。这儿再补一句:如果你确认代码没问题、端口也没被占,就优先检查logging.level配置,把org.springframework.boot设为INFO,端口信息会正常打出来。
3.3 日志落盘和Spring Boot 3.5虚拟线程开关
部署到服务器上的Jar,不能只在控制台看日志,必须落盘。项目里加一份logback-spring.xml,按天滚动、保留历史日志、按大小切割,这是基本操作。我常用的配置核心部分是这样的:
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_PATH:-logs}/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH:-logs}/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxHistory>30</maxHistory> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>100MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern> </encoder> </appender>需要滚动日志的时候,启动命令里加上--logging.file.name=/opt/app/logs/app.log或者用环境变量LOGGING_FILE_NAME指定,Spring Boot会优先使用外置的日志路径配置,这个做法对排查线上问题很有帮助。
还有一个热词是“Java 21 + Spring Boot 3.5启用虚拟线程”,这个和打包运行关系也很紧密。Spring Boot 3.5已经完整支持虚拟线程,启用方式就是在application.yml里加一行:
spring: threads: virtual: enabled: true虚拟机线程对高并发IO密集型应用有很明显的改善,但有个前提:项目必须运行在JDK 21及以上版本。如果你的服务器还是JDK 8或11,编译出的Jar可能都跑不起来Spring Boot 3.x。所以打包前先确认编译JDK和运行JDK的版本一致性,这个坑我见过不少次:本地用JDK 17编译打包,服务器上只有JDK 8,启动直接报UnsupportedClassVersionError。
4. 从Jar到Docker镜像:部署环境的主流打包方案
4.1 一个够用的Dockerfile和基础镜像选型
现在多数项目的最终部署形态已经不只是Jar包了,而是镜像。Spring Boot项目打成Docker镜像,最简单的Dockerfile长这样:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/demo.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]有人会问,为什么用eclipse-temurin而不是其他镜像。eclipse-temurin是Eclipse Adoptium项目维护的开源JDK发行版,有官方更新,安全修复及时,而且在Docker Hub上体积控制得不错。选镜像时一般有这几个选择:
| 基础镜像 | 大概体积 | 适用场景 |
|---|---|---|
eclipse-temurin:17-jdk | 400MB+ | 需要在容器里编译或调试 |
eclipse-temurin:17-jre | 200MB左右 | 大多数生产环境,只运行不编译 |
eclipse-temurin:17-alpine | 更小 | 对体积敏感的场景,但部分原生依赖可能需要额外处理 |
我个人的习惯是:常规Web服务用eclipse-temurin:17-jre就够了,不要为了追求小体积盲目上alpine,否则碰到需要编译原生代码的依赖会很折腾。如果你用的是Spring Boot 3.x,注意基础镜像的JDK版本不能低于17。
4.2 利用layers做镜像分层,构建速度能差好几倍
一个容易被忽略的优化是Spring Boot的镜像分层构建。Spring Boot 2.3之后,spring-boot-maven-plugin支持把可执行Jar拆分成多个layer,典型的分法是dependencies、spring-boot-loader、snapshot-dependencies、application这几层。
在pom.xml里开启:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layers> <enabled>true</enabled> </layers> </configuration> </plugin>然后在Dockerfile里这样构建:
FROM eclipse-temurin:17-jre AS builder WORKDIR /builder COPY target/demo.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /builder/dependencies/ ./ COPY --from=builder /builder/spring-boot-loader/ ./ COPY --from=builder /builder/snapshot-dependencies/ ./ COPY --from=builder /builder/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]这么做的核心收益是Docker缓存。如果只复制依赖层,依赖没有变化时Docker构建可以命中缓存,mvn package之后重新构建镜像,通常只需要重建最后那个application层,构建时间从几十秒降到几秒。对比一下:
| 构建方式 | 依赖变化时 | 只改项目代码时 |
|---|---|---|
| 非分层:COPY整个Jar | 全量重建 | 全量重建 |
| 分层:分COPY多个层 | 依赖层重建 | 只重建application层,秒级完成 |
很多团队CI/CD跑的慢,一大半原因就是没用上这个特性。
4.3 健康检查与容器内存参数
Dockerfile里加健康检查,对编排平台(K8s、Docker Compose等)特别重要。前提是要引入spring-boot-starter-actuator依赖,暴露/actuator/health:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1容器里跑Java,内存参数我建议这样写:
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:InitialRAMPercentage=50.0", "-jar", "app.jar"]为什么不用-Xmx?因为容器限制的是整个进程的内存,如果JVM的-Xmx写死了,它不会根据容器的限制自动调整,很容易出现OOM Kill。-XX:MaxRAMPercentage=75.0的意思是,JVM使用容器可用内存的75%作为堆上限,留一部分给元空间、线程栈和本地内存。这是容器化Java应用最稳妥的做法之一。
5. IDEA与Maven打包报错的完整排查链路
5.1 代码里一切正常,mvn package却报“找不到符号”
这类问题在“IntelliJ + Maven项目打包报错”的搜索里出现频率特别高,表现通常是:在IDEA里运行项目没问题,但命令行mvn package时报一堆找不到符号、程序包xxx不存在。
先说排查链路,不要一上来就怀疑代码:
- 先执行
mvn clean,清掉target目录里的旧编译产物。IDEA的增量编译和Maven的全量编译机制不同,旧class文件可能导致各种灵异错误。 - 确认命令行用的JDK和IDEA里面的JDK是不是同一个版本。命令行执行
java -version和mvn -version,看java.version字段。IDEA里看File -> Project Structure -> Project的SDK设置。Spring Boot 3.x需要JDK 17以上,如果Maven用的是JDK 11,必然报“找不到符号”或者编译版本错误。 - 检查项目的Maven配置。比如
settings.xml里配置的JDK版本,或者POM里的maven.compiler.source/target。 - 看是不是多模块项目。如果是多模块,公共模块没有先
mvn install到本地仓库,子模块编译时找不到公共模块的类,也会报“程序包xxx不存在”。先对公共模块执行mvn install -Dmaven.test.skip=true,再执行整体打包。
还有一个高发原因是Lombok。Lombok的注解处理器依赖JDK内部API,JDK版本过新、Lombok版本过旧时,会直接导致编译阶段“找不到符号”或者“程序包lombok不存在”。我遇到过最典型的就是JDK 21配老版本的Lombok,解决办法是把Lombok升级到支持JDK 21的版本(1.18.30+),或者在POM的annotationProcessorPaths里显式声明Lombok版本。排查思路一句话总结:先看编译环境,再看依赖可见性,最后才是代码本身。
5.2 package阶段repackage失败:Unable to find main class
当你看到这样的报错时,问题基本不在编译,而在spring-boot-maven-plugin的repackage阶段:
[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:3.5.3:repackage [ERROR] Unable to find main class这个报错说明插件已经执行了,但它找不到带public static void main(String[] args)方法的类。
常见原因和应对办法:
- 项目里确实没有入口类,或者入口类的
main方法签名写错了,检查一下方法签名是不是标准的public static void main(String[] args)。 - 项目是多模块结构,你给一个纯工具模块也挂了Spring Boot插件,但它没有主类。工具模块要么不挂这个插件,要么在插件的
<configuration>里指定一个不存在的类名之前先确认它的实际用途。 - 入口类被放在某个未参与构建的源码目录里,比如放在了
src/test/java而不是src/main/java。这个错误很低级,但确实不少人犯过。
我自己在多模块项目里的做法是:只在真正需要生成可执行Jar的模块上配置spring-boot-maven-plugin,其他模块只保留普通Maven编译插件。这样可以避免很多“找不到主类”的纠缠。
5.3 本地仓库依赖损坏和镜像源问题
还有一个经常让人抓狂的场景:项目本身没问题,但mvn package一直提示某个依赖下载失败,或者突然报了一个Could not resolve dependencies。这种情况十有八九是本地Maven仓库的依赖包损坏了,尤其是在换过镜像源之后。
Maven下载依赖时如果断网或者被中断,会在本地仓库生成.lastUpdated后缀的标记文件。之后Maven看到这些标记文件会拒绝重新下载,导致同一个依赖一直报错。
排查步骤:
- 先强制执行一次更新:
mvn clean package -U-U会让Maven强制检查所有依赖的最新快照版本,跳过本地的.lastUpdated缓存判断。
- 如果还不行,直接清理本地仓库里所有失败标记:
find ~/.m2/repository -name "*.lastUpdated" -delete- 检查
settings.xml里的镜像配置。国内网络环境下,把中央仓库换成阿里云镜像通常会快很多:
<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>aliyun public repository</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>这四个点排查下来,绝大多数“依赖加载失败”都能解决。要注意的是,不要动不动就删整个.m2/repository,那会触发全量重新下载,非常浪费时间。
6. 进阶话题:GraalVM原生镜像与Spring Boot 3.5虚拟线程
6.1 打包插件真的可以换成GraalVM吗
热词里有一条“Spring Boot打包插件可以换成GraalVM?”,这说明很多人在探索新技术时都会有这个疑问。先说结论:可以,但不是简单地把spring-boot-maven-plugin换成另一个插件,而是用org.graalvm.buildtools:native-maven-plugin来配合构建原生可执行文件,并且只支持Spring Boot 3.x及之后版本。
GraalVM原生镜像的思路和传统JVM有很大不同。它会在构建期对你的程序做静态分析,把代码、依赖、必要的JVM运行时全部编译成一个独立的原生可执行文件。这样启动时不需要JVM解释和执行字节码,所以启动飞快、内存占用也低。我见过一个普通的Spring Boot Web项目,Jar包启动2秒多,GraalVM原生镜像启动不到0.1秒,内存占用也降了差不多一半。
但代价也很明显:
| 对比项 | 传统可执行Jar | GraalVM原生镜像 |
|---|---|---|
| 启动时间 | 1~5秒 | 50~200毫秒 |
| 内存占用 | 较高 | 明显更低 |
| 构建时间 | 十几秒到几十秒 | 几分钟甚至十几分钟 |
| 反射/动态代理 | 无需特别处理 | 需要额外配置文件或注解 |
| Spring Boot版本要求 | 任意 | 3.x官方支持 |
原生镜像对反射、动态代理、ServiceLoader这类机制处理比较吃力。比如很多框架内部用反射来创建Bean,构建时GraalVM无法推断出这些类,就需要手动提供反射配置文件。Spring Boot通过@RegisterReflectionForBinding、hints机制做了大量适配,但仍然不是所有第三方库都能直接跑通。
我的个人建议是:常规业务系统、管理后台、内部服务,老老实实用可执行Jar加Docker镜像部署,稳定性优先。原生镜像的适用场景是短暂的Serverless函数、边缘计算、命令行工具,或者对启动速度和资源占用有明确要求的场景。不要为了“新”而上,先确认收益能覆盖成本。
6.2 Java 21与Spring Boot 3.5虚拟线程的配置与注意点
虚拟线程是Java 19引入预览特性、Java 21正式落地的能力,Spring Boot 3.2起就开始支持虚拟线程,Spring Boot 3.5对它的支持更成熟了。启用方式非常简单:
spring: threads: virtual: enabled: true开了这个配置之后,Tomcat接收请求的线程会变成虚拟线程,而不是传统的平台线程。对于大量IO密集型场景(比如频繁查询数据库、调用远程接口),虚拟线程能明显提升吞吐量,代码不用改,配置一行搞定。
但要注意几个打包和运行层面的问题:
- 必须在JDK 21及以上版本编译和运行。用JDK 17编译的class文件在JDK 21上虽然能跑,但Spring Boot 3.5对虚拟线程的支持代码本身是用21的特性编译的,所以整个构建链路的JDK版本一定别低于21。
- 虚拟线程并不适合所有代码,特别是那些通过
ThreadLocal保存大量数据、或者依赖synchronized做锁竞争的逻辑,需要评估是否有隐患。 - 容器里的线程池、连接池仍然要关注。虚拟线程解决了“一个请求一个线程”的资源瓶颈,但数据库连接池、Redis连接池这些物理资源不会自动变大,如果连接池设计不合理,照样会排队等连接。
我在测试环境里实测过,一个模拟大量阻塞IO场景的Spring Boot服务,开启虚拟线程后吞吐量大概提升了三倍以上,但业务响应时间没有明显下降。说明虚拟线程对高并发场景的帮助是真实的,但它不是万能药,更不是用来替代合理架构设计的。
6.3 我最想强调的几个打包实战经验
走到这里,Spring Boot打包的主干流程和常见坑基本都过了一遍。最后分享几个我自己在实际项目里沉淀下来的习惯,不算进阶教程,但能给打算开始做部署的同学省不少时间。
第一,项目里始终保留spring-boot-maven-plugin,并且养成打包后看一眼target目录的习惯。里面有.jar.original说明repackage确实是执行过的,没有的话就回去检查插件配置。
第二,配置文件外置从第一天就做。哪怕只有一台服务器,也应该把application.yml放到config目录或用环境变量覆盖,而不是每次改配置都重新打包。环境变量注入数据库密码、MinIO密钥、Redis地址的方式,能让你在多个环境之间切换时完全不用动代码。
第三,Docker部署一定要用分层构建。团队协作时,构建速度直接影响研发效率,分层缓存带来的收益是实打实的。如果发现镜像构建特别慢,优先检查是不是没有用layers。
第四,遇到打包问题不要急着搜索“No main manifest attribute”之类的报错原文,先看完整日志,再顺着repackage这个关键词找到问题根源。绝大多数打包问题都出在插件的执行状态、JDK版本一致性、资源配置这三个维度上,定位到具体维度,解决方向就很清晰了。
最后再补一个扩展思路:如果你已经在用Spring Boot 3.5和Java 21,可以尝试做一个最小的演示项目跑虚拟线程,再对比一下传统模式和原生镜像模式的区别。技术选型这种事,亲自测得出来的数据比任何教程里的结论都靠谱。