上线时那个 NoSuchMethodError:两个 jar 里有同一个类
8 月底发版,新功能上线后立刻报了一堆错:
2019-08-27 20:14:32.881 ERROR [http-nio-8080-exec-3] c.x.web.ExceptionHandler -
Handler dispatch failed; nested exception is java.lang.NoSuchMethodError:
org.apache.commons.codec.binary.Base64.encodeBase64String([B)Ljava/lang/String;
at com.xxx.pay.AliPaySigner.sign(AliPaySigner.java:47)
at com.xxx.pay.PayService.createOrder(PayService.java:88)
奇怪的是,本地跑得好好的,测试环境也正常。而且 commons-codec 这个包我们一直是 1.11 版本,encodeBase64String 这个方法从 1.4 就有了。
用 Arthas 查了一下这个类是从哪个 jar 加载的:
$ java -jar arthas-boot.jar
[arthas@18304]$ sc -d org.apache.commons.codec.binary.Base64
class-info org.apache.commons.codec.binary.Base64
code-source /app/lib/commons-codec-1.3.jar
name org.apache.commons.codec.binary.Base64
class-loader +-sun.misc.Launcher$AppClassLoader@18b4aac2
classLoaderHash 18b4aac2
commons-codec-1.3.jar!不是我们依赖的 1.11。1.3 版本里确实没有 encodeBase64String(这个方法是 1.4 加的)。
查下来是:新接入的支付 SDK 传递依赖了一个很老的 commons-codec:1.3,而 Maven 的最短路径优先原则让 1.3 赢了——它的路径是 2 层,我们直接声明的 1.11 也是 2 层,那就看声明顺序,pom 里 SDK 写在前面。
解决办法是 <exclusions> 或者把 1.11 提到 <dependencyManagement> 里强制锁定。但排障过程中,我意识到自己一直没搞明白"一个类到底是怎么被加载进 JVM 的",就把类加载这块补了一遍。
一个类的生命周期
从 .class 文件到能用,要过三关:加载 → 连接 → 初始化。
加载
三件事:通过类的全限定名拿到二进制字节流;把字节流里的静态存储结构转成方法区的运行时数据结构;在堆里生成一个 java.lang.Class 对象作为访问入口。
注意"二进制字节流"没说必须来自文件。所以才有了从 jar 读、从网络读、运行时动态生成(动态代理、CGLib)、从数据库读、甚至加密后解密读这些玩法。
连接
分三步,其中准备阶段是个考点:
- 验证:文件格式、元数据、字节码、符号引用四轮校验,保证这个 class 文件不会危害 JVM。
- 准备:为类变量(static 变量)分配内存并设置零值。注意是零值不是你写的初始值。
- 解析:把常量池里的符号引用替换成直接引用。
public class InitDemo {
static int a = 123; // 准备阶段 a = 0,初始化阶段才赋 123
static final int b = 456; // 准备阶段就是 456,因为是 ConstantValue
static final String c = "hello"; // 同理,准备阶段就赋值
}
b 和 c 是 static final 的基本类型或字符串,javac 会给它们生成 ConstantValue 属性,在准备阶段就赋值了。普通的 static int a 则要等到初始化阶段,编译器把所有静态变量的赋值动作收集到 <clinit>() 方法里执行。
初始化
执行 <clinit>():类变量的赋值语句 + static {} 块,按代码出现顺序合并。JVM 会保证它加锁同步,所以多个线程同时初始化一个类时只有一个线程在执行,其他线程阻塞等待。这个特性可以用来写单例,但别用,容易死锁。
什么时候会触发初始化?只有这五种情况(主动引用):
new、读/写静态字段(非 final)、调用静态方法,也就是new/getstatic/putstatic/invokestatic这四条字节码- 反射调用,比如
Class.forName("com.xxx.Foo") - 初始化子类时,父类还没初始化,先初始化父类
- 虚拟机启动时,包含
main方法的那个主类 - JDK 7 动态语言支持,
MethodHandle解析出的类是这四种之一
有几个反直觉的例子:
class Parent {
static int value = 100;
static { System.out.println("Parent init"); }
}
class Child extends Parent {
static { System.out.println("Child init"); }
}
// 场景一:通过子类引用父类的静态字段
System.out.println(Child.value);
// 输出:Parent init / 100
// Child 没有初始化!只有直接定义该字段的类才会被初始化
// 场景二:数组
Parent[] arr = new Parent[10];
// 什么都不输出。数组类由 JVM 直接生成,不触发元素类初始化
// 场景三:常量
class Const {
static final String NAME = "abc";
}
System.out.println(Const.NAME);
// 什么都不输出。"abc" 在编译期就进调用方的常量池了,跟 Const 类没关系
双亲委派
JVM 里有三层类加载器(JDK 8):
| 加载器 | 实现 | 加载路径 | 父加载器 |
|---|---|---|---|
| 启动类加载器 | C++ (HotSpot) | $JAVA_HOME/lib 及 -Xbootclasspath | 无 |
| 扩展类加载器 | sun.misc.Launcher$ExtClassLoader | $JAVA_HOME/lib/ext | 启动 |
| 应用类加载器 | sun.misc.Launcher$AppClassLoader | classpath | 扩展 |
Arthas 告诉我那个 Base64 类是 AppClassLoader 加载的,就是从 classpath 来的。
双亲委派的代码在 ClassLoader.loadClass 里,非常短:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 先查自己有没有加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false); // 2. 委托给父加载器
} else {
c = findBootstrapClassOrNull(name); // 3. 父没有了就找启动类加载器
}
} catch (ClassNotFoundException e) {
// 父加载器找不到,正常,继续往下
}
if (c == null) {
c = findClass(name); // 4. 父都找不到,自己加载
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
逻辑就是一句话:先问爹,爹不行自己上。这样做的意义是保证类的唯一性和安全性。比如你自己写了个 java.lang.String,双亲委派会先让启动类加载器去加载 rt.jar 里的真 String,你写的那个压根没机会加载,也就没法替换 JDK 核心类。
注意"双亲"这个翻译有点误导,其实是单亲,只有一个 parent。另外这个 parent 不是继承关系,是组合——ClassLoader 有个 private final ClassLoader parent 字段。
为什么要打破委派
双亲委派解决了"基础类要统一"的问题,但反过来也带来一个问题:上层的类没法访问下层的类。
启动类加载器加载的 java.sql.DriverManager,它要加载 classpath 下的 MySQL 驱动,按双亲委派的规矩,它只能向上委托给扩展和启动类加载器,而驱动在 classpath 里,只有 AppClassLoader 能加载。路被堵死了。
JDBC 的解法:线程上下文类加载器
JDBC 4.0 用 SPI 解决这个问题。驱动 jar 里有个文件 META-INF/services/java.sql.Driver,内容是实现类的全限定名。DriverManager 在静态块里用 ServiceLoader 扫描:
// DriverManager 的静态初始化块
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
// ...
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
while (driversIterator.hasNext()) {
driversIterator.next(); // 触发驱动类的加载和注册
}
return null;
}
});
}
关键在 ServiceLoader.load 内部:
public static <S> ServiceLoader<S> load(Class<S> service) {
// 拿当前线程的上下文类加载器,默认是 AppClassLoader
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
线程上下文类加载器(TCCL)就是打通上下层的后门。它是 Thread 对象的一个字段,默认从父线程继承,主线程里是 AppClassLoader。这样 DriverManager(启动类加载器加载的)就能借道 TCCL 去加载 classpath 下的驱动实现类。
这就是为什么老代码里的 Class.forName("com.mysql.jdbc.Driver") 在 JDBC 4.0 之后可以省略——SPI 自动做了。但要注意,SPI 依赖 TCCL,如果在某些框架(线程池、自定义线程)里 TCCL 被换掉了,SPI 就会失效。我遇到过一次:在线程池里跑的任务调 DriverManager.getConnection 报 No suitable driver found,原因是创建线程池的线程 TCCL 被设成了别的加载器。
Tomcat 的解法:WebAppClassLoader 先自己来
Tomcat 的需求更特殊:一个 Tomcat 要跑多个 Web 应用,这些应用可能依赖同一个库的不同版本(比如 A 应用要 Spring 4.3,B 应用要 Spring 5.1)。按双亲委派,谁先加载谁赢,另一个应用就废了。
Tomcat 的 WebAppClassLoader.loadClass 是反着来的:
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> clazz = null;
// 1. 先查本地缓存
clazz = findLoadedClass0(name);
if (clazz != null) return clazz;
// 2. 查 JVM 的已加载类缓存
clazz = findLoadedClass(name);
if (clazz != null) return clazz;
// 3. 判断是不是 JDK 核心类,是的话委派给系统类加载器
// (这一步保证了 java.lang.String 这些不会被 Web 应用覆盖)
// 4. delegate 标志决定顺序。默认 false = 先自己加载
boolean delegateLoad = delegate || filter(name);
if (delegateLoad) {
clazz = Class.forName(name, false, parent);
if (clazz != null) return clazz;
}
// 5. 自己加载:从 WEB-INF/classes 和 WEB-INF/lib 下找
clazz = findClass(name);
if (clazz != null) return clazz;
// 6. 自己找不到,才委派给父加载器
if (!delegateLoad) {
clazz = Class.forName(name, false, parent);
if (clazz != null) return clazz;
}
throw new ClassNotFoundException(name);
}
}
核心是第 4 到第 6 步的顺序:先找 WEB-INF/classes 和 WEB-INF/lib,找不到才问父加载器。这样每个 Web 应用用自己的库版本,互相隔离。delegate 这个开关可以在 context.xml 里配置,默认 false。
回到开头那个问题
NoSuchMethodError 的本质是:编译期用的是 A 版本,运行期加载的是 B 版本,B 里没有这个方法。类加载器无关,纯粹是 classpath 上有两个 jar 包含同一个类,先加载的那个赢了。
我们的修法:
<dependencyManagement>
<dependencies>
<!-- 强制锁定版本,优先级高于传递依赖 -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.11</version>
</dependency>
</dependencies>
</dependencyManagement>
顺便用 mvn dependency:tree 扫了一遍全项目,又发现 4 个类似的冲突(guava 有 18.0 和 20.0 两个版本,jackson-databind 有 2.8 和 2.9)。我在 CI 里加了 maven-enforcer-plugin 的 dependencyConvergence 规则,有冲突直接构建失败:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.0.0-M2</version>
<executions>
<execution>
<id>enforce</id>
<configuration>
<rules>
<dependencyConvergence/>
<bannedDependencies>
<excludes>
<exclude>commons-codec:commons-codec:(,1.10)</exclude>
</excludes>
</bannedDependencies>
</rules>
</configuration>
<goals>
<goal>enforce</goal>
</goals>
</execution>
</executions>
</plugin>
加了之后又暴露出 11 个冲突,花了两天统一版本。这钱花得值,不然不知道哪天又在哪个调用链上炸。
小结
- 类的生命周期:加载(读字节流、生成 Class 对象)→ 连接(验证、准备、解析)→ 初始化(执行
<clinit>)。准备阶段只赋零值,static final的常量除外。 - 只有"主动引用"才触发初始化。子类引用父类静态字段、定义数组、引用常量,这三种都不会触发类的初始化。
- 双亲委派就 20 行代码:
findLoadedClass→ 委托parent.loadClass→ 都不行才findClass。目的是保证基础类的唯一性和安全性。 - JDBC 靠线程上下文类加载器打破委派,让 rt.jar 里的
DriverManager能加载 classpath 下的驱动实现。 - Tomcat 的
WebAppClassLoader反过来先加载WEB-INF/lib,实现多应用之间的库隔离,只把 JDK 核心类留给父加载器。 NoSuchMethodError基本都是 jar 包冲突,用mvn dependency:tree查,用dependencyManagement锁版本,用maven-enforcer-plugin卡住 CI。