Poison

RestartClassLoader

今天帮同事查了个问题,在此简单记录。起因是同事在本地开发一段时间后会触发这样的异常:

1
2
3
4
5
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

java.lang.ClassCastException: me.tianshuang.dto.BizDTO cannot be cast to me.tianshuang.dto.BizDTO
at net.sf.cglib.empty.Object$$BeanCopierByCGLIB$$ac6a32c6.copy(<generated>) ~[na:na]
at me.tianshuang.bean.BeanCopy.copy(BeanCopy.java:51) ~[commons-tool-1.0-SNAPSHOT.jar:na]

乍一看同名的类的对象实例之间转换不应该出现 ClassCastException,但是对类加载机制稍有了解就应该知道此处同名的两个类的实例所属的类是由不同的类加载器加载,然后强制转换时触发了 ClassCastException。根据栈帧容易知道该异常发生在 net.sf.cglib.empty.Object$$BeanCopierByCGLIB$$ac6a32c6 类的 copy 方法中,且由于是生成的类,导致默认情况下无法 debug,通过查询文档 Access the generated byte[] array directlycglib/DebuggingClassWriter.java 易知,我们仅需设置 DebuggingClassWriter.DEBUG_LOCATION_PROPERTY 即可将生成的 class 文件写入至指定的目录,于是我们在启动前加入如下代码:

1
System.setProperty(DebuggingClassWriter.DEBUG_LOCATION_PROPERTY, "/tmp");

进行如上设置后,cglib 动态生成的 class 类文件将写入至 /tmp 目录下。我们将该目录作为 Library 添加至 IDEA 工程,以便可以进行调试。首先我们反编译为复制 BizDTO 所生成的拷贝器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package net.sf.cglib.empty;

import me.tianshuang.dto.BizDTO;
import me.tianshuang.domain.BizDO;
import net.sf.cglib.beans.BeanCopier;
import net.sf.cglib.core.Converter;

public class Object$$BeanCopierByCGLIB$$ac6a32c6 extends BeanCopier {
public Object$$BeanCopierByCGLIB$$ac6a32c6() {
}

public void copy(Object var1, Object var2, Converter var3) {
BizDTO var10000 = (BizDTO)var2;
BizDO var10001 = (BizDO)var1;
var10000.setId(var10001.getId());
var10000.setName(var10001.getName());
var10000.setStatus(var10001.getStatus());
var10000.setType(var10001.getType());
}
}

通过 debug 易知 ClassCastException 发生在 copy 方法的第一行:BizDTO var10000 = (BizDTO)var2;,外层调用的相关代码如下:

1
BeanCopy.doConvertDto(bizMapper.getById(id), BizDTO.class);

该 BeanCopy 类是公司内部一段较为古老的代码,其部分源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package me.tianshuang.bean;

import net.sf.cglib.beans.BeanCopier;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class BeanCopy {

private static final ConcurrentMap<String, BeanCopier> beanCopierMap = new ConcurrentHashMap<>();

private static String getCopierKey(Class<?> class1, Class<?> class2) {
return class1.getCanonicalName() + "_to_" + class2.getCanonicalName();
}

public static <T> T doConvertDto(Object doObject, Class<T> dtoClass) {
return copy(doObject, dtoClass);
}

public static <S, T> T copy(S sourceObject, Class<T> targetClass) {
if (sourceObject == null) {
return null;
}

String copierKey = getCopierKey(sourceObject.getClass(), targetClass);
BeanCopier copier = beanCopierMap.computeIfAbsent(copierKey, k -> BeanCopier.create(sourceObject.getClass(), targetClass, false));

T targetObject;
try {
targetObject = targetClass.newInstance();
copier.copy(sourceObject, targetObject, null);
} catch (InstantiationException | IllegalAccessException e) {
throw new RuntimeException(e);
}

return targetObject;
}
}

即只是在 cglib BeanCopier 的基础上进行了简单的封装,在此之上增加了对 BeanCopier 实例的缓存,如果查看 net.sf.cglib.beans.BeanCopier#create 的源码且持续跟踪源码,不难发现在 net.sf.cglib.core.AbstractClassGenerator#create 方法中,对生成的 BeanCopier 类对应的 Class 实例已经进行了缓存,源码位于 cglib/AbstractClassGenerator.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
protected Object create(Object key) {
try {
ClassLoader loader = getClassLoader();
Map<ClassLoader, ClassLoaderData> cache = CACHE;
ClassLoaderData data = cache.get(loader);
if (data == null) {
synchronized (AbstractClassGenerator.class) {
cache = CACHE;
data = cache.get(loader);
if (data == null) {
Map<ClassLoader, ClassLoaderData> newCache = new WeakHashMap<ClassLoader, ClassLoaderData>(cache);
data = new ClassLoaderData(loader);
newCache.put(loader, data);
CACHE = newCache;
}
}
}
this.key = key;
Object obj = data.get(this, getUseCache());
if (obj instanceof Class) {
return firstInstance((Class) obj);
}
return nextInstance(obj);
} catch (RuntimeException e) {
throw e;
} catch (Error e) {
throw e;
} catch (Exception e) {
throw new CodeGenerationException(e);
}
}

