1. 复现现场:这个“找不到启动类”到底报了什么
你有没有过这种经历:本地 IDE 里点一下 Run,接口全通,页面正常,数据库读写都没毛病。等到准备发版,把项目传到云服务器,java -jar 按下去,几秒钟后控制台甩出一行冷冰冰的文字:
text复制错误: 找不到或无法加载主类 com.example.demo.DemoApplication
原因: java.lang.ClassNotFoundException: com.example.demo.DemoApplication
这一刻,很多人第一反应是“代码是不是传错了”“服务器上是不是少放了文件”“为什么本地没问题”。我直接说结论:这类问题九成以上不是业务代码的问题,而是构建产物、启动命令、运行环境三者之间的一致性出了问题。更扎心的是,本地“能跑”这件事,恰恰会掩盖真正的差异点。
1.1 两种典型报错,掩盖了不同的故障层
先分清楚你看到的到底属于哪种报错,因为修复方向完全不一样。
第一种是 JVM 启动时直接提示“找不到或无法加载主类”。这种报错发生在 main 方法还没执行之前,JVM 按照类名去 classpath 里扫描对应的 .class 文件,但一无所获。原因要么是类名/包名写错,要么是运行时 classpath 里根本没有那个类文件。
第二种是 Spring Boot 的 banner 已经打出来了,启动过程中突然来一段 Caused by: java.lang.NoClassDefFoundError,或者某个 ClassNotFoundException,里面又带着你自己的 DemoApplication。这种情况往往不是启动类丢失,而是某个运行时要加载的类不存在,只不过栈顶会先出现“启动类相关”的字样,容易让人误判成标题里的“找不到启动类”。
还有一个非常常见的兄弟错误:no main manifest attribute, in app.jar。它通常出现在你直接对普通 jar 执行 java -jar 时,因为 jar 包里的 META-INF/MANIFEST.MF 没有告诉 JVM “Main-Class 是谁”。
我把这几年遇到的现象整理成一张对照表,实际排查时可以对着看:
| 报错现象 | 真正问题方向 | 首选处理动作 |
|---|---|---|
错误: 找不到或无法加载主类 com.xxx.Application |
classpath 中缺失该类,或类名/包名不一致 | 先确认 jar 里是否存在该类,再检查启动类路径 |
no main manifest attribute |
打出来的不是 Spring Boot 可执行 fat jar | 检查 spring-boot-maven-plugin 的 repackage 配置 |
Caused by: java.lang.NoClassDefFoundError: org/springframework/boot/SpringApplication |
直接用 -cp 方式运行 fat jar,绕过 JarLauncher |
改用 java -jar 启动 |
java.lang.UnsupportedClassVersionError |
本地编译 JDK 版本高于服务器运行 JDK | 统一两侧 JDK 版本,或升级服务器 JRE |
1.2 先别改代码,先判断是不是这四类问题
遇到“本地没问题,服务器找不到启动类”,我建议先忍住打开源码改东西的冲动。先问自己四个问题:
第一,你上传到服务器的到底是个什么文件?是 mvn clean package 之后 target 目录里那个真正可运行的 jar,还是直接把整个项目文件夹压缩上传了?如果是后者,服务器上根本没有被正确编译好的产物,你后续所有启动操作都建立在“空气”上。
第二,你的启动命令是什么?很多人参考网上零散教程,习惯写 java -cp target/classes com.example.DemoApplication。这个命令在本地也许能跑,因为类文件真的就在 target/classes 里。但在服务器上,如果你没有执行过编译,或者在另一个目录下执行,target/classes 路径根本不存在,自然找不到主类。
第三,构建方式和本地运行方式是否一致?本地 IDE 点 Run 时,帮你干活的不一定是 Maven 的 package 流程。IDE 会做增量编译,自动拼接 classpath。而服务器上如果是从 Git 拉代码再执行构建,环境变量、JDK 版本、Maven 版本都可能不一样,结果可能差很远。
第四,服务器上运行的 Java 版本和本地编译版本是否一致?Class 文件有版本号,高版本 JDK 编译出的 class,低版本 JDK 无法加载。表现形式上可能不是温和的提示,而是在启动入口处直接抛 UnsupportedClassVersionError,日志又被截断成“找不到类”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么本地没问题:IDE 替你藏了多少事
很多人对“本地能跑 = 程序没问题”这个等式深信不疑。但部署环境里,本地验证通过只是必要不充分条件。你要先理解本地为什么能跑,才可能定位服务器上为什么不跑。
2.1 IDE 的 Run 按钮背后,classpath 是自动拼好的
以 IDEA 为例,你点 Run 的时候,它并不是执行 java -jar xxx.jar。IDEA 会先看项目模块配置,把每个模块的 target/classes 目录以及所有依赖库的路径收集起来,拼成一条长长的 classpath,然后再调用 java -cp 这条长长的classpath com.example.DemoApplication。
这个过程里,IDE 至少帮你做了三件事:
- 自动执行编译,保证最新的
.class文件存在于 target/classes 下; - 自动解析 Maven/Gradle 依赖,把所有第三方 jar 的完整路径都塞进 classpath;
- 自动把当前项目的运行目录(Working directory)设置成你指定的位置。
所以在本地,你的启动类其实是从 target/classes/com/example/DemoApplication.class 这个物理文件加载的,不是从一个可执行 jar 内部加载的。你换一台机器、换一种启动方式,这套“隐形后台”就消失了。
而云服务器上,绝大多数人会用 java -jar app.jar 来启动 Spring Boot 项目。这时候,JVM 不再去 target/classes 里找类,而是去一个特定结构的 fat jar 里找。如果这个 jar 不是按照 Spring Boot 可执行 jar 的标准格式打的,启动类加载自然会失败。
2.2 环境差异的优先级:JDK 版本、操作系统、目录与文件名
除了 IDE 的便利性,环境差异也是重灾区。按影响权重排序,我一般会先看这三样。
JDK 版本不一致是最隐蔽的。JDK 8 编译出的 class 是 52.0 版本,JDK 11 是 55.0,JDK 17 是 61.0。服务器上如果只装了 JDK 8,而你在本地用 JDK 17 编译项目,JVM 加载 class 时会直接拒绝。有些部署脚本为了把日志输出得好看一点,只打印了异常堆栈的最后几行,乍一看全是“找不到类”,仔细看第一行才发现是 UnsupportedClassVersionError。
操作系统差异主要体现在路径分隔符和文件名大小写。Windows 下 classpath 用 ; 分隔,Linux 下用 : 分隔。如果你在本地 Windows 上手工写过一条带 classpath 的启动命令并能跑,直接复制到 Linux 上大概率会出问题。另一个坑是 Windows 文件系统不区分大小写,而 Linux 区分。类名是 DemoApplication,如果服务器上某个环节把文件名写成了 demoApplication.class,JVM 会找不到。
工作目录和脚本路径则更容易让人崩溃。很多启动脚本里习惯使用相对路径,比如 java -jar ./app.jar,但是 systemd 服务的工作目录默认可能是 root 用户的家目录,而不是 jar 所在目录。路径一旦不对,加载不到的就不只是启动类,包括外部配置文件、日志目录都会跟着出问题。
3. 按这个顺序排查,不要跳步骤
遇到报错不要慌,也不要先改代码。你要做的是用排除法一步步锁定问题。下面这条链路我用了很多年,按顺序走,通常几分钟就能定位。
3.1 第零步:先在本地用命令行把服务拉起来
先彻底关掉 IDE 的 Run 窗口。打开终端,进入项目根目录,执行一次干净构建:
bash复制mvn clean package -DskipTests
构建成功后,查看 target 目录下的产物:
bash复制ls -lh target/*.jar
然后运行这个 jar:
bash复制java -jar target/your-app-0.0.1-SNAPSHOT.jar
如果这里能正常启动,说明产物基本可用。如果这一步就报“找不到启动类”,那问题出在构建配置上,跟服务器没有半毛钱关系。需要先回头修 pom.xml 或 build.gradle。
为什么必须做这一步?因为 IDE 的 Run 按钮可能绕过完整打包流程。你本地能跑,只能说明源码编译通过;不代表 mvn package 之后生成的 jar 能被 java 直接执行。先模拟服务器最常见的启动方式,等于在本地把“第一道坎”迈过去。
3.2 检查上传后的 jar 本体,别凭感觉部署
本地 java -jar 成功后,接下来把那个 jar 文件上传到服务器。很多人习惯用压缩工具或者图形界面拖拽上传,小文件没什么问题,大文件传一半断了也不知道,或者文件名被改动。建议先比对一下校验值。
在本地执行:
bash复制md5sum target/your-app-0.0.1-SNAPSHOT.jar
上传到服务器后再执行一次:
bash复制md5sum /opt/app/your-app-0.0.1-SNAPSHOT.jar
两个值一致,才能保证你服务器上运行的东西和本地验证过的东西是同一个文件。这一步虽然基础,但我见过不少同事花费大把时间排查问题,最后发现上传的 jar 是三天前的旧包。
同时检查一下服务器上 jar 文件的位置和当前工作目录。我建议总是使用绝对路径启动,不要依赖相对路径。
3.3 用 MANIFEST 和 jar 结构判断启动入口
如果 jar 文件本身没问题,还是报找不到启动类,那就得打开 jar 内部看看。
先用 jar 命令列出全部内容,确认启动类是否存在:
bash复制jar tf your-app-0.0.1-SNAPSHOT.jar | grep DemoApplication
正常输出应该能看到形如 BOOT-INF/classes/com/example/DemoApplication.class 的条目。如果什么都没看到,说明你打包出来的 jar 根本不含这个类。这时候要么是模块没包含进来,要么是编译就没成功,只是 Maven 把旧的 target 残留物打了进去。
再看 META-INF/MANIFEST.MF:
bash复制unzip -p your-app-0.0.1-SNAPSHOT.jar META-INF/MANIFEST.MF
如果是标准的 Spring Boot 可执行 jar,里面一定会有两行关键内容:
text复制Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.example.DemoApplication
Main-Class 是 JVM 真正加载的入口,Spring Boot 通过它来启动自定义类加载逻辑。Start-Class 指向你自己写的启动类。如果你的 MANIFEST 里根本没有这两行,或者 Start-Class 写错,就会直接“找不到启动类”。
另外要注意,不同 Spring Boot 版本里 JarLauncher 的包路径有变化。早期版本是 org.springframework.boot.loader.JarLauncher,Spring Boot 3.2 之后变成了 org.springframework.boot.loader.launch.JarLauncher。只要结尾是 JarLauncher,基本没问题;如果没有 JarLauncher,多半是在打包环节少配置了 spring-boot-maven-plugin。
3.4 用 verbose:class 看 JVM 实际加载过程
排查到这如果还没头绪,可以让 JVM 自己“交代”它到底加载了什么。
执行启动命令时加上参数:
bash复制java -verbose:class -jar your-app-0.0.1-SNAPSHOT.jar
控制台会输出大量 [Loaded ...] 信息。重点观察启动相关的日志里有没有出现:
text复制[Loaded com.example.DemoApplication from file:/opt/app/your-app-0.0.1-SNAPSHOT.jar]
如果没有任何 DemoApplication 的加载记录,说明启动类压根没被找到;如果能看到加载记录,但随即抛异常,说明类在但依赖不完整,问题可能出在依赖 jar 的加载或被跳过。
这个方法在本地和服务器都适用。对比两个环境下的加载日志,往往能一眼看出差异点。
3.5 服务器进程的工作目录与 JAVA_HOME
最后一步检查运行进程的环境,这一步经常被忽略。很多服务器上同时装了多个 JDK,你登录终端时 java -version 显示的可能是 JDK 17,但 systemd 服务脚本里通过 JAVA_HOME 指定的却是 JDK 8。服务启动时使用的那一套 Java,不一定是你 shell 里看到的那一套。
所以排查时要看两处。
第一,实际执行的 java 路径。在启动脚本里写成 /usr/bin/java 或 $JAVA_HOME/bin/java,而不是直接写 java,避免 PATH 混乱。
第二,systemd 服务单元文件里的 WorkingDirectory。我习惯把所有部署物放到固定目录,比如 /opt/app/,然后在 service 文件里写:
ini复制WorkingDirectory=/opt/app
ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/app/your-app.jar
工作目录设置正确,外部配置、日志路径才不会漂移。
如果启动时用了类似这样的脚本:
bash复制nohup java -cp app.jar com.example.DemoApplication > app.log 2>&1 &
那请立即停下来,换回 java -jar app.jar。原因后面详细说。
4. 修复动作:从“命令能用”到“部署体质健康”
排查到这一步,大多数问题已经能定位。接下来要做的不是只把这一次服务拉起来,而是把部署方式彻底改成不容易出错的形态。
4.1 让 Spring Boot 插件产出真正可执行的 fat jar
如果你的项目还在用 Maven,并且发现打包产物不是标准的 Spring Boot fat jar,最优先的修复动作是在 pom.xml 的 build 节点里检查 spring-boot-maven-plugin 配置。最省心的方式是通过 spring-boot-starter-parent 统一管理插件版本和 repackage 执行绑定:
xml复制<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
如果项目因为特殊原因不能使用父 POM,那至少要显式配置插件并绑定 repackage 目标:
xml复制<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
repackage 的作用是在常规打包之后,再把普通 jar 加工成 Spring Boot 可执行 jar。加工后的 jar 内部会出现 BOOT-INF/classes 和 BOOT-INF/lib,后者放着所有依赖的第三方 jar。没有这一步,打出来的只是一个普通 Java 库 jar,用 java -jar 启动时系统只知道找 MANIFEST.MF 里的主类,不知道去解析 BOOT-INF/lib 里的嵌套依赖。
Gradle 项目对应的是 bootJar 任务。只要应用了 org.springframework.boot 插件,执行 gradle clean bootJar 产出的 jar 就是 fat jar。
4.2 启动命令只保留一条主路径
为什么我反复强调用 java -jar,而不是 java -cp app.jar com.example.DemoApplication?因为两者对 Spring Boot fat jar 的处理机制完全不同。
Spring Boot 的可执行 jar 里,业务代码放在 BOOT-INF/classes,第三方依赖放在 BOOT-INF/lib。JDK 自带的类加载器根本不认识这种“jar 里面的 jar”结构,它只会把最外层的 jar 本身加入 classpath,不会自动穿透到 BOOT-INF/lib 去加载依赖。
java -jar app.jar 能够工作,是因为 JVM 启动时读取 MANIFEST 里的 Main-Class,找到 JarLauncher。JarLauncher 内部实现了对嵌套 jar 的识别和加载,然后再根据 Start-Class 反射调用你的业务启动类。
如果跳过 JarLauncher,直接用 java -cp app.jar com.example.DemoApplication,DemoApplication 这个类本身也许能通过最外层 classpath 找到,但一旦它开始引用 Spring Boot 的核心类,比如 org.springframework.boot.SpringApplication,JVM 在传统的 classpath 里找不到这些位于 BOOT-INF/lib 嵌套 jar 中的类,于是抛出 NoClassDefFoundError。在某些 JDK/Shell 组合下,这个异常信息不完整,最终看上去就像“启动类找不到”。
所以,部署 Spring Boot 项目时,启动命令唯一的主路径就是:
bash复制exec java -Xms512m -Xmx1024m -jar /opt/app/your-app.jar "$@"
不要在你自己的启动脚本里再手工拼 classpath,不要绕过 JarLauncher。这条规则能让你避开大量莫名其妙的类加载问题。
4.3 多模块项目的打包缺口往往在“边缘模块”
如果你的是一个多模块 Maven 项目,比如包含 common、core、web 三个模块,启动类放在 web 模块里,那在根目录执行 mvn clean package 之后,一定要确认你运行的 jar 是 web/target 下生成的那个,而不是 core/target 或者根目录 target 下的东西。
多模块项目比较容易出现两个问题。
一个是模块依赖顺序不对。某个模块先被 install 到本地仓库,但代码改动后没有重新 install,导致最终打的包里引用了旧版本的公共类。这时服务启动后可能在某个懒加载阶段突然找不到符号,而且栈信息可能指向你自定义的公共类,而不是启动类。
另一个是只在某个子模块执行了打包,但该子模块没有正确引入 Spring Boot 插件,于是打出的 jar 是普通 jar。修复方式是保持在一个固定入口模块上执行构建,不建议“到处乱打包”。如果有 CI/CD,最好让流水线在项目根目录统一执行:
bash复制mvn clean package -DskipTests
然后通过脚本找到对应的产物路径,防止人工选错。
4.4 用 Dockerfile 固化运行时环境
如果你的部署环境可控,我更推荐直接用 Docker 把“环境不一致”这个问题彻底消掉。多阶段构建能做到“构建用 JDK 17,运行用 JRE 17”,避免服务器本身装错版本。
一个比较稳的 Dockerfile 结构如下:
dockerfile复制FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
ENV TZ=Asia/Shanghai
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]
这里有几个细节值得说。
多阶段构建的第一阶段负责把依赖下载和源码编译完成,第二阶段只保留运行所需的最小 JRE 和 jar。镜像体积小,启动类与依赖同样被完整打进 app.jar。运行时使用 -XX:MaxRAMPercentage=75.0 可以让 JVM 根据容器内存限制自动调整堆大小,避免手动设置固定内存后容器超限被杀。
如果公司里没有统一镜像源,mvn dependency:go-offline 这一步可能会有点慢,但它能很好地利用 Docker 层缓存。后续每次只改 Java 代码时,不会重复下载依赖。如果你只是在服务器上直接跑原始 jar,那至少要做到:服务器安装的 JDK 版本和本地构建版本完全一致,并且启动前先执行 java -version 确认。
5. 踩坑备忘录:一次教训和一份部署前自检清单
前面把机制、排查、修复都讲完,最后分享一个我自己被带偏过的真实案例,以及我现在每次部署前必过的检查项。
5.1 一个 UnsupportedClassVersionError 引发的“假性失踪”
前两年维护一个老项目,本地用的 JDK 8,但新需求里用到了需要 JDK 11 才能编译的写法。那时候同事建了一个分支,直接用本地的 JDK 11 编译,跑测试没问题,就打了包上传到测试服务器。测试服务器上跑的还是 JDK 8,启动后日志刷出来一堆异常。运维同事第一眼看到“ClassNotFoundException: com.xxx.Bootstrap”,立刻判断是启动类没发布进去。当时大家先后检查了部署目录、配置中心、jar 包结构,甚至怀疑上传工具把文件损坏了。
最后清理日志仔细一看,最顶部的关键错误其实是:
text复制java.lang.UnsupportedClassVersionError: com/xxx/Bootstrap has been compiled by a more recent version of the Java Runtime
由于 Bootstrap 类名出现在异常信息里,加上项目里其他模块某些类因为版本不兼容而没有被正常加载,日志后半段才抛出了各种 ClassNotFoundException。排查团队被“启动类失踪”的假象误导,白白折腾了大半天。
这个案例给我的教训是:看到“找不到类”时,永远先看日志最前面的完整异常,而不是只看包含自己类名的那一段。很多类加载异常是连锁反应,真正的根因往往藏在最上面或者 Caused by 的最底层。
5.2 部署前我必定检查的硬指标
现在每次上线,我都会按下面这张表跑一遍,全部通过后再把流量切过去。
| 检查项 | 执行动作 | 预期结果 |
|---|---|---|
| 干净构建 | mvn clean package -DskipTests |
BUILD SUCCESS |
| 本地 jar 启动 | java -jar target/xxx.jar |
端口正常监听,接口可访问 |
| jar 内部结构 | jar tf target/xxx.jar |
能看到 BOOT-INF/lib 和启动类 class |
| MANIFEST 入口 | unzip -p target/xxx.jar META-INF/MANIFEST.MF |
存在 JarLauncher 和 Start-Class |
| 上传完整性 | md5sum xxx.jar |
服务器和本地校验值一致 |
| JDK 版本 | java -version |
与本地构建 JDK 一致 |
| 启动方式 | 脚本中使用 java -jar + 绝对路径 |
不再手工拼接 classpath |
其中“本地 jar 启动”和“上传完整性”这两项最容易跳过,但恰恰最值得做。很多人只在 IDE 里验证完就自信上传,结果制品本身是坏的。我实操中还有个习惯:第一次在新环境启动服务时,先用前台方式运行,而不是直接丢到 systemd 或 nohup 后台。前台跑的好处是,启动日志会直接输出到终端,任何 ClassNotFoundException、UnsupportedClassVersionError 都能在第一时间看到完整堆栈,不会被容器日志或 shell 切换截断。确认起来没有问题后,再换成后台守护方式。
“找不到启动类”这个错误,本质上是一个“入口失联”的问题。只要入口 class 在、入口配置对、运行环境兼容,它就不会出现。希望这篇排坑记录能让你少浪费一个晚上的睡眠时间。
