有业务(Java)的同学反馈,在接入了 devops 的某些 javaagent 以后会极大概率出现 dubbo 调用失败,dubbo 接口中用到的业务类都提示找不到,导致反序列化失败,部分日志输出如下:
但是通过内存查看,com.seewo.kishframework.page.PageRequest
等失败的类都已经被org.springframework.boot.loader.LaunchedURLClassLoader@66373f77
加载。
离了个大谱。
异常日志是在 com.alibaba.com.caucho.hessian.io.SerializerFactory#getDeserializer(java.lang.String)
这个函数中
try 代码中首先通过 loadSerializedClass
加载待反序列化的类,然后用这个 classloader 获取这个类对应的反序列化对象 deserializer。
loadSerializedClass
函数首先调用 getClassFactory 获取单例的 _classFactory,然后使用这个单例就加载类。
这里的 ClassFactory 创建的时候传入的 classloader 是当前线程的 ContextClassLoader。
在首次初始化以后,反序列的类都是由此 SerializerFactory
加载。通过内存分析,可以看到,ClassFactory 的 classloader 居然是 sun.misc.Launcher$AppClassLoader
,这个 classloader 的 classpath 是不包含 springboot 管理的 BOOT-INF 下的包的,导致这个 AppClassLoader 自然加载不到对应的类。
因此经过分析,之前日志里输出是误导的。
这里显示是用 org.springframework.boot.loader.LaunchedURLClassLoader
去加载类找不到,是完全迷惑的,压根就不是用这个类加载器去加载的,而是用 ClassFactory 中的 _loader 。
现在就要搞清楚,为什么 ClassFactory 中的 _loader 会不对。
通过修改 hessian-lite 的源码,重新替换对应的类,加入了堆栈
看下是谁第一次调用导致了懒加载单例的初始化,第一次调用的堆栈如下:
可以看到 DubboServerHandler-172.30.86.136-thread-2
这个线程的 ContextClassLoader 确实不知为何被设置为了sun.misc.Launcher$AppClassLoader
,然后看更多日志,发现除了这个线程其它的 DubboServerHandler
线程的 ContextClassLoader 都是正常的。
这下问题思路基本上清晰了。因为 DubboServerHandler-172.30.86.136-thread-2 这个线程的 ContextClassLoader 是 sun.misc.LauncherAppClassLoader,这线程最早调用 com.alibaba.com.caucho.hessian.io.SerializerFactory#getClassFactory 导致 SerializerFactory 的 _loader 被赋值为了 sun.misc.LauncherAppClassLoader,后面的线程也没有机会对单例的 SerializerFactory 重新赋值。
接下来就是分析,最早的线程 DubboServerHandler-172.30.86.136-thread-2
是由谁创建的。又因为业务反馈他们去掉 devops 的 dubbo 健康检查 javaagent 以后,就没有再出现,于是把焦点放在了这个插件上。
这个插件的功能也很简单,就是监听一个端口,收到健康检查的请求以后,提交一个 Echo 任务到 dubbo 的任务池里,如果任务池没有被占满,可以被执行,那么表示 dubbo 健康,否则 dubbo 线程池已经被占满,表示不健康。
EchoTask 就是一个 Runnable,里面啥都没做
上面的 ThreadPoolExecutor
是通过字节码注入的方式从 dubbo 框架中获取的
问题就出在这个健康检查 javaagent 往 dubbo 的线程池提交的这个任务。为了弄清楚这个问题,需要一点点🤏🏻 Java ContextClassLoader 的知识。
线程上下文类加载器由继承自父线程。如果父线程没有设置上下文类加载器,则线程将继承类加载器的默认实现。线程 Thread 创建的代码如下:
往线程池里执行一个 runnable 的过程,就是调用 addWorker 创建线程并执行
dubbo 的 ThreadFactory 是 com.alibaba.dubbo.common.utils.NamedThreadFactory
,里面构造了线程名。
从上面的代码可以分析出,创建的 DubboServerHandler
线程上下文类加载器,继承调用 new Thread
的父线程上下文类加载器。
可以用最简单的一个 demo 来看验证上面的结论。
上面的代码输出
通过注入 org.apache.dubbo.common.threadlocal.NamedInternalThreadFactory.newThread
可以看到这个方法创建了 DubboServerHandler-172.30.102.71:20880-thread-1
线程。
于是这里的情况就很清楚了,因为 dubbo-health 这个 javaagent 是由 sun.misc.LauncherAppClassLoader 加载的,其执行的线程上下文类加载器也是 sun.misc.LauncherAppClassLoader,导致项目启动以后,k8s 不停发起健康检查触发了 javaagent 向 dubbo 线程池提交任务,导致了头几个 DubboServerHandler 线程(DubboServerHandler-xx-thread-1、DubboServerHandler-xx-thread-2等)的上下文成为了 sun.misc.Launcher
当真实流量进来,被分派到头几个 DubboServerHandler 处理,此时它第一个调用 SerializerFactory 的 getClassFactory 导致 ClassFactory 的 _loader 被设置为了 sun.misc.Launcher$AppClassLoader
,这个单例开始继续祸害了。
如果初始状态往 dubbo 线程池提交任务是一个很危险的事情,一定要保障线程的上下文是正确的,不然就悲剧了。
这里修改方法也简单,只需要将提交任务的时候把自己 javaagent 的 classloader 切换为业务的 classloader 即可。
发现旧版本的 dubbo 没有这个问题,是因为它是用 SerializerFactory 的 _loader,而不是 ClassFactory 中的 _loader
至于为什么,我就不想知道了。