
我做Java后端开发差不多十年面试过的候选人少说也有两百位。Spring的Bean作用域几乎每场面试都会被我拿来当热身题。“Spring默认的Bean作用域是什么”——十个人里九个能答出singleton。但只要你接着问一句“Spring官方为什么强烈推荐singleton它到底比其他作用域好在哪里什么时候singleton会变成灾难”能说出完整逻辑的人我估计不到三成。很多人背了面试题知道singleton“节省内存、性能好”但不知道这个结论是怎么来的更多人踩过单例Bean的坑却不知道这些坑本来是可以从设计层面提前避开的。这篇文章我就把这块掰开揉碎讲清楚。不管你是在准备Spring面试还是正在维护一个线上Spring Boot项目下面这些内容应该都用得上。1. 为什么Spring默认使用singleton设计初衷与成本账1.1 对比prototype方案为什么默认改成singleton更合理先做个假设如果Spring默认不是singleton而是prototype会发生什么每次getBean、每次依赖注入容器都会创建一个全新实例。听起来好像也还行我们来算算实际账。假设一个电商系统的OrderServiceImpl依赖了ProductService、UserService、InventoryService、CouponService、MessageService这五个Service。如果全部用prototype每次new一个OrderServiceImpl容器就得递归地创建它依赖的那五个Service而ProductService自己可能又依赖ProductMapper、RedisCacheClient、RemoteStockClient……这一套递归展开一次请求可能要new几十个甚至上百个Java对象。这不只是性能问题更要命的是这些对象之间的协作关系会完全失控。依赖要一层一层往下传缓存、代理、事务这些Spring增强能力根本找不到一个统一的“下手点”。用个生活化的类比一家公司来了客户你不可能每次都给客户临时招一批新员工来处理需求那样谁对接谁、谁负责什么全都乱套了。正常做法是让固定的岗位、固定的人来服务每个客户靠制度和流程来保证一致性。Spring容器扮演的就是那个“固定岗位管理者”的角色。所以Spring把默认作用域设计成singleton本质上不是拍脑袋选了单例模式而是一种管理策略让容器统一创建、统一持有、统一销毁。所有Bean的引用关系在容器启动时一次性建立运行期大家拿到的是同一个协作网络。这一点很重要因为Spring作为IoC容器它的核心价值恰恰是帮你管好对象而不是让你每次自己new。1.2 一个Spring Bean的创建成本到底有多高很多人觉得singleton的好处就是“少new几次对象”感觉省不了多少。但Spring容器里的一个Bean从无到有远不止new一下就结束了。我简单列一个典型Service Bean的创建过程解析Bean定义、合并属性确认这个Bean到底是singleton还是其他scope构造器推断Spring要在多个构造函数里挑一个处理Autowired参数实例化通过反射创建对象依赖填充递归解析并注入它依赖的所有Bean执行各种BeanPostProcessorAutowired的字段注入就是其中一个环节执行PostConstruct等初始化回调如果加了Transactional、Async这些注解还需要通过CGLIB或JDK动态代理生成代理对象这一步一步走下来一个Service Bean可能牵扯到几十个乃至上百个关联对象的创建。如果是prototype这个流程每次getBean都要完整重跑一遍。我自己简单跑过一个测试循环创建一万个prototype的Bean实例对比循环获取一万次同一个singleton Bean的引用前者耗时是后者的几十倍期间还产生了大量的临时对象。所以Spring推荐singleton第一个理由其实很朴素绝大多数业务Bean的创建成本并不低复用比重复创建划算得多。这也是Spring容器设计的核心价值所在——把昂贵的对象创建集中管理起来而不是丢给调用方每次现场组装。2. singleton在JVM层面的真实优势2.1 singleton如何降低JVM对象数量与GC压力先算一笔实在账。假设一个接口的QPS是1000这个接口内部会调用OrderService。如果OrderService是prototype每次请求都new一个一天下来就是8640万个实例。如果OrderService还依赖MessageService、LogService这些对象约等于每个请求要new十几个对象那一天就是十几亿个短命对象。这些对象用完之后不会马上消失它们会留在JVM堆里等待GC。年轻代GC会成为常态频繁的Minor GC会带来Stop-The-World暂停哪怕每次只有几十毫秒在高峰期也会让接口响应时间开始抖动。反过来说如果用singleton这些Bean在容器启动时创建一次之后常驻内存整个运行期几乎没有Bean创建的开销GC压力自然小很多。当然有人会说“每次new一个Java对象本身很快”这话用在普通POJO上没毛病。但Spring的Bean不是普通POJO它附带了一整套容器生命周期。prototype带来的开销大头不是那一次new而是背后那一长串依赖解析、初始化回调、代理生成逻辑。这些开销放在高频路径上体感差距非常明显。在高并发场景下少创建上百万个无意义对象对GC的友好程度是实打实的。2.2 无状态Beansingleton线程安全的前提这里必须澄清一个常见的误解很多人一听singleton第一反应是“多线程不安全”。这个说法不能说错但不全面。Singleton本身不保证线程安全但Spring里大量单例Bean之所以安全是因为它们是无状态的。无状态的意思是Bean内部没有可变的实例字段所有数据都通过方法参数传递方法内部只用局部变量。看一段典型的代码Service public class OrderService { // 依赖都是final的且指向无状态或线程安全的组件 private final OrderMapper orderMapper; private final ProductClient productClient; public OrderService(OrderMapper orderMapper, ProductClient productClient) { this.orderMapper orderMapper; this.productClient productClient; } public Order createOrder(Long userId, ListLong skuIds) { // 所有局部变量都在当前线程的栈上天然隔离 // 这里只使用参数和局部变量不修改任何成员字段 return doCreate(userId, skuIds); } }这种情况下多个线程同时调用同一个OrderService实例各线程的局部变量互不干扰根本不存在线程安全问题。Controller、Service、Repository、Config这些层绝大多数都可以设计成无状态。Spring强烈推荐singleton其实是建立在“业务Bean应该无状态”这个开发规范之上的。换句话说Spring默认给你的是一个共享实例而开发者的责任是保证这个共享实例内部没有可变状态。2.3 AOP代理、缓存与懒加载单例复用的隐藏收益还有一点很多人意识不到Spring的AOP、Transactional、Async这些能力本质上都是给Bean包一层代理。代理对象是在Bean初始化阶段由BeanPostProcessor生成出来的它在容器中也是一个对象。如果Bean是prototype每次getBean都会重新生成一个新代理对象。这不仅浪费更麻烦的是切面里如果记录了状态比如埋点计数、耗时统计prototype模式下每个实例各记各的统计结果完全不可控。而singleton模式下代理对象全局只有一个切面逻辑才能保持一致监控数据才能准确聚合。另外Spring容器本身也有基于singleton的优化。比如Lazy标注的Bean第一次被引用时才创建但创建完之后仍然会被缓存起来后续调用依然复用同一个实例。这种懒加载设计也建立在“复用”的基础之上——如果每次获取都新建懒加载省下的那一次启动时间根本毫无意义。3. 依赖注入与三级缓存singleton背后的容器逻辑3.1 依赖注入为什么必须建立在单例协作网络上Spring最核心的两个能力是IoC和AOP而IoC落到代码层面就是依赖注入。一段典型的Spring代码长这样Service public class PaymentService { private final OrderService orderService; private final AccountService accountService; public PaymentService(OrderService orderService, AccountService accountService) { this.orderService orderService; this.accountService accountService; } }注意这里orderService和accountService是PaymentService的成员变量它们是固定的、长生命周期的协作对象。如果它们每次注入的都是新对象PaymentService执行方法时根本不知道后面站的是谁事务边界、数据源绑定、安全上下文这些全都对不上号。换个角度想事务注解为什么能生效因为Spring在容器启动时把PaymentService的原始对象替换成了代理对象代理对象内部持有原始对象的引用和事务管理器。如果每次请求都new一个PaymentService事务管理器根本无法对它做统一增强。只有singleton能让依赖关系保持稳定、可预期让AOP增强正确地织入到特定对象上。所以说到底singleton不是一种可有可无的优化而是IoC容器实现全局协作关系的地基。你想让一个对象图在运行期保持结构不变最自然的做法就是图中的每个节点都只实例化一次大家共享同一个对象网络。3.2 Spring三级缓存原理循环依赖与代理对象的真相“Spring三级缓存原理”这几年在面试里被问得非常频繁它跟singleton的关系其实极紧密——三级缓存就是在singleton Bean的创建过程当中用来支撑循环依赖和AOP代理的一套缓存机制。Spring在创建singleton Bean时会经过三个缓存singletonObjects一级缓存保存已经完整初始化好的BeanearlySingletonObjects二级缓存保存已经实例化、但还没完成属性填充的“早期Bean”singletonFactories三级缓存保存ObjectFactory对象用来生成早期Bean的引用最经典的循环依赖场景是A依赖B、B也依赖A。流程大致如下创建A实例化出A的原始对象把原始对象封装成ObjectFactory放进三级缓存A开始填充属性发现需要B于是触发B的创建B实例化后填充属性发现需要A。此时一级缓存里还没有A二级缓存里也没有A但三级缓存里有A的ObjectFactoryB通过这个ObjectFactory拿到A的早期引用还没完成属性填充的A把引用挪到二级缓存从三级缓存移除。B接着完成自己的创建放进一级缓存回过头来A从一级缓存拿到已经创建好的B完成自己的属性填充和初始化最终放进一级缓存这里有个关键点如果A需要AOP代理三级缓存里的ObjectFactory返回的就不是原始对象而是代理对象。所以才必须保留三级缓存而不是简单地把早期引用直接放二级缓存了事——早期直接放二级缓存B拿到的是未代理的原始对象等A初始化完变成代理对象B手里的引用就和容器中的最终对象不是同一个了AOP增强在B那里就失效了。这块逻辑只看文章确实容易绕晕。我自己是手写了一遍迷你Spring容器之后才真正弄通的——把构造器推断、属性填充、三级缓存、AOP代理四个环节各写一个简化版跑通你对Spring容器设计思路的理解绝对比背十遍面试题都深刻。3.3 prototype Bean在依赖注入中的天然局限理解了三级缓存也就理解了为什么prototype Bean在Spring里总是有些“别扭”。prototype Bean每次获取都是新对象它自身没有“依赖网络”可言。更尴尬的是如果你把一个prototype Bean直接注入到一个singleton Bean里这个prototype会失去“每次新建”的语义。假设PaymentContext是prototype作用域像下面这样注入Service public class PaymentService { Autowired private PaymentContext paymentContext; public void pay() { paymentContext.setOrderId(101L); } }这段代码里PaymentContext虽然注册成了prototype但因为它在容器启动时就被注入了PaymentService所以每次pay()执行时用的都是那个启动时唯一创建的实例。你期待的是“每次调用都拿到新的上下文”实际拿到的却是被字段注入过程固化下来的旧对象。这就是prototype在依赖注入体系中的天然困境也是后面要讲“怎么正确获取prototype Bean”的铺垫。4. singleton不是免死金牌三个最容易翻车的地方4.1 有状态单例的翻车现场从SimpleDateFormat说起singleton最大的坑就是不小心把可变状态放进了共享实例里。最常见的案例是SimpleDateFormat它是出了名的线程不安全。很多人会写成这样Service public class OrderService { private SimpleDateFormat dateFormat new SimpleDateFormat(yyyy-MM-dd); public String formatOrderTime(Date date) { return dateFormat.format(date); } }在一个QPS不低的接口里这种写法早晚会出线上的偶发问题日期错乱、抛NumberFormatException而且很难复现。原因是SimpleDateFormat内部的Calendar实例是共享可变状态多线程同时调用format时互相污染。针对这个问题常用的解决思路有三种无状态化每次方法内新建SimpleDateFormat。成本可接受但高频场景下性能不佳ThreadLocal每个线程持有自己的副本性能和隔离都能兼顾用线程安全的替代类比如Java 8的DateTimeFormatter它本身是线程安全的第三种是我最推荐的做法Service public class OrderService { private static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd); public String formatOrderTime(LocalDate date) { return FORMATTER.format(date); } }类似容易翻车的还有可变的Map、List成员变量尤其是被多个线程同时读写的自增的int计数非线程安全的第三方客户端Socket连接等等。原则其实就一条singleton Bean的字段最好是final的且指向无状态或线程安全的对象。写完代码扫一眼自己的类凡是能改的实例字段都要问一句这个状态属于单个调用还是属于整个应用如果属于单个调用它就不应该站在字段里。4.2 想用prototype先想清楚两个常见的注入坑第一个坑上面已经写过了字段注入会让prototype语义直接失效。第二个坑是即使你聪明地改成每次从ApplicationContext里getBean()虽然能拿到新实例但代码里到处引入ApplicationContext业务类与Spring容器强耦合单元测试也不好做。不是不能用但不优雅。从容器获取“真·新实例”的推荐方式有三种。方式一注入ObjectFactory这也是我项目里最常用的解法Service public class PaymentService { Autowired private ObjectFactoryPaymentContext contextFactory; public void pay(Order order) { PaymentContext context contextFactory.getObject(); // 每次都是新实例 context.setOrderId(order.getId()); } }方式二Lookup方法。Spring会用CGLIB重写被标注的方法每次调用都从容器拿新实例Service public class PaymentService { public void pay(Order order) { PaymentContext context getPaymentContext(); context.setOrderId(order.getId()); } Lookup public PaymentContext getPaymentContext() { // 方法体不会被真正执行Spring会根据返回值类型解析Bean return null; } }方式三Scoped Proxy。给prototype Bean加代理模式让Spring注入一个代理对象每次调用代理方法时才从容器获取真正的实例Component Scope(value prototype, proxyMode ScopedProxyMode.TARGET_CLASS) public class PaymentContext { }这个方案写起来最省事代价是调试时看到的是代理类报错堆栈会绕一些。三种方案里我建议优先用ObjectFactory逻辑直白不引入额外机制也最容易测试。4.3 prototype只管生不管死容易被忽略的销毁陷阱再补充一个容易踩的坑对于prototype BeanSpring只在创建时管销毁时不管。对于singleton Bean容器关闭时会执行PreDestroy、DisposableBean等销毁回调但prototype Bean被调用方拿走之后容器根本不知道它什么时候被丢弃更不会帮你调用销毁方法。这意味着如果你把一个持有数据库连接、文件句柄、线程池的Bean定义成prototype用完之后不会自动释放稍不注意就会出现连接泄漏。prototype Bean的资源释放必须由使用方显式处理比如在finally块里关闭或者交给池化框架管理。所以我的建议是非必要不定义prototype尤其别让prototype去持有重量级资源。如果你真的需要一个“每次都是新的”上下文对象优先让它保持轻量、无资源。5. 实际开发中如何用好singleton5.1 判断Bean用singleton还是prototype的三个标准很多人在实际项目里从来不改scope默认singleton到底这没毛病。但当你开始考虑“要不要把某个Bean改成prototype”的时候可以用三个标准来判断判断维度适合singleton适合prototype状态维度无状态或只有只读状态有可变状态且每次使用都要独立的实例创建成本创建成本高依赖多有AOP代理创建成本低几乎没有依赖生命周期需要容器统一管理和销毁回调临时用一下就扔不持有重量资源结合日常项目来看Controller、Service、Repository、Mapper、Factory、Config、Utils类的Bean几乎全部无状态用singleton是标准做法。真正需要prototype的一般是传递业务上下文的对象、每个请求需要独立计数的统计对象、带独立游标或状态的一次性任务对象。5.2 线上排查单例状态污染的五个步骤如果怀疑某个Bean出现了单例状态污染我一般按下面五个步骤排查检查Bean定义在类声明处看有没有Scope或者配置文件里的scope属性确认它到底是singleton还是其他作用域打印对象身份在方法入口打印this.hashCode()或者直接System.out.println(this)在多个线程/请求里观察输出是否相同。相同说明是共享实例不同说明每次新建搜索可变字段重点排查非static、非final的实例字段尤其是Map、List、SimpleDateFormat、计数器这些。这一步用IDE的Find Usages配合肉眼扫代码就够了构造并发场景复现写一个循环开多个线程同时调用同一个方法观察数据是否错乱。如果问题只在并发下偶发出现十有八九是共享可变状态通过Spring Boot Actuator确认访问/actuator/beans接口能看到每个Bean的scope字段快速核实配置是否正确这五步走下来大部分singleton相关的线上问题都能定位到根因。5.3 Spring Boot项目里更稳妥的作用域选择思路现在很多项目都是Spring Boot 3.x MyBatis这套组合。Spring Boot的自动装配本身就大量依赖singletonDataSource、SqlSessionFactory、Mapper代理对象全部是单例Bean。微服务架构下的Controller、Service一路到Mapper也都是无状态单例整套体系跑得又快又稳。真正需要打破默认的场景通常是Web层的数据承载request作用域单个HTTP请求内共享的数据比如请求ID、用户上下文session作用域单个用户会话内共享的数据application作用域整个ServletContext共享约等于全局单例这些作用域本质上就是在不同的“边界”内做单例管理。Spring通过作用域代理机制让singleton Bean注入这些非单例作用域的Bean时也能正常工作。所以我的建议是默认全用singleton需要共享状态时先想清楚这个状态是请求级的、会话级的还是全局级的确实需要每个调用方独立的临时对象时再考虑prototype并且用ObjectFactory或者Lookup获取。顺带说一句Spring生态这些年变化很快Spring AI、Agent之类的新东西层出不穷但Bean作用域这套底层逻辑一直没变。不管容器里装的是AI Agent还是微服务网关跑得最稳的依然是那些无状态的singleton Bean。理解了这一点再看Spring的新特性会顺畅很多。最后再分享一个我个人的工作习惯每写一个新Bean我会先按“singleton 无状态”来设计所有数据通过方法参数显式传递内部不放可变的共享字段。等真出现“必须在不同场景下保持独立状态”的需求时再把它改造成prototype并同步调整调用方。这个流程看起来保守但在维护过几年大型项目之后你会发现它其实是最省心的——线上出问题里十个有八个不是作用域选错而是没搞清“状态到底该放在哪”。踩过这些坑之后我很确定一件事Spring推荐singleton不是因为它不让你用prototype而是因为它想让你把重点放在业务设计和状态建模上而不是整天纠结“这个对象该不该new”。把状态放对位置singleton就是又快又稳的那个答案。