bug现场谜之困在“init”方法上的那些时间!

war包从tomcat迁移到jetty,报错NoSuchMethodError: xxx.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V,可是找到对应的类,明明方法存在啊?最后得出结论:“出来混,知识的盲区迟早是要还的!”

2022年01月16日

目录


1. bug现场情况

现场将在Tomcat 8.5中运行的war包迁移到jetty 9.4.19上,启动容器后报错:

org.springframework.context.ApplicationContextException: Failed to start bean 'stompWebSocketHandlerMapping'; nested exception is java.lang.NoSuchMethodError: org.eclipse.jetty.websocket.server.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V
    at org.springframework.context.support.DefaultLifecycleProcessor.doStart(DefaultLifecycleProcessor.java:176) ~[spring-context-4.2.3.RELEASE.jar:4.2.3.RELEASE]
    ...
    ...

NoSuchMethodError应该是看到后最有头绪一个错误了:“在加载到JVM的对应类中找不到当前调用的方法”。

如果编译环境中对应类没有对应的方法,是不能编译成功的(集成开发环境会报错)。如果编译成功后部署时候报错NoSuchMethodError,说明运行时和编译时依赖的类不一致。

这里说的“编译时依赖”指的是:构建工具在编译时CLASSPATH中依赖的class;“运行时依赖”指的是:JVM实例运行时加载到JVM中的class。对于同一个class loader,只会成功加载一次class

上面的异常堆栈显示:类org.eclipse.jetty.websocket.server.WebSocketServerFactory没有构造方法WebSocketServerFactory(javax.servlet.ServletContext)。那就看一看运行时依赖的类有没有对应的构造方法吧。

现场的情况下,只能用javap命令,但是首先你要找到这个类是从哪个jar包加载的,如何根据类找到加载的jar包路径在接下来的尝试破案做进一步说明。

javap -cp lib/websocket/websocket-server-9.4.41.v20210516.jar org.eclipse.jetty.websocket.server.WebSocketServerFactory

