Skip to content
Zegging's Tech Blog
Go back

从一次 LinkageError 看 ClassLoader 隔离与开闭设计

一次 fat jar 部署引发的 LinkageError 排查,牵出了 Java ClassLoader 机制、Flink 的类隔离设计,以及一个被”抄了实现忘了抄设计”的内部平台。

参考资料:

一、问题场景

业务背景

公司大数据组维护着一个几经易手的内部系统 Oceanus,它的功能是实现全公司范围内的数据同步任务:从 MySQL 同步到 ES,从 Kafka 同步到数据仓库……遇到了一个需求,需要在 Oceanus 平台上写一个自定义的算子来执行某些平台不能承接的功能。核心是消费商品表变更事件(转换 MySQL binlog 然后写入了 Kafka)。算子的前置过滤逻辑需要查询另一个数据库来判定是否需要继续往下游传递。为了降低数据库压力,引入了 Caffeine 做本地缓存。本来以为是很简单的事情,但是遇到了意想不到问题。

Oceanus 是基于 Flink 二次开发的内部流式计算平台,用户的自定义算子(UDF)以 jar 包形式上传,由平台运行时动态加载执行。

第一个错误:ClassNotFoundException

算子发布后启动失败:

Caused by: java.lang.ClassNotFoundException: com.github.benmanes.caffeine.cache.Caffeine
    at java.net.URLClassLoader.findClass(URLClassLoader.java:387)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
    at com.qunhe.logcomplex.oceanus.udf.UdfClassLoader.loadClass(UdfClassLoader.java:56)

原因显而易见:平台运行时 oceanus-task 没有 Caffeine 依赖,用户 jar 也是按”瘦 jar”方式打包的,运行时找不到这个类。

第二个错误:LinkageError

按经验切换到 maven-shade-plugin 打 fat jar,把 Caffeine 及其传递依赖都打进去,启动时变成了另一个错误:

Caused by: java.lang.LinkageError: loader constraint violation:
when resolving overridden method "org.apache.xerces.jaxp.DocumentBuilderImpl.newDocument()Lorg/w3c/dom/Document;"
the class loader (instance of com/qunhe/logcomplex/oceanus/udf/UdfClassLoader) of the current class,
org/apache/xerces/jaxp/DocumentBuilderImpl,
and its superclass loader (instance of <bootstrap>),
have different Class objects for the type org/w3c/dom/Document used in the signature

这个错误信息的含义也是很清晰的:同一个类被两个不同的 ClassLoader 各加载了一份。 根因肯定出现在 “用户逻辑封装进 Jar 然后框架执行” 的类加载器中,但是为什么出现这个问题还需要进一步排查。

二、根因定位

排查和根因

现象:报错涉及 org.w3c.dom.Documentorg.apache.xerces.jaxp.DocumentBuilderImpl,这是 JDK 内置 XML 解析体系 依赖分析:用 jar tf 列出 fat jar 内容,发现传递依赖把 xml-apis:1.0.b2 打了进去,里面有 org.w3c.dom.*org.apache.xerces.*

那么就应该存在一个有问题的加载路径:

  • JDK 内置的 xerces.DocumentBuilderImplBootstrap ClassLoader 加载,方法签名 newDocument(): Document 中的 Document 也是 Bootstrap 版本
  • fat jar 里的 Document 被某个 Classloader 加载,重写 newDocument() → 父类签名的 Document ≠ 子类签名的 Document → 方法签名校验失败

必须要拿到源码才能知道发生了什么事情。在代码逻辑梳理后定位到了这样一个类:UdfClassLoader (24年初 Coding Agent 还没有那么强力,我们还是习惯于一些愉悦身心的古法编程能力)。

public class UdfClassLoader extends URLClassLoader {  
  
    private static final Set<String> PARENT_FIRST_CLASS_PREFIX_PATTERNS;  
  
