Skip to content

Gravity中类加载器的应用

codingPao edited this page May 20, 2022 · 8 revisions

Gravity类加载器的发展历程

简述一下在Gravity建设过程中有关类加载器的使用

本地开发

agent本身是由AppClassLoader加载(The agent class will be loaded by the system class loader),为了加载插件定义,我们定义了AgentPluginClassLoader,该类加载器用于加载插件的定义,插件的拦截器等。所以一开始的设计如下:
image
(图1:AgentPluginClassLoader设计的初始方案)
在此阶段,都处于本地开发阶段,字节码织入在本地开发模式下都按照预期的情况植入与生效

部署至环境

项目在本地正常开发,运行,但是当应用部署至环境时,字节码是正常植入了,目标实体也成功初始化了,但是切面真正运行时使用到业务class的地方都出现了NoClassDefFoundError,经过定位发现,以spring boot项目举例,如下:
image
(图2:项目在环境部署中实际类加载器的架构)

spring boot项目的类加载器可能还会因为引入web而类加载器还略有不同,但是不影响问题描述,就不画图赘述。

为什么本地正常运行,但是到了环境失败呢,这是因为运行时,类加载器层面的不同,首先观察图1,大家在本地开发spring boot项目时候,肯定都是使用main方法启动的,这时候业务的所有class都是由AppClassLoader加载,而我们的拦截器是由AgentPluginClassLoader加载的,当我们的拦截器代码中,声明要使用业务class时候,JVM会使用当前类加载器,也就是AgentPlugnClassLoader尝试加载的,当然因为双亲委托的原因,最后业务class肯定是由AppClassLoader加载的,这时候能保证找到想要的目标业务类,所以是没问题的。

但是发到环境后,如图2,业务class是由spring boot提供的LaunchedURLClassLoader负责加载的,这时候当我们的拦截器声明要使用业务class的时候,也是委托AgentPlugnClassLoader尝试加载,但是这时候AgentPlugnClassLoader以及所有的parent classLoader都没有业务class,所以这时候底层JVM会抛出NoClassDefFoundError

所以问题未能复现的原因,是本地开发与环境上部署的类加载器层面上行为不一致,其实这个问题可以及早发现,比如早注意spring boot项目特殊的打包方式,要提供spring-boot-maven-plugin,还有打包解压后,特殊的文件目录格式,或者测试tomcat项目,tomcat项目的整体类加载器架构与spring boot类似,只不过在LunchedURLClassLoader层面纵向加了多层级,底层Webapp ClassLoader平行且相互间隔离。因为初接类加载器,并未注意到这些问题点。

为什么实体成功初始化,但是执行时才出现了NoClassDefFoundError,可以参考:
When and how a Java class is loaded and initialized?
When does the JVM load classes?

双亲委托的知识不在赘述,首先解释一下为什么拦截器中使用业务的class,首先是委托由AgentPlugnClassLoader加载,这是因为在运行期中,一个类中所关联的其他类都由当前类的加载器进行加载。由此可以引申出SPI等知识,可以参考:
When a class A attempts to load another class B, the ClassLoader that loaded A is the current ClassLoader.
SPI为什么要使用ContextClassLoader

解决NoClassDefFoundError

为了解决上述问题,我们对类加载器做了调整,如下图:
image
(图3:类加载器调整后,以spring boot举例项目在环境部署中实际类加载器的架构)

这时候左边的AgentPluginClassLoader负责加载我们插件的定义,而右侧的AgentPluginClassLoader负责加载拦截器,这时候拦截器真正生效的时候,遇到需要加载的业务class,JVM首先委托右边的AgentPluginClassLoader负责加载,此时虽然AgentPluginClassLoader中没有业务的class,但是根据双亲委托,最终可以委托父classLoader也就是LaunchedURLClassLoader加载到目标业务class,在此阶段初步解决了织入不生效方面的问题。

满帮Mesh遇到的问题

至此Gravity已经整体上线稳定半年左右。

这时候要搞满帮Mesh(满帮Mesh方案,一站式解决RPCMQRedisConfig等中间件组件无感升级方案,后续即将开源,敬请期待),在为满帮Mesh评估的时候,想了想类加载器怕是又要改造:smile:,具体原因是从Gravity角度看待出发的话是满帮Mesh是轻SDK,重插件形式,将很多组件会打包到插件中,比如各个驱动,工具包等,这时候会出现一个问题,目前的AgentPluginClassLoader是走正常双亲委托模式,如果业务的ClassLoader中也有我们所需的驱动/工具类,走双亲委托的话,会优先使用业务提供的驱动/工具类,这时候极其容易产生版本的冲突,插件反而受到了业务的ClassLoader的污染。当时想了两个解决方案:

  1. 所有插件依赖的驱动/工具包做shade,修改包名。
  2. 调整AgentPluginClassLoader的加载顺序,走非双亲委托模式,优先从AgentPluginClassLoader加载,再委托父类加载器加载。

大致对比一下两个方案,shade改包名改动及其大,更别提涉及到SPI文件,或者其他一些文本配置文件如何变动,或者代码中,硬编码反射使用字符串类名去加载目标类等等方式,都可能出现各种各样的问题。

