background
Some time ago, I submitted a request for an interface test to the testing department. During the process of debugging the interface, the test could not query the data from time to time, but the test process obviously did not measure the interface I submitted, and the tester himself knew it. , but he also wondered for a long time and didn't know what was going on. He couldn't test my interface and could only come to me for help. Then I put down the work at hand and looked around and found that as long as the request conditions remain unchanged, exceptions must be made first. The error message is also very clear: java.lang.ClassCastException
.
identify the problem
From the above analysis, it can be seen that the error does not necessarily appear, but there is an obvious rule: the query condition is unchanged and it will appear. It seems obvious that there will be a problem with hitting the cache. According to the exception stack information to locate the error code line, there are two major findings:
- The method of obtaining data uses the spring-cache annotation:
@Cacheable
- The forced data is obtained from the Redis cache
I can't see why, I can only run it locally to see if I can reproduce the debug, and then I find that the class loader of the class that is forced to be transferred when the cache is not hit is org.springframework.boot.devtools.restart.classloader
, And the class loader after hitting the cache becomes sun.misc.Launcher$AppClassLoader
. It seems that the point of the problem is the hot deployment plug-in springboot devtools, then Bing first and search for keywords: springboot devtools 类型转换异常
:
It seems that many people have encountered it, just click a few and enter, the solution provided by Isshiki is to exclude the jar package where the converted class is located from the hot deployment of springboot devtools, which is obviously not the solution. The question is correct. First of all, if the class is not in a separate jar, do I have to create a separate jar for such a problem? Then if this is the case, does it mean that springboot devtools is debugged? Years of development experience brought me the intuition that spring-cache is not used correctly. With doubts, I am going to look through the source code of springboot-devtools
and spring-cache
!
Troubleshoot
I haven't read the source code of these two tools before, and I can only guess and grope forward when I don't know how to start. Then start with the SpringApplication.run()
method. At least I have seen the source code of springboot before, and I am familiar with it.
Let's take a look at the run method:
//跟本次问题无关的代码都去除了
public ConfigurableApplicationContext run(String... args) {
SpringApplicationRunListeners listeners = getRunListeners(args);
// 关键点就在这里, 看类名就能知道该类是干什么的,监听Spring程序启动的
listeners.starting(bootstrapContext, this.mainApplicationClass);
listeners.started(context, timeTakenToStartup);
allRunners(context, applicationArguments);
return context;
}
Find all the way down org.springframework.boot.context.event.EventPublishingRunListener#starting
,
@Override
public void starting(ConfigurableBootstrapContext bootstrapContext) {
this.initialMulticaster
.multicastEvent(new ApplicationStartingEvent(bootstrapContext, this.application, this.args));
}
At a glance, you can see that this is broadcasting the application startup event: ApplicationStartingEvent
, since there is a broadcast here, there must be a listener for this event, continue to look down SimpleApplicationEventMulticaster.multicastEvent->invokeListener->doInvokeListener
This way is called down Coming to listener.onApplicationEvent(event);
, it should be clearer for those familiar with the spring event model.
private void doInvokeListener(ApplicationListener listener, ApplicationEvent event) {
listener.onApplicationEvent(event);
}
Take a look at onApplicationEvent, and I am startled, so much has been achieved:
How to find this, wasn't there a RestartClassLoader just now, search: try restart
The effect is very obvious, it must be RestartApplicationListener
, go in and see:
There are a total of four events monitored here. Remember what event we broadcast just now? The first is
public void onApplicationEvent(ApplicationEvent event) {
// 这个就是我们今天的主角
if (event instanceof ApplicationStartingEvent) {
onApplicationStartingEvent((ApplicationStartingEvent) event);
}
if (event instanceof ApplicationPreparedEvent) {
onApplicationPreparedEvent((ApplicationPreparedEvent) event);
}
if (event instanceof ApplicationReadyEvent || event instanceof ApplicationFailedEvent) {
Restarter.getInstance().finish();
}
if (event instanceof ApplicationFailedEvent) {
onApplicationFailedEvent((ApplicationFailedEvent) event);
}
}
After calling Restarter.initialize()
in the onApplicationStartingEvent()
method, we enter the core area of springboot-devtools, let's talk about the general process:
- Start a new thread: restartMain, and create a RestartClassLoader bound to the thread context
- Recall main method of springboot application in new thread
- Discard the Main thread
Part of the key source code is posted below:
private Throwable doStart() throws Exception {
// 创建restartClassLoader
ClassLoader classLoader = new RestartClassLoader(this.applicationClassLoader, urls, updatedFiles, this.logger);
return relaunch(classLoader);
}
protected Throwable relaunch(ClassLoader classLoader) throws Exception {
// 创建新线程:restartedMain
RestartLauncher launcher = new RestartLauncher(classLoader,this.mainClassName, this.args,this.exceptionHandler);
launcher.start();
launcher.join();
return launcher.getError();
}
RestartLauncher
Source code:
RestartLauncher(ClassLoader classLoader, String mainClassName, String[] args,
UncaughtExceptionHandler exceptionHandler) {
this.mainClassName = mainClassName;
this.args = args;
// restartedMain线程名称就是在这类设置的
setName("restartedMain");
setUncaughtExceptionHandler(exceptionHandler);
setDaemon(false);
setContextClassLoader(classLoader);
}
@Override
public void run() {
try {
// 使用restartClassLoader重新加载包含main方法的类
Class<?> mainClass = getContextClassLoader().loadClass(this.mainClassName);
// 找到main方法
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
//重新执行main方法
mainMethod.invoke(null, new Object[] { this.args });
}
catch (Throwable ex) {
this.error = ex;
getUncaughtExceptionHandler().uncaughtException(this, ex);
}
}
Going back and calling the doStart() method in the immediateRestart method of the Restarter class, call SilentExitExceptionHandler.exitCurrentThread()
to silently discard our Main thread.
private void immediateRestart() {
try {
// 上文中的doStart方法就是从这里进去的
getLeakSafeThread().callAndWait(() -> {
start(FailureHandler.NONE);
cleanupCaches();
return null;
});
}
catch (Exception ex) {
this.logger.warn("Unable to initialize restarter", ex);
}
SilentExitExceptionHandler.exitCurrentThread();
}
SilentExitExceptionHandler
source:
public static void exitCurrentThread() {
throw new SilentExitException();
}
// 运行时异常什么也不做,不知不觉中把Jvm分配给我们的主线程给替换了
private static class SilentExitException extends RuntimeException {
}
Summarize:
Here we have clarified how RestartClassLoader replaces AppClassLoader. According to normal logic, all local classes in the application should be loaded by RestartClassLoader. The real-time situation is indeed that the classLoader of the class that reports a forced type conversion exception when the cache is not hit is indeed RestartClassLoader, and the one that hits the cache is not. Is the problem in the cache layer? Let's see how spring-cache is used:
Configure CacheManage:
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory){
// 默认的缓存配置
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig();
Set<String> cacheNames = new HashSet<>();
cacheNames.add("cache_test");
// 对每个缓存空间应用不同的配置
Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
configMap.put("cache_test", defaultCacheConfig);
RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig)
.initialCacheNames(cacheNames)
.withInitialCacheConfigurations(configMap)
.build();
return cacheManager;
}
Looking at the code, it is obvious that he uses the default RedisCacheConfiguration configuration
RedisCacheConfiguration.defaultCacheConfig()
source code
public static RedisCacheConfiguration defaultCacheConfig() {
return defaultCacheConfig(null);
}
public static RedisCacheConfiguration defaultCacheConfig(@Nullable ClassLoader classLoader) {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
registerDefaultConverters(conversionService);
return new RedisCacheConfiguration(Duration.ZERO, true, true, CacheKeyPrefix.simple(),
SerializationPair.fromSerializer(RedisSerializer.string()),
SerializationPair.fromSerializer(RedisSerializer.java(classLoader)), conversionService);
}
From the RedisCacheConfiguration#defaultCacheConfig
source code, we can see two points:
- There are overloaded methods to support passing in ClassLoader
- The default value serialization method of redis is:
RedisSerializer.java(classLoader)->new JdkSerializationRedisSerializer(classLoader)
Programmers with a little experience here should all know that JDK serialization is done by java.io.ObjectInputStream
.
I won't post the source code of JdkSerializationRedisSerializer here. The code is relatively simple. Anyway, the last thing to do the deserialization is the subclass of ObjectInputStream org.springframework.core.ConfigurableObjectInputStream
, this class rewrites the resolveClass()
method, In the implementation, first determine whether there is a ClassLoader, and if so, directly use the ClassLoader to load the class. Otherwise, the method of the same name of the parent class is called. The way ObjectInputStream obtains ClassLoader is to call VM.latestUserDefinedLoader()
, if you don't know latestUserDefinedLoader, you can download it by yourself. The problem is clear here.
Then let's change the code and try passing in the ClassLoader of the current thread, as follows:
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig(Thread.currentThread().getContextClassLoader())
Sure enough. Why is this? Because the main thread has been replaced in springboot-devtools, and the ClassLoader bound to the thread has been replaced with RestartClassLoader, the ClassLoader we get from the current thread is also RestartClassLoader:
Then after hitting the cache, the deserialization will use the RestartClassLoader we passed in instead of getting it from VM.latestUserDefinedLoader()
here.
In fact, the second solution has surfaced here. We can specify a serialization tool for RedisCacheConfiguration, such as using fastjson as the serialization component of spring-cache, as follows:
final RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericFastJsonRedisSerializer()));
)
Let's see how fastjson does it:
Source code of com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer#deserialize
public Object deserialize(byte[] bytes) throws SerializationException {
if (bytes == null || bytes.length == 0) {
return null;
}
try {
return JSON.parseObject(new String(bytes, IOUtils.UTF8), Object.class, defaultRedisConfig);
} catch (Exception ex) {
throw new SerializationException("Could not deserialize: " + ex.getMessage(), ex);
}
}
From JSON.parseObject
down to the bottom, I will find the following code in com.alibaba.fastjson.util.TypeUtils#loadClass(java.lang.String, java.lang.ClassLoader, boolean)
, you can see that it also takes the ClassLoader from the current thread context
public static Class<?> loadClass(String className, ClassLoader classLoader, boolean cache) {
.....去除一大段保障代码
try{
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
if(contextClassLoader != null && contextClassLoader != classLoader){
clazz = contextClassLoader.loadClass(className);
if (cache) {
mappings.put(className, clazz);
}
return clazz;
}
} catch(Throwable e){
// skip
}
.....去除一大段保障代码
}
Let's see what type this ClassLoader is:
It's not RestartClassLoader but org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader
? Why is RestartClassloader not the same as where CacheManager is configured? Because the current thread here is the user request thread, the user request thread is created by the web container, and the code for configuring the CacheManager is executed by the springboot program startup thread: restartMain thread. In fact, the parent ClassLoader of TomcatEmbeddedWebappClassLoader is RestartClassLoader. According to the class loading parent delegation mechanism, it can be seen that RestartClassLoader is actually responsible for loading in the end:
Summarize
The essence of the problem:
- Springboot devtools replaced the main thread and class loader with RestartClassLoader
- The cache configuration of spring-cache uses the default serialization configuration: JdkSerializationRedisSerializer, and no ClassLoader is specified
solution:
- Specify the ClassLoader of the current thread in the RedisCacheConfiguration cache configuration
- Or do not use the default serialization component, replace the serializer component: GenericFastJsonRedisSerializer
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。