ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Maven构建失败?解析exec-maven-plugin报错与排查策略

Maven构建失败?解析exec-maven-plugin报错与排查策略

"Maven build failed"这种红字报错见过不少,但Failed to execute goal org.codehaus.mojo:exec-maven-plugin:1.6.0这句话,绝对能排进我最常被问到的榜单前三。第一次遇到它时,我的第一反应是冲进代码里翻main方法,查启动类,折腾了快两小时。后来才发现,这个报错十有八九和你的业务代码没关系,真正的坑藏在插件本身、JDK版本、类路径这几层里。

这篇文章就专门拆解这个报错。我会先说清楚exec-maven-plugin这个插件到底在构建过程中扮演什么角色,再按照我实际踩坑的频率,把最容易出问题的几个层面讲透,最后给出一套完整的排查流程。无论你是刚接触Maven的新手,还是被这个报错折磨了一下午的"老油条",顺着这篇的思路走一遍,基本能定位到问题。

1. 先搞清楚这个报错是什么:它只是一个"包装壳"

很多人看到Failed to execute goal org.codehaus.mojo:exec-maven-plugin:1.6.0:java ...就直接懵了,因为报错信息里没有任何业务线索,只有插件坐标和一个"failed"。

这里的关键认知是:Maven本身不直接跑你的Java程序,它只是负责调度插件。exec-maven-plugin这个插件的作用,是在Maven的构建流程里帮你去执行一个外部程序——最常见的就是执行某个类的main方法,或者运行一条可执行命令。当你看到这句"Failed to execute goal",其实它只是Maven在说"某个插件任务没跑成功",真正的病因藏在后面跟着的Caused by堆栈里。

可以把这个过程理解成快递运输:Maven是快递公司,exec插件是某个中转站,你的代码是包裹。中转站告诉你"包裹未送达",只是结果通知,真正的原因可能是包裹太大、地址写错、车辆坏了,得查具体的派送记录。同理,直接看这句红色加粗的"Failed to execute goal"没有意义,有意义的是它下面那一串以Caused by开头的异常堆栈。

触发这个插件的常见入口有三类:

  • 命令行直接执行:mvn exec:java -Dexec.mainClass=com.example.Main
  • 在 pom.xml 里通过<execution>绑定到某个生命周期阶段,比如compile或test阶段自动执行
  • 在IDE里点右键"Run",IDE底层经常也是帮你调用exec插件

我见过一个典型的案例:某个项目本地跑得好好的,换了一台新电脑拉代码后直接报这个错。很多人第一反应是重新编译、清理缓存,结果还是一样。这种"代码没变但环境变了"的情况,大多数和插件版本、JDK版本、类路径隔离有关,而不是代码本身的逻辑问题。

提示:排查这类报错的第一步,永远是找到Caused by那几行,它才是真正告诉你"哪里出问题"的入口。先不急着改pom,先看堆栈。

2. exec插件的运行机制拆解:java vs exec

要真正理解这个报错,得知道exec插件有两种执行模式,它们的失败表现完全不同。很多人搞混这两个goal,导致排查方向从一开始就跑偏。

2.1 exec:java 和 exec:exec 的本质区别

这两个goal经常让人混淆,但它们做的事情有本质不同。

exec:java是在Maven进程内部直接加载并运行Java类的main方法。所谓"内部",是指它不启动一个新的JVM,而是复用Maven运行时的类加载器(ClassRealm),把你的项目依赖和插件自身依赖全部装进同一个内存空间。这种模式的优点是省事、启动快,非常适合本地调试。缺点是它无法真正做到类路径隔离,如果依赖之间有冲突,很容易出现奇怪的"类明明存在却加载不到"的情况。

exec:exec则是真正fork出一个全新的独立Java进程去执行你的程序。它可以指定独立的jvmArgs、独立的classpath、设置工作目录、配置环境变量,基本上等同于在命令行手动敲一条java -cp xxx YourMainClass。这种模式更接近生产环境的启动方式,但配置复杂度高,而且一旦fork失败,报错信息会绕开Maven的日志框架,直接输出进程退出码或底层错误。

我把两者的核心差异整理成了一张表:

