一次 maven 的 pom 文件修改引发的问题, 你遇到过吗?

283 阅读6分钟

最近在对一个遗留项目进行 pom 文件的优化。这个遗留项目包含多个服务,每个服务又有多个模块。一方面是因为没有统一管理,依赖较为随意,导致有大量重复依赖,且版本不一,例如 A,B 模块都依赖 a,b,c 三个组件,那把 a,b,c 合成一个模块再去被依赖是比较合理的。另一方面项目里面存在部分重复代码,是可以通过抽取组件的方式进行更好的管理的。

所以希望通过梳理组件的依赖关系和抽取组件,让服务依赖的组件尽可能简单,从而提升组件可维护性,后续对组件进行优化也会更简单。

这里分享在过程中遇到了2个的问题。

问题一: NoSuchFieldError,NoSuchMethodError

场景1

Exception in thread "main" java.lang.NoSuchMethodError: com.google.common.collect.Lists.newCopyOnWriteArrayList()Ljava/util/concurrent/CopyOnWriteArrayList;
	at com.ctrip.framework.apollo.internals.AbstractConfig.<init>(AbstractConfig.java:58)
	at com.ctrip.framework.apollo.internals.DefaultConfig.<init>(DefaultConfig.java:63)

image.png

我们随着异常堆栈点进去,就会发现编译错误,这个显然是 Lists 这个类缺少了 newCopyOnWriteArrayList 这个方法。AbstractConfig 和 Lists 这两个类肯定是属于不同的包,他们之间有依赖关系。而出现这个原因在于这两个包的版本对不上,大概率是因为在 pom 文件中重新定义了其中一个包的版本,或者其他组件也依赖了其中一个包。导致两个包的版本出现兼容问题。这时候借助 Maven Helper 插件就可以看到冲突的情况。一般只需要选择最大的版本,可以通过在 pom 文件重新定义新版本从而覆盖旧版本,也可以 exclude 包里面的旧版本。

image.png

NoSuchXXXError 的解决方法是通用的。但有时没那么容易看出编译问题。我们再看另一个例子。

场景2

Caused by: java.lang.NoSuchMethodError: com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V
	at com.google.inject.TypeLiteral.getParameterTypes(TypeLiteral.java:278)
	at com.google.inject.spi.InjectionPoint.forMember(InjectionPoint.java:136)
	at com.google.inject.spi.InjectionPoint.<init>(InjectionPoint.java:95)
	at com.google.inject.spi.InjectionPoint.forConstructorOf(InjectionPoint.java:334)

image.png

image.png

这时候从源码上看没有编译问题,方法参数也匹配的上,但还是提示 NoSuchMethodError。 而且如果断点在报错的地方,重新执行这个语句,是可以正常执行的。那就有点奇怪,代码执行有问题,但调试的时候执行又没问题。

image.png

异常提示表明com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V 这个方法不存在。意味着 Preconditions 没这个方法。实际上确实没有,因为两个方法签名是不同的。

// 方法的签名对应是  checkArgument(ZLjava/lang/String;[Ljava/lang/Object;)V
// 而不是 com.google.common.base.Preconditions.checkArgument(ZLjava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V
public static void checkArgument(boolean expression, @Nullable String errorMessageTemplate, @Nullable Object... errorMessageArgs) 

虽然从反编译之后的代码看没问题,但从字节码层面是不同的方法。异常提示里面对应的方法反编译之后应该是两个object 而非 object[],平时编写代码两个方法都能执行成功,因为编译器帮我们找到了合适的方法,但到了编译之后就固化下来了,不能再改了。

// 下面的签名才是异常提示的方法签名
public static void checkArgument(
    boolean b, String errorMessageTemplate, @CheckForNull Object p1, @CheckForNull Object p2)

要看方法签名可以通过 jclass 来看,非常方便

image.png

场景3

这个报错的代码位于 okhttp-4.10 包里面.

Caused by: java.lang.NoSuchFieldError: Companion
	at okhttp3.internal.Util.<clinit>(Util.kt:70) ~[okhttp-4.10.0.jar:?]
	at okhttp3.internal.concurrent.TaskRunner.<clinit>(TaskRunner.kt:309) ~[okhttp-4.10.0.jar:?]
	at okhttp3.ConnectionPool.<init>(ConnectionPool.kt:41) ~[okhttp-4.10.0.jar:?]
	at okhttp3.ConnectionPool.<init>(ConnectionPool.kt:47) ~[okhttp-4.10.0.jar:?]
	at okhttp3.OkHttpClient$Builder.<init>(OkHttpClient.kt:471) ~[okhttp-4.10.0.jar:?]

image.png

<Clinit> 方法就是静态构造方法,当触发类初始化的时候就会调用这个方法。这里的现象和前面场景2 比较像,也是看上去编译没问题,但又提示 NoSuchFieldError。Companion 对使用过 kotlin 的来说会比较熟悉,kotlin 没有像 java 那样的静态方法,而是会产生一个 Companion 的单例对象,通过调用这个对象的方法来实现类似 java 静态方法的功能。源码看不到 Companion,但字节码可以。同样通过 jclass 可以很方便看到对应方法的字节码。

image.png

但是到底是执行到哪一行字节码导致有问题的呢? 字节码的行号和源码的并不一致,一行源码可能会对应好几行的字节码。其实 .class 其实会存字节码和源码的映射关系,方便输出堆栈信息的行号。在 jclass 里面的 LineNumberTable 就可以看到这个信息.

