本文适合有一定 Java、JVM 基础、了解一些 thrift RPC 序列化知识点;同时本文不会介绍类加载的基础知识,如双新委托、findClass|defineClass 等。
通过本文你可以了解下面知识:
- ClassCastException 异常的解决思路;
- Devtools 是如何使用类加载器来工作的;
- 强制类型转化与类加载器是如何关联上的;
- 美团 mtthrift 注解式的序列化与类加载器片面分析;
- 同名不同类加载器造成的另一个异常(IllegalArgumentException)。
-
-
- 问题现象
- 工程结构说明
- 解决问题思考
- 快速解决问题
- RestartClassLoader 从何而来?
- 这两个类分别对应的类加载器是什么?
- 第二种解决方式
- 第三种解决方式
- 扩展
-
Caused by: org.apache.thrift.TApplicationException:java.lang.ClassCastException:xx.enums.DriverBiz cannot be cast to xx.enums.DriverBiz
DriverBiz 是一个枚举类型的类(为了符合公司规范、这里擦除包名)
工程结构说明- rpc 使用的是 thrift(具体对外提供的 client 的定义采用 java 注解式)
- 开发框架采用 springboot,依赖了
spring-boot-devtools
client 中请求参数结构大概情况
@ThriftStructpublic class XXXRequest { private List ids; // 转化出错的类 private xx.enums.DriverBiz driverBiz;
解决问题思考
快速解决问题
根据多年从事 JAVA 编程的经验如果出现了ClassCastException
,并且包名、类名完全一样,只有一种可能性那就是这两类转换的类来自不同类加载器;(如果是类名不一样,那就是你使用错了^..^)既然我们有一个大致的方向了,就看看这两个同类名的类的类加载器分别是什么?怎么看呢? 来一个好用的大杀器 阿里的 arthas;使用文档 ;
查询类的详情情况,这里我们只关注我们类加载器情况
[arthas@67444]$ sc xx.enums.DriverBiz -d ....... class-loader +-sun.misc.Launcher$AppClassLoader@18b4aac2 +-sun.misc.Launcher$ExtClassLoader@1534f01b ....... class-loader +-org.springframework.boot.devtools.restart.classloader.RestartClassLoader@3ad59b01 +-sun.misc.Launcher$AppClassLoader@18b4aac2 +-sun.misc.Launcher$ExtClassLoader@1534f01b
可以看出这个类被这两个类同时加载了,而且 RestartClassLoader@3ad59b01 的“父”加载与上一个类加载器还是同一个, 看到这个问题的解决办法就出了,我们把 devtool 这个依赖去掉即可,我们去掉 devtools 依赖后,再来看看这个类的加载情况;
class-loader +-sun.misc.Launcher$AppClassLoader@18b4aac2 +-sun.misc.Launcher$ExtClassLoader@46d56d67
重新访问一下接口,正常返回,确实问题解决,符合预期; 看到这里相信大多数人就 fix bug, 下一个 bug continue fix; 没有对这个问题进行深一步的思考,这两个类加载器从何而来? 为什么 DriverBiz 同时被这两个加载器加载? 为什么项目启动时没有报错, 请求访问时才出现呢? 抱着这一系列的疑问开始我们下面的分析之路;
RestartClassLoader 从何而来?很简单,在 idea 中全局地搜,找到了相关的代码 RestartClassLoader.java 在构造方法中直接断点;经历了下面这些方法,这里不再赘述 spring 自动配置机制
org.springframework.boot.devtools.restart.Restarter.relaunch()org.springframework.boot.devtools.restart.Restarter.doStart()org.springframework.boot.devtools.restart.Restarter.start()org.springframework.boot.devtools.restart.Restarter.restart()
最终会调用 RestartLauncher 的 run 方法来重新启动整个工程(在这之前会调用 Restarter.this.stop();来关闭上一次的 context)
@Overridepublic void run() { try { Class mainClass = getContextClassLoader().loadClass(this.mainClassName); Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); mainMethod.invoke(null, new Object[] { this.args }); } catch (Throwable ex) { this.error = ex; getUncaughtExceptionHandler().uncaughtException(this, ex); }}
看到这里可知 devtools 开启了一个线程来监听文件的变化,如果有变化则关闭之前的 context,然后新建了一个 RestartClassLoader,用这个类加载来加载运行启动的入口类(Main 方法所在的类,这里挺有意思的一层层向上推),然后启动入口类调用 main 方法,为什么要这么做呢?只是要保证这个工程的能依赖的类全部由 RestartClassLoader 来加载(具体看 RestartClassLoader 的加载范围);
注意:这里涉及到一个类加载器原则,如果一个类在加载过程,遇到一个没有加载的类则用当前类加载器(实际上可以理解成触发者的类加载器)先尝试去加载,当然也得看看这个类加载器是否遵守双亲委托原则, 想要更多了解可以想想 mysql 驱动器的加载?
既然 RestartClassLoader 能加载 DriverBiz,但另一个类加载器又与 RestartClassLoader 的“父”加载器是同一个,这显然不合理呀;可以从 RestartClassLoader 看出这个类加载能原则能自己加载就自己加载不能的话则交给“父”加载器, 显然这里 RestartClassLoader 能加载,所以其父加载器 ApplicationClassLoader 没有机会加载了,所以可以猜测可能是其它的类加载器(其父加载器也是 ApplicationClassLoader)也会加载 DriverBiz,但是自己不能加载,委托其“父”加载器来加载,才能造成目前这样的一个现象;
出现 ClassCastException 是在请求时,这个还得从 thrifth 的 RPC 框架入手,梳理 RPC 请求参数序列过程代码如下:
###ThriftMethodProcessor.java###private Object[] readArguments(TProtocol in) throws Exception{ try { int numArgs = method.getParameterTypes().length; Object[] args = new Object[numArgs]; TProtocolReader reader = new TProtocolReader(in); reader.readStructBegin(); while (reader.nextField()) { short fieldId = reader.getFieldId(); // 1.parameterCodecs 一个存放解码器的 map // 2.codec 负责把字节流转化成类实例 ThriftCodec codec = parameterCodecs.get(fieldId); if (codec == null) { reader.skipFieldData(); } else { // 3.这里会委托 TprotocolReader.read 去完成 args[thriftParameterIdToJavaArgumentListPositionMap.get(fieldId)] = reader.readField(codec); } } reader.readStructEnd();###TprotocolReader.java###public Object readField(ThriftCodec codec) throws Exception{ if (!checkReadState(codec.getType().getProtocolType().getType())) { return null; } currentField = null; // 4.这里才是真正通过解码器去反序列化 Object fieldValue = codec.read(protocol); protocol.readFieldEnd(); return fieldValue;}
问题的关键点来了,想要了解 fieldValue 类实例(反列化出来的类对象)的类加载器,就要先去知道 解码器 codec 的类加载器是什么? codec 类对象是在启动时完成,根据参数来生成一个对应的解码器 codec,这个解码器的类加载器居然是一个 DynamicClassLoader 新的类加器,其“父”类加载器是 ThriftCodecManager.class 的类加载器,ThriftCodecManager 是由 ApplicationClassLoader 来加载的(RestartClassLoader 加载不了);
ThriftCodecManager.java
@Injectpublic ThriftCodecManager(final ThriftCodecFactory factory, final ThriftCatalog catalog, @InternalThriftCodec Set>() { public ThriftCodec load(ThriftType type) throws Exception { switch (type.getProtocolType()) { case STRUCT: { // 如果 thrift struct 类型则需要产生类加载器,原始类型如 int 则可以直接使用 thrift 自己定义的; return factory.generateThriftTypeCodec(ThriftCodecManager.this, type.getStructMetadata()); }
ThriftCodecByteCodeGenerator.java
public ThriftCodecByteCodeGenerator(){ ... // 直接使用 definClass,也就是这些类加载自身的类类加载器均是 DynamicClassLoader Class codecClass = classLoader.defineClass(codecType.getClassName().replace('/', '.'), byteCode); try { Class[] types = parameters.getTypes(); Constructor constructor = codecClass.getConstructor(types); thriftCodec = (ThriftCodec) constructor.newInstance(parameters.getValues()); } .....}@Overridepublic ThriftCodec generateThriftTypeCodec(ThriftCodecManager codecManager, ThriftStructMetadata metadata){ ThriftCodecByteCodeGenerator generator = new ThriftCodecByteCodeGenerator( codecManager, metadata, classLoader, debug ); // 其实在这之前已经产生了,这里只是返回结果 return generator.getThriftCodec();}
DynamicClassLoader.java
public class DynamicClassLoader extends ClassLoader{ // 只有一个 defineClass 方法,这里面也就说 DynamicClassLoader 满足双新委托原则,但是这个类的使用却是直接使用这个方法,没有使用 loadClass, 或 findClass public Class defineClass(String name, byte[] byteCode) throws ClassFormatError { return defineClass(name, byteCode, 0, byteCode.length); }}
上面分析解码器 codec 的类加载器是什么,下面看这个 codec 的 read(反序化)生成的具体代码(也用 arthas 直接可以导出,自己去研究吧^^)
由于这个 codec 类需要依赖 DriverBiz, 所以会导致 DynamicClassLoader 去加载,而 DynamicClassLoader 满足双亲委托模式,直接让 ApplicationClassLoader 来加载,故才有了上面的现象 RestartClassLoader 、ApplicationClassLoader 均加载了 DriverBiz;
ClassCastException 异常点出现在如上图位置,是强制类型转化类型出错; 先别急看一下最简单的示例;
public void test(){ Object obj = new Object(); DriverBiz driverBiz = (DriverBiz)obj;}
对应字节码
0 new #2 3 dup 4 invokespecial #1 7 astore_1 8 aload_1 9 checkcast #3 12 astore_213 return
通过字节码更加清楚地看出异常实例点在(DriverBiz)obj;这个语句,换成字节码是一条 checkcast 指令;
那这两个类分别对应的类加载器是什么呢, 是类加载 ApplicationClassLoader 的类转化成 RestartClassLoader 类加载器的类呢,还是相反呢;为了清晰的表 我们定义一下:反序列化出来类实例的类 是类 A, 类 A 的类加载器是 ALoader, 而字节码 checkcast 后的类 是类 B(这里有一些类加载器的基础知识,链接阶段把符号引用变成直接引用,不懂的自行去了解 ),类 B 的类加载器是 BLoader;
这两个类分别对应的类加载器是什么?通过前面的分析类 A 是由于解码器 codec 引入过来了,故在 checkcast 后的直接引用对应的类是由 ApplicationClassloader 来加载的(DynamicClassLoader 委托); 由结果推导原因类 B 的类加载器是 RestartClassLoader,通过 DEBUG 也验证了这一点,如图下:
到这里可能又有大部分人停止了思考? 为会反序列化出来的类对象 ALoader 是 RestartClassLoader 呢?
按理 EnumThriftCodec.java 是由 ApplicationClassloader 来加载其引用依赖也应该是 ApplicationClassloader 呀,但这个注意一下,EnumThriftCodec 使用的是泛形 T,而泛形有生命周期仅在编译期,之后就被擦除了;所以这个 T 的类加载器是什么不得而知?
private final Map byEnumValue;
仔细看一下 enumMetadata 居然是一个 Map,这块逻辑也仅是根据一个 int 来获取类实例,那找一下这个类实例是什么时候放进这个 map 的即可!!!
public ThriftEnumMetadata( String enumName, Class enumClass) throws RuntimeException{ // 实例 enumConstant 是由 enumClass 类获取其自己所有的实例,重点是这些实现必然与 enumClass 的类加载是一致的 for (T enumConstant : enumClass.getEnumConstants()) { Integer value; try { value = (Integer) enumValueMethod.invoke(enumConstant); }... // 把实例 enumConstant 放进 map byEnumValue.put(value, enumConstant);
在 new ThriftEnumMetadata 对象进传入的 enumClass 是由 serviceImpl(自定义的类由 RestartClassLoader 加载的)依赖引入的,具体过程如下(可以不看 哈哈):
# serviceImpl 的类加载器为 RestartClassLoaderThriftServiceMetadata serviceMetadata = new ThriftServiceMetadata(serviceImpl.getClass(), codecManager.getCatalog());# 获取 serviceImpl 的 method, 类加载器当然也是同一个呀Method method : findAnnotatedMethods(serviceClass, ThriftMethod.class)ThriftMethodMetadata methodMetadata = new ThriftMethodMetadata(name, method, catalog);# 获取 method 的参数,类加载器当然也是同一个呀 Type[] parameterTypes = method.getGenericParameterTypes();# 把参数一级一级传ThriftType thriftType = catalog.getThriftType(parameterType);thriftType = getThriftTypeUncached(javaType);ThriftEnumMetadata> thriftEnumMetadata = getThriftEnumMetadata(rawType);enumMetadata = new ThriftEnumMetadataBuilder((Class) enumClass).build();# 最终到这里了,所以这个 enumClass 是自定义类某一个函数的参数引出来的一个类对象return new ThriftEnumMetadata(enumName, enumClass);
从上分析 enumClass 获取的枚举类实例当然类加载器为 RestartClassLoader,是在 RestartClassLoader 加载 serviceImpl 时就触发了 enumClass 的加载;
第二种解决方式通过上面的分析 知道 Aloader 是 AppClassloader, Bloader 为 RestartClassLoader; 那么在不去掉 devtools 情况让,我们缩小 devtools 类加载器的范围,让其不加载 DriverBiz 即可,这样 RestartClassLoader 加载不了,会委托其“父”加载器来加载,还是上在的分析由与两个 AppClassloader 其实是同一个对象,所以问题就不存在了;devtools 为我们提供排除目录文件的配置为
spring.devtools.restart.additional-exclude
第三种解决方式
第二种解决是改变 Bloader,那么我们有没有办法改变 Aloader 呢, 通过上面的分析我们知道是由于 DynamicClassLoader 产生解码器类 codec 才导致的,而采用定义 idl 文件时再编译成 java 类对象,就不需要动态地产生解码器 codec, 所以 他们的类加载器均会是 RestartClassLoader;
扩展一个与本文同样本源的异常
java.lang.IllegalArgumentException: argument type mismatch
这个异常出现的时机点是 method.invoke()时参数不匹配, 不匹配的原因很多,上面同名不同类加载器也其中的一种,所以下次你看到这类问题,如果排除了其它情况外如果还没有解决的话,看看或许也是同名不同类加载器的原因。
本文首发于 GitChat,未经授权不得转载,转载需与 GitChat 联系。
阅读全文: http://gitbook.cn/gitchat/activity/5db41918e5d2ef314182ae20
您还可以下载 CSDN 旗下精品原创内容社区 GitChat App ,阅读更多 GitChat 专享技术内容哦。