Administrator
发布于 2019-08-28 / 1428 阅读
33

JVM 类加载机制与双亲委派模型

上线时那个 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"; // 同理,准备阶段就赋值
}

bcstatic final 的基本类型或字符串,javac 会给它们生成 ConstantValue 属性,在准备阶段就赋值了。普通的 static int a 则要等到初始化阶段,编译器把所有静态变量的赋值动作收集到 <clinit>() 方法里执行。

初始化

执行 <clinit>():类变量的赋值语句 + static {} 块,按代码出现顺序合并。JVM 会保证它加锁同步,所以多个线程同时初始化一个类时只有一个线程在执行,其他线程阻塞等待。这个特性可以用来写单例,但别用,容易死锁。

什么时候会触发初始化?只有这五种情况(主动引用):

  1. new、读/写静态字段(非 final)、调用静态方法,也就是 new / getstatic / putstatic / invokestatic 这四条字节码
  2. 反射调用,比如 Class.forName("com.xxx.Foo")
  3. 初始化子类时,父类还没初始化,先初始化父类
  4. 虚拟机启动时,包含 main 方法的那个主类
  5. 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$AppClassLoaderclasspath扩展

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.getConnectionNo 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/classesWEB-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-plugindependencyConvergence 规则,有冲突直接构建失败:

<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。

参考