关于java:springbootdevtools导致springcache-缓存命中出现类型强转异常

6次阅读

共计 8709 个字符,预计需要花费 22 分钟才能阅读完成。

背景

前段时间向测试部门提交了一个接口测试的需要,测试在调试接口的过程中时不时的就呈现查问不到数据的状况,然而测试流程很显著都还没测到我提交的接口,测试自己也晓得,然而他也是纳闷了半天不晓得什么状况,没方法测试我的接口只能来向我求助,而后我放下手头工作大抵看了下发现只有是申请条件不变异样必先。报错信息也很清晰: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 类型转换异样

看来有不少人都遇到过了,轻易点了几个进去,一色的提供的解决方案都是将被转换的类所在的 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 看下,哎吆吓一跳,实现那么多:

这怎么找,方才不是有个 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 偷天换日的外围地带了,先说下大抵流程:

  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:

那么在命中缓存后反序列化就会应用咱们传入的这个 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 负责加载工作:

总结

问题实质:

  1. Springboot devtools 更换了主线程及类加载器为 RestartClassLoader
  2. spring-cache 的缓存配置应用了默认的序列化配置:JdkSerializationRedisSerializer,且没有指定 ClassLoader

解决方案:

  1. 在 RedisCacheConfiguration 缓存配置里指定以后线程的 ClassLoader
  2. 或者不应用默认的序列化组件,更换序列化器组件:GenericFastJsonRedisSerializer
正文完
 0