您当前的位置: 首页 > 

devtools引发的一场关于类加载问题的探究

蔚1 发布时间:2019-10-26 23:30:54 ,浏览量:2

本文适合有一定 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 在构造方法中直接断点;devtoolsWX20191025-173305@2x.png经历了下面这些方法,这里不再赘述 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 直接可以导出,自己去研究吧^^)vvWX20191026-174855@2x.png

由于这个 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 也验证了这一点,如图下:xwwwwww.png

到这里可能又有大部分人停止了思考? 为会反序列化出来的类对象 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 专享技术内容哦。

FtooAtPSkEJwnW-9xkCLqSTRpBKX

关注
打赏
1688896170
查看更多评论

蔚1

暂无认证

  • 2浏览

    0关注

    4645博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文
立即登录/注册

微信扫码登录

0.0749s