策略模式中的策略对象需要考虑线程安全吗?
简化版
需要考虑。策略对象在 Spring 中通常是单例 Bean,会被多个线程并发调用。最佳实践是让策略对象保持无状态,把请求相关数据放在方法参数或上下文对象里;如果策略必须缓存数据,要使用线程安全结构、不可变对象、合理过期机制,并避免把用户级、订单级状态存在策略字段中。
详细版
策略模式本身不保证线程安全。线程安全取决于具体策略对象是否共享、是否有可变字段、是否访问非线程安全依赖。
最常见的安全写法是:
@Component
class VipDiscountStrategy implements DiscountStrategy {
public Money discount(OrderContext context) {
return context.amount().multiply("0.9");
}
}
策略没有成员变量保存请求状态,每次计算只使用入参和不可变配置,多个线程并发调用也不会互相污染。
危险写法是:
class BadStrategy implements DiscountStrategy {
private OrderContext current;
}
如果策略是单例,两个请求同时进入就会覆盖 current,导致串单、金额错误或权限错乱。
记忆钩子:策略对象通常可以共享,业务状态不能共享;共享的是算法,不是请求数据。
完整版教学
面试提示:线程安全题要先判断策略对象是否有状态,再谈单例复用、上下文隔离和不可变设计。
一、策略对象为什么常被共享
在 Spring 项目里,策略类通常写成:
@Component
class CouponDiscountStrategy implements DiscountStrategy {
}
默认作用域是 singleton。也就是说,整个容器中通常只有一个 CouponDiscountStrategy 实例,被多个请求线程共同调用。
这本身没有问题,因为策略对象代表的是“算法实现”。如果算法是无状态的,它天然适合共享。
二、无状态策略是首选
无状态策略的特点是:
- 不保存请求级字段;
- 不保存用户级字段;
- 不保存订单级字段;
- 只读取不可变配置或线程安全依赖;
- 每次执行所需数据都从参数传入。
示例:
class FreightStrategy implements PriceStrategy {
public Money calculate(PriceContext context) {
Money base = context.weight().multiply(context.unitPrice());
return context.remoteArea() ? base.add(Money.of(10)) : base;
}
}
这里每次调用都只依赖 context,不会污染下一次调用。
三、危险的是共享可变字段
下面这种写法很危险:
class BadFreightStrategy implements PriceStrategy {
private BigDecimal lastWeight;
private Money currentPrice;
public Money calculate(PriceContext context) {
this.lastWeight = context.weight();
this.currentPrice = context.basePrice();
return currentPrice.add(lastWeight.multiply(new BigDecimal("2")));
}
}
两个线程同时执行时,字段可能互相覆盖:
Thread A: lastWeight = 2
Thread B: lastWeight = 9
Thread A: 使用 lastWeight 继续算,拿到 9
这种 bug 很隐蔽,尤其在价格、权限、路由场景中风险很高。
四、配置缓存和请求状态要分清
策略里可以有配置缓存,但要满足线程安全:
class RegionPriceStrategy implements PriceStrategy {
private final AtomicReference<Map<String, Money>> priceTable =
new AtomicReference<>(Map.of());
public void refresh(Map<String, Money> newTable) {
priceTable.set(Map.copyOf(newTable));
}
public Money calculate(PriceContext context) {
return priceTable.get().getOrDefault(context.region(), Money.ZERO);
}
}
这里共享的是不可变价格表快照,不是某个请求正在计算的中间状态。
五、ThreadLocal 不是默认解法
有些人会用 ThreadLocal 保存当前上下文:
private static final ThreadLocal<OrderContext> HOLDER = new ThreadLocal<>();
这不是策略模式的常规解法。在线程池环境中,如果忘记 remove(),可能造成内存泄漏或上下文串用。能通过方法参数传递,就不要把请求状态塞进 ThreadLocal。
六、原型作用域也不是万能
把策略 Bean 改成 prototype 可以让每次获取创建新对象,但会带来新问题:
| 方案 | 优点 | 风险 |
|---|---|---|
| singleton 无状态 | 简单、高效、安全 | 需要约束字段设计 |
| prototype | 每次新对象 | 注入到 singleton 后可能只创建一次 |
| request scope | 适合请求状态 | 依赖 Web 上下文 |
| ThreadLocal | 获取方便 | 清理和线程池风险 |
大多数策略对象使用 singleton + 无状态设计就够了。
七、策略依赖也要线程安全
即使策略自身无字段,它依赖的对象也可能不安全:
class DateFormatStrategy implements FormatStrategy {
private final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
}
SimpleDateFormat 不是线程安全的。可以改用 DateTimeFormatter,或者每次创建局部对象。面试中不要只盯着策略类字段,也要看依赖对象。
八、常见误区与追问
- 误区:用了策略模式就自动线程安全。 模式只定义职责组织方式,不提供并发保证。
- 误区:Spring 单例策略一定有问题。 无状态策略共享没有问题,问题在共享可变请求状态。
- 误区:把字段加 synchronized 就解决了。 可能降低并发性能,还可能掩盖错误的状态建模。
- 误区:ThreadLocal 可以随便存上下文。 线程池复用下必须清理,且会增加隐式依赖。
- 追问:策略中可以缓存配置吗? 可以,但建议使用不可变快照、并发容器或原子引用,并做好刷新一致性。
- 追问:如果策略确实需要一次调用内的中间状态怎么办? 使用局部变量或创建本次调用的上下文对象,不要放共享字段。
- 追问:如何发现线程安全问题? 代码审查字段、并发压测、单测模拟并发、重点检查金额和权限类策略。
九、加强记忆
- 策略对象常是 Spring singleton。
- 无状态策略最安全,把请求数据放参数里。
- 共享可变字段会导致串单、串用户、串金额。
- 配置缓存可以共享,但要用不可变快照或线程安全结构。
- ThreadLocal 和 prototype 都不是默认答案。
- 依赖对象的线程安全也要检查。