java.lang.NoSuchMethodError

最近有个应用上线, 在本地环境和测试环境运行的好好的, 可是发布到生产环境竟然跑不通. 每次就报下面的这个错误:

java.lang.NoSuchMethodError: 'io.grpc.netty.NettyChannelBuilder io.grpc.netty.NettyChannelBuilder.maxInboundMessageSize(int)'
at org.nugraph.client.gremlin.driver.remote.GrpcConnectionPool.createNewConnection(GrpcConnectionPool.java:775)
at org.nugraph.client.gremlin.driver.remote.GrpcConnectionPool.fillConnectionPool(GrpcConnectionPool.java:596)
at org.nugraph.client.gremlin.driver.remote.GrpcConnectionPool.createNewConnections(GrpcConnectionPool.java:649)
at org.nugraph.client.gremlin.driver.remote.GrpcConnectionPool.connect(GrpcConnectionPool.java:684)

出错信息分析

从上面的信息看, 还是说很明确的. 就是找不到 NettyChannelBuildermaxInboundMessageSize 方法. 这个方法传入一个 int 类型的参数, 返回一个 NettyChannelBuilder 实例对象.

然后我们在开发工具里面很快就找到了这个类 NettyChannelBuilder, 虽然它没有声明这样一个方法, 但是它实现的抽象父类 AbstractManagedChannelImplBuilder 确实有这个方法. 也就是它有这么一个期望的方法.

public final class NettyChannelBuilder
    extends AbstractManagedChannelImplBuilder<NettyChannelBuilder>

初步分析

先是问了谷歌, 确实有人遇到过类似的问题, 答案是当时版本不一致造成的. 于是看看本地的jar包版本, 分别是:
grpc-core-1.31.1.jar1.31.1, 它包含 AbstractManagedChannelImplBuilder.
grpc-netty-1.31.1.jar1.31.1, 它包含 NettyChannelBuilder.

于是远程登录到生产环境, 解压开总的jar包, 核对一下上面的2个 jar 包, 发现一模一样. 奇怪.

还有其它不同版本的jar包?

为了确认一定加载的是上面提及的两个版本的jar包, 于是我去审查了这个进程的启动参数. 原因是在生产环境使用的启动命令和本地不一样, 生产环境配置了更多的参数. 对比下来, 发现生产环境并没有多加额外的jar包进去.

于是在生产环境的启动参数里面添加了 -verbose:class 的启动参数, 这样就能打印出加载的所有类来自于那个jar包.

INFO   | jvm 1    | 2023/09/22 08:04:22 | [86.958s][info][class,load] io.grpc.netty.NettyChannelBuilder source: jar:file:/tmp/myapp-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/grpc-netty-1.31.1.jar!/
INFO   | jvm 1    | 2023/09/22 08:04:22 | [86.958s][info][class,load] io.grpc.internal.AbstractManagedChannelImplBuilder source: jar:file:/tmp/myapp-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/grpc-core-1.31.1.jar!/

统一版本不同的jar包内容?

于是从生产环境把这些 jar包复制到本地, 然后使用反编译软件查看内容, 是一样的.

该类没加载成功?

上面的类加载日志可以看到, 类其实是加载了的. 并且做了 heap dump 能够看到这两个类.
abs.png
builder.png

反射查看类的方法

