← 返回 Java核心图解 目录

🔍 单例 · 共享 · 新建 — 到底怎么区分? 从一次线上事故说起

事故引入 → 核心概念 → 决策树 → 完整代码追踪(含Controller)→ 线程安全实战 → 速查表

🔗 想理解Spring单例的完整创建流程?推荐搭配

本页侧重并发场景下单例成员变量的安全问题。若需Spring单例创建6步流程与GoF对比详解,请看:

📘 05-Spring单例深度图解

① 一个真实事故 —— 为什么必须搞懂这个

💥 线上事故:订单金额串了!用户A的订单显示用户B的金额

🔴 时间线:09:00 上线 → 10:03 A下单(amount=500) → 10:03:01.003 B下单覆盖(amount=9999) → A写DB时用了B的值 → 客服炸了 09:00 一切正常 A10:03:01 A amount=500 B!10:03:01.003 B amount=9999 覆盖! A写入DB amount=9999(B的!) 💣50个投诉电话 🔍 根因:OrderService是单例,里面写了 private BigDecimal currentAmount; 用户A设500 → 还没写DB → 用户B在同一实例设了9999 → A继续执行读到B的值! 仅差3ms! 读了被覆盖的值

代码看起来完全正常,编译通过,单元测试也绿了。但并发场景下——多个线程同时访问同一个单例Bean——成员变量被串写了。

本页面解决:① 变量在哪?② 几个线程能碰到?③ 安不安全?④ 怎么改?

② 三种对象的本质区别

三种对象的生命周期与共享范围 🟠 单例 Singleton容器启动时创建,永不销毁 ★ 创建1个实例 ★ OrderService 实例全局唯一,所有请求共用 ↓ 所有请求使用同一个实例 ↓ 请求1 请求2 请求3 ... ⚠️ 成员变量存数据 → 线程不安全! ✅ 只用局部变量 + 参数 → 安全 典型:@Service @Component (默认) 🔴 共享引用 Shared 多个引用指向同一个堆对象 堆中的一个对象(DTO / 缓存Map / 连接池) ↑ 多个地方都持有引用 ↑ 变量A 变量B 参数dto ⚠️ 任一引用都能修改对象! 多线程同时改 → 数据错乱 典型:参数传递/static成员/缓存 🟢 局部新建 Local/New 每次调用/new都产生独立新对象 Order #1调用#1 new Order #2调用#2 new Order #3调用#3 new Order #4调用#4 new 每次都是全新的、独立的! ✅ 天然线程安全!各玩各的 方法结束 → 对象可GC回收 典型:new / 方法返回值 / 局部变量
核心差异:生命周期 → 共享范围 → 线程安全性
💡 核心口诀:
new / 方法内声明 → 🟢 每次新建,天然安全
参数传入 / static / 单例的成员变量 → 🔴 可能共享
@Service/@Component Bean → 🟠 全局只有1个,成员变量=危险
✅ ②搞懂了区别 → 下面③决策树,每个分支配完整代码

③ 代码识别决策树 — 一眼判定变量归属

决策树:看到一个变量,怎么判断它是哪种? 这个变量声明在哪? 类的成员变量 方法的参数 方法内的局部变量 static修饰? 实例成员变量 🟠 单例+static 全局共享! 最危险! 🔴 单例+成员变量 所有请求共享! 危险! 🔴 共享引用(同一请求链路) Controller→Service→Mapper 传同一个对象 不同请求的参数是各自new的(跨请求安全) 但链路内别乱改! 会影响下游 new出来的 基础类型(int等) 🟢 局部新建对象 独立! 天然安全! 🟢 栈上值类型 独立! 天然安全! 判定公式:成员变量看是不是单例 → 参数看是不是引用传递 → 局部变量看是不是new的
一眼识别:看声明位置 → 看是否被共享 → 看是否可变

分支1:单例 + 成员变量 — 💣 最危险的组合

OrderService.java — ❌ 危险写法
📁 service
OrderService.java
📁 controller
OrderController.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
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 被并发写
📁 config
ConfigCache.java
@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
📁 config
ConfigCache.java
@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
OrderService.java
📁 controller
OrderController.java
@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
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
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
OrderService.java
📁 mapper
OrderMapper.java
@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请求到数据库写入