对比项exec:javaexec:exec
是否fork新JVM否,复用Maven进程是,启动独立JVM
classpath来源Maven的运行时类路径(可能混入插件依赖)需显式指定或依赖classpathScope配置
可配置JVM参数通过<jvmArgs>有限支持完整支持,最接近生产环境
失败后的报错抛MojoExecutionException,堆栈在Maven日志中可能看到process exited with不同退出码
典型使用场景本地快速调试main方法需要隔离环境、自定义启动参数的场景
常见失败点类冲突、mainClass写错、模块访问限制进程启动失败、路径配置错误、环境变量缺失

我个人的习惯是:本地调试用exec:java,验证"脱离Maven环境后还能不能跑"时用exec:exec。很多在exec:java下能正常跑的程序,一换到exec:exec就报类找不到,这种前后对比本身就是照妖镜。

2.2 为什么Maven只展示"Failed to execute goal"这么干巴巴的一句话

这是Maven插件体系的一个特性,也恰恰是它"不友好"的地方。

Maven调用任何插件执行时,最终都是通过反射调用该插件的execute()方法。只要插件内部抛出异常,Maven就会把它包装成MojoExecutionException,然后向外输出统一的错误头——也就是你看到的Failed to execute goal <插件坐标>:<goal>。这意味着无论底层是ClassNotFoundException、InaccessibleObjectException、端口被占用,还是依赖下载失败,最上层的报错永远长一个样子。

所以排错时必须有"剥洋葱"的心态。一个完整的报错通常长这样:

