先说明一个很常见的尴尬场景:你在 IDEA 里写好了一个 Spring Boot 项目,Application类里的main方法写得明明白白,点击 Maven 面板里的package,控制台却甩出这么一行:
[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:2.7.5:repackage (repackage) on project demo: Execution repackage of goal org.springframework.boot:spring-boot-maven-plugin:2.7.5:repackage failed: Unable to find main class中文翻译过来就是“打包阶段重打包失败,找不到主类”。但你心里很清楚:主类就在那儿,明明昨天还用同样的方式打出来了 jar。问题到底出在哪?
这个报错在 Maven 使用者的日常里出现频率极高,几乎每个做 Spring Boot 多模块项目的人都至少碰过一两次。它牵扯到的不仅仅是“你有没有写 main 方法”,而是 Maven 构建顺序、插件继承、编译产物目录、JDK 版本等多个层面的东西。这篇文章我就从底层机制开始,把这个报错彻底拆开,再按不同根因给出对应的修复方案,最后复盘一次完整的多模块排查过程。
1. 先还原报错现场:这条“Unable to find main class”从哪一步冒出来
老规矩,先看报错的位置。整条报错的核心是spring-boot-maven-plugin:2.7.5:repackage,注意这里的repackage是插件的 goal,不是 Maven 的package生命周期。整个流程是这样的:
Maven 执行mvn clean package时,package阶段会把项目编译后的 class 文件打成普通的 jar 包。打完这个普通 jar 后,如果pom.xml配置了spring-boot-maven-plugin的repackage目标,插件会紧接着对这个 jar 进行“二次加工”,把它转换成 Spring Boot 的可执行 JAR。
这个二次加工发生在同一个 package 阶段里,顺序大致是:
compile -> test -> jar -> repackage所以你在日志里通常会先看到spring-boot-maven-plugin ... repackage,然后才看到失败信息。报错的触发点非常明确:repackage 在处理已经生成的 jar 时,没能在里面找到适合作为应用入口的主类。
这就出现了一个认知上的常见偏差:很多人以为“找不到主类”指的是编译器找不到,去查源码、查目录结构,其实编译器早就把源码编译成 class 了,问题出在 later 的重打包阶段。
先记住一句话:这个错误跟你的源码“里有没有” main 方法没有必然关系,它关心的是插件有没有能力从构建产物中“提取到”一个合法的启动类。源码里有,不代表插件一定能认出来。
另外,这个报错并不是只有命令行执行mvn package才会出现。你在 IDEA 的 Maven 面板里双击package、在 CI 脚本里跑构建、甚至某些开发者在配置 Docker 镜像构建流程时启用 Maven 打包步骤,都可能踩到完全相同的一行错误。报错虽然只有一句话,背后对应的项目状态却可能五花八门,下面我们就先把 repackage 的查找逻辑彻底讲透。
2. repackage 的查找机制:Maven 是怎么在 class 文件里“找”主类的
要解决这个报错,就必须知道 repackage 到底怎么找主类、按什么规则判断一个类能不能当主类。
Spring Boot 的可执行 JAR 和普通 JAR 有一个核心区别:普通 JAR 只是把项目自身编译后的 class 文件和资源文件压缩在一起,而 Spring Boot 可执行 JAR 需要让java -jar能直接启动整个应用,所以它必须额外写入两类信息:
Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: com.example.demo.DemoApplicationMain-Class是 JVM 启动时的真正入口,Spring Boot 会用自己的JarLauncher来负责加载嵌套在 BOOT-INF/lib 里的依赖 jar;Start-Class才是你自己写的那个带 main 方法的应用类。repackage 过程中,插件必须先拿到Start-Class的完整类名,才能把它写进 MANIFEST.MF,否则这个可执行 JAR 就缺少启动入口。
插件获取主类名的顺序也很关键:
- 先看
pom.xml里 spring-boot-maven-plugin 的<configuration>中是否显式配置了mainClass。如果配置了,插件直接用这个类名,不再扫描。 - 如果没有配置,插件会自动扫描
target/classes目录下的所有.class文件。 - 扫描规则是找
public static void main(String[] args)方法,方法名必须是main,参数必须是String[],修饰符必须是public static void,这个匹配是严格到字节码级别的。 - 找到符合条件的类,作为
Start-Class写入清单文件。
这里有几个非常典型的反例:
- 有人把
main方法写成了public void main(String[] args),少了static,编译不会报错,但插件不认。 - 有人写的是
public static void main(String args),参数不是数组,同样编译能通过,插件还是看不出这是入口。 - 有人把主类写在了没有
public修饰的类里,或者把 main 方法放进了一个普通工具类中,识别阶段也可能失败。
插件在扫描阶段找不到可以匹配的类,就会抛出Unable to find main class。同时,如果模块里存在多个 main 方法,不同版本的处理方式也不一样,有的版本会选第一个,有的版本会直接抛异常。但无论哪种情况,工程实践上都不该依赖“碰运气”让它随便选一个当入口,正确做法永远是显式声明主类,或者保证模块里只有一个可执行的 main 方法。
所以问题的本质可以归纳成一句话:repackage 需要一个明确的启动入口,要么你告诉它在哪,要么它自己能在编译产物里精准地找到唯一一个合法的入口。
3. 按根因分类修复:从改配置到重新设计模块结构
了解了查找机制后,再去看各种报错场景就会清晰很多。下面按照实际项目里最常见的几类根因,给出对应的解决方案。
3.1 应用模块的主类没被插件识别:先显式声明 mainClass
最直接的情况:项目本身是一个 Spring Boot 应用,主类也有,但因为某些原因插件扫描不到,这时候最省事的办法就是在pom.xml里显式指定主类。
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.demo.DemoApplication</mainClass> </configuration> </plugin> </plugins> </build>如果你使用了spring-boot-starter-parent作为父工程,还有一个更偷懒的方式,是通过属性指定start-class:
<properties> <start-class>com.example.demo.DemoApplication</start-class> </properties>这里要注意一个细节:start-class是 Spring Boot 定义的属性名,在spring-boot-starter-parent里被声明成了可配置项;而<configuration><mainClass>是 spring-boot-maven-plugin 插件自己的配置参数。两者都能影响最终Start-Class的写入,但作用范围略有不同。项目中如果同时出现两者且值不一致,以插件配置里的mainClass为准。
显式声明的另一个好处是:如果模块里恰好有多个 main 方法,不管插件版本的处理策略是什么,你都可以锁定真正的启动类,彻底避免插件“选错入口”带来的一系列奇怪问题。
3.2 纯库模块被误加了打包插件:用 skip 参数跳过重打包
这个场景在微服务项目里特别常见。比如你的工程里有common、dal、utils这类基础模块,它们内部根本没写 main 方法,也不可能单独启动。但如果你在父工程的<build><plugins>里直接声明了spring-boot-maven-plugin,这个插件会被所有子模块继承,结果是每一次构建到这些普通 jar 模块时,repackage 都会去扫描主类,扫不到就直接报错。
解决办法有两种,选哪一种取决于你的工程结构:
如果你只希望某个模块不触发 repackage,就在该模块的插件配置里加上skip:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <skip>true</skip> </configuration> </plugin> </plugins> </build>如果整个父工程下面的子模块只有少数几个是可启动应用,我更推荐的做法是把插件从父工程的<plugins>挪到真正的应用模块中去。这样普通模块连插件都不会继承,自然也不会执行 repackage,省得为每个库模块都写一遍 skip。
<!-- 父 pom 中只做版本管理 --> <build> <pluginManagement> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </pluginManagement> </build> <!-- 仅应用模块 pom 中声明使用 --> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>这里特别提醒一句:不要以为删除普通 jar 模块里的 main 类就能解决,真正的问题是“一个不需要可执行 jar 的模块根本不该执行 repackage 这个动作”。你可以在mvn package之后去看 target 目录下生成的 jar 文件大小,如果模块里没有任何依赖但突然冒出一个几十 MB 的 fat jar,那基本可以确定是 repackage 在这个模块上也生效了。
3.3 多模块依赖顺序和父工程插件继承引起的“连锁反应”
再来看一个更容易混淆的情况:多模块项目里,应用模块 A 依赖普通模块 B,B 模块自己又因为继承了上面的插件而需要执行 repackage。构建时 Maven 会先构建 B,B 构建到 package 阶段就报Unable to find main class,导致整体构建中断。很多人第一反应是去查 A 模块的主类,查了半天才发现报错信息里写的是 B 模块。
遇到这种情况,最有效的方式是第一时间定位报错中的on project xxx字段。比如:
[ERROR] Failed to execute goal ... on project common: ...这个common才是要处理的对象。然后判断它到底是不是一个需要被打成可执行 JAR 的模块:不是,就按前面 3.2 节的思路处理;是,才进入主类排查的流程。
还有一种比较隐蔽的坑:有些模块本身是已经打好的可执行应用,但依赖方把它当成普通 jar 引进来。这种情况项目会莫名其妙地多出一个“启动另一个应用”的副作用,同时如果同一个仓库里有多个可执行模块,构建顺序也可能不稳定。规范做法是:多模块仓库中,保证只有最顶层的应用子模块是可执行的,其余模块一律按普通 jar 处理。
3.4 JDK 版本不匹配引发的“伪报错”
这个坑很少有人第一时间想到,但它真实存在。Spring Boot 插件在用字节码方式扫描 class 文件时,需要能正常读取 class 文件结构。如果你的 Maven 运行在一个 JDK 版本,而项目编译参数又指定了另一个版本,或者编译器产出的 class 版本过高,插件读取阶段就可能出现底层异常,偶尔会连带表现为Unable to find main class。
最常见的场景是:
- 本地
JAVA_HOME是 JDK 17,项目pom.xml里java.version写的是 8,但maven-compiler-plugin没有正确设置release参数。 - 编译产物实际是 JDK 17 的 class 文件,而 Spring Boot 2.x 某个版本的 repackage 读取这些 class 时出现兼容问题。
所以遇到这个报错,不要光盯着主类,顺手检查一遍环境也是分内的事:
java -version mvn -versionmvn -version输出里能看到 Maven 当前使用的 Java 版本。如果它和java.version相差过大,先统一版本再重新打包试试。这里我建议在pom.xml里明确加上 compiler 的release参数:
<properties> <java.version>17</java.version> <maven.compiler.release>17</maven.compiler.release> </properties>maven.compiler.release的作用是告诉编译器,生成的 class 文件必须符合对应 JDK 版本的规范,这样比只设置source/target更严格,也能避免一些跨版本编译导致的问题。
4. 命令行 + IDEA 双路径排查:一次多模块实际踩坑过程演示
很多朋友遇到这种报错习惯直接搜解决方案,但我更建议你先花五分钟把问题定位清楚。下面是我自己用的排查流程,基本能覆盖 90% 的情况。
第一步,先看报错里的模块名。命令行执行完整构建时,注意on project xxx后面的值。如果这个模块根本不该启动,直接加skip=true或移除插件即可。
第二步,进入报错模块,查看编译产物中是否存在主类。先执行mvn clean compile,然后确认 class 文件是否生成:
ls target/classes/com/example/demo/DemoApplication.class如果没有这个文件,说明源码编译或目录结构有问题,比如src/main/java路径被错误修改、源码被 IDE 排除了编译范围等。从 Git 最近改动里对比一下,往往很快能找到原因。
第三步,class 文件存在,继续用javap验证 main 方法签名:
javap -p -classpath target/classes com.example.demo.DemoApplication正常情况下输出里有这么一行:
public static void main(java.lang.String[]);如果看到void main(java.lang.String[]),缺少public static,那插件当然不认。如果看到的是泛型或别的参数类型,同样不符合要求。
第四步,如果这些都没有问题,再检查是不是多模块共用父 pom 导致的插件继承问题。最直观的方式是单独进到应用模块目录,执行:
mvn package -pl demo -am然后观察是不是只构建应用模块本身时也会报错。如果单独构建成功、整体构建失败,那就是模块之间的插件继承关系在捣乱。
在实际排查中,我还会经常用到一个快速判断方式:直接解压打出来的普通 jar 看看有没有主类声明。因为 repackage 是在普通 jar 的基础上处理的,如果生成的原始 jar 里 MANIFEST.MF 都没有Main-Class,那 repackage 自然无从下手。
在 IDEA 里排查的时候,有两点容易踩:
第一,IDEA 自身的 Build 和 Maven 构建不是一回事。如果你在 IDEA 里先执行了 Build Project,IDEA 会把 class 输出到out/目录,但 Maven 打包用的是target/目录。这时候最好先双击 Maven 面板的clean,再执行package,避免老旧的目标文件干扰判断。
第二,IDEA 对 Maven 项目的自动导入偶尔会“假成功”。如果pom.xml改了插件配置但没有触发 Maven reload,实际生效的仍是旧的依赖关系。看到 IDEA 右下角提示Maven projects need to be imported时,点一下刷新再试,这个细节省时省力。
为了更直观地对照问题根因和解决方案,我把常见的几种情况整理成了表格:
| 报错场景 | 根因 | 最直接的处理 |
|---|---|---|
| 单模块 Spring Boot 应用,主类存在但报错 | 插件没扫描到主类 | 显式配置 mainClass / start-class |
| 普通工具模块继承打包插件 | 不需要 repackage | 加 skip 参数或移除插件 |
| 多模块整体构建失败,报错模块不是应用模块 | 模块被错误地执行了 repackage | 将插件声明限制到真正的应用模块 |
| 主类存在且签名正常,仍扫描失败 | JDK 版本或编译产物不兼容 | 统一 JAVA_HOME 和 java.version,clean 后重打 |
| 主类签名不正确 | main 方法缺 static 或参数错误 | 修正 main 方法签名 |
| target 目录旧文件残留 | 重复构建导致元数据混乱 | 先执行 mvn clean 再 package |
表格里的这些路径基本覆盖了我见过的绝大多数情况,剩下的属于插件自定义程度特别高的项目,那就需要去对照 spring-boot-maven-plugin 的官方文档里mainClass、skip、classifier这几个参数的具体语义了。
5. 避开这块“第 N 次踩坑的感受和之后的习惯”
入行这么多年,我用 Maven 打包踩过的坑里有三分之一都和 repackage 有关。第一次碰到这个报错的时候,我还应当是在给一个老旧的 Spring Boot 1.x 项目升版本,彼时对“主类明明存在却被说找不到”的困惑相当大。后来一步步翻源码、对照文档,才算把这个机制理顺。
这里分享几个我自己固定的例行习惯,能大大减少踩坑概率:
习惯一:每个模块的职责要清晰。同一个 Maven 仓库里,我会严格区分“可执行应用模块”和“被依赖的库模块”。库模块的 pom 里不继承 spring-boot-maven-plugin,除非它确实需要被打成可执行 jar。这样的结构从源头杜绝了库模块触发 repackage 的可能。
习惯二:不在父 pom 的 plugins 里声明会执行 repackage 的插件。父 pom 只放 pluginManagement,负责锁定版本和默认配置,具体要不要启用,留给各个子模块自己决定。这招对多模块项目的可维护性提升非常明显。
习惯三:给应用模块显式声明 mainClass。即使当前模块里只有一个 main 方法,我也会写上。因为项目后期大概率会加入一些带 main 的调试工具类、批处理入口,到时候多入口并存,不写 mainClass 的话插件行为就不稳定,运气不好就会打包出一个错误入口的可执行 jar。
习惯四:每次构建前先 clean。这个听起来像是在说废话,但实际项目中因为 target 目录残留旧 class 文件导致的诡异问题,我至少见过十次以上。Maven 构建不是 IDE 增量编译,不 clean 的情况下,某些被删除的类可能在旧 jar 里继续存在,干扰 repackage 的判断。
最后再说一个很多人不知道的小点:如果你确实不需要 repackage,又不想改太多配置,可以用classifier方式绕过:
<configuration> <classifier>exec</classifier> </configuration>它会额外生成一个xxx-exec.jar作为可执行包,原始 jar 则保持普通库 jar 的形态。这样你的模块既能被别人当依赖引用,也能独立启动。不过,这个方案对团队协作不那么友好,因为命名变了之后,依赖方引用的坐标可能也要跟着调,所以不是特殊情况,我一般还是会优先选择按职责拆分模块和 skip 方案。
Maven 的报错信息有时候很直接,有时候又特别容易让人绕远路,Unable to find main class就属于后者。它表面在说“没找到主类”,实际上大部分时候是“构建配置没有正确告诉插件主类在哪里”。理解了 repackage 的工作方式,再按报错模块、编译产物、插件配置、JDK 版本这个顺序排查,问题基本都能在一个小时内解决。下次再看到这行红色报错,你已经知道它真正想说的是什么了。