注意这里如果使用 javap -cp lib/websocket/* xxxx这样指定classpath*配置的方式无效,但是对于javac命令是有效的。

这不是存在WebSocketServerFactory(ServletContext context)构造方法吗???

后来笔者又尝试了多种途径确认这个构造方法是存在的,但是却报错NoSuchMethodError,网上一大堆找“java.lang.nosuchmethoderror but method exists”,无果。因为网上说的最后都证明确实没有对应的方法。

但本案发现场的情况是它有啊!现场变得诡异起来了!难道笔者找到了一个超级bug?直觉告诉我100%不会,一定是自己哪块错了。

2. 尝试破案

回顾一下案发现场的情况,报错java.lang.NoSuchMethodError: org.eclipse.jetty.websocket.server.WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V,可是通过javap工具反编译明明有构造方法WebSocketServerFactory(javax.servlet.ServletContext)啊!

一般NoSuchMethodError异常有两种情况:

  1. classpth中该方法的类在多个jar包中,而JVM加载的jar包的那个类没有该方法。
  2. 只有一个jar包,jar包中的类没有该方法。

这两种情况归根结底是JVM运行时加载的类中确实缺失了方法。但是上面遇到的问题查找加载类是存在报错的构造方法的。

如果JVMclasspth中有多个包存在同一个class,到底JVM会加载哪个包中的class是平台相关的(Linux系统和Windows系统上可能加载的不是同一个jar包)。需要注意:JVMclasspth下的jar包中load对应的class文件,这跟jar包的命名没有关系。

可以通过以下方法根据报错信息定位加载的jar包:

1). JVM使用参数-verbose:class,这个参数能够输出加载classjar包绝对路径。

2). 使用java代码:

Class<?> clazz = null;
try {
     clazz = Class.forName("org.eclipse.jetty.websocket.server.WebSocketServerFactory");
} catch (ClassNotFoundException e) {
     e.printStackTrace();
}
CodeSource cs = clazz.getProtectionDomain().getCodeSource();
String location = cs.getLocation().getPath();
System.out.println(location);

3). 使用linux命令:

for file in *.jar; do
  echo $file;
  jar tvf $file |grep WebSocketServerFactory
done

在允许重启系统或者启动的JVM中设置了--verbose:class参数的话“1)”方法是最方便的,可以直接在日志中查找对用的类。

不允许重启JVM的话可以采用方法“2)”,但是要指定正确的classpath,否则加载不到对应的类。查找classpath可以从jvm对应的进程中查找。

对于springboot框架打成的jar包,一般依赖都打进在jar包中了;对于severlet容器使用的war包,依赖除了WEB-INF/lib外还包括容器安装目录下的lib包;对于普通的jar包,依赖可能定义在了MANIFEST元文件中(更多关于MANIFEST内容可以参考:https://fengmengzhao.github.io/2021/12/18/bug-scene-of-old-jar-classpath-mystery.html

如果想查找指定目录的哪个jar包含有某个class,可以使用“3)”方法,列出jar中包含的文件清单并查找匹配。

为什么要费劲找到报错类是从哪个jar包中加载的呢?一来jar包一般能提供版本相关的信息;二来javap命令是需要指定jar包作为classpath才能成功反编译。

使用javap命令反编译,语法如下:

#这种方式是指定类信息和类所在的jar包为classpath反编译
javap [-verbose] -cp /some/path/to/lib/xxx.jar com.xx.SomeClass

#这种方式是将class文件从jar中解压,直接反编译class文件
mkdir dir
cd dir
jar xvf ../SomeClass-belong-to.jar
javap [-verbose] com/xx/SomeClass.class

javap-verbose参数展示class文件的详细编译信息,如果只想判断是是否有某个方法,可以不加-verbose参数。

通过上面的方法确认本示例的情况:明明方法存在啊,为什么NoSuchMethodError,百思不得其解!

3. 真相浮出水面

怎么办呢?问题总是要解决的。

在开发环境上准备调试代码,突然意识到报错中的init是不是一个普通方法啊?

赶快看看反编译的代码发现确实没有init普通方法,只有init构造方法。问题就出在这里,查了一下发现jetty9.3升级到9.4的时候对WebSocketServerFactoryinit普通方法改为构造方法

这是笔者的一个知识误区,以为WebSocketServerFactory.init(Ljavax/servlet/ServletContext;)V是一个构造方法,实际上如果是构造方法报错是长这样的:

10:24:09.590 [main] ERROR org.springframework.boot.SpringApplication - Application run failed
java.lang.NoSuchMethodError: org.springframework.boot.builder.SpringApplicationBuilder.<init>([Ljava/lang/Object;)V

普通方法和构造方法实际上就是.init().<init>()的区别。

这里报错中.<init>([Ljava/lang/Object;)V.表示是一个方法的调用;<init>表示构造方法的调用;[表示一个数组;Ljava/lang/Object;表示java.lang.object对象;V表示返回类型是void。实际上就是SpringApplicationBuilder(java.lang.Object...)的构造方法,方法的参数是java.lang.Object数组。这种写法和class文件的内部表示是一致的。jvm更多内部实现 类型表示参考:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html

对于一个程序员来说,异常的的堆栈信息是司空见惯的,也就懒得深究其中的一些玄机,果然“报应不爽”!出来混,迟早要还的。

4. 总结

  1. 很多看似玄学的bug解释不了,最后原因总是归结为“知识的盲区”。很多知识不必懂的很深入,但是基本的东西要了解,此时“不求甚解”,彼时“这是玄学?”。
  2. 有些时候会无意识的想当然一些结论(比如本示例中.init()方法自然认为是构造方法)。没办法十分敲定的东西,要多查一查,多一份思路。
  3. 排查问题,针对一个思路要充满信心,即使这个思路不能解决问题,至少也要能得出这条思路的结论。不能急躁、粗心、盲目尝试。思路窄了,就停下来,明天再尝试,避免进入死胡同。

更新记录

  • 2022-01-24 16:10 微信公众号“冯兄画戟”文章发表前重读、优化、勘误
  • 2022-01-26 15:20 掘金专栏发表前重读、优化、勘误