OrderController.java
📁 controller
OrderController.java
📁 service
OrderService.java
📁 mapper
OrderMapper.java
📁 dto
OrderDTO.java
@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改了! } }
OrderService.java
📁 controller
OrderController.java
📁 service
OrderService.java
📁 mapper
OrderMapper.java
@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 } }
OrderMapper.java
📁 controller
OrderController.java
📁 service
OrderService.java
📁 mapper
OrderMapper.java
@Mapper public interface OrderMapper { // ⑧ Mapper是无状态接口 → ✅ 天然安全 @Insert("INSERT INTO orders(amount, status) VALUES(#{amount}, #{status})") void insert(Order order); // ← order是Service传过来的引用 }

两个请求并发时的内存全景

两个并发请求的内存视图 — 为什么DTO"既安全又不安全" Thread-A 栈帧 dto → OrderDTO@A1B2 order → Order@X1 (new的) amount=500 status="CREATED" Thread-B 栈帧 dto → OrderDTO@C3D4 order → Order@Y2 (new的) amount=9999 status="CREATED" 堆 (Heap) OrderDTO@A1B2 amount=500 OrderDTO@C3D4 amount=9999 Order@X1 (A的) amount=500 Order@Y2 (B的) amount=9999 单例Bean区域 (全局唯一) OrderService@1个实例 ← orderMapper(无状态) ✅ ✅ 跨请求:DTO是安全的 Jackson每个请求new一个新DTO A的dto≠B的dto,互不影响 ⚠️ 链路内:DTO是共享的 Controller.dto → Service.dto → 同一个对象 Service改了dto → Controller也受影响!
一张图理解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
request1个HTTP请求1请求1个✅ 请求隔离Web请求上下文
session1个HTTP Session1用户1个✅ 用户隔离购物车、用户偏好
applicationServletContext生命周期1个⚠️ 同singleton全局配置
ScopeDemo.java — prototype真的安全吗?
📁 service
ScopeDemo.java
📁 config
TaskWorker.java
@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秒判断安不安全

线程安全判定矩阵:变量位置 × 是否可变 = 安全? 变量在哪 只读(不可变) 可变(会修改) 单例 成员变量 ⚠️ 安全但不推荐 final + 不可变对象 → OK ❌ 危险! 多线程同时改 → 数据串 static 成员变量 ⚠️ 安全但不推荐 static final + 不可变 → OK ❌ 超危险! 全JVM共享 → 必须用并发集合 方法参数 (引用传递) ✅ 安全 只读不改 → 各线程独立 ⚠️ 链路内不安全 改了影响同一请求的下游 局部变量 (方法内new) ✅ 天然安全 栈帧独立,互不可见 ✅ 天然安全 每个线程有自己的栈帧 注入的Bean (@Autowired) ✅ 安全 无状态Bean → 只读OK ⚠️ 看被注入的Bean 有可变成员变量 → 危险
3秒判定:看变量在哪 × 是否会修改 → 绿色安全/红色危险/黄色慎用
面试速答:"Spring Bean线程安全吗?"
Spring Bean默认是singleton,所有请求共享同一个实例。Bean本身不保证线程安全——线程安全取决于你的写法。如果你在单例Bean里写可变成员变量,就不安全;如果Bean是无状态的(只有final成员或没有成员变量),就是安全的。解决方案:①用局部变量替代成员变量 ②prototype scope ③ThreadLocal ④加锁
✅ ⑥判定矩阵掌握了 → 下面⑦汇总三大经典陷阱,都是前6节没覆盖的

⑦ 三大经典陷阱 — 前面没覆盖的坑

③④⑤⑥已经覆盖了"单例成员变量""static集合""参数DTO修改"的❌✅对比,这里补充3个不一样的坑。

陷阱1:SimpleDateFormat — 以为是局部变量就安全?

DateService.java — ❌ 成员变量SimpleDateFormat
📁 service
DateService.java
@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
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
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
OrderCounter.java
@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
TaskService.java
📁 worker
TaskWorker.java
@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
TaskService.java
@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集合用并发版本
← 返回 Java核心图解 目录