    /**  
     *  alwaysParentFirstLoaderString 这部分依赖抄的 {@link org.apache.flink.configuration.CoreOptions.ALWAYS_PARENT_FIRST_LOADER}  
     *  flink 本身有一个 {@link org.apache.flink.runtime.execution.librarycache.FlinkUserCodeClassLoaders.ChildFirstClassLoader}  
     *  类加载器,也是默认的 runtime classloader,它本身的实现逻辑里,会将 alwaysParentFirstLoaderString 这部分加载逻辑交给父加载器,  
     *  因为这部分是公共类库和 flink/hadoop 的依赖,这些依赖应该由 bootstrap 和 app ClassLoader 加载,否则会导致 class 不同的问题。  
     *  
     *  com.qunhe.datax.oceanus.udf 路径下的 class 是 oceanus 自定义算子基础类,这些 class 会被 oceanus 底层和用户使用,  
     *  为了保证在 JVM 中 class 不会因为不同的 ClassLoader 加载,此包下面的 class 应该由父加载器加载,保证 class 是相同的。  
     *  
     *  UdfClassLoader 不仅仅实现了自定义加载 hdfs jar,还实现了自定义 Jar 包类加载隔离,  
     *  自定义 Jar 包相关的依赖都是用 UdfClassLoader 加载(除了 PARENT_FIRST_CLASS_PREFIX_PATTERNS 前缀路径的 class)  
     */  
    static {  
        final String alwaysParentFirstLoaderString = "java.;scala.;org.apache.flink.;com.esotericsoftware.kryo;org.apache.hadoop.;javax.;" +  
                "org.slf4j;org.apache.log4j;org.apache.logging.log4j;org.apache.commons.logging;ch.qos.logback";  
        final Set<String> set = new HashSet<>(Arrays.asList(alwaysParentFirstLoaderString.split(";")));  
        set.add("com.qunhe.datax.oceanus.udf.");  
        PARENT_FIRST_CLASS_PREFIX_PATTERNS = Collections.unmodifiableSet(set);  
        registerAsParallelCapable();  
    }  
  
    public UdfClassLoader(ClassLoader parent) {  
        super(new URL[0], parent);  
    }  
  
    public void addHdfsUrl(final URL url) {  
        addURL(url);  
    }  
  
    @Override  
    protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {  
        for (String alwaysParentFirstPattern : PARENT_FIRST_CLASS_PREFIX_PATTERNS) {  
            if (name.startsWith(alwaysParentFirstPattern)) {  
                return super.loadClass(name, resolve);  
            }  
        }  
        Class<?> c = findLoadedClass(name);  
        if (c == null) {  
            try {  
                c = findClass(name);  
            } catch (ClassNotFoundException e) {  
                c = super.loadClass(name, resolve);  
            }  
        }  
        if (resolve) {  
            resolveClass(c);  
        }  
        return c;  
    }  
  
    @Override  
    public URL getResource(String name) {  
    }  
  
    @Override  
    public Enumeration<URL> getResources(String name) throws IOException {    
    }  
}

可以明显看出问题的根本原因出在 com.qunhe.logcomplex.oceanus.udf.UdfClassLoader 的 parent-first 白名单不完整,遗漏了 org.w3cjavax.xmlorg.apache.xercesorg.rocksdb 等 Flink 原生包含的前缀,导致 JDK 体系类被用户 ClassLoader 重复加载。相关代码在 Flink 中是这样的:

    @Internal
    public static final String[] PARENT_FIRST_LOGGING_PATTERNS =
            new String[] {
                "org.slf4j",
                "org.apache.log4j",
                "org.apache.logging",
                "org.apache.commons.logging",
                "ch.qos.logback"
            };

    @Documentation.Section(Documentation.Sections.EXPERT_CLASS_LOADING)
    public static final ConfigOption<List<String>> ALWAYS_PARENT_FIRST_LOADER_PATTERNS =
            ConfigOptions.key("classloader.parent-first-patterns.default")
                    .stringType()
                    .asList()
                    .defaultValues(
                            ArrayUtils.concat(
                                    new String[] {
                                        "java.",
                                        "scala.",
                                        "org.apache.flink.",
                                        "com.esotericsoftware.kryo",
                                        "org.apache.hadoop.",
                                        "javax.annotation.",
                                        "org.xml",
                                        "javax.xml",
                                        "org.apache.xerces",
                                        "org.w3c",
                                        "org.rocksdb."
                                    },
                                    PARENT_FIRST_LOGGING_PATTERNS))
                    .withDeprecatedKeys("classloader.parent-first-patterns")
                    .withDescription(
                            "A (semicolon-separated) list of patterns that specifies which classes should always be"
                                    + " resolved through the parent ClassLoader first. A pattern is a simple prefix that is checked against"
                                    + " the fully qualified class name. This setting should generally not be modified. To add another pattern we"
                                    + " recommend to use \"classloader.parent-first-patterns.additional\" instead.");