image.png

堆栈里面的行号是 70 的,对应起始程序计数器为 121,也就是在字节码的 121 行。就是在这个 Options$Companion 这里抛的异常。

image.png

从 IDEA 点进去 Options 这个类,确实有这个 companion object,查看这个类对应所在的包,并检查 okio-jvm 是否存在冲突或者版本不一致,但经过统一版本之后仍然报错。

image.png

虽然我们确认依赖没问题,但会不会是因为缓存,或者其他原因导致引入的类是错误的呢? 这时候最简单的方式就是查看加载的类到底是在哪个包里面的。可以通过类对象的 getProtectionDomain 获取类所在的包。

image.png

发现竟然在 okio-1.13.0 里面,而非我们以为的 okio-jvm。原来是有两个包有一样类名包名完全一样的类。jvm 会根据类的依赖关系来进行加载,对我们来说是黑盒的。这次加载的是 okio,下次改了一点代码可能加载的就是 okio-jvm 了.

image.png

这里是因为正常来说 okhttp3 依赖的是 okio,而 okhttp4 依赖的是 okio-jvm,如果只是依赖 okhttp 是不会有冲突的,但项目里面有其他组件依赖了 okio,因此项目存在两个全限定类名一样的类,jvm 会根据加载时机选择了 okio 而非 okio-jvm,就会出现这个异常。即使当前 jvm 选择了 okio-jvm,但只要依赖关系发生些变化,则有可能下次就会选择 okio。

解决办法就是 exclude okio 就搞定了。

总结

NoSuchXXXError 很多时候还是很隐秘的,需要结合字节码,查看类的加载情况去判断异常原因。

问题二: 日志代码死循环导致堆栈溢出

SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/C:/work/maven/m2/org/slf4j/slf4j-log4j12/1.7.12/slf4j-log4j12-1.7.12.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/C:/work/maven/m2/org/apache/logging/log4j/log4j-slf4j-impl/2.17.1/log4j-slf4j-impl-2.17.1.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J: Actual binding is of type [org.slf4j.impl.Log4jLoggerFactory]
Exception in thread "main" java.lang.StackOverflowError
	at java.util.concurrent.ConcurrentHashMap.get(ConcurrentHashMap.java:936)
	at org.slf4j.impl.Log4jLoggerFactory.getLogger(Log4jLoggerFactory.java:55)
	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:363)
	at org.apache.log4j.Category.<init>(Category.java:57)
	at org.apache.log4j.Logger.<init>(Logger.java:37)
	at org.apache.log4j.Log4jLoggerFactory.getLogger(Log4jLoggerFactory.java:43)
	at org.apache.log4j.LogManager.getLogger(LogManager.java:45)
	at org.slf4j.impl.Log4jLoggerFactory.getLogger(Log4jLoggerFactory.java:63)
	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:363)
	at org.apache.log4j.Category.<init>(Category.java:57)
	at org.apache.log4j.Logger.<init>(Logger.java:37)
	at org.apache.log4j.Log4jLoggerFactory.getLogger(Log4jLoggerFactory.java:43)
	at org.apache.log4j.LogManager.getLogger(LogManager.java:45)
	at org.slf4j.impl.Log4jLoggerFactory.getLogger(Log4jLoggerFactory.java:63)
	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:363)
	at org.apache.log4j.Category.<init>(Category.java:57)

项目里面的日志用到的是 slf4j 和 log4j2。slf4j 是通用的日志打印框架,只是一种协议和接口,并没有实现日志打印。需要代理给真正输出日志的日志组件,例如 logback,log4j,log4j2。因此大部分项目都会依赖 slf4j 的接口来打印日志,这样以后就算要改日志实现,也无需改动业务代码。

要想实现 slf4j 和 log4j2 结合除了这两个包之外还要有一个它们之间的连接器。这就是 slf4j-log4j12。

slf4j 和 log4j2 需要一个连接器把他们接起来,这就是 log4j-slf4j-impl。其他常见的日志框架也是一样的逻辑。

image.png

因为项目里面会用到其他框架,其他框架也会集成了别的日志框架。例如我这个项目用的是 slf4j 和 log4j2,但其他框架有用了 logback 的,也有用 log4j 的,因此还需要把这些日志框架转成 slf4j。

image.png

这时候如果同时引入了 log4j-over-slf4j 和 slf4j-log4j12 则会出现相互委托,log4j -> log4j-over-slf4j -> slf4j -> log4j,死循环导致溢出。

其实一开始也有提示了,提示 slf4j 可以绑定 log4j 和 log4j2 (对应那两个代理类),其实也是有问题的,按道理我们只想引入 log4j2,因此把 slf4j-log4j12 这个包 exclude 掉就可以了。

SLF4J: Found binding in [jar:file:/C:/work/maven/m2/org/slf4j/slf4j-log4j12/1.7.12/slf4j-log4j12-1.7.12.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/C:/work/maven/m2/org/apache/logging/log4j/log4j-slf4j-impl/2.17.1/log4j-slf4j-impl-2.17.1.jar!/org/slf4j/impl/StaticLoggerBinder.class]

总结

关键还是要对日志框架的使用方式了解,才能快速分析出问题。