单例Bean 与 线程安全

1. 先分清两个概念:单例 和 线程安全不是一回事

很多人会把这两个词混在一起。

单例 bean 说的是什么

Spring 里的 singleton 说的是:

在 Spring 容器里,这个 bean 只创建一份实例。

注意,是容器级别一份,不是 JVM 全局单例,也不是天然带锁。

线程安全说的是什么

线程安全说的是:

多个线程同时访问这个对象时,结果是否正确,数据是否会乱。

所以:

一个对象只有一份实例,不代表它就是线程安全的。

单例只是“共享一个对象”。
线程安全是“共享这个对象时会不会出问题”。


2. 为什么 Spring 单例 bean 容易有并发问题

在 Web 项目里,请求一来,Tomcat 会分配线程处理。

假设你有这样一个 UserService,它是 Spring 默认单例:

1
2
3
@Service
public class UserService {
private int count = 0;
1
2
3
4
    public void add() {
count++;
}
}

现在多个请求同时进来,本质上是:

  • 多个线程

  • 同时访问同一个 UserService 对象

  • 同时修改 count

那就会有竞态条件。

因为 count++ 不是原子操作,它大致会拆成:

  1. 读 count

  2. count + 1

  3. 写回去

两个线程交错执行,就可能丢失更新。

所以这里的核心不是 Spring 做错了什么,
而是:

Spring 把同一个 bean 交给多个线程共用,而这个 bean 自己又保存了可变状态。


3. 为什么大家又常说“Spring 的单例 bean 一般没问题”

因为在实际项目里,大多数 Spring bean 都被设计成了无状态 bean

比如:

  • Service

  • Controller

  • DAO / Mapper

  • Component

它们通常只做这些事:

  • 调别的组件

  • 查数据库

  • 组装参数

  • 返回结果

它们不把每个请求的数据存在成员变量里,而是把数据放在:

  • 方法参数

  • 方法局部变量

  • ThreadLocal

  • 数据库

  • Redis

举个安全的例子:

1
2
3
4
5
6
7
@Service
public class OrderService {
public String createOrder(String userId) {
int result = 1;
return "success:" + userId;
}
}

这里 userIdresult 都是方法里的数据,
每个线程调用时都有自己的栈帧,互不影响。

所以这种 bean 虽然是单例,
但因为无状态,通常就不会有线程安全问题。

一句话记忆:

单例 bean 本身不天然线程安全,但无状态单例 bean 通常可安全复用。


4. 什么叫“有状态”,什么叫“无状态”

这个一定要会判断,面试官很爱顺着问。

无状态 bean

类里没有会随着请求变化的共享字段,或者字段虽然有,但不参与并发修改。

例如:

1
2
3
4
5
6
@Service
public class ProductService {
public Product getById(Long id) {
return new Product();
}
}

这个类没有保存“上一次请求的数据”,所以是无状态。

有状态 bean

类里保存了会变化的成员变量,并且这些变量会被多个线程共享访问。

例如:

1
2
3
@Service
public class CounterService {
private int counter = 0;
1
2
3
4
    public int next() {
return ++counter;
}
}

这就是典型有状态。

再比如你把一次请求中的用户信息、订单信息、分页信息放到成员变量里,也都危险。


5. 如果单例 bean 有状态,怎么解决

核心思路就一句:

不要让多个线程共享可变状态。

常见做法有 4 种。

第一种:最推荐,改成无状态

这是最工程化、最符合 Spring 习惯的方式。

把原来放成员变量的数据,改成:

  • 方法参数传入

  • 方法局部变量保存

  • 必要时存数据库/缓存

比如把:

private String currentUser;

改成:

public void handle(String currentUser)

这是最优解。


第二种:自己做线程安全控制

比如:

  • synchronized

  • Lock

  • AtomicInteger

  • 并发容器

例子:

private AtomicInteger counter = new AtomicInteger(0);

1
2
3
public int next() {
return counter.incrementAndGet();
}

但这里你要知道,能加锁不代表就优雅
加锁会带来:

  • 性能开销

  • 代码复杂度上升

  • 锁竞争问题

所以这一般不是首选。


第三种:改作用域,不用 singleton

比如改成 prototype:

1
2
3
4
@Scope("prototype")
@Service
public class MyBean {
}

这样每次获取 bean 都会创建新对象。

但这里有个面试坑要注意:

prototype 也不等于绝对线程安全。
它只是减少共享实例的问题。
如果你自己又把它共享出去,还是可能有问题。

而且在 Web 开发里,更常见的是:

  • request 作用域

  • session 作用域

不过这些是 Web 环境相关,不是所有 bean 都适合这样设计。


第四种:用 ThreadLocal 保存线程隔离数据

这个适合“每个线程一份上下文”的场景,比如:

  • 用户信息

  • traceId

  • 请求上下文

但 ThreadLocal 也有坑:

  • 在线程池环境下可能脏数据串线程

  • 不及时 remove 会内存泄漏风险

所以能不用就别滥用。