前缀必须 parent-first 的原因
java.
javax.annotation.
javax.xml
JVM/JDK 核心类,本来 BootstrapClassLoader 就独占了,子加载器不允许定义 java.*,否则直接 SecurityException
scala.Flink 早期 runtime 用 Scala 写了一部分,Scala 的 FunctionX、Option、Tuple 这些类型会跨边界传递
org.apache.flink.Flink 框架类型(StreamRecord、Configuration、Function 等)会在宿主和用户代码之间传递,必须要框架加载
com.esotericsoftware.kryoFlink 默认序列化框架。Kryo 注册表是全局静态状态,如果两个 ClassLoader 各持一份 Kryo,注册的 Serializer 互不可见
org.apache.hadoop.Flink 的 FileSystem 抽象底层依赖 Hadoop 的 org.apache.hadoop.fs.FileSystem,且 Hadoop 有大量静态全局状态:
org.slf4j / log4j / logback / commons.loggingSLF4J 的核心机制是静态绑定,需要使用统一的日志实现
org.rocksdb.RocksDB 是 Flink 的 state backend,通过 JNI 调用 native 库 librocksdbjni.so。
org.w3c.DOM 接口:Document、Node、Element、Attr。
JDK 内置的 javax.xml.parsers.DocumentBuilder
javax.xml.一个 JDK 历史问题

JDK 内置的 javax.xml.parsers.DocumentBuilderabstract 类:

public abstract class DocumentBuilder {
    public abstract org.w3c.dom.Document newDocument();
    //                                   ^^^^^^^^
    //                                   返回类型!
}

任何 DocumentBuilder 的实现类(包括用户 jar 带的)都必须重写这个方法。JVM 在做方法重写校验时,要求父子类签名引用的 Class 完全相等(不只是名字相等,是 Class 对象相等)。这次的 LinkageError 问题就出在这里:

JDK DocumentBuilder (Bootstrap 加载)
    └── newDocument(): Document_A    ← Document_A 来自 Bootstrap

用户 jar 里的 MyBuilder (UserClassLoader 加载)
    └── newDocument(): Document_B    ← Document_B 来自 UserClassLoader(因为 org.w3c 没 parent-first)
                                      Document_A ≠ Document_B

                                      LinkageError: loader constraint violation

Oceanus 平台在 方法签名链路 这个部分的 parent-first 保护没了,问题也因此发生。

再比较一下 Flink 的源码:

public final class ChildFirstClassLoader extends FlinkUserCodeClassLoader {

    /**
     * The classes that should always go through the parent ClassLoader. This is relevant for Flink
     * classes, for example, to avoid loading Flink classes that cross the user-code/system-code
     * barrier in the user-code ClassLoader.
     */
    private final String[] alwaysParentFirstPatterns;

    public ChildFirstClassLoader(
            URL[] urls,
            ClassLoader parent,
            String[] alwaysParentFirstPatterns,
            Consumer<Throwable> classLoadingExceptionHandler) {
        super(urls, parent, classLoadingExceptionHandler);
        this.alwaysParentFirstPatterns = alwaysParentFirstPatterns;
    }

    @Override
    protected Class<?> loadClassWithoutExceptionHandling(String name, boolean resolve)
            throws ClassNotFoundException {

        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);