其中 firstInstance 方法和 nextInstance 方法在 BeanCopier 类中的实现如下:

1
2
3
4
5
6
7
protected Object firstInstance(Class type) {
return ReflectUtils.newInstance(type);
}

protected Object nextInstance(Object instance) {
return instance;
}

那么在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* Allows a running application to be restarted with an updated classpath. The restarter
* works by creating a new application ClassLoader that is split into two parts. The top
* part contains static URLs that don't change (for example 3rd party libraries and Spring
* Boot itself) and the bottom part contains URLs where classes and resources might be
* updated.
* <p>
* The Restarter should be {@link #initialize(String[]) initialized} early to ensure that
* classes are loaded multiple times. Mostly the {@link RestartApplicationListener} can be
* relied upon to perform initialization, however, you may need to call
* {@link #initialize(String[])} directly if your SpringApplication arguments are not
* identical to your main method arguments.
* <p>
* By default, applications running in an IDE (i.e. those not packaged as "fat jars") will
* automatically detect URLs that can change. It's also possible to manually configure
* URLs or class file updates for remote restart scenarios.
*
* @author Phillip Webb
* @author Andy Wilkinson
* @since 1.3.0
* @see RestartApplicationListener
* @see #initialize(String[])
* @see #getInstance()
* @see #restart()
*/
public class Restarter {

private Throwable doStart() throws Exception {
Assert.notNull(this.mainClassName, "Unable to find the main class to restart");
URL[] urls = this.urls.toArray(new URL[0]);
ClassLoaderFiles updatedFiles = new ClassLoaderFiles(this.classLoaderFiles);
ClassLoader classLoader = new RestartClassLoader(this.applicationClassLoader, urls, updatedFiles);
if (this.logger.isDebugEnabled()) {
this.logger.debug("Starting application " + this.mainClassName + " with URLs " + Arrays.asList(urls));
}
return relaunch(classLoader);
}

}

该类的注释再次对两个类加载器的思想进行了阐述,从代码实现也可以看出,每次重启时创建了一个新的 RestartClassLoader 实例,其传入的参数 this.applicationClassLoader 在本地开发时为 AppClassLoader,即文档中所描述的 base 类加载器。我们再看 RestartClassLoader 源码中加载类的部分 spring-boot/RestartClassLoader.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* Disposable {@link ClassLoader} used to support application restarting. Provides parent
* last loading for the specified URLs.
*
* @author Andy Clement
* @author Phillip Webb
* @since 1.3.0
*/
public class RestartClassLoader extends URLClassLoader implements SmartClassLoader {

@Override
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
String path = name.replace('.', '/').concat(".class");
ClassLoaderFile file = this.updatedFiles.getFile(path);
if (file != null && file.getKind() == Kind.DELETED) {
throw new ClassNotFoundException(name);
}
synchronized (getClassLoadingLock(name)) {
Class<?> loadedClass = findLoadedClass(name);
if (loadedClass == null) {
try {
loadedClass = findClass(name);
}
catch (ClassNotFoundException ex) {
loadedClass = Class.forName(name, false, getParent());
}
}
if (resolve) {
resolveClass(loadedClass);
}
return loadedClass;
}
}

}

通过该类的注释我们知道,该类的实例是一次性的,用于应用重启。并对指定的 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
2
3
private static String getCopierKey(Class<?> class1, Class<?> class2) {
return class1.getCanonicalName() + "_to_" + class2.getCanonicalName();
}

导致在重建了 RestartClassLoader 的情况下,进行对象的拷贝时,从缓存中获取到了之前生成的 Object$$BeanCopierByCGLIB$$ac6a32c6 类的实例。而在之前的实例中,导入的 BizDTO 类所属的类加载器为之前的 RestartClassLoader 实例,而传入给 copy 方法的第二个参数 var2 为新创建的 RestartClassLoader 实例加载的 BizDTO 的实例,最终触发 ClassCastException。

明确问题原因后,不难想出解决方案,比如可以让 BeanCopy 类中的缓存 map 的 key 中含有类加载器信息,如修改 getCopierKey 方法实现为:

1
2
3
private static String getCopierKey(Class<?> class1, Class<?> class2) {
return String.format("%s@%d_to_%s@%d", class1.getCanonicalName(), class1.getClassLoader().hashCode(), class2.getCanonicalName(), class2.getClassLoader().hashCode());
}

进行如上调整后,就能保证 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 的问题。

Reference

GitHub - cglib/cglib: cglib - Byte Code Generation Library is high level API to generate and transform Java byte code. It is used by AOP, testing, data access frameworks to generate dynamic proxy objects and intercept field access.