本地IDE里跑得好好的,mvn package打成jar扔到服务器上就炸,报错是这一类:
java.lang.NoSuchMethodError: org.apache.commons.lang3.StringUtils.isNoneBlank(Ljava/lang/CharSequence;)Z java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils java.lang.NoClassDefFoundError: org/slf4j/LoggerFactory
编译不报错、单测也能过,一到运行期就找不到方法或者找不到类。这种事跟代码本身没关系,是classpath上同一个类被塞进了两份不同版本的jar,JVM按classpath顺序加载了先碰到的那一份。
这个坑踩过不止一次,而且每次都发生在"本地没问题"的前提下。2026年处理过的一个项目,commons-lang3被两条路径引进来,一条3.12.0、一条3.1,仲裁结果是3.1进了包,isNoneBlank在3.2才加进来,于是上线当天接口全500。
1、先确认JVM到底加载了哪个jar
别急着翻pom,先让JVM自己说出来。临时加一段:
System.out.println(StringUtils.class.getProtectionDomain() .getCodeSource().getLocation());
打印出来是file:/app/libs/commons-lang3-3.1.jar这种完整路径,比猜快得多。不能改代码就加启动参数-verbose:class,日志里每一行都是"[Loaded org.apache.commons.lang3.StringUtils from file:/.../commons-lang3-3.1.jar]",grep一下类名即可。
2、看Maven的仲裁结果
Maven的依赖仲裁就两条:路径最短者优先,路径长度相同则先声明者优先。所以A依赖传进来3.12.0、B依赖传进来3.1,谁的路径更短或者谁在pom里写得更靠前,谁就进包,另一个被标成omitted。直接看:
mvn dependency:tree -Dincludes=org.apache.commons:commons-lang3
想看被仲裁掉的那一份,加-X开debug输出,被丢弃的依赖会写明原因:
mvn -X dependency:tree | grep -B2 -A2 commons-lang3
输出里能见到(org.apache.commons:commons-lang3:3.12.0:compile - omitted for conflict with 3.1)这种行,"omitted for conflict with"后面那个版本就是最终进包的。实测Maven 3.8.8、3.9.6、3.9.9,这行输出的格式都一样。
3、往上追是谁引进来的
dependency:tree是缩进树,被仲裁掉的那一行的父节点就是引入方。树太深看不清时,把范围收窄到某个模块:
mvn dependency:tree -Dincludes=org.apache.commons:commons-lang3 -pl order-service mvn dependency:list -DincludeScope=runtime
之前碰到过一个更绕的:pom里根本没写commons-lang3,是某个内部starter把它当传递依赖带进来的,翻了两层才知道要改的是starter的版本号。
4、两种处理办法
精确剔除用exclusions,挂在引入方的dependency下面:
<dependency> <groupId>com.xxx</groupId> <artifactId>xxx-starter</artifactId> <version>2.4.0</version> <exclusions> <exclusion> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </exclusion> </exclusions> </dependency>
多模块项目更推荐dependencyManagement统一版本,一处改完全局生效,也不会因为加了一个新依赖又把老版本带回来:
<dependencyManagement> <dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.17.0</version> </dependency> </dependencies> </dependencyManagement>
顺手记一下:升commons-lang3的版本一般安全,3.12.0、3.14.0、3.17.0,这几个版本之间基本没有破坏性变更。
5、在构建期就拦住,别等上线
enforcer插件能把版本不一致直接判失败,配dependencyConvergence或者requireUpperBoundDeps:
<plugin> <artifactId>maven-enforcer-plugin</artifactId> <version>3.5.0</version> <executions> <execution> <id>enforce</id> <goals><goal>enforce</goal></goals> <configuration> <rules><dependencyConvergence/></rules> </configuration> </execution> </executions> </plugin>
maven-enforcer-plugin 3.4.1、3.5.0,都支持这个规则。老项目一开dependencyConvergence通常哗啦啦报几十条,可以先只开requireUpperBoundDeps,把"最终版本低于传递依赖声明版本"这类真正有风险的先堵上。
6、打包之后复核一次
Spring Boot的fat jar依赖都在BOOT-INF/lib下面,直接列:
jar tf target/app.jar | grep 'BOOT-INF/lib/commons-lang3' jar tf target/app.jar | grep 'BOOT-INF/lib/guava'
普通jar用dependency:copy-dependencies倒出来看目录更直观:
mvn dependency:copy-dependencies -DoutputDirectory=target/libs -DincludeScope=runtime ls target/libs | grep guava
同一个artifactId出现两个版本号,就是还有冲突没解决干净。Spring Boot 2.7.18、3.2.5、3.3.4,这三个系列的dependencyManagement里都锁了guava,继承spring-boot-starter-parent的项目一般不用自己管guava,反而是绕开parent自己引依赖的时候容易出岔子。
7、IDE和命令行结果不一致
改完pom,IDEA 2024.1、2024.2、2024.3,需要Reload All Maven Projects才会重新解析,不然IDE里跑的还是旧classpath,命令行mvn已经修好了,看起来就像"命令行的锅"。命令行侧记得加-U强制更新快照,并且先clean:
mvn clean package -U -DskipTests
还有一种看着像冲突、其实是scope写错:provided和test都不进运行包。日志框架、servlet-api这类依赖标了provided,本地有容器提供所以能跑,打成jar单独跑就ClassNotFoundException,跟版本冲突的现象一模一样,别查错方向。
数据截至2026年9月,Maven的版本仲裁规则以官方文档为准。