        if (c == null) {
            // check whether the class should go parent-first
            for (String alwaysParentFirstPattern : alwaysParentFirstPatterns) {
                if (name.startsWith(alwaysParentFirstPattern)) {
                    return super.loadClassWithoutExceptionHandling(name, resolve);
                }
            }

            try {
                // check the URLs
                c = findClass(name);
            } catch (ClassNotFoundException e) {
                // let URLClassLoader do it, which will eventually call the parent
                c = super.loadClassWithoutExceptionHandling(name, resolve);
            }
        } else if (resolve) {
            resolveClass(c);
        }

        return c;
    }
}

Flink 通过代码于与配置(alwaysParentFirstPattern 是可以扩展的)规定了什么类需要委派给父类加载,什么类子类自己就可以加载。Oceanus 平台抄了但是没抄全,也没抄明白。

解决方案

首先肯定是先完成需求让功能先上线,在【pom <excludes> 黑名单,把冲突类排除】和【pom <artifactSet><includes> 白名单,只打 Caffeine】之间选择了 <artifactSet><includes> 通过白名单把需要的依赖打进去。

在用简单方式(更简单显然是实现一个自己的缓存,但是相比较 pom 里的几行配置反而更加复杂了)处理完眼前的问题,那么下面需要解决的问题就是:如何让 Oceanus 更好支持用户的自定义 Jar?

MR 提供的方案

先确认 Oceanus 的本质:

Oceanus 是一个多租户的流式计算平台,承载 N 个业务方的 N 个作业,每个作业由业务方自己写 UDF jar,通过平台运行时(基于 Flink)执行。

这个定位决定了几个根本特征:

  1. 业务方多元:每个业务方的依赖、依赖版本、依赖来源都不一样
  2. 平台居中:平台不写业务代码,但要保证所有业务作业能稳定运行
  3. 故障域要可控:A 业务方的 bug 不能影响 B 业务方
  4. 演进路径要解耦:平台升级不能强制所有业务方一起改

所有这些特征都指向同一个核心能力诉求:把”业务侧知识”和”平台侧知识”清晰分离,让两边各自演进。单从 ClassLoader 来看正是缺少每个 Job 独立的运行时配置能力。Flink 本身提供 API 来实现,在我们的场景中可以允许业务方在 jar 中配置元数据,平台启动时读取并应用。

udf-fat.jar
  ├── com/qunhe/udf/MyUdf.class
  └── META-INF/
       ├── oceanus-classloader.properties     ← ClassLoader 配置
       ├── oceanus-runtime.properties         ← 运行时配置
       └── oceanus-monitoring.properties      ← 监控埋点

我们在 load jar 之前可以通过 JarFile 的方式获取配置,然后再创建 ClassLoader

    /**
     * 用 JarFile 直接读 jar entry,不创建任何 ClassLoader 实例。
     * 避免 ClassLoader leak 和"同一 jar 被两个 ClassLoader 加载"问题。
     */
    private static Set<String> readFromJarManifest(File jarFile) throws IOException {
        try (JarFile jar = new JarFile(jarFile)) {
            JarEntry entry = jar.getJarEntry("META-INF/oceanus-classloader.properties");
            if (entry == null) {
                return Collections.emptySet();
            }
            Properties props = new Properties();
            try (InputStream in = jar.getInputStream(entry)) {
                props.load(in);
            }
            return parse(props.getProperty(KEY_PARENT_FIRST_ADDITIONAL));
        }
    }
    /**
     * 新构造器:允许追加 parent-first 前缀。
     *
     * @param parent              父 ClassLoader
     * @param additionalPatterns  额外的 parent-first 前缀,可为 null 或 empty
     */
    public UdfClassLoader(ClassLoader parent, Collection<String> additionalPatterns) {
        super(new URL[0], parent);
        Set<String> merged = new HashSet<>(DEFAULT_PARENT_FIRST_PATTERNS);
        if (additionalPatterns != null) {
            for (String p : additionalPatterns) {
                if (p != null && !p.trim().isEmpty()) {
                    merged.add(p.trim());
                }
            }
        }
        this.parentFirstPatterns = Collections.unmodifiableSet(merged);
    }

