springboot-devtools導致spring-cache 快取命中出現型別強轉異常?

idgq發表於2022-04-07

背景

前段時間向測試部門提交了一個介面測試的需求,測試在除錯介面的過程中時不時的就出現查詢不到資料的情況,但是測試流程很明顯都還沒測到我提交的介面,測試本人也知道,但是他也是納悶了半天不知道什麼情況,沒辦法測試我的介面只能來向我求助,然後我放下手頭工作大致看了下發現只要是請求條件不變異常必先。報錯資訊也很清晰:java.lang.ClassCastException

問題定位

從上面的分析,可以看出錯誤並非必現,但是有著明顯的規律:查詢條件不變就能必現。這樣看來很明顯是命中快取就會有問題。根據異常堆疊資訊定位到報錯的程式碼行,有兩點重大發現:

  1. 獲取資料的方法使用了spring-cache的註解:@Cacheable
  2. 被強轉的資料是從Redis快取中獲取

這麼也看不出個所以然,只能本地跑起來看能不能復現debug看看吧,然後就發現在沒命中快取的時候被強轉的類的類載入器是org.springframework.boot.devtools.restart.classloader,而命中快取後的類載入器就變成sun.misc.Launcher$AppClassLoader。這麼看來問題的矛頭指向了熱部署外掛springboot devtools, 那就先Bing一下,搜尋一下關鍵字:springboot devtools 型別轉換異常
image.png
看來有不少人都遇到過了,隨便點了幾個進去,一色的提供的解決方案都是將被轉換的類所在的jar包,從springboot devtools熱部署中排除掉,這顯然不是解決問題正確思路呀,首先如果該類並不是在獨立的jar內呢,難道為了這麼個問題我要單獨搞了jar嗎?然後如果真的是這樣是不是意味著springboot devtools是有debug的呢?多年的開發經驗帶給我的直覺是沒有正確的使用spring-cache,帶著疑惑的角度我準備翻翻springboot-devtoolsspring-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看下,哎吆嚇一跳,實現那麼多:
image.png
這怎麼找,剛才不是有個RestartClassLoader嗎,搜下:restart試試

image.png
效果很明顯還真有,肯定就是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偷天換日的核心地帶了,先說下大致流程:

  1. 啟動一個新執行緒:restartMain,並建立一個RestartClassLoader繫結到執行緒上下文中
  2. 在新執行緒中重新呼叫springboot應用程式的main方法
  3. 丟棄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原始碼可以看出兩個點:

  1. 存在過載方法支援傳入ClassLoader
  2. 預設提供的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:
image.png
那麼在命中快取後反序列化就會使用我們傳入的這個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是什麼型別:
image.png
這裡竟然不是RestartClassLoader而是org.springframework.boot.web.embedded.tomcat.TomcatEmbeddedWebappClassLoader? 為什麼這裡不像配置CacheManager那裡一樣是RestartClassloader呢?因為這裡的當前執行緒是使用者請求執行緒,使用者請求執行緒是由Web容器建立的,而配置CacheManager的程式碼是由springboot程式啟動執行緒執行的:restartMain執行緒。而實際上TomcatEmbeddedWebappClassLoader的父ClassLoader就是RestartClassLoader,根據類載入雙親委派機制可知實際上最終還是由RestartClassLoader負責載入工作:
image.png

總結

問題本質:

  1. Springboot devtools更換了主執行緒及類載入器為RestartClassLoader
  2. spring-cache的快取配置使用了預設的序列化配置:JdkSerializationRedisSerializer,且沒有指定ClassLoader

解決方案:

  1. 在RedisCacheConfiguration快取配置裡指定當前執行緒的ClassLoader
  2. 或者不使用預設的序列化元件,更換序列化器元件:GenericFastJsonRedisSerializer

相關文章