[ERROR] Failed to execute goal org.codehaus.mojo:exec-maven-plugin:1.6.0:java (default-cli) on project demo: Execution default-cli of goal org.codehaus.mojo:exec-maven-plugin:1.6.0:java failed: ... [ERROR] ... -> [Help 1] [ERROR] [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. [ERROR] Re-run Maven using the -X switch to enable full debug logging.

注意看最后两行提示:用-e看完整堆栈,用-X开启调试模式。这是Maven在明示你"报错被我包装过了"。排查的第一步就是执行mvn exec:java -e -X,把完整的堆栈打出来,然后从Caused by开始往上看。

3. 最隐蔽的根因之一:JDK版本与exec-plugin 1.6.0的版本错配

这是我在实际项目中遇到最多的原因,尤其是从JDK 8升级到JDK 11或17之后,报错比例飙升。

3.1 版本发布时间线:插件比JDK"老"很多

exec-maven-plugin 1.6.0 这块版本,第一次发布大约是在2017年前后,设计目标基本是围绕JDK 8的。它默认的行为、反射方式、类加载逻辑都建立在"JDK内部API随便访问"这个前提上。

JDK 9开始引入了模块系统(JPMS),最显著的变化是JDK内部API不再默认开放给外部程序随意反射访问。编译期可能一切正常,到了运行期,代码一旦尝试用反射去改JDK类库的内部字段,或者访问非导出模块里的类,JVM就会不客气地抛InaccessibleObjectException。而这个过程恰好发生在exec插件为你启动的那个JVM里,于是异常被一层层包装,最终从表面看就是那个万能报错头。

3.2 典型异常长什么样

如果你在高版本JDK下用了exec-maven-plugin 1.6.0,报错堆栈里通常会出现类似下面的内容:

Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make field private final java.util.ArrayList java.util.Collections.emptyList accessible: module java.base does not "opens java.util" to unnamed module @6b3eafae at java.base/java.lang.reflect.Field.canSetAccessible(Field.java:...) at java.base/java.lang.reflect.Field.setAccessible(Field.java:...)

看到InaccessibleObjectException、does not "opens ..." to unnamed module这两个关键字,基本可以断定是JDK版本与插件版本不兼容。这里说的"unnamed module",指的就是Maven进程以及exec插件fork出的程序所在的那个不成模块的普通类路径环境。

3.3 两种修复路线

路线一:升级插件版本(推荐)

最省力的方案是直接升级exec-maven-plugin到 3.x 系列。新版本对JDK 9+做了大量适配,默认处理了模块访问相关的问题。在pom.xml里显式声明版本即可:

<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>3.1.0</version> <executions> <execution> <goals> <goal>java</goal> </goals> </execution> </executions> </plugin>

路线二:保留旧版本,但手动开放模块访问

如果因为某些原因无法升级插件版本,可以通过jvmArgs传递--add-opens参数来手动开放访问权限。比如:

<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>1.6.0</version> <configuration> <jvmArgs> <jvmArg>--add-opens=java.base/java.util=ALL-UNNAMED</jvmArg> <jvmArg>--add-opens=java.base/java.lang=ALL-UNNAMED</jvmArg> </jvmArgs> </configuration> </plugin>

这条路只能说"能用",实际使用中往往会遇到没开放干净的包,接二连三也要补参数。我的经验是:排查顺序先从插件版本入手,升级到3.x往往能直接解决几十个奇怪的小问题。

3.4 判断是不是这个原因的最快方法

不用猜,直接看三个数:

  • mvn -version显示的Java版本
  • pom或父pom中exec-maven-plugin的版本
  • 报错堆栈里是否包含InaccessibleObjectException、module相关字样

顺手排一个对照表(配合项目使用的JDK版本选择推荐插件版本):

JDK版本exec-plugin 1.6.0exec-plugin 3.1.0
JDK 8正常正常
JDK 11可能报InaccessibleObjectException正常
JDK 17大概率报模块访问错误正常
JDK 21严重不兼容正常

提示:还容易忽略的一点是,IDE里默认的JDK和你terminal里JAVA_HOME指向的可能不是同一个版本。我遇到过几次"命令行没问题、IDE一直报错"的情况,最后发现是IDE的Runner JRE配得太老。这个属于环境错配,和插件本身无关,但排查时一定要想到。

4. 不是模块问题的那些根因:类路径与mainClass

如果说JDK版本是"时代造成的坑",那类路径和mainClass的问题就是"自己挖的坑"。这类问题在团队协作项目里特别常见,因为每个人IDE状态、Maven本地仓库缓存都不一样。

4.1 mainClass写错,报错却不像报错

使用exec:java时,Maven需要通过-Dexec.mainClass或pom配置里<mainClass>找到入口。这里的常见错误有几个:

第一,类名没有写全限定名。比如落在了com.example.demo.Main,结果配置写了Main。Maven解析不到时,抛出的往往是ClassNotFoundException,但它在堆栈里很容易扫过就忽略。

第二,main方法签名不对。Java要求入口方法必须是public static void main(String[] args),一个字都不能错。如果写成了public void main或者参数名写错,Java运行时会报Main method not found in class ...。

第三,类放在test目录但配置在compile阶段执行。exec:java默认使用runtime阶段的classpath,如果你的启动类在src/test/java里,没经过test-compile就执行,自然找不到类。这个坑在跑测试辅助工具时特别容易踩。

4.2 依赖冲突:编译期不报,运行期才崩

exec:java复用的是Maven的"运行时类路径"scope,这意味着项目里compile和runtime范围内的依赖会被全部塞进去。如果一个类在多个依赖里都存在(比如不同版本带了同一个包),Maven会按照依赖声明顺序或最近定义优先策略选择其中一个,但这个过程不一定和你的预期一致。

于是就会出现这种情况:编译阶段一切正常,因为编译只需要接口;但运行到具体实现时,类加载器在一个意想不到的位置找到了同名类,抛NoSuchMethodError、LinkageError,或者NoClassDefFoundError。这些错误被包装成exec执行失败后,非常迷惑人,因为它们都不是常规的ClassNotFoundException。

我还遇到过META-INF/services服务发现机制导致的诡异问题。某个模块用了SPI方式加载实现,编译期只需要依赖接口模块,运行期的具体实现来自另一个runtime依赖。某个依赖声明为了provided或没有正确传递,整个程序编译全通过,一exec:java就崩,报NoClassDefFoundError,查半天才发现是SPI实现类的JAR根本没进入运行classpath。

4.3 一个典型的"编译期正常运行期崩"案例

某次做数据处理模块,本地main方法里加载了一个配置中心的客户端,编译、打包都正常。但用mvn exec:java启动时,程序刚跑几行就报NoClassDefFoundError,指向配置中心的一个内部类。

排查过程让我印象最深:这个内部类所在的JAR确实在本地仓库里,pom里也写了依赖,按道理不应该缺。最后用了mvn dependency:tree一查才发现,那个依赖在父pom中被声明成了optional,导致它不会传递到本模块的运行时路径;而IDE里能跑,是因为IDE的依赖分析逻辑更"宽容",自动把optional依赖也加进去了。

这个案例说明一个道理:exec插件报错时,不要只盯着代码看,先跑一遍dependency:tree,把依赖图从上到下捋一遍。很多时候所谓"缺类",不是真的缺,是没进入运行时。

5. 排错实操:从复现到定位的标准化流程

前面讲了原理,这部分给一套可以照做的排查流程。我每次遇到这个报错,基本都按这个顺序走,从拿到报错到定位,半小时内能收敛。

5.1 第一步:抓住原始堆栈,别让Maven帮你"藏"

第一步很重要,但也是最容易被跳过的。直接在终端里跑:

mvn exec:java -e -X

-e会输出完整堆栈,-X会输出调试级别日志。这两个参数一上来就把Maven包装报错的那层壳揭掉了。跑完不要急着关终端,从输出里找到第一个Caused by,它通常离真正的异常最近。

同时检查当前终端JAVA_HOME指向:

mvn -version echo $JAVA_HOME java -version

这三条命令显示的内容必须保持一致。这里经常出现一个情况:Maven本身用的是JDK 17,但你pom里<java.version>写的是1.8,编译确实是按兼容模式走,可运行时反射行为可能依然受JDK 17底层限制。版本信息不一致就是隐患。

5.2 第二步:最小化复现,隔离变量

如果你手里是一个大型项目,依赖多、模块多,不建议在一个庞大的项目里来回试。我的做法是直接新建一个最小的临时目录,放三个文件:一个pom.xml、一个带main方法的类、一个最简单的exec插件配置。

能复现问题,说明问题大概率出在插件、JDK、环境这三者的组合上;不能复现,说明问题出在项目本身的依赖或配置组合上。这个对照能快速把排查范围缩小一半。

在这个最小工程里,分别测试:

  • 升级exec插件版本
  • 切换JDK版本
  • 修改jvmArgs
  • 调整classpathScope

每调整一个变量,记录一次mvn exec:java的结果,基本几轮就能锁定具体是哪个变化解决了问题。

5.3 第三步:按检查表逐项排除

当你掌握了一个最小复现工程,下面这张表可以直接当成清单用:

检查项验证命令/操作通过标准
JDK版本一致性mvn -version与项目要求的JDK版本一致
exec插件版本查看pom中artifact版本优先使用3.x
mainClass全限定名mvn exec:java -Dexec.mainClass=...能加载到对应类
依赖树mvn dependency:tree -Dverbose无版本冲突或缺失
pom中的classpathScope检查exec插件配置视启动需求设为compile/runtime
IDE Runner JRE检查IDE设置与命令行一致
Maven本地仓库缓存mvn -U强制更新排除下载不完整可能
系统字符集Windows下检查GBK乱码排除编码导致的解析问题

这表里的每一项我都踩过。比如第6项IDE的Runner JRE,之前说过,是IDE和命令行行为不一致的元凶;第7项Maven本地仓库缓存,一旦之前依赖下载中断留下半截文件,Maven不会自动重新下载,跑步起来的情况也会以exec报错出现;第8项GBK乱码,某些Windows环境下日志输出乱码会让你误解异常位置,影响判断。

5.4 第四步:验证修复效果

问题定位并修复后,不要只在命令行跑一次就收工。我通常在同一台机器上用两种方式验证:

mvn clean verify mvn exec:java -Dexec.mainClass=YourMainClass

如果项目打包本身有问题,clean verify会先暴露出来,避免把插件问题混在一起。跑完后再用exec:exec测试一次进程独立启动模式,确认fork出的JVM退出码是0。只有这三种方式全部通过,我才会认为这个报错真正解决。

6. 修复后的预防:让pom配置变得"能打"

报错解决不是终点,关键是不让它反复出现。最后这部分谈谈配置规范化和多环境构建的策略。

6.1 一份值得抄作业的exec插件配置

经过多次打磨,我现在倾向用下面这套配置,兼顾隔离性、可控性和可调试性:

<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>3.1.0</version> <configuration> <mainClass>com.example.Main</mainClass> <classpathScope>runtime</classpathScope> <cleanupDaemonThreads>false</cleanupDaemonThreads> </configuration> <executions> <execution> <id>run-main</id> <phase>compile</phase> <goals> <goal>java</goal> </goals> <configuration> <!-- 只在这里留mainClass,方便命令行覆盖,不容易混乱 --> <mainClass>com.example.Main</mainClass> </configuration> </execution> </executions> </plugin>

几点说明:

  • classpathScope默认是runtime,如果启动时需要用到测试/编译期的类,需要显式调整。
  • cleanupDaemonThreads设为false可以避免某些后台线程在程序结束后仍拖住Maven进程的问题,这个参数在启动带线程池的程序时会救你一命。
  • 如果在命令行执行mvn exec:java -Dexec.mainClass=...,注意它和pom里<mainClass>的优先级关系,命令行参数会覆盖pom配置,利用这个特性做切换很方便。

6.2 多JDK环境下的统一策略

每个开发者电脑上的JDK版本、Maven版本完全一致是不可能做到的。项目组里一半人用JDK 8、一半用JDK 17时,exec插件报错几乎是必然的。我建议借用构建辅助插件来强制固定构建环境,比如maven-enforcer-plugin,通过规则约定本项目必须使用哪个Java版本,不符合就提前失败。

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-enforcer-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <id>enforce-java</id> <goals> <goal>enforce</goal> </goals> <configuration> <rules> <requireJavaVersion> <version>[17,18)</version> </requireJavaVersion> </rules> </configuration> </execution> </executions> </plugin>

这样配置后,谁用了不匹配的JDK,Maven会直接给出"Java版本不满足要求"的明确提示,而不是让exec插件在运行到一半时才以Failed to execute goal的方式爆出来。这个"把错误提前到构建启动阶段"的思路,能让团队整体的排错效率高很多。

6.3 团队协作中那些容易被忽略的"版本漂移"

还有一个细节值得专门提一下:尽量在项目pom里显式声明exec-maven-plugin的版本,不要依赖父pom或超级pom里"继承来的版本"。有些项目把插件版本放在一个公共父pom的pluginManagement里,子模块只管引用artifactId,不写version。这个做法的风险是,一旦父pom升级或父模块引用了另一个父模块,插件版本可能在不知不觉中发生变化。版本变化带来的行为差异,比业务代码变更隐蔽得多。

我自己维护的一些基础脚手架项目里,现在都强制要求"插件版本必须在模块内部显式声明",宁可多写几行,也不要在别人电脑上跑不出来时才去找版本漂移的问题。

另外,如果你在团队里经常被同事问"为什么我这台机器报这个错",可以在代码仓库的README里直接写清楚:JDK要求、命令行运行方式、IDE运行方式、需要显式的环境变量。看着像废话,实际上能省掉大量反复沟通的成本。有一次我照着这个思路写了一段运行环境说明,之后团队里关于exec插件报错的提问量明显降了一个档次。

结尾

从我第一次看到Failed to execute goal org.codehaus.mojo:exec-maven-plugin:1.6.0时的手足无措,到现在基本能在几分钟里定位问题,最大的体会就是:Maven报错不可怕,可怕的是只盯着最上面那行红色加粗字。现在只要看到这个报错,我的肌肉记忆就是先往下翻Caused by,再看JDK版本,最后才轮得到检查自己项目的代码和依赖。

最后分享一个小技巧:如果你经常用mvn exec:java启动本地服务,可以把这个命令固化成一个小脚本,把jvmArgs、mainClass、系统环境变量都写进去。这样既避免了每次敲长串参数容易出错,也等于把"这个项目应该怎么启动"这件事固化成了团队约定。再遇到这个报错,就能更快地区分到底是环境问题、配置问题还是插件本身的坑了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进