NoSuchMethodError 上线才炸:依赖冲突的定位与收敛

预发正常,生产某个冷门接口报 NoSuchMethodError。dependencyManagement 把版本锁回了旧版,dependency:tree 里标 managed 的那条就是真凶。

版本上线的第二天,一个很少被访问的导出接口开始报 500。堆栈只有一行核心信息:

java.lang.NoSuchMethodError:
  com.acme.common.json.JsonWriter.writeString(Ljava/lang/String;Z)V

NoSuchMethodError 的特点是「编译期不报,运行期才炸」。本地跑得好好的,预发环境跑得也好好的,一上生产就出问题。更麻烦的是这个接口不在主链路,灰度的时候没人发现,全量之后才被运营发现。

我拿到这个报错的第一件事,是去确认它到底是「代码写错了」还是「jar 里没有这个方法」。编译期通过意味着写代码时引用的那个类里确实有这个方法,那么运行期加载的类,就不是编译期用的那个。

一、先确认类到底从哪个 jar 来

猜是没用的,让 JVM 自己说。报错时把加载来源打出来,是最省时间的一步:

public static void main(String[] args) throws Exception {
    Class<?> c = Class.forName("com.acme.common.json.JsonWriter");
    System.out.println("class    : " + c.getName());
    System.out.println("from     : " + c.getProtectionDomain().getCodeSource().getLocation());
    System.out.println("loader   : " + c.getClassLoader());
    for (Method m : c.getDeclaredMethods()) {
        if (m.getName().equals("writeString")) {
            System.out.println("method   : " + m);
        }
    }
}

生产上不方便加代码,可以用 -verbose:class 或者在启动参数里加一个 agent。更轻的办法是 jcmd 直接看已加载的类来自哪个 jar:

# 找到 pid
jps -l

# 看目标类从哪个 jar 加载
jcmd 12345 VM.class_hierarchy com.acme.common.json.JsonWriter -s

# 或者直接看 classpath 里这个类出现在哪几个 jar
javap -classpath $(cat cp.txt) -p com.acme.common.json.JsonWriter | grep writeString

我这边跑出来两个来源:

# 把运行期真实 classpath 打出来,逐个 jar 找这个类
for j in $(tr ':' '\n' < cp.txt); do
  if unzip -l "$j" 2>/dev/null | grep -q "com/acme/common/json/JsonWriter.class"; then
    echo "== $j"
    javap -classpath "$j" com.acme.common.json.JsonWriter | grep "writeString"
  fi
done

结果是 acme-common-2.4.1.jar 里有 writeString(String, boolean),而 acme-common-2.1.0.jar 里只有 writeString(String)。生产加载的是 2.1.0。

二、为什么编译期用的是新版本,运行期加载了旧版本

Maven 的依赖调解规则是「最短路径优先,同深度先声明优先」。当两个依赖分别传递引入了 acme-common 的 2.4.1 和 2.1.0,且深度相同时,谁在 pom.xml 里声明得靠前,谁生效。

编译时用的是 dependency:build-classpath 算出来的顺序,运行时用的是打包产物的 BOOT-INF/lib 顺序。两者理论上一致,但只要中间有任何一步改过依赖树——比如某个 starter 在 CI 里被替换了版本,或者有人手工往镜像里塞了一个 jar——就会出现「编译用 A、运行用 B」。

我们这次的原因更隐蔽:acme-common 是通过一个内部 starter 引入的,那个 starter 在 1.7.0 版本把 acme-common 从直接依赖改成了 optional。结果是,主工程里显式声明的 2.4.1 依然在,但另一个业务依赖传递进来的 2.1.0 因为「深度更浅」抢到了位置。

<!-- 冲突示意:两条路径都引了 acme-common,深度不同 -->
<dependency>
  <groupId>com.acme</groupId>
  <artifactId>acme-common</artifactId>
  <version>2.4.1</version>   <!-- 主工程显式声明,深度 1 -->
</dependency>
<dependency>
  <groupId>com.acme</groupId>
  <artifactId>biz-report-sdk</artifactId>
  <version>3.2.0</version>   <!-- 深度 1,内部又依赖 acme-common 2.1.0,深度 2 -->
</dependency>

这时 acme-common 的深度是 1(主工程直接声明),2.1.0 的深度是 2,按理说 2.4.1 应该赢。问题出在 biz-report-sdk 的 pom 里把 acme-common 写成了 compile scope 的直接依赖,而它在我们的 dependencyManagement 里被另一个 BOM 锁到了 2.1.0——dependencyManagement 的优先级高于传递依赖调解,直接把版本改掉了。

三、用 dependency:tree 把路径打出来

# 1. 找所有引入 acme-common 的路径
mvn -q dependency:tree -Dincludes=com.acme:acme-common -Dverbose

# 输出
# [INFO] +- com.acme:acme-common:jar:2.4.1:compile
# [INFO] \- com.acme:biz-report-sdk:jar:3.2.0:compile
#    +- (com.acme:acme-common:jar:2.1.0:compile - omitted for conflict with 2.4.1)
# [INFO] \- com.acme:acme-platform-bom:pom:1.9.0:import
#    \- (com.acme:acme-common:jar:2.1.0:compile - managed)

# 2. 看最终生效的版本
mvn -q dependency:list -DincludeArtifactIds=acme-common

最后一行 managed 是关键证据。dependencyManagement 里锁的 2.1.0 直接覆盖了显式声明的 2.4.1,而且 mvn dependency:tree 不会把它标成「冲突」,因为它不是调解出来的,是被管理掉的。

