讲个真实经历。有次面试,候选人简历上写着“熟悉Spring”,我顺手问了一句:“Spring里默认的bean是singleton,那这个singleton是单例模式吗?线程安全吗?”对方愣了几秒,然后开始背单例模式的双重检查锁。那一刻我就知道,很多人把这两件事混在一起了。后来我自己带团队做项目评审,发现不少开发者在Service里写了一个HashMap当缓存,结果生产环境偶尔出现数据错乱,排查半天却发现Bean明明只有一个实例,怎么还会互相干扰?其实答案早就藏在Spring的设计里,只是我们没仔细琢磨。
这篇东西写给谁?刚接触Spring的Java新人,或者做了几年开发但没深究过容器原理的同行。你能搞清楚三件事:Spring的singleton到底是什么意思,它和经典单例模式差在哪,以及遇到线程安全问题到底该怎么办。不需要你背源码,但我会把关键源码位置和常见坑都点出来。
1. 先搞清楚:Spring的singleton到底是个什么东西
1.1 不是GoF单例,而是“作用域”
先明确一个概念:Spring里说的singleton,和《设计模式》里那本GoF书上的单例模式,完全不是一回事。
GoF单例模式的核心是“类本身只允许有一个实例”。它通常把构造方法设为private,然后通过一个静态方法返回同一个实例。这个控制权在类自己手里,谁也绕不过去。
Spring的singleton说的是“Bean的作用域”。一个Bean定义好之后,Spring容器启动时(或者第一次获取时)会创建这个Bean的实例,然后把它放在容器内部的一个Map里。以后每次通过getBean或者依赖注入获取,拿到的都是同一个对象。这个控制权在容器手里,类本身根本不需要做任何事——不需要私有化构造器,不需要静态方法,就是一个普通的Java类,照样可以被Spring当成单例Bean来管理。
两者最大的区别,我用大白话说就是:
- GoF单例:是“我这一辈子就只有一个我”,谁来要都一样。
- Spring singleton:是“容器里只有一个我”,但如果再起一个容器,那就是另一个我了。
如果你在代码里手动new了两个Spring容器,每个容器都可以各自创建一个同一个类的singleton bean。这在GoF单例模式下是不可能的(同一个ClassLoader下),但Spring完全允许。
1.2 从源码角度理解singleton bean的实例化
很多人一听到源码就头大,其实Spring的Bean存储机制特别直白。默认的Bean工厂叫DefaultSingletonBeanRegistry,它里面有个字段:
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);这个Map的key是bean名字,value就是那个单例对象。你每次getBean("userService"),Spring会先去这个Map里查。如果存在,直接返回;如果不存在,就创建并放进去。这就是“singleton”最朴素的实现。
不过光说这个不够,因为Spring还处理了循环依赖的问题。面试题里常说的“三级缓存”,其实是在这个类里定义了另外几个Map:
singletonObjects:一级缓存,存放完整的单例Bean。earlySingletonObjects:二级缓存,存放提前暴露的早期Bean引用(还没完成属性填充)。singletonFactories:三级缓存,存放ObjectFactory,可以生成早期Bean。
这套机制解决的是A依赖B、B又依赖A的情况。但注意,三级缓存的线程安全经常被忽略。它在并发获取Bean时有特殊的处理,比如getSingleton方法里用了synchronized块,防止两个线程同时创建同一个Bean导致重复实例化。但这是“Bean创建过程”的线程安全,跟你Bean内部状态的线程安全是两码事。
2. Spring singleton线程安全吗?——先给结论,再说为什么
2.1 直接结论:不等于线程安全
我们用一句话回答面试题:Spring的singleton只保证一个容器里Bean实例是唯一的,不保证这个Bean是线程安全的。
“唯一”和“安全”是两个维度。线程安全指的是在多线程访问下,对象的行为始终正确,不会出现数据错乱。一个Bean无论是不是单例,只要它内部有可变的共享状态,在多线程下都有可能出问题。
举个最典型的例子。假设有个计算器Bean:
@Component public class CounterService { private int count = 0; public void increment() { count++; } public int getCount() { return count; } }count++看着简单,但它实际上包含三步:读取count的值,加1,写回。多线程同时执行时,可能两个线程都读到了0,然后各自加1,最后写回1,而不是期望的2。这就是线程不安全。
你可以做个实验:用100个线程,每个线程循环调1000次increment(),理论上最终结果应该是100000,但实际跑出来经常是九万多。这就是Spring单例Bean在有状态时的天然问题。
2.2 为什么很多人误以为安全?因为无状态Bean才是主流
我见过很多开发者理直气壮地说:“Spring的单例Bean一直都是这么用的,从来没出过线程问题。”他说得没错,但那是因为他写的Bean大多是无状态的。
什么是无状态?就是Bean内部没有可变的成员变量。比如一个典型的Service:
@Service public class OrderService { private final OrderMapper orderMapper; // 依赖注入,本身线程安全 public Order createOrder(OrderDto dto) { // 局部变量都在方法内部,不会跨线程共享 return orderMapper.insert(dto); } }这个Bean里只有一个orderMapper引用,它本身是Spring管理的另一个singleton Bean,而且通常没有内部状态(Mapper一般是接口代理)。所有数据都通过参数传递,或者通过局部变量计算,不写入Bean自己的字段。这种Bean天然线程安全,因为多线程调用同一个方法时,各自独立的变量不会互相干扰。
Spring生态里大部分Service、Controller、Repository都是这种无状态Bean。大家用得顺手了,自然觉得“Spring单例很安全”。但实际上安全的是你的写法,不是Spring的单例机制。
2.3 有状态Bean该怎么处理?
如果你的Bean确实需要保存状态,有几种常见办法:
办法一:用ThreadLocal
每个线程一份独立副本,天然隔离。常见场景是保存用户请求上下文,比如登录用户信息。
@Component public class UserContextHolder { private static final ThreadLocal<User> USER_HOLDER = new ThreadLocal<>(); public static void set(User user) { USER_HOLDER.set(user); } public static User get() { return USER_HOLDER.get(); } public static void clear() { USER_HOLDER.remove(); } }但注意,ThreadLocal使用完一定要remove(),否则在线程池场景下会内存泄漏,因为线程复用时旧数据还在。
办法二:用并发安全的数据结构,比如ConcurrentHashMap、AtomicInteger。
@Component public class CounterService { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }AtomicInteger就是热搜词里那个“atomicinteger线程安全吗”的经典答案——它基于CAS实现,线程安全,适合高并发计数器。
办法三:加锁。用synchronized或ReentrantLock保护临界区。但加了锁会降低并发性能,尽量别用在热点路径上。
办法四:把状态外置。比如放到数据库、Redis里。这类系统天然支持并发控制,单体Bean内部就不用操心状态了。
我个人的原则是:优先写无状态Bean。如果必须有状态,先把状态能不能放外部缓存或数据库里;不行再用ThreadLocal;再不行才考虑加锁。这个顺序能省掉很多线上事故。
3. Spring singleton与单例模式的详细对比
3.1 维度对比:看这张表就够
| 对比维度 | Spring Singleton | GoF单例模式 |
|---|---|---|
| 控制方 | Spring容器 | 类自身 |
| 构造器 | 通常是public,Spring负责调用 | private,外部不能new |
| 实例获取 | @Autowired或getBean | 静态方法,如getInstance() |
| 唯一性范围 | 每个Spring容器内唯一 | 整个JVM(ClassLoader)内唯一 |
| 生命周期 | 容器管理,容器关闭时销毁 | 类加载时初始化或懒加载,全程存活 |
| 可测试性 | 好,可以替换Mock | 差,静态方法难Mock |
| 默认应用场景 | Spring管理的一切Bean | 连接池、配置类等需要全局唯一的工具类 |
| 线程安全 | 不保证,看Bean有没有状态 | 同样不保证,需要自己写同步 |
看到没,两者在很多层面是截然相反的。GoF单例强调“如何保证唯一”,而Spring singleton强调“容器统一管理”。Spring完全可以做到容器内唯一,但你不需要在类上写任何单例逻辑。
3.2 两者共存会怎样?——一个类既是Spring Bean又是GoF单例
有人会问:我把一个GoF单例类注册成Spring Bean,会有问题吗?
技术上没问题,但不推荐。比如:
public class ConfigManager { private static final ConfigManager INSTANCE = new ConfigManager(); private ConfigManager() {} public static ConfigManager getInstance() { return INSTANCE; } }如果我把它加上@Component,Spring容器会通过反射调用私有构造器吗?实际上Spring对私有构造函数处理比较麻烦,默认情况下它会用无参构造器反射创建,但私有构造器会报错。除非在@Component类上手动指定@Autowired或者使用工厂方法,否则很难注册成功。
即便你通过静态工厂注入成功,也违背了Spring的初衷。因为GoF单例的实例是没办法被Mock替换的,Spring单元的@MockBean那一套在它面前失效。而且它自己控制了生命周期,Spring容器关闭时也管不了它,容易造成资源泄漏。
我见过一些老项目把数据库连接池直接写成GoF单例,然后又用Spring管理一遍,结果出现了两个连接池实例,一个在Spring容器里,一个在静态变量里,后来排查了半宿。现在基本都是用Spring的@Configuration加@Bean来管理第三方资源,让容器接管生命周期。
3.3 常见的误区:static变量、prototype作用域,一锅乱炖
误区一:static变量就是单例?
有同学以为给类的字段加个static,就成了单例。准确说,static变量是“类级别共享”,在同一个ClassLoader下只有一个副本,但它跟Spring单例没有任何关系。而且static变量常常比Bean实例更难管理,因为Spring的Bean还受容器生命周期影响,static变量是全局的,谁都能改,更容易出现线程安全问题和内存泄漏。
误区二:Spring只有singleton这一种作用域?
远不止。Spring Bean支持prototype、request、session、application等作用域。
prototype:每次获取Bean都是新实例。适合有独享状态的组件,但不被Spring管理完整生命周期。request:每个HTTP请求一个实例,只在Web应用里有效。session:每个用户会话一个实例。application:整个ServletContext一个实例,相当于全局单例。
在Spring Boot中,可以通过@Scope("prototype")指定:
@Component @Scope("prototype") public class TaskContext { private List<String> steps = new ArrayList<>(); }但注意,prototype Bean如果被一个singleton Bean依赖注入,那这个prototype Bean实际上只会被注入一次,后面一直用的是同一个实例,完全起不到“每次新创建”的效果。这个问题我们放到第5节讲。
4. 线程安全实战:怎么判断你的Bean是否有状态?
4.1 快速判断方法:盯住Bean的字段
在代码评审时我有个习惯:拿到一个类,先看非静态字段。
- 没有非静态字段(只依赖注入其他Bean,且那些Bean也无状态):基本线程安全。
- 有非静态字段,但是final且不可变(比如String、基本类型包装类,或者用
Collections.unmodifiableList包裹的集合):只要引用不变,也是安全的。 - 有非静态可变字段(比如
HashMap、ArrayList、自定义对象):危险,需要看这些字段会不会被多线程写入。 - 有ThreadLocal字段:安全,但要注意清理。
还有一种隐蔽情况:虽然你的类没有字段,但注入进来的某个Bean内部有状态。比如你注入了一个第三方库提供的SimpleDateFormat,它是线程不安全的。这种问题很容易漏,因为源码不在你手里。遇到这种情况,要么不用它,要么用ThreadLocal<SimpleDateFormat>包一层,要么直接改用Java 8以后的时间API。
4.2 案例演示:一个带缓存的Service,并发下怎么翻车的
假设我们写一个用户查询服务,为了性能加了个本地缓存:
@Component public class UserService { private final Map<String, User> cache = new HashMap<>(); public User getUser(String id) { if (cache.containsKey(id)) { return cache.get(id); } User user = queryFromDb(id); cache.put(id, user); return user; } private User queryFromDb(String id) { // 模拟慢查询 try { Thread.sleep(50); } catch (InterruptedException e) { } return new User(id, "name-" + id); } }看起来没毛病?实际上并发时会有两个问题:
- HashMap本身线程不安全。多个线程同时
put时,有可能导致链表成环,CPU直接飚到100%。这是个经典问题了,JDK 7时期特别容易触发。 - 缓存穿透。两个线程同时发现缓存没有,同时去查库,同时写缓存,数据库中压力翻倍。
修复方案之一是改成ConcurrentHashMap:
private final Map<String, User> cache = new ConcurrentHashMap<>();但ConcurrentHashMap也不能解决“缓存穿透”中的重复查询问题,因为判断containsKey和put之间还是存在时间差。更稳妥的做法是用computeIfAbsent,它会保证同一个key只有一个线程执行查询逻辑:
public User getUser(String id) { return cache.computeIfAbsent(id, this::queryFromDb); }这个方法是原子的,允许同时传同一个key,但只有一个线程执行计算,其他线程等待结果。实际用下来很顺。
需要注意的是,computeIfAbsent如果value的创建过程里有递归调用同一个Map,会出问题(死循环或报错),需要格外小心。我遇到过一例,不过具体情况不常见,写代码时留意就行。
4.3 在Spring Boot中真正需要非单例的场景
假设你要在Controller里保存一个“仅供本次请求使用”的上下文对象,比如记录请求开始时间、操作日志、临时变量。如果用singleton Bean,得用ThreadLocal,但去清理比较麻烦;更优雅的是用request作用域:
@Component @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS) public class RequestContext { private long startTime = System.currentTimeMillis(); private String requestId; // getter/setter }proxyMode这一行很关键。因为Controller本身是singleton,它依赖注入RequestContext时,Spring会注入一个代理对象。每次调用代理时,代理会找到当前HTTP请求对应的真实实例,从而保证同一个请求内拿到的是同一个对象,不同请求之间互不干扰。
如果不加proxyMode,直接注入request作用域的Bean到singleton Controller里,启动时可能直接报错,或者永远拿到同一个固定实例,反而不安全。
session作用域同理,适合放购物车数据。但这两种作用域只适用Web环境,如果在非Web环境下启动,会有问题。
5. 常见问题与排查技巧
5.1 面试高频追问:singleton Bean注入prototype Bean的坑
项目里有个常见需求:一个单例的Service,希望每次调用时都用一个全新的PrototypeWorker状态。很多人写成这样:
@Service public class ParentService { @Autowired private Worker worker; // Worker是@Scope("prototype") public void doWork() { worker.execute(); } }结果发现,虽然Worker标记了prototype,但ParentService里的worker对象始终是同一个。原因是Spring在创建ParentService这个singleton时,就已经把当时创建好的Worker注入进去了。之后你每次调用ParentService,用的都是那个“当初的”Worker,跟prototype的名头完全矛盾。
解决办法有三种:
- 用
ObjectProvider<Worker>。
@Autowired private ObjectProvider<Worker> workerProvider; public void doWork() { Worker w = workerProvider.getObject(); w.execute(); }每次调用getObject(),Spring都会从容器重新拿一个prototype实例,保证每次都是全新的。
- 用
@Lookup方法。
@Service public abstract class ParentService { @Lookup protected abstract Worker getWorker(); public void doWork() { getWorker().execute(); } }Spring通过CGLIB生成子类来重写这个方法,每次调用都会返回新Bean。这个写法省了ObjectProvider,但类不能是final,Spring Boot里用起来能接受。
- 注入
ApplicationContext,手动getBean(Worker.class)。最灵活,但依赖容器,一般最后选。
这三种方法都能解决,个人最推荐ObjectProvider,代码直白且不易出错,也是Spring官方推荐的思路。
5.2 状态污染排查技巧:怎么定位是不是单例Bean的问题
假如你线上出了诡异Bug:用户A下单,结果用户B收到了A的购物车。第一反应就是怀疑有状态Bean共享。
排查顺序我建议这样走:
- 看报错堆栈,找到涉及业务逻辑的类。如果多个线程同时操作同一个类,重点关注这个类的非静态字段。
- 在字段写入处打断点或打日志,记录当前线程ID和值。如果多个线程都进入同一个方法修改同一个字段,实锤了。
- 检查这个Bean的scope。默认都是singleton,不要在没验证的情况下假设它是prototype。
- 检查ThreadLocal的清理时机。很多状态污染其实是ThreadLocal没清理,导致线程池复用时旧数据被下一个请求读到了。
我之前帮同事查过一个问题:一个支付回调服务,把订单号存在ThreadLocal里,但忘记在finally里调用remove()。结果Tomcat线程池复用,线程被另一个请求拿到时,读取的还是上一个请求的订单号,导致对账全错。这种问题光看代码逻辑看不出毛病,得看线程调度才知道。
所以凡是用ThreadLocal的地方,建议封装成一个清理工具类,在请求结束时统一清理。Spring的HandlerInterceptor里的afterCompletion方法就是干这个的,别嫌麻烦。
5.3 分布式环境下的扩展:本地锁没用,怎么办?
如果服务只部署了一个实例,用synchronized或者ReentrantLock保护本地状态没问题。但一旦上了多实例,你加的锁只对当前JVM有效,另一个实例完全不知道锁的存在。这时候就得用分布式锁,比如基于Redis的SETNX,或者用数据库乐观锁。
举个场景:一个发号器Bean,本地维护一个序号,单机没问题。部署两台后,两台都从0开始发,就重复了。解决办法是把号段分配给每个实例(比如A用1-1000,B用1001-2000),或者直接用Redis原子自增。这些都是“Spring singleton线程安全”在单机之外的延伸,本质都是要保证共享资源的并发一致性。
不过这个是另一个大话题了,做微服务的时候会经常碰到。现阶段先记住一条:单例Bean的状态管理,作用域不止一个JVM,系统边界一变,原来的“安全”就更脆弱了。
最后说点实在的
我在实际项目里踩过几次坑之后,总结了一句口头禅:“真正让Spring单例安全的,不是Spring,是你不给它写状态的机会。”大部分业务Service都应该长成“无状态计算器”的样子:输入参数,调用其他服务,返回结果。所依赖的东西靠注入,注入进来的Bean又都是无状态的,整个调用链就是干净的。
如果你非要写有状态的Bean,那就在设计阶段明确三点:这个状态属于谁(请求?会话?线程?),谁来维护生命周期,并发访问时允许多少线程同时写。想清了这三点,再去选ThreadLocal、原子类还是分布式锁,基本不会错。
这篇聊得已经很细了,从源码到实战,再到面试题里的坑。以后再有人问你“Spring singleton线程安全吗”,你就反问他:你先告诉我这个Bean里有没有状态字段。你俩的眼神大概都会变得有点东西。