一次诡异的NoSuchMethodError问题排查

最近遇到了一次诡异的 NoSuchMethodError 错误,通常情况下遇到这个报错,大概率是类加载冲突导致的,然而这次的情况却不大一样。

背景

按照以往的经验,如果是类加载冲突导致的出错,那么实际加载的类与预期的会不一致。

要确认最终加载了哪个类,可以在 jvm 启动参数里加上 -verbose:class,这样在程序启动后会打印出类加载日志。如下所示是我截取了一段日志。

1
2
3
4
5
6
[Loaded com.gorden5566.Test from file:/home/gorden5566/github/nosuchmethod/client/target/classes/]
[Loaded java.lang.Void from /home/gorden5566/Application/jdk1.7.0_79/jre/lib/rt.jar]
[Loaded com.gorden5566.ShowUtil from file:/home/gorden5566/github/nosuchmethod/sdk/target/classes/]
[Loaded com.gorden5566.Shape from file:/home/gorden5566/github/nosuchmethod/common/target/classes/]
[Loaded com.gorden5566.Circle from file:/home/gorden5566/github/nosuchmethod/common/target/classes/]
[Loaded com.gorden5566.ShapeUtil from file:/home/gorden5566/github/nosuchmethod/common/target/classes/]

从类加载日志中可以看到加载的类名以及所在的路径。如果类是在 jar 包中,则打印出 jar 包的完整路径名。而对于当前项目中的类,则是打印出编译出的 class 文件所在的路径。

通过类加载日志可以定位到有问题的类是从哪个地方加载的。如果是从一个非预期的 jar 包中加载的,那么说明存在 jar 包冲突,也就是说两个 jar 包中存在命名完全相同的类(类名一样并且包路径也一样)。解决了 jar 包冲突,也就解决了类冲突的问题。

重现报错

然而这次的情况并非是类加载冲突。通过猜测和排除法最终确定是方法签名变更导致,为了方便描述问题,我建了一个项目 nosuchmethod ,项目结构如下

1
2
3
4
5
6
7
8
9
10
├── client
│   ├── pom.xml
│   └── src
├── common
│   ├── pom.xml
│   └── src
├── pom.xml
└── sdk
├── pom.xml
└── src

项目中共包含 3 个模块:common、sdk 和 client。其中 common 是用于提供公共工具的类库,sdk 用于对外提供特定功能,它依赖于 common 模块,client 表示业务功能,它依赖于 common 和 sdk 模块

1
2
3
sdk -> common
client -> sdk
client -> common

common模块

common 模块提供了一个 ShapeUtil 类,它提供了一个 show 方法,方法内部会调用 shape 实例的 show 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* @author gorden5566
* @date 2021/04/07
*/
public class ShapeUtil {
public static void show(Shape shape) {
if (shape != null) {
shape.show();
}
}
}

/**
* @author gorden5566
* @date 2021/04/07
*/
public interface Shape {
void show();
}

/**
* @author gorden5566
* @date 2021/04/07
*/
public class Circle implements Shape {
@Override
public void show() {
System.out.println("I am a circle");
}
}

在 init 分支中,ShapeUtil 的 show 方法的入参类型为 Shape,打包出来的版本号为 1.0-low-RELEASE

在 update 分支中,ShapeUtil 的 show 方法的入参类型为 Circle,打包出来的版本号为 1.0-high-RELEASE

sdk 模块

sdk 中仅有一个类 ShowUtil,它的 show 方法调用了 common 中的 ShapeUtil 的 show 方法,注意入参是 Circle 类型的实例

1
2
3
4
5
6
7
8
9
/**
* @author gorden5566
* @date 2021/04/07
*/
public class ShowUtil {
public static void show() {
ShapeUtil.show(new Circle());
}
}

打包出来的版本为 1.0-low-RELEASE

client 模块

client 模块只有一个类 Test,在它的 main 方法中调用了 sdk 中的 ShowUtil 的 show 方法

1
2
3
4
5
6
7
8
9
/**
* @author gorden5566
* @date 2021/04/07
*/
public class Test {
public static void main(String[] args) {
ShowUtil.show();
}
}

在 init 分支中,依赖的 common 模块和 sdk 模块的版本均为 1.0-low-RELEASE

在 update 分支中,依赖的 common 模块版本升级为 1.0-high-RELEASE,但是 sdk 的版本仍保持为 1.0-low-RELEASE

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>com.gorden5566</groupId>
<artifactId>common</artifactId>
<version>1.0-high-RELEASE</version>
</dependency>

<dependency>
<groupId>com.gorden5566</groupId>
<artifactId>sdk</artifactId>
<version>1.0-low-RELEASE</version>
</dependency>

复现问题步骤

要重现前述问题,可按如下步骤操作:

  1. 切换到该项目的 init 分支,执行 mvn clean install 命令,编译项目并将 commonsdk1.0-low-RELEASE 版本安装到本地仓库

  2. 切换到 update 分支,执行 mvn clean install 命令再次编译项目

  3. 找到 client 模块下的 com.gorden5566.Test 类,运行其 main 方法,不出意外的话你将看到如下报错

    1
    2
    3
    Exception in thread "main" java.lang.NoSuchMethodError: com.gorden5566.ShapeUtil.show(Lcom/gorden5566/Shape;)V
    at com.gorden5566.ShowUtil.show(ShowUtil.java:9)
    at com.gorden5566.Test.main(Test.java:9)