四、收敛:四种手段从软到硬

手段 写法 适用场景 风险
显式声明版本 主工程直接写 <version> 单模块项目 多模块时容易漏
dependencyManagement 锁版本 在父 pom 里统一锁 多模块、多团队 需要有人维护
exclusions 排除 排除传递依赖 明确知道不需要 排错了运行期才炸
enforcer 强制拦截 bannedDependencies / dependencyConvergence 长期防复发 首次接入要清一批历史债

我选的是「父 pom 锁版本 + enforcer 拦截」的组合。先在最顶层的 dependencyManagement 里把 acme-common 锁到 2.4.1,并且明确注释为什么是这个版本:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.acme</groupId>
      <artifactId>acme-common</artifactId>
      <version>2.4.1</version>
      <!-- 2.4.1 起 JsonWriter 增加 writeString(String, boolean),
           与 acme-platform-bom 1.9.0 冲突,BOM 后续版本再对齐 -->
    </dependency>
  </dependencies>
</dependencyManagement>

然后加 enforcer,让「同一个 groupId:artifactId 出现多个版本」在构建阶段直接失败,而不是等到生产:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <version>3.4.1</version>
  <executions>
    <execution>
      <id>enforce-convergence</id>
      <goals><goal>enforce</goal></goals>
      <configuration>
        <rules>
          <dependencyConvergence/>
          <bannedDependencies>
            <excludes>
              <exclude>com.acme:acme-common:[,2.3.9]</exclude>
            </excludes>
            <message>acme-common 必须 >= 2.4.1,低版本缺 JsonWriter 新方法</message>
          </bannedDependencies>
        </rules>
        <fail>true</fail>
      </configuration>
    </execution>
  </executions>
</plugin>

dependencyConvergence 一开,团队里先炸出 30 多个模块的历史冲突,这个是必然的代价。我们的处理方式是「先只对生产服务开启,历史模块进白名单」,用 failFast 控制,逐步清理。

五、验证不能只看编译通过

改完依赖,最怕的是「编译过了、启动过了、某个冷门分支还是炸」。验证要落在字节码层面:

# 打出来的包里,acme-common 应该只有一个版本
unzip -l target/app.jar | grep -E "acme-common" 

# 反编译确认方法签名存在
javap -classpath target/app.jar -p 'BOOT-INF/lib/acme-common-2.4.1.jar' 2>/dev/null
javap -cp BOOT-INF/lib/acme-common-2.4.1.jar com.acme.common.json.JsonWriter | grep writeString

另外加了一个启动自检:应用启动时反射校验关键类的方法签名,缺失就打 ERROR 并退出。这个检查只对少数几个跨团队共享的工具类做,成本很低:

@Component
public class DependencySanityCheck implements ApplicationRunner {
    private static final String[][] REQUIRED = {
        {"com.acme.common.json.JsonWriter", "writeString", "java.lang.String,boolean"},
    };

    @Override
    public void run(ApplicationArguments args) throws Exception {
        for (String[] r : REQUIRED) {
            Class<?> c = Class.forName(r[0]);
            String[] types = r[2].split(",");
            Class<?>[] params = new Class<?>[types.length];
            for (int i = 0; i < types.length; i++) params[i] = Class.forName(types[i]);
            try {
                c.getMethod(r[1], params);
            } catch (NoSuchMethodException e) {
                throw new IllegalStateException("依赖版本不匹配: " + r[0] + "#" + r[1]
                        + " 来自 " + c.getProtectionDomain().getCodeSource().getLocation());
            }
        }
    }
}

上线后这个检查在预发环境立刻拦住了一次同类问题——另一个服务的 biz-report-sdk 升级时又把版本带回了 2.1.0。

六、同类问题的三个变种

NoSuchMethodError 只是最直白的一种,同一类问题还有几种表现,排查思路相通:

  • NoClassDefFoundError:类在编译期存在,运行期整个类都找不到,多半是 provided scope 打错了,或者被 exclusions 排掉了。
  • AbstractMethodError:接口加了方法,实现类还是旧版本。典型场景是 SDK 新版本给接口加方法,而某个实现类由另一个 jar 提供。
  • ClassCastException: com.acme.Foo cannot be cast to com.acme.Foo:同一个类被两个 ClassLoader 各加载了一次,常见于插件化、热部署、shade 之后没有 relocate 的场景。

第三种最容易被误判成业务代码写错。判断方法很简单:把两边的 getClass().getClassLoader() 打出来,不是同一个 loader 就是类加载隔离问题,跟强转逻辑无关。

小结

  • NoSuchMethodError 意味着编译期与运行期加载的不是同一份类,先定位类来自哪个 jar,别改业务代码。
  • dependencyManagement 的优先级高于传递依赖调解,dependency:tree 里标 managed 的那条就是真凶。
  • 排查用 mvn dependency:tree -Dincludes=g:a -Dverbose,验证用 javap 看方法签名,两步就能闭环。
  • 收敛靠父 pom 锁版本 + maven-enforcer-plugin,dependencyConvergence 首次接入必然炸出历史债,要分批清。
  • 冷门接口最容易漏测,关键共享类加一个启动期签名自检,比任何流程都可靠。
  • 同一个类被两个 ClassLoader 加载时表现为 ClassCastException,先比 loader,再谈类型转换。
查看「Java 与 JVM」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。