今天帮同事查了个问题,在此简单记录。起因是同事在本地开发一段时间后会触发这样的异常:
1 | 2022-09-05 18:10:28.172 ERROR - [nio-8079-exec-2] o.a.c.c.C.[.[.[.[dispatcherServlet]:175 - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.ClassCastException: me.tianshuang.dto.BizDTO cannot be cast to me.tianshuang.dto.BizDTO] with root cause |
乍一看同名的类的对象实例之间转换不应该出现 ClassCastException,但是对类加载机制稍有了解就应该知道此处同名的两个类的实例所属的类是由不同的类加载器加载,然后强制转换时触发了 ClassCastException。根据栈帧容易知道该异常发生在 net.sf.cglib.empty.Object$$BeanCopierByCGLIB$$ac6a32c6
类的 copy
方法中,且由于是生成的类,导致默认情况下无法 debug,通过查询文档 Access the generated byte[] array directly 及 cglib/DebuggingClassWriter.java 易知,我们仅需设置 DebuggingClassWriter.DEBUG_LOCATION_PROPERTY
即可将生成的 class 文件写入至指定的目录,于是我们在启动前加入如下代码:
1 | System.setProperty(DebuggingClassWriter.DEBUG_LOCATION_PROPERTY, "/tmp"); |
进行如上设置后,cglib 动态生成的 class 类文件将写入至 /tmp 目录下。我们将该目录作为 Library 添加至 IDEA 工程,以便可以进行调试。首先我们反编译为复制 BizDTO 所生成的拷贝器代码:
1 | // |
通过 debug 易知 ClassCastException 发生在 copy 方法的第一行:BizDTO var10000 = (BizDTO)var2;
,外层调用的相关代码如下:
1 | BeanCopy.doConvertDto(bizMapper.getById(id), BizDTO.class); |
该 BeanCopy 类是公司内部一段较为古老的代码,其部分源码如下:
1 | package me.tianshuang.bean; |
即只是在 cglib BeanCopier
的基础上进行了简单的封装,在此之上增加了对 BeanCopier
实例的缓存,如果查看 net.sf.cglib.beans.BeanCopier#create
的源码且持续跟踪源码,不难发现在 net.sf.cglib.core.AbstractClassGenerator#create
方法中,对生成的 BeanCopier
类对应的 Class 实例已经进行了缓存,源码位于 cglib/AbstractClassGenerator.java:
1 | protected Object create(Object key) { |
其中 firstInstance
方法和 nextInstance
方法在 BeanCopier
类中的实现如下:
1 | protected Object firstInstance(Class type) { |
那么在 cglib 底层 AbstractClassGenerator
类中已经具有缓存功能的情况下,BeanCopy
类中的 beanCopierMap
缓存只是为了避免每次使用缓存的 Class 对象去反射创建 BeanCopier
的实例。且根据 Object$$BeanCopierByCGLIB$$ac6a32c6
的源码可知,copy
方法不存在线程安全问题,可以被多线程同时使用,参考:cglib-devel cglib and thread safeness。回到之前的 ClassCastException,就不得不提 Spring Boot 的 Developer Tools,首先建议阅读一遍文档:Spring Boot Reference Documentation - 6.8. Developer Tools,即为了便于本地开发,当本地 classpath 中的文件变更时,Spring Boot 将自动重启应用以使变更生效,重启功能由两个类加载器实现,一个是 base 类加载器,一个是 restart 类加载器。base 类加载器用于加载依赖的 jar 中的类,restart 类加载器用于加载 IDE 项目中的类。以上是文档中的描述,我们看一下与 Spring Boot 重启相关的源码 spring-boot/Restarter.java:
1 | /** |
该类的注释再次对两个类加载器的思想进行了阐述,从代码实现也可以看出,每次重启时创建了一个新的 RestartClassLoader
实例,其传入的参数 this.applicationClassLoader
在本地开发时为 AppClassLoader,即文档中所描述的 base 类加载器。我们再看 RestartClassLoader
源码中加载类的部分 spring-boot/RestartClassLoader.java:
1 | /** |
通过该类的注释我们知道,该类的实例是一次性的,用于应用重启。并对指定的 urls 提供 parent last 的加载。对于 parent last,我更愿意称之为 child first。总之,通过源码我们知道 RestartClassLoader
的 parent 为 AppClassLoader
,且在 loadClass
方法实现中,首先从自己负责的 urls 中尝试加载类,加载不到再交给 parent 进行加载。
回到文首的报错信息,其中 BeanCopy
类为公司内部的工具类,且通过 commons-tool-1.0-SNAPSHOT.jar 的方式提供,并不是 IDE 项目中的代码,所以 BeanCopy
类由 AppClassLoader 加载。在 IDE 中修改相关类并重新编译后,BeanCopy
类并不会被重新加载,即 BeanCopy
类中的静态变量 beanCopierMap
缓存依然可用,且因为缓存的 key
仅使用了两个类的全限定类名:
1 | private static String getCopierKey(Class<?> class1, Class<?> class2) { |
导致在重建了 RestartClassLoader
的情况下,进行对象的拷贝时,从缓存中获取到了之前生成的 Object$$BeanCopierByCGLIB$$ac6a32c6
类的实例。而在之前的实例中,导入的 BizDTO
类所属的类加载器为之前的 RestartClassLoader
实例,而传入给 copy
方法的第二个参数 var2
为新创建的 RestartClassLoader
实例加载的 BizDTO
的实例,最终触发 ClassCastException。
明确问题原因后,不难想出解决方案,比如可以让 BeanCopy
类中的缓存 map 的 key
中含有类加载器信息,如修改 getCopierKey
方法实现为:
1 | private static String getCopierKey(Class<?> class1, Class<?> class2) { |
进行如上调整后,就能保证 Spring Boot 经过 restart 后新创建的 RestartClassLoader
加载的业务相关类调用 BeanCopy
中的相关方法时,获取缓存的拷贝器时不会获取到之前的拷贝器,也就避免了 ClassCastException。但是在实际开发过程中,我们应该尽量避免在热点代码中执行字符串拼接、字符串格式化等操作,可以参考 cglib 的缓存实现,根据类加载器进行分组后再根据类名缓存这样使用多级 Map 的实现方式,此实现方式相比使用拼接后的字符串作为 key 的方式性能更优,我在本地使用 jmh 进行基准测试后发现基于多级 Map 的实现方式每秒吞吐量是拼接字符串作为 key 的实现方式的四倍,除此之外,内存申请率的表现亦更优,再进一步还可以使用 WeakHashMap 以避免 ClassLoader 不再使用后缓存的内容得不到释放的问题。
另一种解决方案可以参考 Spring Boot 文档中的建议:Customizing the Restart Classloader,将 BeanCopy
所在的 commons-tool-1.0-SNAPSHOT.jar pull up 至 restart 类加载器。即我们进行如下的配置:
1 | restart.include.commons-tool=/commons-tool-1.0-SNAPSHOT.jar |
进行如上配置后,当 Spring Boot 应用重启时,BeanCopy
类也会被重新加载,从而保证 BeanCopy
类中的 beanCopierMap
为一个全新的 map 实例,这样也规避了因使用到之前的拷贝器实例导致 ClassCastException 的问题。