1. 报错拆解:exec-maven-plugin 与 default-cli 究竟是谁在说话
先把这个报错放回它出现的场景里看。你在 IDEA 里写了一个带 main 方法的类,想在 Maven 项目里快速跑一下;或者在命令行敲了mvn exec:java;又或者用 IDEA 的 Run Anything 窗口输入了某个 Maven Goal——紧接着 Build 面板就飘出一行:
[ERROR] Failed to execute goal org.codehaus.mojo:exec-maven-plugin:3.0.0:exec (default-cli) on project demo: ...很多人的第一反应是“Maven 坏了”,重装、清缓存、重启三连,折腾半天发现毫无变化。实际上 Maven 大概率没坏,问题在于这行报错本身只是个“外包装”,真正的错误原因还藏在后面的堆栈信息里。
先说清楚这个报错里出现的几个角色。
exec-maven-plugin是一个老牌的 Maven 插件,归属org.codehaus.mojo组织,它做的事情很简单:在 Maven 构建过程中帮你执行 Java 类或者外部程序。官方文档里它的描述是“Executes Java and other programs in a separate process or the same JVM”,翻译过来就是既能跑main方法,也能跑系统命令。日常开发里最常见的用法是:
mvn exec:java -Dexec.mainClass="com.example.DemoMain"或者直接在 pom.xml 里对插件的 goal 做绑定,让它在某个生命周期阶段自动执行。
3.0.0是插件版本。exec是插件暴露的 goal(目标),它和java、exec两个子命令的关系是这样的:exec:java是在 Maven 当前 JVM 进程中直接运行 Java 主类;exec:exec则是启动一个独立的外部进程去执行命令或类。报错信息里写的exec通常来自exec:exec,但 IDEA 和命令行常见的exec:java也会产生同款错误签名。
(default-cli)这个括号里的内容特别容易被忽略,但它恰恰解释了“这个 goal 是从哪冒出来的”。当你在命令行直接输入mvn exec:java而没有在 pom.xml 里预先定义 execution 时,Maven 会自动生成一个默认的执行 ID,就叫default-cli。换句话说,看到default-cli,基本可以确定这次执行来自命令行或 IDE 的 Maven 面板,而不是 lifecycle 阶段里的绑定额外配置。
on project demo指的是当前执行失败的具体 Maven 模块名。如果你遇到的是多模块聚合项目,还要注意它到底报的是哪个子模块——这个问题我在后文排查顺序里会再提。
顺手补充一个容易搞混的知识点:Maven 插件不光能在命令行手动触发,还能绑定到 lifecycle 阶段。比如你在 pom 里这样写:
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>3.0.0</version> <executions> <execution> <id>my-execution</id> <phase>compile</phase> <goals> <goal>java</goal> </goals> <configuration> <mainClass>com.example.DemoMain</mainClass> </configuration> </execution> </executions> </plugin>此时再执行mvn compile,你会看到exec:java (my-execution)这样的日志,而不是default-cli。区分这两者,对下一步排查方向有很大帮助:如果是default-cli,那大概率是手抖写错了 mainClass 或者参数;如果是自定义 ID 绑定了生命周期阶段,那问题可能出在插件配置和项目生命周期设计上。
2. Caused by 才是关键:把隐藏异常从堆栈里挖出来
“Failed to execute goal” 这行字本质上只是 Maven 在告诉你:某个插件在执行时抛了异常,整条构建链路断了。Maven 默认打印出的错误信息非常克制,通常只有一行 ERROR,最后跟着[Help 1]。很多新手就停在这行红色日志前,拿着[Help 1]一通搜索,越搜越迷茫。
正确的做法是:忽略第一行,往下滚,找 Caused by。
我用一个真实场景来说明。假设我在一个新项目里执行:
mvn exec:java -Dexec.mainClass="com.example.HelloWorld"控制台打出来的完整报错可能长这样:
[ERROR] Failed to execute goal org.codehaus.mojo:exec-maven-plugin:3.0.0:exec (default-cli) on project demo: The parameters 'mainClass' for goal org.codehaus.mojo:exec-maven-plugin:3.0.0:exec are missing or invalid [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. [ERROR] [ERROR] For more information about the errors and possible solutions, please read the following articles: [ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/PluginExecutionException看清楚,上面这个场景里,“Failed to execute goal” 的下一行直接就告诉了真实问题:mainClass缺失或无效。也就是说,exec 插件在执行时根本不知道要运行哪个类。解决方案很简单,pom.xml 里显式配置 mainClass:
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>3.0.0</version> <configuration> <mainClass>com.example.HelloWorld</mainClass> </configuration> </plugin>或者执行时通过-Dexec.mainClass传入。
但更多情况下,mainClass已经配好了,却依然报Failed to execute goal。这时候就一定要加参数看完整堆栈了。Maven 自己都提示了,用-e或-X可以拿到完整线索:
mvn exec:java -Dexec.mainClass="com.example.HelloWorld" -e-e是 error 模式的堆栈,-X是 debug 模式,日志量非常大,平时用-e就够了。加完参数后再看,通常会在堆栈中下部看到真正的Caused by: xxxException。
我归纳了一下,常见的真正的异常类型主要有这几种:
| Caused by 类型 | 含义 | 常见触发原因 |
|---|---|---|
ClassNotFoundException | 运行时找不到某个类 | mainClass 类名写错、依赖缺失 |
NoClassDefFoundError | 类在编译期存在但运行期加载失败 | 依赖 scope 问题、打包排除 |
The parameters 'mainClass' ... missing or invalid | mainClass 没配或类不存在 | 命令行参数拼写错误 |
BindException: Address already in use | 端口被占用 | Spring Boot 等应用启动时端口冲突 |
Command execution failed | 外部进程执行失败 | 命令不存在、退出码非 0 |
ExceptionInInitializerError | 静态初始化块炸了 | 业务代码问题 |
这里我想特别强调一点:exec 插件的报错只是“门卫”,它拦住的是后面那一堆业务异常。很多时候真正报错的是你自己写的main方法里的逻辑——比如 NPE、IO 异常、连接超时,这些异常会被插件捕获并统一包装成Failed to execute goal,然后错误地让开发者以为 Maven 出了问题。
我见过一个印象很深的案例:同事启动 Spring Boot 应用时一直报这个错,排查了一下午,最后发现是 8080 端口被另一个本地服务占了。堆栈里的Caused by: java.net.BindException: Address already in use才是真正的凶手。
所以,面对这个报错,第一原则就是:别修 Maven,先看堆栈。
3. exec:java 的 classpath 陷阱:类找不到为什么如此高频
如果说mainClass缺失是最基础的错误,那类找不到(ClassNotFoundException或NoClassDefFoundError)就是最让人头大的错误。它的诡异之处在于:项目明明能mvn compile通过,IDEA 里代码也不飘红,但一用 exec 插件运行就炸。
根本原因是:exec 插件运行 main 方法时的 classpath 和 Maven 编译期的 classpath 不完全一致。
先理清一个基本概念。Maven 项目里的依赖有个scope属性,常见的有compile、provided、runtime、test等。其中provided表示这个依赖在编译和测试时需要,但运行时由外部容器或 JDK 提供。比如开发 Servlet 项目时,servlet-api 通常就是provided。
问题来了:当你用mvn exec:java运行 main 方法时,exec 插件默认构造的 classpath 会包含compile和runtime作用域的依赖,但provided作用域的依赖不会被加进去。如果你在 main 方法里 import 了一个provided依赖的类,编译没问题,一运行就报ClassNotFoundException。
举一个具体例子。假设 pom.xml 里有这样一段:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>然后你在 main 方法里写:
import javax.servlet.ServletContext; public class DemoMain { public static void main(String[] args) { System.out.println(ServletContext.class.getName()); } }mvn compile肯定能过,因为编译期provided依赖是可见的。但mvn exec:java跑起来就会报NoClassDefFoundError。解决方案是把依赖的 scope 改成compile,或者新建一个 profile 专门处理运行期的 classpath。
另一个高频场景是依赖版本冲突。一个大型项目的依赖树深不见底,某个类在编译期能解析到,但运行时被另一个版本的 jar 抢了先,导致NoSuchMethodError、NoClassDefFoundError这类看起来莫名其妙的错误。
遇到依赖相关的问题,我最常用的命令是:
mvn dependency:tree -Dincludes=com.example:some-artifact-Dincludes可以精确过滤某个组织或某个模块的依赖,输出结果会列出一棵树:
[INFO] +- com.example:some-artifact:jar:1.2.3:compile [INFO] \- org.foo:bar:jar:2.0.0:compile [INFO] \- com.example:some-artifact:jar:1.0.0:compile (版本冲突,实际可能被覆盖)看到版本冲突后,有几种处理思路:在 pom 里用dependencyManagement统一版本、用<exclusions>排除旧的传递依赖、或者直接在dependencyManagement里指定最终版本。这个处理逻辑对任何 Maven 项目都适用,不只是 exec 插件场景。
还有一个容易忽略的场景是父工程定义了 dependencies 但子模块没有继承干净。多模块项目中,如果某个模块的主类依赖了兄弟模块的类,而兄弟模块没被打进 classpath,同样会报类找不到。这种情况建议在子模块 pom 里显式声明对兄弟模块的依赖,或者编译时检查父 pom 的模块依赖关系。
顺带提一下exec:exec和exec:java的选择。exec:java默认在 Maven 所在的 JVM 进程里直接运行,classpath 由 Maven 构造;exec:exec则 fork 一个独立进程,更像你自己在命令行敲java -cp xxx YourMain。如果项目对 classpath 有非常麻烦的特殊要求,我会用exec:exec配合<commandlineArgs>显式指定-cp,虽然配置繁琐,但灵活性高很多。
需要特别小心的是,某些项目在 main 方法里调用了System.exit()。如果用exec:java,因为 main 跑在 Maven 进程里,System.exit()会直接把整个 Maven 构建进程干掉,IDEA 里通常表现为“Build 进程意外退出”。换成exec:execfork 出子进程后,System.exit()只影响子进程,就不会炸 Maven 本体了。
4. 工作目录与控制台编码:两个低调但高频的翻车点
如果说类找不到是 80% 的人都会遇到的常规坑,那工作目录(working directory)和控制台编码这两个问题,就是那种“不遇到则已,一遇到就卡半天”的隐藏雷区。
先聊工作目录。exec 插件执行 main 方法时,有一个大家都默认却容易忽略的设定:进程的工作目录默认是模块的 basedir,也就是存放 pom.xml 的那个目录。听起来可能觉得这有什么可说的?问题往往出在 IDEA 的 Run Configuration 上。
IDEA 里创建 Maven 项目的运行配置时,会自动设置 Working directory 为$MODULE_WORKING_DIR$,但开发者在排查问题过程中常常手动改过。还有一种情况更隐蔽:你用命令行在项目根目录执行正常,但用了mvn -f /path/to/pom.xml exec:java后,工作目录可能变成你当前敲命令的目录,而不是 pom 所在目录。
举个例子。我在 main 方法里写了:
File configFile = new File("config.properties");如果工作目录是模块根目录,config.properties在根目录,程序跑得好好的。但如果我把 working directory 换成了模块下的src/main/resources目录,或者用 IDEA 的 Working directory 改成某个输出目录,文件就找不到了,抛FileNotFoundException,然后又被包装成Failed to execute goal,误导你往依赖方向排查。
规避这个问题最靠谱的做法是:不要在代码里依赖相对工作目录的路径。需要读取配置文件时,优先用类路径资源:
InputStream in = DemoMain.class.getClassLoader().getResourceAsStream("config.properties");这样无论工作目录在哪里,资源都能被正确找到。如果项目确实需要指定工作目录,可以在 exec 插件配置里显式设置:
<configuration> <workingDirectory>${project.basedir}</workingDirectory> </configuration>再来看控制台编码。这个问题的典型表现是:main 方法里System.out.println("中文"),控制台输出一堆乱码。这类问题本身不影响程序运行,但如果你在 CI 或终端里通过断言匹配日志文本,乱码就会直接导致判断失效。
编码问题的根源是 Java 默认 charset 和项目文件编码不一致。Maven 自身有个最基础的项目属性project.build.sourceEncoding,它控制着编译器读取源代码时的解码方式。如果 pom.xml 里没定义这个属性,而项目文件是 UTF-8,在中文 Windows 环境下很容易出现“编译时字符串字面量乱码 + 运行时乱码”的双重问题。建议在 pom.xml 的<properties>里显式声明:
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>至于 exec 插件运行时 JVM 的file.encoding,Linux 上受LANG环境影响,Windows 上则跟随系统默认。如果实在遇到乱码,可以在 exec 插件配置里加 JVM 参数:
<configuration> <jvmArgs> <jvmArg>-Dfile.encoding=UTF-8</jvmArg> </jvmArgs> </configuration>或命令行执行时直接传:
mvn exec:java -Dexec.mainClass="com.example.DemoMain" -Dfile.encoding=UTF-8IDEA 里如果控制台还是乱码,可以在 Help -> Edit Custom VM Options 里加一行-Dfile.encoding=UTF-8,重启 IDEA 基本能解决。
这类问题最讨厌的地方在于:它不会在构建日志里留下任何明显错误,程序照样跑,只是输出不符合预期,而后续所有基于输出的判断都跟着错位。所以遇到莫名其妙的情况,先排查编码和工作目录,往往能省掉大量时间。
5. 环境侧排查:settings.xml、镜像仓库与 IDEA 的 Maven 面板
前面几节讲的都是项目内部的问题,但还有一类Failed to execute goal是环境层面的。这类问题有个鲜明的特征:不是某个具体项目才有,而是所有项目都报同一个错,或者“同一个项目昨天还好好的,今天突然就崩了”。
先看镜像仓库。exec-maven-plugin 这个插件本身是从 Maven 仓库下载的。如果settings.xml里的镜像配置不当,导致插件拉取失败,Maven 也会把它包装成Failed to execute goal。最典型的现象是:本地仓库一直缺org/codehaus/mojo/exec-maven-plugin/3.0.0目录,或者日志里出现Could not resolve dependencies这样的关键字。
国内开发者最常用阿里云镜像,在 Maven 的settings.xml里配置一个 mirror 就好:
<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>aliyun public repository</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>mirrorOf配置成central,只接管中央仓库的下载请求。如果你公司内部有私服,需要把私服地址也考虑进去,但平时个人项目用阿里云镜像就够了。配置完成后,如果之前已经下载了损坏的插件缓存,建议把本地仓库里org/codehaus/mojo/exec-maven-plugin整个目录删掉,再重新解析一遍,让 Maven 拉取完整的插件文件。
再来看 IDEA 的 Maven 面板。IDEA 里有很多地方可以配置 Maven:Settings -> Build, Execution, Deployment -> Build Tools -> Maven。你要重点检查三个字段:
| 配置项 | 说明 | 常见问题 |
|---|---|---|
| Maven home path | Maven 安装目录 | 指向了不存在的目录或过旧版本 |
| User settings file | settings.xml 文件路径 | 指向了空文件或路径错误会导致私服/镜像不生效 |
| Local repository | 本地仓库目录 | 如果手动指定了目录,但目录里没有插件缓存,会重新下载 |
我遇到过一种情况:IDEA 里 Maven home path 配置的是 IDEA 自带的 Maven,但这个内置 Maven 版本偏老,和一个需要较新插件的项目冲突,构建时报奇怪的错。这时候改成自己安装的 Maven 版本,问题立刻消失。
还有一种“External Libraries 完全没有 Maven 依赖”的现象,看着像编译环境坏了,实际上就是 IDEA 还没正确加载依赖,或者本地仓库地址变了导致所有依赖全部落空。这种情况先在 Maven 面板里点一下“刷新所有 Maven 项目”的图标(Reload All Maven Projects),如果还不行,检查本地仓库目录是否被清理了。
Maven 版本和 JDK 版本的兼容关系也不能忽视。exec-maven-plugin:3.0.0本身对 JDK 的要求不算高,但如果你项目里用了高版本 JDK(比如 17 或 21)编译,而 Maven 运行时的JAVA_HOME指向的还是 JDK 8,构建时就可能出现各种不匹配的异常。用这条命令确认:
mvn -v看输出的 Java version 是否和你预期一致。如果不一致,修改环境变量JAVA_HOME,或者在 IDEA 里给项目配置对应的 JDK。
环境侧排查的另一个方向是本地依赖缓存损坏。Maven 下载失败时会生成.lastUpdated后缀的文件,后续构建看到这些文件就直接跳过重新下载,于是出现“明明仓库有更新,但项目永远拉到旧版本或报错”的情况。遇到这种,找到对应目录,把所有.lastUpdated文件删掉,再执行:
mvn clean install -U-U强制更新快照版本和远程依赖,排查环境问题时基本是必用的。
6. 从报错到定位:我稳定复现后总结的排查顺序
前面内容比较发散,有项目内部的坑,也有环境侧的问题。为了避免每次遇到这个报错都从头试一遍,我把自己的排查路径固定成了一套流程。每次踩这个坑,我就按这个顺序走一遍,大部分问题能在五到十分钟内定位。
第一步:看堆栈,找 Caused by。
不要盯着Failed to execute goal这行看超过三秒。直接往日志下方翻,找到第一个Caused by或Exception。如果日志太短,加-e参数重新跑一次。核心原则:这个错误是包装出来的,真实原因必定藏在异常链中。
第二步:确认 mainClass 配置。
如果堆栈里出现The parameters 'mainClass' for goal ... are missing or invalid,说明 exec 插件没拿到有效的 mainClass。检查三种情况:pom 里有没有配<mainClass>;命令行有没有传-Dexec.mainClass;类名是不是写错了(包括包名)。
第三步:定位是真找不到类还是依赖问题。
如果报的是ClassNotFoundException或NoClassDefFoundError,先判断这个类是自己项目的类还是第三方 jar 里的类。自己项目的类,检查模块依赖关系;第三方 jar 里的类,用mvn dependency:tree查依赖树和冲突情况,特别注意 scope 是provided的依赖。
第四步:排除业务异常和资源问题。
看堆栈里有没有业务代码抛出的异常(NPE、IO 异常、端口占用等)。有则直接修业务逻辑,别在 Maven 配置上浪费时间。同时快速确认端口有没有被占用、工作目录下相关的配置文件是否存在。
第五步:环境兜底检查。
如果前面全部没问题,进入环境排查模式。检查 Maven 设置、镜像仓库、本地仓库缓存,可以尝试删掉.lastUpdated后mvn clean install -U,或者用mvn -v确认 JDK 版本。
第六步:最小化复现。
这是我压箱底的方法。当项目体积很大、依赖特别复杂、报错看得云里雾里时,直接新建一个空白 Maven 项目,只写一个输出 Hello World 的 main 类,配好 exec 插件跑一次:
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>3.0.0</version> <configuration> <mainClass>com.example.Main</mainClass> </configuration> </plugin>如果空白项目能成功,说明环境没问题,问题一定出在项目本身的依赖或配置上;如果空白项目也失败,那十有八九是环境层面的问题。这个“最小化复现”的判断逻辑,能把排查范围瞬间缩小一半,远比在堆积如山的依赖里盲猜要高效。
最后说一个我一直以来的习惯:把常用的exec:java命令固化下来。因为每次敲-Dexec.mainClass很容易敲错,还不如直接做成 IDEA 的 Maven Run Configuration,或者写一行 Makefile/Script:
mvn exec:java -Dexec.mainClass="com.example.DemoMain" -Dexec.args="--config=dev"这样平时用起来无脑,不容易因为参数敲错而再次触发这堆报错。实际上每次排查这类问题都是一次对 Maven 构建链路更深的理解,踩过一次坑后,以后再看到Failed to execute goal心里就会不慌——先看Caused by,再对照依赖,最后查环境,这条路永远比盲目重装 Maven 靠谱得多。