ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring为何默认用Singleton而非Prototype?性能与线程安全深度解析

Spring为何默认用Singleton而非Prototype?性能与线程安全深度解析 Singleton还是原型Spring 容器里最常见的一个问题也是面试必考题但很多人其实没真正想明白为什么 Spring 强烈推荐你用 singleton而不是 prototype这绝不是一个简单的默认值选择背后牵扯到容器设计、性能开销、状态管理、并发安全甚至是你整个应用的架构形态。我用实际项目踩过的坑来聊聊这件事。先说一个最直观的对比我见过不少团队把所有 Service、Mapper、Controller 全配上Scope(prototype)理由是“多线程环境下每个线程拿到独立实例更安全”。这个想法听起来合理实际上是把容器内部机制当成了线程安全工具来用结果就是对象创建次数暴增、依赖注入链路被拉长、GC 压力上升最后问题不但没解决反而更难排查。Spring 官方在文档和源码注释里都明确表达了对 singleton 的强烈倾向这背后有非常清晰的工程逻辑。1. 先搞清楚 Spring 里的 singleton 到底是什么1.1 singleton 不是 GoF 的单例模式最容易混淆的一点就在这里。GoF 设计模式里的 Singleton强调的是“一个 JVM 中某个类全局只有一个实例”通常靠私有构造器、静态方法、双重检查锁来实现核心是控制实例数量。Spring 的 singleton scope 完全不同它强调的是“在一个 Spring 容器中一个 bean 对应一个实例”。注意几个关键点同一个 bean 的实例数在一个容器内是被控制的但不限制你创建多个容器它不要求类构造器私有类可以随意new它的实例由容器创建和管理生命周期由容器掌控从代码使用者的角度看你感知不到“全局唯一”这层约束你只是拿 bean 用而已所以 Spring 的 singleton 是作用域层面的概念不是设计模式层面的概念。它解决的问题是“当我在代码里注入这个 bean 时容器给我的是哪个实例”。默认情况下容器给你的是一个缓存住的共享实例。1.2 默认 scope 的前世今生Spring 从最早的版本开始就把 singleton 定为默认的 scope。在 XML 配置时代bean标签不写scope属性就是 singleton在注解时代不写Scope同样默认 singleton。值得一提的是Spring Boot 中绝大多数组件也都是基于 singleton 的。你天天用的Service、Component、Repository、Controller没有特殊声明全部是 singleton。这也是为什么 Spring Boot 应用能这么快启动起来的原因之一——容器启动时帮你把那些需要提前初始化的单例都创建好了后续请求直接复用。从容器实现上看singleton bean 的实例会保存在一个ConcurrentHashMap中也就是DefaultSingletonBeanRegistry里的singletonObjects。这个 Map 的 key 是 beanNamevalue 是 bean 的实例。注入的时候先去 Map 里查查到就直接返回。整个查询过程的时间复杂度是 O(1)效率极高。2. 为什么 Spring 强烈推荐 singleton2.1 性能开销是第一个硬性理由创建一个对象到底有多贵很多人对“对象创建开销”没概念。一个 Java 对象的创建不是简单的内存分配它要经历类加载校验、内存分配、零值初始化、设置对象头、执行构造器等流程如果这个类的继承层级深还要一路向上执行所有父类的构造器。更不用说如果构造器里有复杂逻辑比如加载配置、初始化连接池、扫描资源那成本就更可观了。prototype scope 的语义是每次获取都创建一个新实例。这意味着一笔业务请求过来涉及 Controller - Service - Mapper 三层注入如果全部是 prototype一次请求就要创建十几个对象。一秒钟一千个请求就是上万个对象被创建、丢弃等待 GC 回收。在高并发场景下这就是性能断崖的源头。我之前优化过一个报表导出接口压测时发现吞吐率一直上不去CPU 飙得离谱。用 JFR 分析后发现某个负责做数据映射的 Component 被配成了 prototype它的构造器里还要做一次配置文件的解析。每来一个请求它就要重新读一遍配置文件、解析一遍。改成 singleton 之后配置解析只做一次接口的 TP99 从 850ms 降到了 210msCPU 直接降了 40%。这就是最直观的差异。2.2 依赖注入的核心优势依赖于共享实例Spring 框架的 IoC 和 AOP 能力很大程度上建立在 singleton 这个基础上。拿 AOP 举例子。你给一个 bean 加TransactionalSpring 会在容器启动时创建该 bean 的代理对象并且这个代理对象是以 singleton 的形式缓存住的。每个使用该 bean 的地方注入的都是同一个代理。这样事务拦截逻辑只需要存在于一个代理实例上执行任何方法都能被正确拦截。如果每次获取都创建新实例代理对象也要反复创建代理逻辑本身倒还能工作但额外的实例化成本完全被放大了。再说依赖注入本身。如果一个 singleton bean A 注入了一个 prototype bean B那 B 的实例在 A 创建那一刻就被“固定”下来了。后续每次调用 A 的方法用的都是同一个 B 实例。这是很多人都容易踩的坑——你以为注入的是 prototype实际行为跟 singleton 没区别。要真的每次拿到新实例得通过ObjectProvider或者Lookup方法注入。这个问题的本质恰恰说明Spring 的整个设计导向就是让你更多地使用共享的单例prototype 更像是给特定场景准备的补充方案。2.3 无状态设计是工程实践的主流Spring 推荐 singleton 还有一个很重要的前提在大多数业务场景中Service 层、Controller 层、Mapper 层的 bean 设计成“无状态”是完全合理且推荐的。无状态的意思是这个对象内部不保存与具体请求相关的可变数据。它可能有依赖的字段这些字段是容器注入的、本身也很可能是无状态的但没有“当前用户是谁”“当前订单号是多少”这样的临时状态。所有需要的数据都以方法参数的形式传递。比如一个OrderService它的职责是处理订单业务逻辑内部可能有OrderMapper、StockService等依赖这些都无状态。那么在一个共享实例上并发执行不同的方法调用完全不存在状态冲突的问题。因为方法内的局部变量是线程私有的方法间互不干扰。这个模式和 HTTP 请求的生命周期配合得很好。每个请求对应一次方法调用链数据都是通过参数在线程栈中传递共享的只是那些不携带业务状态的 bean 实例。这种做法在 C 和 Go 这类语言里也有类似的概念叫做“无共享架构”在 Java 里通过 singleton 无状态 bean天然就能搭出这种结构。3. singleton 的线程安全问题到底怎么看待3.1 线程安全不等于单例不安全而是看你怎么用很多人一听到 singleton 就开始担心线程安全。这个焦虑本身是合理的但问题往往不在 singleton 本身而在你往 singleton bean 里放了什么东西。如果 singleton bean 只依赖不可变对象、只使用方法局部变量、不修改自身的成员变量那么它在高并发下完全安全。如果你在里面声明了一个可变的成员变量比如一个HashMap在某个方法里往里塞数据另一个方法里拿出来用那么无论你是不是 singleton 都会出事——除非你的类本来就是 prototype。但把 prototype 当成线程安全工具是长期维护的噩梦。更实际的做法是如果你发现一个 bean 需要保存带状态的可变数据你先停下来想想这个数据是不是应该放在请求上下文里是不是应该放在数据库里是不是应该放在外部缓存里绝大多数情况下答案都是“不应该放在 bean 里”。如果确实要放再考虑 ThreadLocal线程封闭或者改用 prototype 并用正确的方式获取新实例。我见过一个真实案例项目里为了做数据权限过滤在 singleton 的 service 里塞了一个成员变量来保存当前用户的 ID结果并发一上来用户 A 拉到了用户 B 的数据排查问题排查了一整天。最后改成从上下文参数里传用户 ID彻底解决。这不是 singleton 的锅是设计错误。3.2 singleton 配合并发控制的正确姿势singleton 本身保证了实例的全局共享当多个线程同时访问同一个实例上的同一资源时你可以采用以下几种方式无状态化尽量把可变状态挡在方法外部不可变对象字段声明为 final内部对象也不可变加锁同步在需要保护的临界区使用同步或者锁但注意并发性能ThreadLocal让每个线程持有一份自己的数据副本并发安全容器ConcurrentHashMap、CopyOnWriteArrayList等这些方案都建立在 singleton 的前提下。本质上singleton 把你从“每次创建对象”的负担里解放出来让你把精力集中在真正的并发控制上。4. 什么时候该打破默认用 prototype4.1 有状态且轻量的对象有些对象天然是带状态的而且创建成本很低比如一个表示“用户会话”的实体、一次请求的上下文容器。这类对象用 prototype 是合理的但它们通常根本不需要 Spring 容器来管理——直接new出来就行。Spring 对 prototype 的支持更偏向于“当你的对象需要容器托管、需要注入依赖、但又要每次获取新实例时”才用。比如一个业务规则校验器它的构造器依赖了大量的Validator组件同时又携带当前校验批次的状态。这种场景下让 Spring 每次通过依赖注入组装一个新实例比你手动 new 手动 set 依赖要优雅得多。4.2 原型下的完整生命周期管理要提醒的是prototype bean 的生命周期和 singleton 完全不同。singleton bean 从容器启动创建到容器关闭销毁整个过程容器全权负责。prototype bean 则是每次获取时创建、返回给你之后容器就不再关心它的后续生命周期了甚至不会帮你调用销毁方法。这意味着如果你在 prototype bean 里开着连接、持有外部资源用完你得自己清理否则会出现资源泄漏。我碰过的案例里有人在 prototype bean 的PreDestroy方法里写了关闭资源的逻辑结果发现根本不会被调用因为原型 bean 销毁时容器不干预。如果你真的需要在获取 prototype 后执行销毁逻辑一个方案是实现DisposableBean接口并注册bean.getDestroyMethod()到自定义逻辑中去调用另一个更常用的做法是直接用ObjectProvider拿到实例后手动调用清理方法或者干脆把资源释放放在业务流程的finally块里。4.3 获取 prototype 的正确姿势如果你在 singleton bean 里直接注入 prototype bean如前文所说其实是“伪原型”。想要每次获取新实例Spring 官方推荐的方式有ObjectProviderT通过getObject()每次获取一个新实例Lookup方法注入在单例 bean 中声明一个抽象方法标注Lookup返回你想要的原型 beanSpring 会通过 CGLIB 生成子类来重写这个方法每次调用都会从容器重新获取ApplicationContext.getBean(Class)直接向容器要注意这是侵入式 API不建议在业务代码中大量使用我用得最多的是ObjectProvider因为它和构造器注入配合得比较顺畅。比如你要在单例处理类中每次拿一个新的规则引擎执行器Component public class RuleDispatchService { private final ObjectProviderRuleExecutor executorProvider; public RuleDispatchService(ObjectProviderRuleExecutor executorProvider) { this.executorProvider executorProvider; } public void execute(RuleContext ctx) { RuleExecutor executor executorProvider.getObject(); executor.run(ctx); } }这样既保留了单例的高效复用又在需要时动态获取新实例而且没有耦合ApplicationContext。5. 原理深挖三级缓存与 singleton 的关联5.1 三级缓存解决了什么问题Spring 解决循环依赖的核心机制就是三级缓存这个机制只对 singleton bean 生效。很多人听到“三级缓存”觉得很玄其实它其实就是三个 Map第一级singletonObjects存放已经完全初始化好的单例 bean直接可以给客户端使用 第二级earlySingletonObjects存放已经创建了原始对象、完成了属性注入但还没执行初始化回调的早期单例对象 第三级singletonFactories存放一个 ObjectFactory这个工厂可以用来获取“早期引用”通常是为了生成 AOP 代理为什么要三级而不是两级核心在于代理对象的创建时机。在循环依赖的场景中A 依赖 BB 依赖 A当 B 需要 A 的引用时A 还只创建了一半原始对象已创建但尚未完全初始化。如果此时直接把裸的原始 A 暴露给 B后续 A 初始化完成时如果 A 被 AOP 代理了B 拿到的还是裸 A就出问题了。三级缓存通过 ObjectFactory 的延迟处理使得 A 的代理可以在 B 需要时被正确创建从而保证最终所有注入的都是代理后的对象。这个机制能够成立依赖的是两个前提singleton容器里每个 bean 只有一个实例才有缓存的意义完整生命周期实例在容器启动时或首次获取后初始化初始化过程中的引用可被提前暴露。prototype scope 不在三级缓存的管理范围内这也是 prototype 无法解决循环依赖问题的原因之一。5.2 从三级缓存看 singleton 的工程地位三级缓存的设计完全是围绕 singleton 展开的。如果是 prototype每次获取新实例缓存本身没有意义如果同一个 bean 可能有多个实例二级缓存里存哪个这直接说明了 singleton 在 Spring 容器模型中的核心地位。设计者在最底层的数据结构层面就是为 singleton 量身定做的。还有一点Spring 生成代理时如果目标 bean 是 singleton代理对象会被缓存起来后续所有请求都用同一个代理。这大大简化了事务管理、安全校验等横切逻辑的处理。如果是 prototype代理逻辑每次都要生成新的代理实例同一个类可能有成千上万个代理对象来代表同一个业务方法这简直是性能灾难。6. 结合 Spring Boot 与 MyBatis 场景看 singleton 实践6.1 Mapper、Service、Controller 全链路单例化在典型的 Spring Boot MyBatis 项目中Controller、Service、Mapper 这些核心组件几乎全部是 singleton。MyBatis 的MapperProxy本身也是被持有在 singleton 的 Mapper 接口实例中的。Spring 通过MapperScannerConfigurer将每个 Mapper 接口注册为一个 singleton bean这个 bean 内部持有 JDK 动态代理调用方法时由代理去执行 SQL。这意味着你整个数据访问链路从 Controller 到 Service 到 Mapper全部复用同一套实例。每个请求进入时都在这套固定的对象图上运行区别只在于传入的参数和线程栈中的局部变量。这种设计保证了极高的吞吐率。我在做接口性能优化时经常看到有人对无状态 bean 加Scope(prototype)以为能提升并发安全实际上对这种 bean 来说prototype 只带来纯开销。无状态 singleton 才是最佳实践。6.2 事务与单例的协同Transactional也依赖 singleton 机制。Spring 事务的实现方式是在你调用被注解的方法时通过代理拦截开启事务执行业务逻辑提交或回滚。这个代理对象是 singleton 的。如果你把一个 service 改成 prototype并且从某个 singleton 组件中通过直接注入来使用它那么每次请求都会创建一个新代理事务拦截器也重新创建虽然逻辑上没问题但效率大打折扣。更隐蔽的问题是如果 prototype 代理对象里有事务上下文的存储比如某些事务同步管理器它跟全局事务状态之间的配合会变得奇怪。还有一点Spring 的Transactional默认只对 public 方法生效如果你在一个 prototype bean 内部调用自己的另一个事务方法同样不生效。这个规则和 scope 无关但很多人把两者混在一起排查方向搞错了。7. 实操经验项目中的 scope 选型速查7.1 一张表看清怎么选场景推荐 scope原因Service / Mapper / Controller / Repositorysingleton无状态共享复用性能最优工具类、配置持有者、缓存持有者singleton生命周期统一容器管理有状态的会话对象、请求上下文不放入 Spring 容器手动 new生命周期由当前线程或请求控制每次调用都需要全新状态的规则引擎实例prototype通过 ObjectProvider需要容器注入依赖但状态有隔离需求短期复杂依赖组装场景prototype减少手动 new 的依赖组装代码7.2 踩坑清单如果你决定使用 prototype我列几个常见坑不要直接在 singleton 里注入 prototype 就以为每次是新实例实际只有一个固定实例不要在 prototype bean 里依赖构造器之外的全局共享状态它并不能帮你解决并发不要期待容器会帮你执行 prototype 的销毁逻辑资源释放得自己管Lookup方法注入需要类不是 final 的否则 CGLIB 无法生成子类用ObjectProvider时注意异常处理容器可能创建实例失败你需要捕获并处理不要把所有 bean 都改成 prototype 来“解决问题”先从设计层面排除状态污染7.3 我个人的选型心得实际项目中我几乎只在两种场景下考虑 prototype一是确实需要一个携带状态的业务组件并且希望它依赖注入的能力二是一些需要隔离执行的批处理任务组件每个任务持有自己的进度状态。其余场景一律 singleton。在 singleton bean 里需要变化状态时优先考虑把状态作为参数传入方法而不是塞进字段。如果状态必须跨多个方法共享用ThreadLocal或者请求作用域 beanSpring 中Scope(request)来保存比把状态硬塞进 bean 里安全得多。线程安全问题归根结底是状态归属问题scope 只是表面差异。8. 面试和学习中怎么理解 singleton 才到位考虑到这个主题频繁出现在 Spring 面试题中我多说几句。面试官问“Spring 为什么默认使用 singleton”想听的绝不是“官方文档这么说的”。你可以从三个层面回答第一层性能。singleton 避免了对象反复创建的开销bean 只创建一次后续直接复用这在长生命周期应用中优势明显。第二层容器设计。Spring 的 IoC 容器本身就是一个大工厂它的核心职责是管理 bean 生命周期。singleton 使得生命周期管理变得简单可控配合三级缓存解决循环依赖、配合 AOP 做代理增强都是建立在单例基础上的。第三层工程实践。主流企业应用大多是无状态的请求处理模型singleton 天然适合这种模型。这也呼应了 Spring 的核心理念面向接口编程而不是面向实例编程。如果你面试中还能补充一个实际项目的优化案例比如把一个 prototype 改成 singleton 后性能提升了多少那基本上这个点就答得很扎实了。面试官更在意你是否真正理解背后原理而不是背概念。我自己在维护老代码时最常做的一件事就是查找项目里的Scope(prototype)逐个评估是否真的需要。大多数情况下这些注解都是开发者在某个瞬间的过度设计。Spring 推荐 singleton不是没有理由的它已经在无数高并发项目中验证过了。你要做的不是挑战这个默认值而是理解它然后把它用对。
返回列表