根治了吗?

没有。对于一个老旧业务的维护方来说,优化的风险、避免风险的工作和受益不对等。最终的改造没有真的落实。

三、为什么 Java 需要 ClassLoader

它要解决什么问题

.class 文件不是 JVM 启动时一次性加载的,而是按需加载。ClassLoader 负责把字节码翻译成 JVM 内部的 Class<?> 对象。它要回答三个问题:

  1. 去哪里找 .class 字节流?(jar、目录、网络、HDFS、加密文件、动态生成的字节码……)
  2. 同名的类如何避免被重复定义?(双亲委派)
  3. 如何在一个 JVM 里允许多份”同名但不同实现”的类共存?(隔离)

类加载器层级

JDK 8 经典模型:

Bootstrap ClassLoader   (C++ 实现,加载 $JAVA_HOME/jre/lib/rt.jar:java.*、javax.*)

Extension ClassLoader   (加载 jre/lib/ext/*.jar)

Application ClassLoader  (加载 -classpath 上的 jar,即"系统类加载器")

自定义 ClassLoader      (UdfClassLoader、Flink 的 ChildFirstClassLoader、Tomcat 的 WebappClassLoader)

两个类相等 ⇔ 类全限定名相同 加载它的 ClassLoader 实例相同

这是 LinkageErrorClassCastException: A cannot be cast to A 的全部根源。也是 ClassLoader#loadClass 的默认实现机制叫做:双亲委派(Parent-First) 产生的根本原因。

举个最具体的例子:

// 用户代码 1
List<String> list = new ArrayList<>();
list.add("hello");

// 用户代码 2
public void process(List<?> data) {
    for (Object o : data) {
        System.out.println(o);
    }
}

process(list);  // 把 list 传给 process

这段代码运行起来要满足一个隐含前提:process 方法签名里的 List 和 list 变量的类型 List 必须是同一个 Class。如果没有双亲委派:

  • list 由 ClassLoader A 加载,对应 List(A)
  • process 方法由 ClassLoader B 加载,签名里的 List 对应 List(B)
  • List(A) ≠ List(B) → 调用 process(list) 直接 ClassCastException

所有跨模块的 API 调用都会失败。整个 Java 生态崩溃。

双亲委派的副作用

对 JDK 自身很友好,但对插件化容器就是噩梦:

  • Tomcat 上跑 N 个 webapp,每个 webapp 都想用自己版本的 Spring
  • Flink TaskManager 跑 N 个 Job,每个 Job fat jar 里依赖不同版本的 Kafka client
  • IDE 插件、OSGi bundle、Class hot reload

这些容器全都打破了双亲委派,发明了 Child-First / Inverted Delegation

// 伪代码:child-first
protected Class<?> loadClass(String name) {
    Class<?> c = findLoadedClass(name);
    if (c != null) return c;

    // 1. 白名单:必须 parent-first 的类,老老实实走双亲委派
    for (String prefix : alwaysParentFirstPatterns) {
        if (name.startsWith(prefix)) {
            return super.loadClass(name);  // parent-first
        }
    }

    // 2. 其他类:先自己找
    try {
        return findClass(name);            // 从用户 jar 里找
    } catch (ClassNotFoundException e) {
        return parent.loadClass(name);     // 自己找不到再问爹
    }
}

类加载的五阶段生命周期

Loading → Linking { Verification → Preparation → Resolution } → Initialization
  1. Loading:ClassLoader 找到 .class 字节流,defineClass() 注册到方法区
  2. Verification:校验字节码合法性
  3. Preparation:静态字段分配内存 + 赋默认值
  4. Resolution:常量池里的”符号引用”替换成”直接引用”
    • LinkageError 通常在这一步抛——发现”我以为的 Document 和父类签名里的 Document 不是同一个 Class 对象”
  5. Initialization:执行 <clinit>(静态块、静态字段初始化)

类的卸载

ClassLoader 和它加载的 Class 之间是双向强引用环。要让类被 GC:

  • ClassLoader 实例不再被任何线程/对象持有
  • 所有该类的实例被 GC
  • 所有 Class 反射引用消失

满足后,整个 ClassLoader + 所有 Class + 所有静态字段 一起被卸载。这就是 Flink Job 结束后能 GC 掉 UserCodeClassLoader 的原理。

反过来,ClassLoader leak 是 Metaspace OOM 的常见原因:用户代码起守护线程引用了用户 jar 的类 → ClassLoader 永远活着。Flink 有个开关 classloader.check-leaked-classloader,作业结束后还有人用这个 loader 就直接报错。

开闭设计:算法 + 配置入口 双层暴露

Flink 把”parent-first 列表”拆成三层,每层都给出口:

配置项修改方式
默认列表classloader.parent-first-patterns.defaultflink-conf.yaml(不推荐)
追加列表classloader.parent-first-patterns.additionalflink-conf.yaml(推荐)
解析策略classloader.resolve-orderchild-first / parent-first

业务方遇到 LinkageError

# flink-conf.yaml
classloader.parent-first-patterns.additional: org.w3c;javax.xml;org.apache.xerces

或命令行:

flink run -D classloader.parent-first-patterns.additional='org.w3c' ...

完全不需要碰 Flink 源码——加一行配置、重启 TaskManager 就行。这是开闭原则的标准实现:对扩展开放(配置追加),对修改封闭(默认值固化)

Oceanus 的实现也不是没有好的地方,比如从 HDFS 加载算子:

Oceanus 的 UdfClassLoader extends URLClassLoader,复用了 JDK 处理 jar URL 的能力:

public class UdfClassLoader extends URLClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 1. 把类名转成路径:"com.foo.Bar" → "com/foo/Bar.class"
        String path = name.replace('.', '/') + ".class";
        // 2. 从 HDFS / 腾讯云 拉字节流(或本地缓存)
        byte[] bytes = loadFromRemote(path);
        // 3. 交给 JVM 注册
        return defineClass(name, bytes, 0, bytes.length);
    }
}

实际上 URLClassLoader 已经把”从 URL 拉 jar”这套实现好了。Oceanus 只是把 URL 协议从本地 file:// 换成了远程拉取,剩下解析 jar、读 entry、defineClass 的逻辑全部白嫖。Spark 的 RemoteJarClassLoader、OSGi bundle 的远程加载、甚至 Java Agent 的 Premain-Class 也是类似思路

五、在设计此类平台化复杂系统的启示

抄实现容易,抄设计难

UdfClassLoader 完整继承了 Flink 的 child-first 算法,但丢了承载算法的”可配置基础设施”:

  • Flink 把列表放在 ConfigOption 里 → 自动获得 YAML 配置、命令行 -D、Web UI 展示、文档生成(@Documentation.Section)等一整套能力
  • Oceanus 把列表放在 static String 里 → 什么都没有

借鉴上游设计时,要分清”业务逻辑”和”承载它的扩展机制”。两者都抄,才能真正享受到上游的设计红利。

开闭原则的本质:决策权下放

对扩展开放,对修改封闭。

很多人把这条原则理解成”用继承代替修改”,但更本质的理解是:把”什么会变”识别出来,把这个”变化点”的决策权交给最有上下文的人

  • Flink 知道 parent-first 列表会因业务而异 → 暴露为配置 → 决策权交给作业开发者
  • Oceanus 没有识别出这个变化点 → 硬编码 → 决策权锁在平台团队手里 → 每次变更都要走”改代码 + 发版 + 回归”的重路径

好的抽象 = 把变化点暴露出来;坏的抽象 = 把变化点埋起来

八股文背起来简单,更重要的是理解。总是有一个真实场景让我惊呼:卧槽,这八股文真的有道理。


Share this post on:

Previous Post
SKILL 的演进与终局
Next Post
AWS S3 回源配置踩坑:从 x-oss-process 到 CloudFront Function