← 返回 Java核心图解 目录
🔍 单例 · 共享 · 新建 — 到底怎么区分? 从一次线上事故说起
事故引入 → 核心概念 → 决策树 → 完整代码追踪(含Controller)→ 线程安全实战 → 速查表
🔗 想理解Spring单例的完整创建流程?推荐搭配
本页侧重并发场景下单例成员变量的安全问题。若需Spring单例创建6步流程与GoF对比详解,请看:
① 一个真实事故 —— 为什么必须搞懂这个
💥 线上事故:订单金额串了!用户A的订单显示用户B的金额
代码看起来完全正常,编译通过,单元测试也绿了。但并发场景下——多个线程同时访问同一个单例Bean——成员变量被串写了。
本页面解决:① 变量在哪?② 几个线程能碰到?③ 安不安全?④ 怎么改?
② 三种对象的本质区别
核心差异:生命周期 → 共享范围 → 线程安全性
💡 核心口诀:
① new / 方法内声明 → 🟢 每次新建,天然安全
② 参数传入 / static / 单例的成员变量 → 🔴 可能共享
③ @Service/@Component Bean → 🟠 全局只有1个,成员变量=危险
✅ ②搞懂了区别 → 下面③决策树,每个分支配完整代码
③ 代码识别决策树 — 一眼判定变量归属
一眼识别:看声明位置 → 看是否被共享 → 看是否可变
分支1:单例 + 成员变量 — 💣 最危险的组合
OrderService.java — ❌ 危险写法
@Service // ← 单例!全局只有1个实例
public class OrderService {
// ❌ 成员变量:所有请求共享这一个字段!
private BigDecimal currentAmount;
public void createOrder(OrderDTO dto) {
this.currentAmount = dto.getAmount(); // Thread-A设500
// ... 中间有IO操作 ...
orderMapper.insert(currentAmount); // Thread-B可能已经覆盖成9999!
}
}
OrderService.java — ✅ 安全写法
@Service
public class OrderService {
// ✅ 无状态:没有成员变量,所有数据走参数和局部变量
public void createOrder(OrderDTO dto) {
BigDecimal amount = dto.getAmount(); // ✅ 局部变量,栈上独立
// ... IO操作 ...
orderMapper.insert(amount); // ✅ 读自己的栈,不可能被别人改
}
}
核心原则:单例Bean必须无状态——不要存请求数据到成员变量。所有请求数据走方法参数或局部变量。
分支2:单例 + static成员 — 💣💣 全局炸弹
ConfigCache.java — ❌ static HashMap 被并发写
@Component
public class ConfigCache {
// ❌ static HashMap:全JVM共享,并发put死循环(JDK7)或数据丢失(JDK8+)
private static Map<String, String> cache = new HashMap<>();
public void put(String key, String val) {
cache.put(key, val); // 并发写!
}
}
ConfigCache.java — ✅ 用ConcurrentHashMap
@Component
public class ConfigCache {
// ✅ ConcurrentHashMap:分段锁,线程安全
private static final Map<String, String> cache = new ConcurrentHashMap<>();
public void put(String key, String val) {
cache.put(key, val); // ✅ 安全
}
}
铁律:static集合 ≈ 全局变量。必须用ConcurrentHashMap / CopyOnWriteArrayList,或加锁。
分支3:方法参数 — 🔴 同一请求链路内共享
OrderService.java — ❌ 修改了传入的DTO
@Service
public class OrderService {
public void createOrder(OrderDTO dto) {
// ❌ 修改了参数对象!调用方(Controller)也持有同一引用
dto.setAmount(dto.getAmount().multiply(new BigDecimal("1.1")));
orderMapper.insert(dto);
}
}
OrderService.java — ✅ 不修改参数,用局部变量
@Service
public class OrderService {
public void createOrder(OrderDTO dto) {
// ✅ 不改参数,新变量接收计算结果
BigDecimal finalAmount = dto.getAmount().multiply(new BigDecimal("1.1"));
orderMapper.insertWithAmount(dto.getId(), finalAmount);
}
}
参数规则:①不同请求的DTO是Jackson每次new的独立对象(跨请求互不影响)但②同一个请求链路内 Controller→Service→Mapper 传的是同一个引用(链路内共享,别乱改会影响下游)。
分支4:方法内new的局部变量 — 🟢 天然安全
OrderService.java — ✅ 局部变量,各线程独立
@Service
public class OrderService {
public void createOrder(OrderDTO dto) {
// ✅ 每次调用都在栈上新建,各线程完全独立
Order order = new Order();
order.setAmount(dto.getAmount());
order.setStatus("CREATED");
orderMapper.insert(order);
// 方法结束 → order可被GC回收
}
}
为什么安全?每个线程有独立的栈帧,局部变量存在栈帧中。Thread-A的order和Thread-B的order是两个完全不同的对象,互相看不见。
分支5:@Autowired注入的对象 — 看注入的是什么
OrderService.java — 注入的是单例Bean
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper; // ← 注入的是单例
// orderMapper 本身是单例 → 但它没有可变状态 → ✅ 安全
// 如果注入的Bean有可变成员变量 → ❌ 危险(见⑦陷阱)
}
判断注入对象是否安全:看它有没有可变成员变量。Mapper/Repository无状态✅;如果注入了一个有成员变量的Service,那这个Service的成员变量就是共享的⚠️。
✅ ③决策树搞懂了 → 下面④用完整代码追踪,看一个请求从Controller到Mapper每行发生了什么
④ 完整请求链路逐行追踪 — Controller → Service → Mapper
类比:一个快递从寄件人到收件人
Jackson(寄件人) → new一个DTO包裹 → 交给Controller(快递站) → 传给Service(分拣中心) → 传给Mapper(投递员)
关键点:同一个包裹(对象引用),从寄件到投递,中间任何一环改了包裹里的东西,后面的人看到的都是改后的!
完整代码:从HTTP请求到数据库写入
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService; // ← 注入单例,但无状态✅
@PostMapping
public Result create(@RequestBody OrderDTO dto) {
// ① Jackson反序列化:new OrderDTO() → 从请求body填充字段
// 每个HTTP请求都会new一个全新的DTO对象!
// Thread-A的dto → 指向DTO-A (amount=500)
// Thread-B的dto → 指向DTO-B (amount=9999)
// ② 调用Service,传的是dto的引用(不是拷贝!)
orderService.createOrder(dto);
// ③ Service返回后,Controller的dto仍然指向同一个对象!
// 如果Service里面改了dto → Controller这里也会看到修改!
return Result.ok(dto.getId()); // dto.getId()可能已被Service改了!
}
}
@Service // ← 单例!全局1个实例
public class OrderService {
@Autowired
private OrderMapper orderMapper; // ← 注入单例,无状态✅
public void createOrder(OrderDTO dto) {
// ④ dto是参数 → 和Controller指向同一个堆对象!
// Controller.dto → ─┐
// ├──→ 堆中的 OrderDTO@A1B2 (amount=500)
// Service.dto → ─┘
Order order = new Order(); // ⑤ new的局部变量 → 栈上独立✅
order.setAmount(dto.getAmount()); // ⑥ 读dto(只读安全)
order.setStatus("CREATED");
// ❌ 如果这里写了 dto.setAmount(...) → Controller也会看到!
orderMapper.insert(order); // ⑦ 传order给Mapper
}
}
@Mapper
public interface OrderMapper {
// ⑧ Mapper是无状态接口 → ✅ 天然安全
@Insert("INSERT INTO orders(amount, status) VALUES(#{amount}, #{status})")
void insert(Order order); // ← order是Service传过来的引用
}
两个请求并发时的内存全景
一张图理解DTO的"安全"与"不安全":跨请求安全(Jackson每次new) + 链路内共享(引用传递)
🎯 核心澄清:之前页面说的"DTO是共享的"和"DTO是独立的"都对!
跨请求维度:Jackson为每个HTTP请求反序列化时都 new OrderDTO(),所以不同请求的DTO是完全独立的对象(Thread-A的DTO@A1B2 ≠ Thread-B的DTO@C3D4),跨请求不会串数据。
链路内维度:同一个请求内,Controller.create(dto) → Service.createOrder(dto) → 传的是同一个引用,不是拷贝!所以Service如果dto.setAmount(...),Controller里的dto也变了。
结论:不要修改参数DTO,只读OK。改了会影响同一请求链路中的其他方法。
✅ ④逐行追踪搞懂了DTO的共享范围 → 下面⑤看Spring的5种Scope如何影响对象生命周期
⑤ Spring的5种Scope — 对象存活多久?
类比:公司的用工形式
singleton = 正式员工(全公司1个) | prototype = 临时工(每次叫1个新的) | request = 项目外包(1个项目1个) | session = 个人助理(1个人1个) | application = 公司CEO(全公司1个,但比singleton更早)
| Scope | 生命周期 | 几个实例 | 线程安全? | 典型场景 |
| singleton | 容器启动→容器关闭 | 1个 | ⚠️ 成员变量危险 | @Service @Component(默认) |
| prototype | 每次getBean新建 | 每次1个新的 | ✅ 各自独立 | 有状态的Bean |
| request | 1个HTTP请求 | 1请求1个 | ✅ 请求隔离 | Web请求上下文 |
| session | 1个HTTP Session | 1用户1个 | ✅ 用户隔离 | 购物车、用户偏好 |
| application | ServletContext生命周期 | 1个 | ⚠️ 同singleton | 全局配置 |
ScopeDemo.java — prototype真的安全吗?
@Service
public class ScopeDemo {
@Autowired
private TaskWorker worker; // ← 注入的TaskWorker是什么Scope?
// 如果TaskWorker是prototype → 但ScopeDemo是singleton
// → worker在ScopeDemo创建时注入一次 → 之后永远是同一个!
// → prototype"失效"了!这就是⑦的陷阱之一
}
Scope陷阱:singleton Bean里@Autowired注入prototype Bean,prototype只会创建一次(因为singleton只初始化一次)。要真正每次拿新的,用 ObjectFactory 或 @Lookup。
✅ ⑤5种Scope搞懂了 → 下面⑥一张矩阵表,快速判定线程安全
⑥ 线程安全判定矩阵 — 3秒判断安不安全
3秒判定:看变量在哪 × 是否会修改 → 绿色安全/红色危险/黄色慎用
面试速答:"Spring Bean线程安全吗?"
Spring Bean默认是singleton,所有请求共享同一个实例。Bean本身不保证线程安全——线程安全取决于你的写法。如果你在单例Bean里写可变成员变量,就不安全;如果Bean是无状态的(只有final成员或没有成员变量),就是安全的。解决方案:①用局部变量替代成员变量 ②prototype scope ③ThreadLocal ④加锁
✅ ⑥判定矩阵掌握了 → 下面⑦汇总三大经典陷阱,都是前6节没覆盖的
⑦ 三大经典陷阱 — 前面没覆盖的坑
③④⑤⑥已经覆盖了"单例成员变量""static集合""参数DTO修改"的❌✅对比,这里补充3个不一样的坑。
陷阱1:SimpleDateFormat — 以为是局部变量就安全?
DateService.java — ❌ 成员变量SimpleDateFormat
@Service
public class DateService {
// ❌ 看起来"只是个工具",但SimpleDateFormat内部有Calendar成员变量
// 多线程同时format() → Calendar被并发修改 → 日期错乱或ArrayIndexOutOfBoundsException
private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
public String format(Date date) {
return sdf.format(date); // ❌ 隐式修改了内部Calendar状态
}
}
DateService.java — ✅ 三种修法
@Service
public class DateService {
// ✅ 方式1:每次new(简单粗暴)
public String format(Date date) {
return new SimpleDateFormat("yyyy-MM-dd").format(date);
}
// ✅ 方式2:ThreadLocal(高性能)
private static final ThreadLocal<SimpleDateFormat> TL =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatSafe(Date date) {
return TL.get().format(date);
}
// ✅ 方式3:DateTimeFormatter(Java8+,不可变,线程安全)
private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd");
}
识别技巧:如果一个"工具类"对象有可变内部状态(如Calendar、NumberFormat),放进单例成员变量就是坑。优先用不可变替代品(DateTimeFormatter)。
陷阱2:AtomicInteger做计数器 — 原子 ≠ 业务正确
OrderCounter.java — ❌ 原子但逻辑错
@Service
public class OrderCounter {
private AtomicInteger count = new AtomicInteger(0);
public void createOrder(OrderDTO dto) {
// ❌ incrementAndGet本身是原子的 ✅
// 但"先检查count,再操作"不是原子的!
if (count.get() < 1000) { // Thread-A读999
count.incrementAndGet(); // Thread-B也读999 → 都通过了!
orderMapper.insert(dto); // 结果:1001条记录
}
}
}
OrderCounter.java — ✅ 用compareAndSet
@Service
public class OrderCounter {
private AtomicInteger count = new AtomicInteger(0);
public void createOrder(OrderDTO dto) {
// ✅ CAS:比较并设置,整个操作是原子的
int current;
do {
current = count.get();
if (current >= 1000) throw new BusinessException("超出限额");
} while (!count.compareAndSet(current, current + 1));
orderMapper.insert(dto); // ✅ 最多1000条
}
}
原子类 ≠ 线程安全业务逻辑:AtomicXxx保证单操作原子,但"检查+操作"复合动作不原子。用CAS(compareAndSet)或synchronized包裹复合操作。
陷阱3:prototype注入singleton — prototype"失效"
TaskService.java — ❌ prototype注入singleton只创建一次
@Service // ← singleton
public class TaskService {
@Autowired
private TaskWorker worker; // ← 以为是prototype每次新的?
// ❌ TaskService创建时注入worker → 之后永远是同一个worker实例
// prototype"失效"!因为singleton只初始化一次
public void doTask() {
worker.run(); // 每次都是同一个worker!
}
}
@Component
@Scope("prototype") // ← 每次getBean新建
public class TaskWorker {
private String taskId = UUID.randomUUID().toString(); // 每次应该不同的
}
TaskService.java — ✅ 用ObjectFactory或@Lookup
@Service
public class TaskService {
// ✅ 方式1:ObjectFactory(Spring推荐)
@Autowired
private ObjectFactory<TaskWorker> workerFactory;
public void doTask() {
TaskWorker worker = workerFactory.getObject(); // ✅ 每次拿新的
worker.run();
}
// ✅ 方式2:@Lookup方法注入
@Lookup
protected abstract TaskWorker createWorker(); // Spring会覆盖这个方法
}
记住:singleton里@Autowired prototype,prototype只创建一次。要每次拿新的,用ObjectFactory<T>或@Lookup。
✅ ⑦三大陷阱避开了 → 最后⑧一张速查表收尾
⑧ 速查总结 — 一页纸带进面试
| 场景 | 变量在哪 | 几份 | 安全? | 一句话结论 |
| @Service里的 private BigDecimal x; | 单例成员变量 | 1份 | ❌ | 所有请求共享,并发写会串 |
| private static Map cache = new HashMap() | static成员 | 1份 | ❌ | 全JVM共享,用ConcurrentHashMap |
| public void foo(OrderDTO dto) | 方法参数 | 跨请求独立 / 链路内1份 | ⚠️ | 不同请求各自new,但链路内共享引用 |
| Order o = new Order() | 局部变量 | 每线程1份 | ✅ | 栈帧独立,天然安全 |
| @Autowired OrderMapper | 注入的Bean | 看Scope | ⚠️ | 无状态Bean安全,有状态Bean危险 |
| private SimpleDateFormat sdf | 单例成员(工具) | 1份 | ❌ | 内部可变状态,用DateTimeFormatter |
面试高频FAQ
Q1: Spring Bean是线程安全的吗?
不是自动安全的。默认singleton,所有请求共享。安全取决于你的写法——无状态(没有可变成员变量)就安全,有可变成员就不安全。解决:①无状态设计 ②prototype ③ThreadLocal ④加锁
Q2: 为什么不能在Controller里直接用servlet实例变量?
Spring MVC的Controller默认是singleton。如果你写了 private String name; 那么请求A设name="张三",请求B设name="李四",A可能读到"李四"。和@Service成员变量一个道理——单例 + 可变成员 = 线程不安全。
Q3: DTO作为参数传递,到底安不安全?
两个维度看:
①跨请求:安全。Jackson为每个HTTP请求 new OrderDTO(),不同请求的DTO完全独立。
②链路内:共享。Controller→Service→Mapper传的是同一个引用,Service改了DTO,Controller也看到。
结论:只读OK,别修改参数DTO。
Q4: @Autowired注入的Bean什么时候不安全?
看注入的Bean有没有可变成员变量。Mapper/Repository无状态→安全。但如果你注入了一个有 private List<String> cache 的Service,那这个cache就被所有线程共享了,并发修改→不安全。
Q5: SimpleDateFormat为什么线程不安全?看起来只是个工具啊
SimpleDateFormat内部维护了一个 Calendar 对象作为成员变量。format()时会修改Calendar的状态。多线程同时调用format(),Calendar被并发修改→日期错乱或ArrayIndexOutOfBoundsException。解决:用Java8的DateTimeFormatter(不可变,线程安全)。
Q6: ThreadLocal能解决所有单例成员变量问题吗?
不能,且可能引入新问题。ThreadLocal让每个线程有独立副本,适合SimpleDateFormat、用户上下文等场景。但①内存泄漏风险(线程池复用线程,ThreadLocal不remove就累积)②不能跨线程传递(需要InheritableThreadLocal或TransmittableThreadLocal)③不如无状态设计来得根本。
🎯 全文一句话总结
单例Bean无状态写 = 安全 | 单例 + 可变成员 = 定时炸弹 | 参数只读不改 = 安全 | 局部new = 天然安全 | static集合用并发版本