关于 Java Agent 为何需要做类加载隔离,我在实际开发 Java Agent 之前是不清楚的,直到业务需要将 Java Agent 用于应用监控,在开发过程中,对整个类加载器层次及类隔离有了更深入的理解,本文简要记录。
在早期我们用于监控的 Java Agent 的实现中,是没有做类加载隔离的,因为起初的 Java Agent 实现非常简单,仅仅是监控是否有堆转储文件产生,然后触发告警,此时 Java Agent 没有任何依赖。随着业务发展,越来越多的依赖加入至 Java Agent 后,我们发现集成至 JVM 应用后,会触发各种关于类加载的异常,如:X cannot be cast to X exceptions。
首先简要介绍一下 Java Agent。根据 Oracle 的文档 java.lang.instrument (Java Platform SE 8 ),我们知道 Java Agent 是作为 Jar 文件部署的,有两种启动方式,一种是通过命令行随 Java 应用一起启动,另一种是在 JVM 已经启动后,attach 至已经启动的应用,并将 Agent 加载到正在运行的 Java 应用中。
文档还提到 Agent 的入口类由系统类加载器加载,同时系统类加载器也是加载应用 main
方法的入口类的类加载器。
在我们的场景中,Java Agent 是在 Docker 镜像层统一接入的,即通过命令行随 Java 应用一起启动,我们在 Java Agent 的入口类中提供了 premain
方法。关于 Agent 中的 premain
方法,其内部可以做什么没有具体的限制,任何应用程序 main
方法可以做的在 premain
方法中都可以做,包括创建线程等操作都是合法的。
那么我们为什么需要对 Java Agent 做类加载隔离呢,我们用一个例子来说明不进行类加载隔离会出现什么问题。现在,在我们的 Java Agent 实现中,因为需要打印日志,所以我们引入了如下依赖:
1 | <dependency> |
即在 Java Agent 中依赖了 log4j-core 2.14.1
,在我们的 web 应用中,含有如下依赖:
1 | <dependency> |
即 web 应用中依赖了 log4j-core 2.11.1
。根据之前的文章:Classloader Hierarchy for Tomcat,我们知道 web 应用中的依赖存在于 /WEB-INF/lib
中,且由 Webapp ClassLoader 加载。在 log4j 中,其对自身插件部分的加载采取了类似 Java SPI 机制的方式进行加载,原理为扫描 META-INF/org/apache/logging/log4j/core/config/plugins/Log4j2Plugins.dat
文件,读取该文件中的插件的实现类名,然后对这些插件的实现类进行加载,那么在 web 应用看来,将使用 log4j-core 2.11.1
的代码去查询接口的实现类并加载,其加载部分代码位于 PluginRegistry.java at rel/2.11.1:
1 | private Map<String, List<PluginType<?>>> decodeCacheFiles(final ClassLoader loader) { |
其中关键之处在 line 5 及 line 24,在 web 应用的 log4j-core 2.11.1
执行 line 5 的代码后,从 Webapp ClassLoader 发起调用,那么会返回整个类加载器层次上的 Log4j2Plugins.dat
资源,此时会返回两个,一个是 web 应用依赖的 log4j-core 2.11.1
中含有的 Log4j2Plugins.dat
,一个是 Java Agent 依赖的 log4j-core 2.14.1
中含有的 Log4j2Plugins.dat
,即两个不同版本的 Log4j2Plugins.dat
资源,且根据 Webapp ClassLoader 加载资源的顺序可以得出,log4j-core 2.11.1
中的 Log4j2Plugins.dat
在 log4j-core 2.14.1
中的 Log4j2Plugins.dat
之前返回。随即调用 line 9 将两个 Log4j2Plugins.dat
的数据进行 merge 操作,源码位于 PluginCache.java at rel/2.11.1:
1 | private final Map<String, Map<String, PluginEntry>> categories = |
由以上代码可知,将扫描出的两个 Log4j2Plugins.dat
资源的内容进行了 merge 操作,随后,在之前代码的 line 24:final Class<?> clazz = loader.loadClass(className);
中会使用 Webapp ClassLoader 去尝试加载类,而因为 web 应用依赖的 log4j-core 2.11.1
不含有 Java Agent 依赖的 log4j-core 2.14.1
中插件配置文件 Log4j2Plugins.dat
含有的类,那么在 line 24 的调用中,对这部分在 web 应用依赖中不存在的类,最终会委托给 System ClassLoader 加载并成功加载。在随后的处理逻辑中,会将加载到的类进行子类具体化,源码位于 Interpolator.java at rel/2.11.1:
1 | /** |
其中关键的代码为 line 16,该行代码会调用 Class.asSubclass
方法,源码如下:
1 | /** |
那么,在调用 java.lang.Class#asSubclass
方法时,this
指向的类可能为通过系统类加载器加载的 log4j-core 2.14.1
中的类,而参数 StrLookup.class
此时是由 Webapp ClassLoader 所加载,根据之前对 Class.isAssignableFrom 的分析我们知道,即使两个类满足继承关系,但是当这两个类不是由同一个类加载器加载时,该方法会返回 false
,从而执行 else
逻辑,触发 ClassCastException
,异常栈帧如下:
1 | 2021-10-30 20:34:23,956 RMI TCP Connection(2)-127.0.0.1 ERROR Unable to create Lookup for event java.lang.ClassCastException: class org.apache.logging.log4j.core.lookup.EventLookup |
以上就是 Java Agent 未进行类加载隔离导致的问题之一,因为应用依赖的复杂性,在真实的业务场景中,报的错远远不止这一种,那么如何解决这个问题呢,我参考了几个开源的 Java Agent 实现,比如 Uber 的 jvm-profiler,其在构建 Java Agent 的 Jar 时使用 Maven Shade Plugin 将类进行重定位,将资源进行排除,其 pom.xml 截取部分代码如下:
1 | <plugin> |
该实现方式的好处在于实现相对简单,只需在打包时进行配置即可,缺点在于必须将所有依赖涉及的类进行重定位及对资源进行排除,且由于 Maven 依赖的传递性,那么需要将依赖引入的依赖树涉及的类都进行重定位,如果 Java Agent 中的依赖重定位声明漏掉了一些类,那么就和我们开始提到的情况一样,在应用依赖的版本不一致的情况下,容易出现类转换等异常。
另一种实现方式可以参考 elastic 的 Java Agent,源码可以参考 AgentMain.java 及 ShadedClassLoader.java,其实现思路在 PR:Isolated agent classloader by felixbarny · Pull Request #2109 · elastic/apm-agent-java · GitHub 中有详细解释,大家可以参考。个人认为这是更通用且安全的方式,即使用独立的类加载器去加载 Java Agent 依赖的类,该独立的类加载器的 parent
指向 Bootstrap ClassLoader,且将 Java Agent 依赖的类的默认后缀 .class
进行调整,以避免系统类加载器加载到这些类,以实现类的隔离,目前我们内部的 Java Agent 实现即采用的类似的方式进行了实现,解决了 Java Agent 集成至应用后的相关类加载问题。
类似的实现在 opentelemetry 中也能看到,源码可以参考 OpenTelemetryAgent.java 及 AgentClassLoader.java,实现思路可以参考 javaagent-jar-components.md,文档中有详尽的解释,且有图片示例,此处不再赘述。
Reference
Load pre-loaded lookups via SPI, rather than hard-code in Interpolator. by tbwork · Pull Request #396 · apache/logging-log4j2 · GitHub
技术分享:How To Write a JavaAgent (袁伟)
The definitive guide to Java agents by Rafael Winterhalter
How to Create a Java Agent and Why Would You Need One?