为了确认该方法确实存在, 于是使用反射机制去查看它声明的方法:

      try {
            Class<?> c = NettyChannelBuilder.class;
            Method[] declaredMethods = c.getDeclaredMethods();
            for (Method method : declaredMethods) {
                System.out.println(method.getName() + " is declared in NettyChannelBuilder.");
                System.out.println("params: " + Arrays.asList(method.getParameterTypes()));
                System.out.println("return type: " + method.getReturnType());
            }

            System.out.println("NettyChannelBuilder super class is: " + c.getSuperclass());

            //c = AbstractManagedChannelImplBuilder.class;
            c = c.getSuperclass();
            declaredMethods = c.getDeclaredMethods();
            for (Method method : declaredMethods) {
                System.out.println(method.getName() + " is declared in AbstractManagedChannelImplBuilder.");
                System.out.println("params: " + Arrays.asList(method.getParameterTypes()));
                System.out.println("return type: " + method.getReturnType());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

确实是存在的:

2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT maxInboundMessageSize is declared in AbstractManagedChannelImplBuilder.
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT params: [int]
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT return type: class io.grpc.internal.AbstractManagedChannelImplBuilder
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT maxInboundMessageSize is declared in AbstractManagedChannelImplBuilder.
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT params: []
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT return type: int
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT maxInboundMessageSize is declared in AbstractManagedChannelImplBuilder.
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT params: [int]
2023-09-22 19:42:54,692 INFO [DefaultThreadPool-7] STDOUT return type: class io.grpc.ManagedChannelBuilder

手动修改调用类

根据出错栈, 出错的方法是: GrpcConnectionPool.createNewConnection(GrpcConnectionPool.java:775). 根据source code 代码可以看出, 其 775 行确实是调用了 maxInboundMessageSize 方法.

因为本地环境无法重现, 所以把这个代码复制到本地, 写一个同样包名类名的类, 然后放到 classes 目录, 这样 classes 目录里的优先级高先会被加载, 本来在 jar 包里面的就无法被加载. 如此就能在复制的这个类里面加一些日志代码, 方便打印一些信息.

加入一些打印日志信息后, 上传到服务器 classes 目录, 然后在测试, 竟然没在报错. 神奇!

在复制覆盖的这个类的过程中, 只有2处疑点:

  1. 原来包含 GrpcConnectionPool 类的jar包是使用 JDK 8 编译的, 而我本地和生产环境都是 JDK 11.
  2. source jar 中包含的 GrpcConnectionPool 使用了 lombok.extern.slf4j.Slf4j 的注解 @Slf4j, 但是我本地开发环境没设置 lombok, 所以报错. 只能去除 @Slf4j, 手工加入该类的 private static final Logger log = LoggerFactory.getLogger(GrpcConnectionPool.class)

也就是说重新编译上传的是可以运行的.

重新编译的差别在哪?

猜测之一是: 重新编译使用的是 JDK 11编译, 所以没报错. 于是单独对复制过来的类使用 JDK 8 编译, 然后重新调用, 发现还是好的.

于是对出错的版本和刚编译的新版本进行反编译, 然后对比, 竟然发现了差别:

// 之前出错版本的反编译:
((NettyChannelBuilder)(
    (NettyChannelBuilder)(
        (NettyChannelBuilder)
            NettyChannelBuilder.forAddress(entry.getHost(), entry.getRpcPort())
                .sslContext(GrpcSslContexts.configure(SslContextBuilder.forClient()).trustManager(trustManagerFactory).build())
                .maxInboundMessageSize(messageSizelimit)
                .intercept(new ClientInterceptor[] { clientInterceptor })
        ).overrideAuthority(strAuthorityOverride)
    ).idleTimeout(entry.getTtl(), TimeUnit.MILLISECONDS)
).build()
// 现在运行正确的版本反编译:
((NettyChannelBuilder)
    ((NettyChannelBuilder)
        ((NettyChannelBuilder)
            ((NettyChannelBuilder)
                NettyChannelBuilder.forAddress(entry.getHost(), entry.getRpcPort())
                .sslContext(GrpcSslContexts.configure(SslContextBuilder.forClient()).trustManager(trustManagerFactory).build())
                .maxInboundMessageSize(messageSizelimit)
            ).intercept(new ClientInterceptor[]{clientInterceptor})
        ).overrideAuthority(strAuthorityOverride)
    ).idleTimeout(entry.getTtl(), TimeUnit.MILLISECONDS)
).build();

仔细对比2个版本, 就会发现差别. 之前的代码少一次强制转换, 新的代码在字节码中多一次转换. 但是2次的原代码都是一样的, 原代码如下:

NettyChannelBuilder.forAddress(entry.getHost(), entry.getRpcPort())                    .sslContext(GrpcSslContexts.configure(SslContextBuilder.forClient()).trustManager(trustManagerFactory).build())
    .maxInboundMessageSize(messageSizelimit)
    .intercept(clientInterceptor)
    .overrideAuthority(strAuthorityOverride)
    .idleTimeout(entry.getTtl(), TimeUnit.MILLISECONDS)
    .build();

对比2次编译结果, 可以发现在出错的版本里面, 编译器认为执行完 maxInboundMessageSize(messageSizelimit) 方法后返回的是 NettyChannelBuilder, 不需要强转, 而后面的执行正常的版本任务执行完maxInboundMessageSize(messageSizelimit) 方法后返回的是 AbstractManagedChannelImplBuilder, 需要一次强转. 这都是编译起自动做的工作, 源代码体现不出来.

为什么那次编译不需要强转

最新的编译版本需要强转, 是因为那个方法声明在 AbstractManagedChannelImplBuilder 类, 返回的值是它的之类, 但是它不知道子类的具体类型, 所以加一个强转(这也是我之前由于对这块不熟悉, 不能做为推理依据的原因).

但是为什么之前那个版本不需要强转呢? 尝试使用了不同版本的 JDK 去编译, 然后在反编译, 也不能找出答案.

于是把那jar所在的项目 clone 下来, 然后本地建立工程, 再去查看源代码, 竟然发现它依赖的 grpc-coregrpc-netty 竟然和我依赖的不一样. 在那个版本里面 maxInboundMessageSize(messageSizelimit) 这个方法竟然是声明在 NettyChannelBuilder 里面的, 当然不需要强转. 然而我现在使用的版本, 这个方法是声明在 AbstractManagedChannelImplBuilder 里面的, 当然需要强转.

所以问题就出在依赖的 grpc-coregrpc-netty 的版本不一致造成的, 出错的那个 jar 依赖的grpc版本较新, 而我的项目里面依赖的grpc 版本较旧.

为什么依赖的版本不一致?

按照依赖传递的原则, 我的项目依赖那个出错的jar, 它把它所依赖的版本传递进来, 应该是一致, 可是现在我这边看到的却是老版本. 我这个项目没有直接依赖 grpc, 间接依赖的共2处. 就是出错的这个 graph-xxx.jar 和 jetch-xxx.jar. 仔细察看这两个 jar, 他们依赖的 grpc 相关的jar 都是新版本, 为什么我的项目却依赖的一个旧版本呢?

为了排除干扰, 分别去除 graph-xxx.jar 或 jetch-xxx.jar, 每个依赖的仍然是旧版本. 使用 mvn dependency:tree 去单独查看这两个jar 包的 pom 文件, 看到的每个依赖的都是新版本. 怪异.

修改自己项目的 pom.xml, 单独排除

为了彻底查找到底是哪里引入的旧版本, 于是二分法去除其它依赖的jar, 最后发现即便仅仅依赖 graph-xxx.jar 或 jetch-xxx.jar, 仍旧是老版本, 但是单独查看这两个jar的pom.xml, 却都是新版本.

最后发现自己的项目还有 parent 项目

<parent>
  <groupId>com.tianxiaohui.platform</groupId>
  <artifactId>raptor-io-parent</artifactId>
  <version>0.18.1-RELEASE</version>
  <relativePath></relativePath>
</parent>

若是去掉做个parent project, 那么依赖都变成了新版本, 也就说由于有个 parent project, 它管制了 grpc 版本的依赖, 导致依赖到了老版本.

查看 parent project 依赖

当有parent project 的时候, 如果父子project 对某个jar 都有依赖, 就会使用 parent project 使用的. 所以要找出是哪个 parent project 使用了旧版本.

使用下面命令能查看当前项目的依赖:

# 自己体会下面3个不同
apache-maven-3.9.1/bin/mvn  dependency:tree 
apache-maven-3.9.1/bin/mvn  dependency:tree -Dincludes=io.grpc:grpc-netty 
apache-maven-3.9.1/bin/mvn  dependency:tree -Dincludes=io.grpc:grpc-netty -Dverbose

但是它不能显示 parent project 依赖.

方法的签名

在最早的 Error message java.lang.NoSuchMethodError 后面, 给出了缺少的方法名字. 但是我们看到当时那个版本的类的父类是包含做个方法签名的.

在Java 里面, 一个方法的签名是指由 方法名, 参数类型, 参数顺序, 参数个数 这几个因素决定的. 方法的返回值并不能决定方法签名.

但是在字节码中, 方法的签名是包含包含返回值的. 因为字节码支持的其它动态语言是需要返回值做签名的.

如果 类A extends 非抽象类 B, 那么类A 的方法(A.getClass().getMethods())会包含 类B 的所有实例方法(非static).
如果 类A extends 抽象类 B, 那么类A 的方法(A.getClass().getMethods())会包含 类B 的所有实例方法, 若A override 了某个方法, 并且返回了不一样的值类型, 那么会出现2个不同的方法, 一个属于A, 一个属于B.

标签: none

添加新评论