问题原因

对于一个问题,如果能够稳定复现,那么离找到问题原因也不远了。

在上面的报错中,可以看到是找不到 ShapeUtil 的一个 show 方法,该方法的签名为 show(Lcom/gorden5566/Shape;)V,从中可以看到它的入参类型为 com.gorden5566.Shape,对应的是使用 init 分支打包出来的 1.0-low-RELEASE 版的 common 模块。

回顾下 update 分支中 client 依赖的版本号

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>com.gorden5566</groupId>
<artifactId>common</artifactId>
<version>1.0-high-RELEASE</version>
</dependency>

<dependency>
<groupId>com.gorden5566</groupId>
<artifactId>sdk</artifactId>
<version>1.0-low-RELEASE</version>
</dependency>

此时依赖的 sdk 是 1.0-low-RELEASE 版,它在打包时 ShapeUtil 提供的 show 方法是 public static void show(Shape shape) ;

然而 common 包已经升级到 1.0-high-RELEASE ,ShapeUtil 的 show 方法签名已经变更 public static void show(Circle shape) ;

注意这是两个不同的方法,可以重载但不是重写。

1
2
public static void show(Shape shape) ;
public static void show(Circle shape) ;

我们有理由相信,正是打包时记录的链接信息不同,进而导致出现 java.lang.NoSuchMethodError 错误。

为进一步验证,我们可以通过 javap 命令查看 1.0-low-RELEASE 版 sdk 的链接信息和 1.0-high-RELEASE 版的 common 包提供的方法信息,确认是否真是这样。

解压 common-1.0-high-RELEASE.jar ,执行 javap ShapeUtil 查看 ShapeUtil.class 类文件

1
2
3
4
5
6
警告: 二进制文件ShapeUtil包含com.gorden5566.ShapeUtil
Compiled from "ShapeUtil.java"
public class com.gorden5566.ShapeUtil {
public com.gorden5566.ShapeUtil();
public static void show(com.gorden5566.Circle);
}

可以看到方法提供的 show 方法为 public static void show(com.gorden5566.Circle);

解压 sdk-1.0-low-RELEASE.jar ,执行 javap -verbose ShowUtil,输出结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
警告: 二进制文件ShowUtil包含com.gorden5566.ShowUtil
Classfile /home/gorden5566/test/com/gorden5566/ShowUtil.class
Last modified 2021-4-7; size 439 bytes
MD5 checksum a650d8f8addd854ec094d908c0ad2444
Compiled from "ShowUtil.java"
public class com.gorden5566.ShowUtil
minor version: 0
major version: 51
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#17 // java/lang/Object."<init>":()V
#2 = Class #18 // com/gorden5566/Circle
#3 = Methodref #2.#17 // com/gorden5566/Circle."<init>":()V
#4 = Methodref #19.#20 // com/gorden5566/ShapeUtil.show:(Lcom/gorden5566/Shape;)V
#5 = Class #21 // com/gorden5566/ShowUtil
#6 = Class #22 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/gorden5566/ShowUtil;
#14 = Utf8 show
#15 = Utf8 SourceFile
#16 = Utf8 ShowUtil.java
#17 = NameAndType #7:#8 // "<init>":()V
#18 = Utf8 com/gorden5566/Circle
#19 = Class #23 // com/gorden5566/ShapeUtil
#20 = NameAndType #14:#24 // show:(Lcom/gorden5566/Shape;)V
#21 = Utf8 com/gorden5566/ShowUtil
#22 = Utf8 java/lang/Object
#23 = Utf8 com/gorden5566/ShapeUtil
#24 = Utf8 (Lcom/gorden5566/Shape;)V
{
public com.gorden5566.ShowUtil();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/gorden5566/ShowUtil;

public static void show();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: new #2 // class com/gorden5566/Circle
3: dup
4: invokespecial #3 // Method com/gorden5566/Circle."<init>":()V
7: invokestatic #4 // Method com/gorden5566/ShapeUtil.show:(Lcom/gorden5566/Shape;)V
10: return
LineNumberTable:
line 9: 0
line 10: 10
}
SourceFile: "ShowUtil.java"

注意第 58 行 Method com/gorden5566/ShapeUtil.show:(Lcom/gorden5566/Shape;)V,它表明 ShowUtil 调用的是 ShapeUtil 的 show 方法签名为 (Lcom/gorden5566/Shape;)V ,其中入参类型为 com.gorden.5566.Shape,确实与 common-1.0-high-RELEASE.jar 包提供的方法不一致

至此也就定位到了原因

总结

java.lang.NoSuchMethodError不是 Exception,一般的 try catch 并不能捕获这个错误,如果不慎遇到这个问题,极有可能会造成系统无法正常工作。

对外的 api 或工具类,一旦提供出去,就不要再修改定义,可以新增方法,但不要删除或修改方法,除非确定确实没有问题。

方法签名变更导致的不一致问题,在 IDEA 里查看源码不会有错误提示,这也使得问题的排查变得更难。java 官方提供的 javap 等工具在关键时候还是挺有用的。

如果你有遇到类似问题,希望本文可以对你有所帮助。