单例Bean 与 线程安全
1. 先分清两个概念:单例 和 线程安全不是一回事
很多人会把这两个词混在一起。
单例 bean 说的是什么
Spring 里的 singleton 说的是:
在 Spring 容器里,这个 bean 只创建一份实例。
注意,是容器级别一份,不是 JVM 全局单例,也不是天然带锁。
线程安全说的是什么
线程安全说的是:
多个线程同时访问这个对象时,结果是否正确,数据是否会乱。
所以:
一个对象只有一份实例,不代表它就是线程安全的。
单例只是“共享一个对象”。
线程安全是“共享这个对象时会不会出问题”。
2. 为什么 Spring 单例 bean 容易有并发问题
在 Web 项目里,请求一来,Tomcat 会分配线程处理。
假设你有这样一个 UserService,它是 Spring 默认单例:
1 |
|
1 | public void add() { |
现在多个请求同时进来,本质上是:
多个线程
同时访问同一个
UserService对象同时修改
count
那就会有竞态条件。
因为 count++ 不是原子操作,它大致会拆成:
读 count
count + 1
写回去
两个线程交错执行,就可能丢失更新。
所以这里的核心不是 Spring 做错了什么,
而是:
Spring 把同一个 bean 交给多个线程共用,而这个 bean 自己又保存了可变状态。
3. 为什么大家又常说“Spring 的单例 bean 一般没问题”
因为在实际项目里,大多数 Spring bean 都被设计成了无状态 bean。
比如:
ServiceControllerDAO / MapperComponent
它们通常只做这些事:
调别的组件
查数据库
组装参数
返回结果
它们不把每个请求的数据存在成员变量里,而是把数据放在:
方法参数
方法局部变量
ThreadLocal
数据库
Redis
举个安全的例子:
1 |
|
这里 userId 和 result 都是方法里的数据,
每个线程调用时都有自己的栈帧,互不影响。
所以这种 bean 虽然是单例,
但因为无状态,通常就不会有线程安全问题。
一句话记忆:
单例 bean 本身不天然线程安全,但无状态单例 bean 通常可安全复用。
4. 什么叫“有状态”,什么叫“无状态”
这个一定要会判断,面试官很爱顺着问。
无状态 bean
类里没有会随着请求变化的共享字段,或者字段虽然有,但不参与并发修改。
例如:
1 |
|
这个类没有保存“上一次请求的数据”,所以是无状态。
有状态 bean
类里保存了会变化的成员变量,并且这些变量会被多个线程共享访问。
例如:
1 |
|
1 | public int next() { |
这就是典型有状态。
再比如你把一次请求中的用户信息、订单信息、分页信息放到成员变量里,也都危险。
5. 如果单例 bean 有状态,怎么解决
核心思路就一句:
不要让多个线程共享可变状态。
常见做法有 4 种。
第一种:最推荐,改成无状态
这是最工程化、最符合 Spring 习惯的方式。
把原来放成员变量的数据,改成:
方法参数传入
方法局部变量保存
必要时存数据库/缓存
比如把:
private String currentUser;
改成:
public void handle(String currentUser)
这是最优解。
第二种:自己做线程安全控制
比如:
synchronizedLockAtomicInteger并发容器
例子:
private AtomicInteger counter = new AtomicInteger(0);
1 | public int next() { |
但这里你要知道,能加锁不代表就优雅。
加锁会带来:
性能开销
代码复杂度上升
锁竞争问题
所以这一般不是首选。
第三种:改作用域,不用 singleton
比如改成 prototype:
1 |
|
这样每次获取 bean 都会创建新对象。
但这里有个面试坑要注意:
prototype 也不等于绝对线程安全。
它只是减少共享实例的问题。
如果你自己又把它共享出去,还是可能有问题。
而且在 Web 开发里,更常见的是:
request作用域session作用域
不过这些是 Web 环境相关,不是所有 bean 都适合这样设计。
第四种:用 ThreadLocal 保存线程隔离数据
这个适合“每个线程一份上下文”的场景,比如:
用户信息
traceId
请求上下文
但 ThreadLocal 也有坑:
在线程池环境下可能脏数据串线程
不及时 remove 会内存泄漏风险
所以能不用就别滥用。