YAOTU INSIGHTS

Spring Bean作用域详解:从singleton/prototype到Web作用域与注入失效

Spring Bean作用域详解:从singleton/prototype到Web作用域与注入失效
先讲一个我在真实项目里遇到的翻车现场。当时要统计短信接口的当天调用量需求很简单每次请求进来计数器加一。我图省事把计数器直接做成Bean的成员变量还给累加方法加了synchronized结果上线跑了不到半天数字就开始忽大忽小。排查了很久才发现问题根本不在并发锁而是这个Bean被Spring默认当成单例在管理所有请求线程共享的是同一个实例、同一个count字段。从那天起我才意识到Bean作用域不是配置上随便抄一下的东西它决定了实例在容器里的存活边界、创建时机、共享方式甚至能直接影响业务数据的正确性。这是Spring6笔记系列的第三篇我专门把Bean作用域这件事一次性讲透。内容从最基础的singleton与prototype出发深入到容器源码是怎么管理不同作用域的再讲Web环境下的request、session、application、websocket四种作用域以及日常开发里最常见的singleton注入prototype失效问题。如果后面你还想折腾自定义作用域我也把扩展点写出来了。无论你是刚学Spring还是面试前突击这篇文章都值得花点时间从头读一遍。1. 从容器视角看Bean作用域singleton和prototype的本质区别1.1 作用域到底是什么作用域Scope这个词听起来抽象翻译成大白话就是一个Bean在Spring容器里能活多久以及谁能看到同一个实例。它属于Spring IoC容器对Bean管理的一部分和Bean生命周期实例化方式紧密相关。你在配置里写一个scopeprototype本质上是在告诉容器这个Bean不要复用每次给我新的。Spring容器支持的作用域大致分两类一类是内置的标准作用域也就是singleton和prototype在任何Spring应用中都能用另一类是Web环境下才启用的request、session、application、websocket只在WebApplicationContext里有效。严格来说用户还可以通过实现Scope接口自己注册新的作用域比如常见的线程作用域、自定义业务作用域等。对于singleton和prototype最直观的区别可以用一句话表达singleton是容器级别的唯一实例prototype是每次获取时的全新实例。但这句话背后还有两个容易忽略的点一是唯一指的是同一个容器内、同一个BeanName之下唯一而不是整个JVM进程唯一二是Spring没有规定单例Bean必须无状态它只是保证实例复用状态管理得靠你自己负责。1.2 创建时机容器启动时创建还是拿到时才创建很多人以为singleton是第一次getBean时才创建其实不对。Spring默认会在容器刷新refresh()阶段就把大部分单例Bean实例化好除非你配置了Lazy。这块逻辑在DefaultListableBeanFactory.preInstantiateSingletons()里源码会遍历所有非抽象、非懒加载的单例BeanName然后逐个调用getBean触发创建。所以在Spring Boot项目启动日志里你经常能看到一些Bean的初始化日志在启动阶段就打印出来这就是容器在预实例化单例。prototype则完全不同。它没有预实例化这个说法容器启动时不会提前创建只有当你调用applicationContext.getBean()或者某个地方通过Autowired、ObjectProvider.getObject()去获取它时才会现场创建一个新实例返回。用一个最简单的例子来验证Component Scope(ConfigurableBeanFactory.SCOPE_SINGLETON) public class SingletonBean { } Component Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public class PrototypeBean { }然后写个启动类在ApplicationContext里取两次ApplicationContext context SpringApplication.run(App.class, args); SingletonBean s1 context.getBean(SingletonBean.class); SingletonBean s2 context.getBean(SingletonBean.class); System.out.println(s1 s2); // true PrototypeBean p1 context.getBean(PrototypeBean.class); PrototypeBean p2 context.getBean(PrototypeBean.class); System.out.println(p1 p2); // falseSingletonBean两次取得的引用完全相等因为容器缓存的就是同一个对象PrototypeBean每次都不同内存中会存在多个独立实例。理解这个差异是后面所有坑的基础尤其是理解为什么单例容器里注入一个原型Bean会有问题。1.3 默认值不是设计建议什么时候该用prototypeSpring对Bean的默认作用域是singleton这是历史选择也是绝大多数场景下的最优解。无状态的Service、DAO、Mapper、工具类这些Bean内部不保存调用方相关数据一个实例被所有线程共享没有任何问题反而节省内存、省去反复创建的损耗。但如果你遇到以下情况就需要把作用域切换为prototypeBean内部持有属于某个调用流程的状态比如一次任务处理中的临时数据同一个Bean配置需要生成多个不同配置的实例类似于原型模式对象本身创建开销大但每次业务都需要独立副本不能复用某个对象在并发场景下因为内部状态共享而出现线程安全问题。我个人的习惯是默认全部用singleton只有在确实需要每次新对象时才显式加Scope(prototype)。更关键的是要区分Bean有状态和业务有状态——很多所谓的有状态场景其实更适合把数据放到方法的参数、返回值或ThreadLocal里而不是堆在Bean字段里。作用域的切换是最后手段不是第一选择。2. 单例Bean的状态共享实际开发里最容易踩的坑2.1 成员变量不是私有数据而是共享数据我开头提到的计数器事故就是典型的singleton状态共享问题。当一个Bean被Spring容器设置为单例后它的成员变量在JVM堆里就只有一份所有线程在并发访问时看到的是同一个内存地址。你以为自己写了一个每个调用方独立计数的普通对象实际上写的是一个全局共享计数器。看这段代码Component public class OnlineUserCounter { private int count 0; public void add() { count; } }在没有Spring的情况下每次new OnlineUserCounter()都有一份独立count彼此互不影响。但一旦交给Spring且保持默认singletoncount就成了整个应用共享的变量。如果你只是想统计某个用户维度、某个请求维度的数量这行代码就是事故源头。更隐蔽的一种情况是缓存型成员变量。很多老项目喜欢在单例Bean里放一个Map做本地缓存然后多个线程往里put、get。如果不做线程安全的集合加锁处理轻则数据错乱重则导致内存溢出。记住一个原则在Spring里面写Bean时默认就得假设这个Bean会被并发复用并发情况下你还能不能保证字段安全如果不能就不要把状态放到Bean字段里。2.2 线程安全问题的三个应对方向碰到这类问题不是说改成prototype就万事大吉了。prototype只是让每次获取的对象不共享但如果同一个prototype实例被多个线程同时持有并使用线程安全问题依然存在。我见过有人把Service全改成prototype来解决并发问题结果只是把问题从共享变量变成了局部变量性能还下降了完全没必要。我实践下来有三个更靠谱的方向第一尽量写成无状态Bean。所有请求相关的数据都通过方法参数传进来计算结果通过返回值返回Bean内部不保存任何可变数据。这是最推荐的方案符合Spring/Java开发的主流习惯也是容器能放心复用一个Bean的前提。第二如果确实要在Bean里维护状态就使用ConcurrentHashMap、AtomicInteger这类并发安全容器并且想清楚并发语义。比如前面那个计数器用AtomicInteger就能避免count的非原子问题但如果你需要每个用户计一次数数据模型就不应该放在JVM内存里该用Redis之类的外部存储。第三按场景明确作用域。比如某个Bean就是专门服务一个Web请求的那么使用request作用域某个Bean要在一个线程内保持一致可以使用自定义线程作用域。这部分我在后面章节详细展开。2.3 顺带解决一个面试高频问题prototype的destroy方法会不会被调用既然聊到了作用域和生命周期我必须把prototype的销毁问题说清楚。Spring官方文档里明确写过Spring容器在prototype Bean创建之后就不再负责它的完整生命周期了持有者调用方需要自己负责销毁并释放资源。换句话说一个prototype Bean如果实现了DisposableBean接口或者配置了destroy-method容器关闭时不会主动调用这些销毁逻辑。那为什么很多人感觉prototype也能调用初始化方法因为InitializingBean接口、PostConstruct注解、init-method这些是在Bean创建过程中由initializeBean()统一执行的prototype也走这个过程所以初始化会被执行。但销毁回调只在容器管理到销毁阶段的Bean上才会执行prototype不在其中。实际开发中遇到需要释放资源的prototype状态机、任务对象我会选择让调用方在finally块里显式清理或者引入一个专门的ResourceHolder来处理。不要指望Spring替你收尾。3. 作用域背后的容器源码从缓存到Scope接口3.1 单例池的本质是三级缓存Spring容器对单例的管理核心在DefaultSingletonBeanRegistry。它内部有一个MapString, Object singletonObjects说白了就是一个缓存池。每次getBean(userService)时AbstractBeanFactory都会先走getSingleton(String beanName)去这个Map里找找到就直接返回不再执行创建逻辑。配合Bean生命周期的完整流程创建singleton时不是一次性把对象放进singletonObjects的而是先后经历三个缓存阶段singletonFactories存放对象工厂用于提前暴露半成品对象earlySingletonObjects存放提前暴露出来的早期单例主要是为了解决循环依赖singletonObjects存放完全初始化好的最终单例。正常情况下你调用getBean拿到的一定是singletonObjects里的成品。只有发生循环依赖时才可能从earlySingletonObjects或singletonFactories中拿到尚未完全初始化的引用。这块源码我建议有时间自己翻一遍能把AOP代理什么时候生成循环依赖为什么三级缓存就能解决这些老大难问题串起来。3.2 prototype和自定义Scope走的是Scope接口prototype的创建逻辑和singleton不在同一条路上。AbstractBeanFactory.doGetBean()里获取单例的路径是走getSingleton()获取prototype的路径则单纯地执行createBean()创建完成直接返回不做缓存。同时容器内部有一个prototypesCurrentlyInCreation集合专门标记正在创建中的prototype Bean用于在创建过程中检测是否发生了循环依赖。为什么prototype循环依赖基本无解因为singleton能通过提前暴露半成品引用打破循环而prototype每次都要完整创建新实例没有提前缓存机制两个prototype互相引用时就会不断创建对方最终导致BeanCurrentlyInCreationException。在Spring的设计里singleton和prototype属于容器内置的基础逻辑但其他一切自定义作用域都统一走Scope接口。Scope接口定义了五个方法Object get(String name, ObjectFactory? objectFactory)获取当前作用域下的Bean不存在则调用objectFactory.getObject()创建Object remove(String name)移除作用域中的Beanvoid registerDestructionCallback(String name, Runnable callback)注册Bean销毁回调Object resolveContextualObject(String key)解析上下文对象比如request作用域里的requestString getConversationId()返回当前作用域标识比如session id。ConfigurableBeanFactory通过registerScope(String scopeName, Scope scope)方法把这些实现注册进容器Scope(thread)这类注解最终就是靠这个机制找到对应Scope的。3.3 单例Bean在容器关闭时的销毁顺序单例Bean的销毁发生在AbstractApplicationContext.close()阶段。容器会从singletonObjects中取出所有单例按照依赖关系逆序调用销毁逻辑先执行PreDestroy注解方法再执行DisposableBean.destroy()最后执行destroy-method配置。同时也会触发DisposableBeanAdapter去遍历注册好的disposableBeans。这段逻辑如果展开能写很长但和本文主题相关的核心就是销毁是作用域的收尾动作只有被容器持有生命周期的Bean才会统一销毁。singleton因为生命周期等同容器所以容器关闭时一定会处理prototype的引用散落在外部容器无从下手。你在设计自定义Scope时也要考虑清楚自己的缓存区域何时清理、如何回调registerDestructionCallback。4. Web应用里才有的四个作用域request、session、application与websocket4.1 四种作用域各自的存活边界如果你的项目不是纯ApplicationContext而是WebApplicationContextSpring MVC、Spring Boot Web项目Spring还会额外提供四种作用域。request作用域每个HTTP请求创建一个实例同一个请求内多次获取同一个Bean得到同一个对象请求处理完毕就销毁。适合存放单次请求内的上下文数据。session作用域每个HTTP Session创建一个实例同一个用户会话内共享会话失效时销毁。适合存放用户登录信息、权限快照等。application作用域整个ServletContext内一个实例可以理解为Web应用级别的单例范围比Spring容器的singleton更贴近Servlet规范适合存放全应用共享的配置对象。websocket作用域每个WebSocket会话一个实例连接关闭时销毁适合WebSocket聊天室、实时推送等场景。用代码配置时可以写成这样Bean Scope(value WebApplicationContext.SCOPE_REQUEST, proxyMode ScopedProxyMode.TARGET_CLASS) public RequestContext requestContext() { return new RequestContext(); } Bean Scope(value WebApplicationContext.SCOPE_SESSION, proxyMode ScopedProxyMode.TARGET_CLASS) public UserSession userSession() { return new UserSession(); }注意我都加了proxyMode这个参数不是可选项后面会解释为什么要加。4.2 底层实现与常见使用场景这几个Web作用域的实现类都在org.springframework.web.context.support包下。RequestScope继承AbstractRequestAttributesScope每次get()时会通过RequestContextHolder.currentRequestAttributes()拿到当前请求的RequestAttributes再从请求级别的attributeMap里取BeanSessionScope的机制类似只是attributeMap挂在Session上ApplicationScope本质是把Bean放进了ServletContext的属性管理器里。所以这几个作用域成立的前提是当前线程必须绑定了RequestContextHolder。在普通的Controller层、Service层中Spring MVC的DispatcherServlet已经帮你绑定了请求上下文直接注入没问题。但如果是在Async异步线程、消息监听器、定时任务里使用request/session作用域Bean就会报No thread-bound request found之类的错误。遇到这种场景不能依赖自动绑定要么自己手动设置RequestContextHolder要么改用其他方式传递数据。日常开发中我见过最典型的需求是写一个request作用域的Bean存放请求ID、用户ID、地区信息后续Service层代码统一从里面读取省得每个方法都加参数。这个用法本身没什么问题但一定要控制好范围内数据的生命周期防止在异步线程里拿到过期的request数据。4.3 代理模式为什么在Web作用域里几乎是必选项Web作用域的Bean生命周期比singleton短得多。假设你有一个controller是singleton里面Autowired了一个request作用域的Bean容器在启动阶段创建controller时request根本不存在直接注入一个真实对象是不可能的。Spring的解决办法是在创建目标Bean时不是把真实对象注入进去而是注入一个代理对象。代理对象自身是一个singleton级别的存在可以随controller一起创建但每次调用代理对象的方法时它都会通过RequestContextHolder去当前请求上下文里获取真实的request作用域Bean再委托执行。这就是ScopedProxyMode.TARGET_CLASS的作用机制。使用代理模式的代价是通过代理访问时会有额外的间接调用开销而且getClass()拿到的class不是目标Bean的class如果你在代码里依赖具体类型判断容易踩坑。所以我的建议是能用ObjectProvider延迟获取的就尽量别用代理代理更适合那种调用点分散、希望调用方无感知的场景。5. singleton里注入prototype为何失效三种解法逐一拆解5.1 问题根因注入动作只发生了一次这是Spring开发里一个极其经典的问题。写一个singleton的Service依赖一个prototype的Helper代码看起来没毛病但运行后发现Service里拿到的Helper永远是同一个实例。原因不复杂singleton Bean在容器刷新的预实例化阶段就会被创建创建时执行属性注入Autowired把当时那个getBean(helper)得到的新对象放进了Service的字段里。此后Service一直复用这个字段即使你再去context.getBean(helper)能拿到另一个新对象Service内部这个字段也不会刷新。Autowired注入的是创建时刻的对象快照不是每次调用的动态寻址。如果你只是简单地给Helper加prototype然后继续用Autowired注入那么作用域配置基本是无效的。下面三种解法是业内通行方案。5.2 解法一ObjectProvider延迟获取ObjectProviderT是Spring 4.0之后提供的延迟获取Bean的标准方式。它的核心思路是不直接注入目标Bean而是注入一个工厂对象每次需要的时候再通过它去容器里取。Component public class UserService { private final ObjectProviderTaskProcessor taskProcessorProvider; public UserService(ObjectProviderTaskProcessor taskProcessorProvider) { this.taskProcessorProvider taskProcessorProvider; } public void execute() { TaskProcessor processor taskProcessorProvider.getObject(); processor.run(); } }TaskProcessor的Bean声明保持Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)即可每次调用getObject()Spring都会从容器中重新生成一个。这种方法最轻量也最直观我平时在业务代码里用得最多。需要支持默认值时还可以用getIfAvailable()配合Lambda表达式。如果不想自己管理null判断ObjectProvider是很顺手的选择。5.3 解法二Lookup方法注入Lookup是Spring提供的另一种方式适合不想在调用方显式依赖ObjectProvider的场景。用法是在一个类里写个抽象或具体方法方法上标注LookupSpring容器会以CGLIB生成一个子类重写这个方法来执行getBean()逻辑Component public abstract class ReportService { public void generate() { PdfExporter exporter createExporter(); exporter.export(); } Lookup protected abstract PdfExporter createExporter(); }方法返回类型就是要获取的prototype Bean类型方法名可以随便起Spring会通过返回类型找到Bean。需要指定BeanName时也可以Lookup(pdfExporter)。但我有个多年踩出来的经验要提醒Lookup依赖Spring容器为当前Bean生成CGLIB子类如果目标类被final修饰或者方法本身是private或final的这个方法就行不通。还有如果Bean是通过Configuration里返回配置类实例注册的也得注意CGLIB子类对构造器的要求。总体可用但限制比ObjectProvider多一些。5.4 解法三Scoped Proxy代理模式第三种方式就是给prototype Bean配上scoped proxyComponent Scope(value ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode ScopedProxyMode.TARGET_CLASS) public class TaskProcessor { }这样在singleton Service里照样用Autowired TaskProcessor processor;注入但实际注入的是一个代理对象。当代理对象的方法被调用时Spring才会去容器中获取一个新的prototype实例来执行方法。这种方式的好处是调用方无感知原有代码不用改。坏处是代理对象在每次方法调用时都要经容器转发一次而且代理对象本身在equals、hashCode上的语义和真实对象不一致。如果你只是想要每次拿到新对象处理一段逻辑用ObjectProvider更清晰如果目标是让多个调用点统一透明地拿到新实例这个方案才合适。5.5 三种方案怎么选方案侵入性限制适用场景ObjectProvider延迟获取调用方需要改为getObject()无特殊限制大多数业务场景我强烈优先用Lookup方法注入需要写抽象方法或标记方法类不能是final方法不能private/final想要封装获取逻辑或不想暴露ObjectProviderScoped Proxy代理模式调用方无感知有代理开销类型判断受影响Web作用域Bean的透明注入一句话总结我的选择原则能用ObjectProvider就不用代理能用编码解决就不依赖动态代理。代理模式看着方便但调试和排障的隐性成本不低。6. 自定义作用域实战写一个线程作用域Bean6.1 为什么需要自定义作用域标准作用域覆盖了大部分场景但每个线程一个实例、线程内共享这类需求官方没有内置。比如在线程池中处理任务时你希望同一个线程内所有地方都能拿到同一个TraceId、同一个事务上下文而不同线程之间互不干扰。这时可以自己实现一个线程作用域。Spring官方其实提供了一个SimpleThreadScope可以当参考但真正生产环境下我倾向自己实现一个更可控的版本核心就是实现Scope接口。6.2 手写ThreadLocalScope先写一个ThreadLocalScope内部用ThreadLocal保存一个MapString, Objectpublic class ThreadLocalScope implements Scope { private final ThreadLocalMapString, Object threadScope ThreadLocal.withInitial(HashMap::new); Override public Object get(String name, ObjectFactory? objectFactory) { MapString, Object scope threadScope.get(); Object bean scope.get(name); if (bean null) { bean objectFactory.getObject(); scope.put(name, bean); } return bean; } Override public Object remove(String name) { MapString, Object scope threadScope.get(); return scope.remove(name); } Override public void registerDestructionCallback(String name, Runnable destructionCallback) { // 在线程结束场景下可以收集回调统一执行 } Override public Object resolveContextualObject(String key) { return null; } Override public String getConversationId() { return String.valueOf(Thread.currentThread().getId()); } }get()方法的设计思路和Spring内置Scope一样先从当前线程的Map里查查不到就通过objectFactory.getObject()创建后放入Map这样同一个线程内多次获取就是同一个对象不同线程之间由于ThreadLocal隔离互不影响。然后用ConfigurableBeanFactory.registerScope来注册它Bean public BeanFactoryPostProcessor threadScopeRegistrar() { return beanFactory - { if (beanFactory instanceof ConfigurableBeanFactory cbf) { cbf.registerScope(thread, new ThreadLocalScope()); } }; }注册完成后Bean上就能直接用Scope(thread)了Component Scope(thread) public class TraceContext { private String traceId; public String getTraceId() { return traceId; } public void setTraceId(String traceId) { this.traceId traceId; } }在同一个线程里TraceContext对象只有一个切到另一个线程后会拿到另一份。这在多线程任务拆分、日志埋点时非常实用。6.3 自定义Scope的注意事项与踩坑记录自定义Scope听着简单落地时有几个细节很容易被忽略。第一ThreadLocal清理问题。线程池中的线程会复用如果你在线程任务结束时没有清掉ThreadLocal里的数据下一个任务就会读到上一个任务的脏数据。所以任务结束后需要显式调用线程作用域的清理逻辑把当前线程的Map移除或者用try-finally保证cleanup。我见过线上出现过traceId串线的事故排查到最后就是ThreadLocal没清理。第二registerDestructionCallback不能空着不管。Spring在容器关闭时会遍历Scope中注册的所有Bean执行对应的销毁回调。如果线程作用域里的Bean持有文件句柄、数据库连接等资源你需要在线程结束时主动触发这些回调。否则即使你手动remove()了对象资源也不会自动释放。第三自定义Scope和singleton的注入问题依然存在。如果你在一个singleton Service里Autowired一个thread作用域的Bean同样会拿到快照对象线程隔离效果完全失效。解决办法和prototype那章一样要么用ObjectProvider要么给thread作用域Bean配置proxyMode。作用域越短越要注意注入时机。我实际用下来线程作用域最适合配合ObjectProvider使用。调用方每次provider.getObject()都会从当前线程的Map中拿到同一个Bean跨线程自然隔离配合ThreadPoolExecutor做任务级上下文传递非常顺手。如果你也在做类似的异步任务改造可以照着这个思路自己扩一个事务上下文作用域或批量任务作用域比在全局静态变量里塞状态靠谱得多。