于是评估了一下,还是动ClassLoader简单,于是我们的类加载器架构再次调整,如下图:

image
(图4:类加载器调整后,以spring boot举例项目在环境部署中实际类加载器的架构)

因为类加载器层面实现了隔离,满帮Mesh依赖驱动与目标业务依赖驱动完全隔离,互不影响。

满帮Mock遇到的问题

经过上述调整,产生了新的问题,这时候Mock同学找来了,说他们的插件,为什么发到环境上会出现java.lang.ClassCastException: A cannot be cast to A ,明明是相同的class,但是却无法转换的问题:sweat_smile:。精简一下问题代码,如下:

A a= (A) target;

定位了一下,原因是在拦截器中,获取业务的对象我们先叫做target,该对象的classA,同时该A刚好是某个驱动的class,该targetclass A由业务的ClassLoader负责加载的。再看在拦截器中,声明了A a,这时候声明的class A会在该方法第一次调用时,会委托当前拦截器的ClassLoader也就是AgentPluginClassLoader加载,因为该驱动刚好因为Mesh开发,打到了我们的插件中,而为了Mesh的驱动不受用户引入的驱动而影响,做了上图4的改造,AgentPluginClassLoader走的非双亲委托模式,所以这个时候class A是由AgentPluginClassLoader加载的。这就是问题的所在,target的确是class A,但是这个class是由用户类加载器加载的,声明的A a,它的class是由AgentPluginClassLoader加载的,看起来同名的class,却是由两个不同ClassLoader加载的,根本就是两个class对象,自然就无法强转了。

针对这个问题当时想了两个解决方案:

  1. Mock同学改造纯走反射模式,不声明使用任何我们打到插件中的class
  2. 改造AgentPluginClassLoader,插件本身是否走双亲委托各自控制

于是评估了下方案,感觉方案一的确不是那么友好,Mock同学反馈感觉改造起来也太痛苦了,同时想了下,如果自己以后新开发的插件遇到class冲突也这样,也会很痛苦的。想了下,还是选择方案二,继续改造AgentPluginClassLoader,于是调整如下图:
image
(图5:类加载器调整后,以spring boot举例项目在环境部署中实际类加载器的架构)

由插件定义(io.manbang.gravity.plugin.PluginDefine#isDelegated)告诉我们具体的拦截器到底是否走双亲委托,像Mock这种场景,就需要走双亲委托,使用业务提供的class,而Mesh场景,就需要走非双亲委托,避免被业务的引入的驱动所影响。

满帮APM遇到的问题

经过上述调整,Gravity侧又稳定运行了一段时间。:smile: 这时候开发满帮APM插件(Application Performance Management)的同学找过来了,他们正准备做一整套打点监控,针对底层驱动进行拦截切入,切用户的驱动肯定是没问题的,但是如果Mesh化改造的话,驱动都由插件加载了,他们切不到了,可能就白做功了,具体原因是因为Gravity内部有个判断逻辑,如果发现需要增强的目标类是由AgentPluginClassLoader加载的话,会跳过增强,其实就是避免插件之间互相拦截,产生切面层面上递归循环问题。

思考了下,提供两个解决方案:

  1. APM开发同学在开发切业务驱动的同时,修改一下我们Mesh插件打包的驱动class,从源码层面直接植入期望的打点
  2. AgentPluginClassLoader角度出发,提供插件之间互切能力

评估了一下方案,其实Mesh插件的驱动源码是掌握在自己手里的,但是让APM同学重新又为Mesh驱动改一套又得重新开发一次,有点麻烦。而且调整驱动源码,修改,重新打包,的确麻烦。 于是还是调整AgentPluginClassLoader,让插件支持互切,这一次变动,类加载器架构上没有变动,只是增强字节码的判断有调整,代码片段如下:

private AgentPluginClassLoader getClassLoader(ClassLoader classLoader, String targetClass) {
        AgentPluginClassLoader agentClassLoader;
        if (classLoader instanceof AgentPluginClassLoader) {
            if (pluginDefine.ignoreEnhanceAgentClass()) {
                log.info(String.format("The current classLoader is instanceof AgentPluginClassLoader , pluginDefine: %s is user space, ignore transform: %s", pluginDefine.getName(), targetClass));
                agentClassLoader = null;
            } else {
                log.info(String.format("The current classLoader is instanceof AgentPluginClassLoader , pluginDefine: %s , transform: %s", pluginDefine.getName(), targetClass));
                agentClassLoader = (AgentPluginClassLoader) classLoader;
            }
        } else {
            if (pluginDefine.isDelegated()) {
                log.info(String.format("The current classLoader is %s , pluginDefine: %s is delegated , transform: %s", classLoader, pluginDefine.getName(), targetClass));
                agentClassLoader = CLASS_DELEGATION_LOADER_MAP.computeIfAbsent(classLoader, c -> new AgentPluginClassLoader(c, true));
            } else {
                log.info(String.format("The current classLoader is %s , pluginDefine: %s , transform: %s", classLoader, pluginDefine.getName(), targetClass));
                agentClassLoader = CLASS_LOADER_MAP.computeIfAbsent(classLoader, c -> new AgentPluginClassLoader(c, false));
            }
        }
        return agentClassLoader;
    }