背景
前段時間向測試部門提交了一個介面測試的需求,測試在除錯介面的過程中時不時的就出現查詢不到資料的情況,但是測試流程很明顯都還沒測到我提交的介面,測試本人也知道,但是他也是納悶了半天不知道什麼情況,沒辦法測試我的介面只能來向我求助,然後我放下手頭工作大致看了下發現只要是請求條件不變異常必先。報錯資訊也很清晰:java.lang.ClassCastException
。
問題定位
從上面的分析,可以看出錯誤並非必現,但是有著明顯的規律:查詢條件不變就能必現。這樣看來很明顯是命中快取就會有問題。根據異常堆疊資訊定位到報錯的程式碼行,有兩點重大發現:
- 獲取資料的方法使用了spring-cache的註解:
@Cacheable
- 被強轉的資料是從Redis快取中獲取
這麼也看不出個所以然,只能本地跑起來看能不能復現debug看看吧,然後就發現在沒命中快取的時候被強轉的類的類載入器是org.springframework.boot.devtools.restart.classloader
,而命中快取後的類載入器就變成sun.misc.Launcher$AppClassLoader
。這麼看來問題的矛頭指向了熱部署外掛springboot devtools, 那就先Bing一下,搜尋一下關鍵字:springboot devtools 型別轉換異常
:
看來有不少人都遇到過了,隨便點了幾個進去,一色的提供的解決方案都是將被轉換的類所在的jar包,從springboot devtools熱部署中排除掉,這顯然不是解決問題正確思路呀,首先如果該類並不是在獨立的jar內呢,難道為了這麼個問題我要單獨搞了jar嗎?然後如果真的是這樣是不是意味著springboot devtools是有debug的呢?多年的開發經驗帶給我的直覺是沒有正確的使用spring-cache,帶著疑惑的角度我準備翻翻springboot-devtools
和spring-cache
的原始碼一探究竟!
問題排查
之前也沒有閱讀過這兩個工具的原始碼,在不知如何下手的情況下,只能猜測摸索著前進。那就從SpringApplication.run()
方法入手吧,至少之前看過springboot的原始碼,還算熟悉。
來看看run方法:
//跟本次問題無關的程式碼都去除了
public ConfigurableApplicationContext run(String... args) {
SpringApplicationRunListeners listeners = getRunListeners(args);
// 關鍵點就在這裡, 看類名就能知道該類是幹什麼的,監聽Spring程式啟動的
listeners.starting(bootstrapContext, this.mainApplicationClass);
listeners.started(context, timeTakenToStartup);
allRunners(context, applicationArguments);
return context;
}
沿著SpringApplicationRunListeners.starting一路向下找到org.springframework.boot.context.event.EventPublishingRunListener#starting
,
@Override
public void starting(ConfigurableBootstrapContext bootstrapContext) {
this.initialMulticaster
.multicastEvent(new ApplicationStartingEvent(bootstrapContext, this.application, this.args));
}
一眼掃去就知道這是在廣播應用程式啟動事件:ApplicationStartingEvent
,既然這裡有廣播那肯定就有監聽這個事件的,繼續往下找經過SimpleApplicationEventMulticaster.multicastEvent->invokeListener->doInvokeListener
這一路呼叫下來來到listener.onApplicationEvent(event);
,這個熟悉spring事件模型的應該比較清楚了吧。
private void doInvokeListener(ApplicationListener listener, ApplicationEvent event) {
listener.onApplicationEvent(event);
}
進onApplicationEvent看下,哎吆嚇一跳,實現那麼多:
這怎麼找,剛才不是有個RestartClassLoader嗎,搜下:restart試試
效果很明顯還真有,肯定就是RestartApplicationListener
了,進去看看:
這裡面一共監聽了四種事件,還記得剛才我們廣播的是什麼事件吧?第一個就是
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);
}
}
在onApplicationStartingEvent()
方法中呼叫Restarter.initialize()
後我們就進入到springboot-devtools偷天換日的核心地帶了,先說下大致流程:
- 啟動一個新執行緒:restartMain,並建立一個RestartClassLoader繫結到執行緒上下文中
- 在新執行緒中重新呼叫springboot應用程式的main方法
- 丟棄Main執行緒
部分關鍵原始碼貼下:
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
原始碼:
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);
}
}
回過頭來在Restarter類中immediateRestart方法中doStart()方法呼叫之後,呼叫SilentExitExceptionHandler.exitCurrentThread()
靜默丟棄我們的Main執行緒。
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
原始碼:
public static void exitCurrentThread() {
throw new SilentExitException();
}
// 執行時異常什麼也不做,不知不覺中把Jvm分配給我們的主執行緒給替換了
private static class SilentExitException extends RuntimeException {
}
總結:
到這裡我們理清了RestartClassLoader是如何替換AppClassLoader的,那按照正常的邏輯後面應用程式中所有的本地類都應該由RestartClassLoader載入。實時情況確實是,在沒有命中快取的時候報強制型別轉換異常的類的classLoader確實是RestartClassLoader,命中快取的就不是了,那問題是否是出在快取層了呢。來看下spring-cache是如何使用的:
配置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;
}
看程式碼很明顯他使用了預設的RedisCacheConfiguration的配置RedisCacheConfiguration.defaultCacheConfig()
原始碼
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);
}
從RedisCacheConfiguration#defaultCacheConfig
原始碼可以看出兩個點:
- 存在過載方法支援傳入ClassLoader
- 預設提供的redis的Value序列化方式是:
RedisSerializer.java(classLoader)->new JdkSerializationRedisSerializer(classLoader)
到這裡稍有經驗的程式設計師應該都知道JDK的序列化是由java.io.ObjectInputStream
來完成的。
我這裡就不貼JdkSerializationRedisSerializer的原始碼了,程式碼比較簡單,反正最後做反序列化這個工作的是ObjectInputStream的子類org.springframework.core.ConfigurableObjectInputStream
,該類重寫了resolveClass()
方法,實現上首先判斷是否存在ClassLoader,有的話直接用該ClassLoader載入該類。否則就呼叫父類的同名方法。而ObjectInputStream獲取ClassLoader的方式則是呼叫VM.latestUserDefinedLoader()
,不瞭解latestUserDefinedLoader的可以自己百度下。到這裡問題就很清晰了吧
那我們改下程式碼,傳入當前執行緒的ClassLoader試試,向下面這樣:
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig(Thread.currentThread().getContextClassLoader())
果然可以了。這是為什麼呢?因為在springboot-devtools中已經替換了主執行緒,同時更換了與執行緒繫結的ClassLoader為RestartClassLoader,所以我們這裡從當前執行緒中取到的ClassLoader也是RestartClassLoader:
那麼在命中快取後反序列化就會使用我們傳入的這個RestartClassLoader而不是去從VM.latestUserDefinedLoader()
這裡獲取。
其實到這裡第二個解決方案也就浮出水面了,我們可以給RedisCacheConfiguration指定一個序列化工具,比如用fastjson作為spring-cache的序列化元件,向下面這樣:
final RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericFastJsonRedisSerializer()));
)
來看下fastjson是如何做的: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);
}
}
從JSON.parseObject
往下一直找到很深處,會在com.alibaba.fastjson.util.TypeUtils#loadClass(java.lang.String, java.lang.ClassLoader, boolean)
中找到如下程式碼,看到了吧它也是從當前執行緒上下文中取ClassLoader
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
}
.....去除一大段保障程式碼
}
來看下這個ClassLoader是什麼型別:
這裡竟然不是RestartClassLoader而是org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader
? 為什麼這裡不像配置CacheManager那裡一樣是RestartClassloader呢?因為這裡的當前執行緒是使用者請求執行緒,使用者請求執行緒是由Web容器建立的,而配置CacheManager的程式碼是由springboot程式啟動執行緒執行的:restartMain執行緒。而實際上TomcatEmbeddedWebappClassLoader的父ClassLoader就是RestartClassLoader,根據類載入雙親委派機制可知實際上最終還是由RestartClassLoader負責載入工作:
總結
問題本質:
- Springboot devtools更換了主執行緒及類載入器為RestartClassLoader
- spring-cache的快取配置使用了預設的序列化配置:JdkSerializationRedisSerializer,且沒有指定ClassLoader
解決方案:
- 在RedisCacheConfiguration快取配置裡指定當前執行緒的ClassLoader
- 或者不使用預設的序列化元件,更換序列化器元件:GenericFastJsonRedisSerializer