记一次因破坏双亲委派导致的 Spring 循环依赖血案,三级缓存到底救没救场?

发布时间:2026/6/3 1:53:07

记一次因破坏双亲委派导致的 Spring 循环依赖血案,三级缓存到底救没救场? 记一次因破坏双亲委派导致的 Spring 循环依赖血案三级缓存到底救没救场前言去年双十一前夕线上有个服务突然启动卡死。排查半天发现是某个中间件引入了自定义 ClassLoader。这个 ClassLoader 为了隔离依赖直接破坏了 Java 的双亲委派模型。结果 Spring 容器初始化时同一个类被加载了两次。更离谱的是Spring 的三级缓存机制居然没能拦截住这个循环依赖。最后搞得我加班到凌晨三点才把这个问题定位清楚。今天就把这个血泪教训摊开来讲讲。很多兄弟觉得 Spring 的三级缓存是万能药。觉得只要开了单例循环依赖都能解。其实一旦涉及 ClassLoader 的“家族秘密”三级缓存也会失效。咱们今天不聊虚的直接拆解底层源码看看这里面的门道。一、底层原理1.1 核心机制要懂这个问题得先搞清楚两件事。第一Java 的双亲委派是怎么工作的。第二Spring 的三级缓存到底存的是啥。双亲委派就像家里的“父子传承”。子类加载器收到请求先问问老子。老子能加载我就不出手。这样能保证核心类比如java.lang.Object的安全性。但有时候子类想“先出道”就得破坏这个规则。SPI 机制就是典型的破坏者。Spring 的三级缓存本质是三个 Map。singletonObjects存成品 Bean。earlySingletonObjects存早期引用半成品。singletonFactories存工厂对象用于生成早期引用。当 Spring 发现 A 依赖 BB 又依赖 A 时。A 实例化后还没填充属性就把自己工厂放进三级缓存。B 需要 A 时从三级缓存拿到早期引用。这样 A 和 B 就能互相持有对方启动就通了。但是如果 A 和 B 的类是由不同的 ClassLoader 加载的。在 Java 眼里它们就是两个完全不同的类。哪怕字节码一模一样地址也不一样。这时候Spring 的缓存 Key 是 Class 对象。Key 都不一样缓存自然失效。循环依赖直接爆掉。下面这张图展示了正常加载与破坏委派时的区别。graph TD subgraph 正常双亲委派 AppLoader[应用 ClassLoader] --|委托 | ParentLoader[父 ClassLoader] ParentLoader --|加载 | CoreClass[核心类 java.lang.Object] AppLoader --|加载 | BeanA[业务 Bean A] end subgraph 破坏双亲委派 CustomLoader[自定义 ClassLoader] -.-|直接加载 | BeanA[业务 Bean A (版本 1)] AppLoader --|加载 | BeanA_V2[业务 Bean A (版本 2)] end subgraph Spring 三级缓存 Cache1[singletonObjects] Cache2[earlySingletonObjects] Cache3[singletonFactories] end BeanA --|Key 不匹配 | Cache1 BeanA_V2 --|Key 不匹配 | Cache1设计优势很明显。正常模式下三级缓存能完美解决循环依赖。但在多 ClassLoader 环境下Spring 必须感知 ClassLoader 的隔离性。否则就会出现“类冲突”导致的启动失败。1.2 与同类方案的对比为了解决类加载冲突业界主要有三种思路。咱们来对比一下看看 Spring 是怎么选的。方案原理优点缺点适用场景标准双亲委派子类委托父类加载安全避免类冲突无法灵活加载同名类绝大多数标准应用破坏双亲委派子类优先加载隔离依赖版本共存易导致类转换异常容器化、插件化架构Spring 上下文隔离多个 BeanFactory逻辑隔离 Bean 作用域内存开销大配置复杂多租户 SaaS 系统从表里能看出来。单纯靠 Spring 配置很难完美解决 ClassLoader 层面的冲突。必须从类加载器设计阶段就规划好。很多框架如 Tomcat、OSGi都是这么干的。Spring 只是被动适应它没法强行统一 ClassLoader。二、快速上手光说不练假把式。咱们写个 Demo模拟一下破坏双亲委派后Spring 启动会发生什么。这个例子能在 3 分钟内跑通让你直观看到报错。// 自定义类加载器模拟破坏双亲委派 class MyCustomClassLoader extends ClassLoader { // 定义要加载的类字节码这里简化为加载同一个类的不同副本 private final byte[] classData; public MyCustomClassLoader(ClassLoader parent, byte[] data) { super(parent); this.classData data; } // 重写 loadClass实现“先自己加载不委托父类” Override protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 先检查是否已经加载过 Class? c findLoadedClass(name); if (c null) { // 关键点这里没有调用 super.loadClass而是直接 findClass // 这就破坏了双亲委派模型 try { c findClass(name); } catch (ClassNotFoundException e) { // 如果自定义加载失败再委托给父类可选策略 c super.loadClass(name, resolve); } } if (resolve) { resolveClass(c); } return c; } protected Class? findClass(String name) throws ClassNotFoundException { // 模拟从不同来源获取字节码这里假设数据不同 return defineClass(name, classData, 0, classData.length); } }上面的代码核心就在loadClass方法。通常我们调用super.loadClass让父类先去试。这里直接findClass自己先干。这就是破坏双亲委派的标准姿势。接下来咱们看看 Spring 怎么反应。三、核心 API / 深水区3.1 核心方法速查在 Spring 源码中有几个方法直接关联到三级缓存。搞懂它们你就懂了循环依赖的解法。方法名所在类作用备注addSingletonFactoryDefaultSingletonBeanRegistry注册早期引用工厂三级缓存的核心入口getSingletonDefaultSingletonBeanRegistry获取单例 Bean包含三级缓存的查找逻辑getEarlyBeanReferenceSmartInstantiationAwareBeanPostProcessor生成早期引用AOP 代理通常在这里生成3.2 生产级配置在生产环境如果必须破坏双亲委派。一定要配置好 Spring 的ClassLoader感知。虽然 Spring 默认不处理但可以通过BeanFactory隔离。// 生产级配置示例隔离不同 ClassLoader 的 Bean 定义 public class IsolatedBeanFactoryConfig { public void init() { // 创建两个独立的 BeanFactory // 它们运行在不同的 ClassLoader 上下文中 DefaultListableBeanFactory factoryA new DefaultListableBeanFactory(); DefaultListableBeanFactory factoryB new DefaultListableBeanFactory(); // 分别加载不同 ClassLoader 下的 Bean 定义 // 注意这里不能直接互相引用因为类对象不同 XmlBeanDefinitionReader readerA new XmlBeanDefinitionReader(factoryA); readerA.setResourceLoader(new CustomResourceLoader(loaderA)); readerA.loadBeanDefinitions(classpath:a-context.xml); // 异常处理捕获类加载冲突 try { factoryA.preInstantiateSingletons(); } catch (BeanCreationException e) { // 记录日志提示类加载器冲突 System.err.println(检测到类加载器冲突请检查双亲委派破坏情况); throw e; } } }这段代码的关键在于“物理隔离”。既然缓存救不了那就把圈子划清楚。A 工厂不管 B 工厂的事。通过资源加载器ResourceLoader区分来源。这样能避免大部分冲突。3.3 高级定制如果你想让 Spring 支持跨 ClassLoader 的依赖。得实现InstantiationAwareBeanPostProcessor。在这个接口里手动处理早期引用的转换。但这属于“地狱难度”一般不建议这么做。除非你是写框架的人否则别轻易挑战 JVM 的类加载机制。四、实战演练咱们来一个真实场景。假设你正在开发一个插件化系统。主程序加载核心业务插件加载扩展功能。插件用了自定义 ClassLoader破坏了双亲委派。主程序需要调用插件里的服务且存在循环依赖。// 模拟插件服务接口 public interface PluginService { void execute(); } // 主程序依赖插件 public class MainService { private final PluginService pluginService; // 构造函数注入模拟循环依赖场景 public MainService(PluginService pluginService) { this.pluginService pluginService; } public void run() { pluginService.execute(); } } // 测试入口 public class ClassLoaderConflictTest { public static void main(String[] args) { // 模拟加载插件类 byte[] pluginBytes loadPluginBytes(); MyCustomClassLoader pluginLoader new MyCustomClassLoader( MainService.class.getClassLoader(), pluginBytes ); // 尝试获取 Bean // 这里会抛出异常因为 Spring 容器无法识别插件加载的类 try { ApplicationContext ctx new AnnotationConfigApplicationContext(Config.class); // 如果插件类是在 ctx 启动后动态加载的三级缓存完全无效 MainService service ctx.getBean(MainService.class); service.run(); } catch (Exception e) { // 捕获类转换异常或循环依赖异常 System.out.println(启动失败 e.getMessage()); // 实际生产中应记录详细堆栈方便排查 ClassLoader 树 e.printStackTrace(); } } private static byte[] loadPluginBytes() { // 模拟读取插件字节码 return new byte[1024]; } }运行结果通常是BeanCurrentlyInCreationException。或者更惨ClassCastException。因为 Spring 认为这是两个类。三级缓存里的 Key 对不上。这就验证了前面的理论。一旦 ClassLoader 乱了缓存机制就瞎了。五、避坑指南与最佳实践踩了这么多坑总结几条血泪经验。希望能帮你在生产环境少掉几根头发。技巧尽量使用标准双亲委派。除非你有极强的理由如热更新、多版本共存否则别动 ClassLoader。Spring 的设计是建立在标准加载模型上的。你越界它就懵。⚠️警告不要试图在运行时动态替换 ClassLoader。很多框架喜欢搞“类重载”来实现热部署。这在 Spring 单例模式下是灾难。三级缓存里的对象引用的是旧 ClassLoader 的类。新加载的类无法匹配直接报错。✅推荐使用模块化设计替代类加载器隔离。如果只是为了隔离依赖考虑用 Maven 模块或者 OSGi。而不是自己写个 ClassLoader 硬扛。Spring Cloud 的 Microservices 架构就是很好的例子。服务间通过 HTTP 调用彻底避开类加载问题。还有一个细节。如果必须破坏委派确保你的自定义 ClassLoader 能正确识别父类。比如java.lang.Object必须还是由 Bootstrap ClassLoader 加载。否则连基础类型都匹配不上Spring 连启动都启动不了。六、综合实战演示最后给出一套精简的闭环代码。演示如何在隔离的 ClassLoader 环境下安全地初始化 Spring 容器。这套代码可以直接作为插件化架构的参考模板。// 综合实战隔离环境下的 Spring 初始化 public class IsolatedSpringRunner { public void startPlugin(String pluginPath) { // 1. 创建独立的 ClassLoader // 父加载器设为应用加载器但重写 loadClass 实现隔离 URL pluginUrl getPluginUrl(pluginPath); URLClassLoader pluginClassLoader new URLClassLoader( new URL[]{pluginUrl}, this.getClass().getClassLoader() ); // 2. 创建独立的 BeanFactory // 每个插件拥有独立的单例池互不干扰 DefaultListableBeanFactory beanFactory new DefaultListableBeanFactory(); // 3. 设置类加载器确保 Bean 定义能正确加载 beanFactory.setBeanClassLoader(pluginClassLoader); // 4. 加载配置 XmlBeanDefinitionReader reader new XmlBeanDefinitionReader(beanFactory); reader.setResourceLoader(new FileSystemResourceLoader(pluginUrl)); try { // 5. 预实例化单例 // 此时三级缓存生效但仅限于当前 ClassLoader 加载的类 beanFactory.preInstantiateSingletons(); System.out.println(插件容器启动成功单例池大小 beanFactory.getBeanDefinitionCount()); } catch (Exception e) { // 6. 异常处理与资源清理 System.err.println(插件启动失败正在清理资源); beanFactory.destroySingletons(); pluginClassLoader.close(); throw new RuntimeException(插件初始化异常, e); } } private URL getPluginUrl(String path) { try { return new File(path).toURI().toURL(); } catch (MalformedURLException e) { throw new IllegalArgumentException(插件路径无效, e); } } }这段代码的核心逻辑是“分而治之”。既然共享缓存会冲突那就每个插件一个缓存。DefaultListableBeanFactory就是独立的容器实例。它内部的三级缓存只对自己加载的类有效。这样既利用了 Spring 的依赖注入能力。又规避了 ClassLoader 冲突带来的循环依赖问题。这是目前最稳妥的插件化方案。七、总结Spring 的三级缓存是解决循环依赖的神器。但它有个前提类必须是同一个 ClassLoader 加载的。一旦你破坏了双亲委派引入了多个 ClassLoader。缓存的 Key 就失效了循环依赖也会随之爆发。解决之道不在于修补 Spring而在于规划好类加载架构。要么标准委派要么物理隔离。别试图在两者之间走钢丝。技术选型没有银弹只有取舍。搞清楚底层原理才能在关键时刻做出正确决定。

相关新闻