← 返回 Java核心图解 目录
🌱 Spring单例深度图解
从"会用@Autowired"到"理解Spring为什么只创建1个对象" · SVG流程图 + 生活类比 + IDE源码 + 面试速答
🔗 想看单例在并发场景下的真实翻车案例?推荐搭配
本页侧重Spring单例的创建流程与原理。若需并发场景下成员变量串写的真实事故,请看:
1. 单例到底是什么?
公司公共打印机——全公司只有1台打印机,谁需要打印就去用这台,不会每来一个人就买一台新打印机。
Spring的单例也是这个道理:整个Spring容器只创建1个对象,谁@Autowired就拿同一个引用去用。
反面:如果不用单例——每次请求来都new一个UserService,100个请求 = 100个对象 = 内存浪费!而且对象之间状态不共享,你改了他看不到。
图1:单例 vs 非单例 — 公共打印机 vs 每人一台
单例的关键数字
| 维度 | 非单例(每次new) | Spring单例(默认) |
| 对象数量 | 100个请求 = 100个对象 | 100个请求 = 1个对象 |
| 内存占用 | 大量重复对象 → GC压力大 | 只占1份内存 |
| 创建时机 | 每次请求时创建 | 容器启动时创建1次 |
| 注入的依赖 | 每个对象各注入一份 | 所有请求共享同一份依赖 |
| 状态共享 | ❌ 不共享,各自为政 | ✅ 共享(但要注意线程安全) |
2. GoF单例 vs Spring单例 —— 不是一个东西!
GoF单例 = 自己锁门,你自己写private构造器,自己控制只能创建1个。
Spring单例 = 物业管钥匙,你不管构造器是不是public,Spring容器帮你管理,只创建1个放池子里,谁要给谁引用。
图2:GoF单例 vs Spring单例 — 自己锁门 vs 物业管钥匙
面试高频坑点:Spring的单例和GoF设计模式的Singleton不是同一个概念!
- GoF单例:一个JVM里某个类只有1个实例,靠private构造器+static保证
- Spring单例:一个Spring IoC容器里某个Bean只有1个实例,靠容器管理保证
- 如果你有2个Spring容器(比如父子上下文),同一个Bean可以有2个实例!
3. Spring创建单例的6步流程
公司入职流程:1. 收到offer(扫描@Component)→ 2. 登记花名册(BeanDefinition)→ 3. 人来了但还没配电脑(实例化空壳)→ 4. 配电脑/门禁卡(属性注入)→ 5. 岗前培训+发工牌(初始化+AOP代理)→ 6. 登记到员工系统(放入单例池)
图3:Spring创建单例Bean的6步完整流程
DefaultSingletonBeanRegistry.java
// Spring单例池的核心数据结构 — 就是一个ConcurrentHashMap
public class DefaultSingletonBeanRegistry {
// 🔑 单例池!所有单例Bean都存在这里
private final Map<String, Object> singletonObjects
= new ConcurrentHashMap<>(256);
// 获取单例:名字 → 对象
protected Object getSingleton(String beanName) {
return this.singletonObjects.get(beanName);
}
// 注册单例:创建好之后放进去
protected void addSingleton(String beanName, Object singleton) {
this.singletonObjects.put(beanName, singleton);
}
}
源码 singletonObjects 是一个 ConcurrentHashMap<String, Object>,key是Bean名字(如"userService"),value是Bean实例对象。Spring启动时把所有单例Bean创建好放进去,后续@Autowired就从这里get,所以永远是同一个对象。
4. 单例的线程安全问题 —— 面试必问
无状态 = 每个人自带纸笔:方法里的局部变量是每个线程栈里的,别人看不见,安全!
有状态 = 桌上共享的笔:成员变量是堆上的,所有线程都能改,两个人同时拿就抢!
图4:Spring单例的线程安全问题 — 无状态安全,有状态危险
UserService.java
@Service
public class UserService {
@Autowired
private UserMapper userMapper; // ✅ 安全:注入的引用,只读
private int count = 0; // ❌ 危险!可变成员变量
public User getUser(int id) {
// ✅ 安全:局部变量在栈上,线程独立
User result = userMapper.selectById(id);
return result;
}
public synchronized void increment() {
count++; // 加synchronized可以解决,但影响性能
}
}
面试必背:Spring的Bean默认是单例的,不要定义可变的成员变量!
- ✅ 安全的:注入的其他Bean引用(@Autowired)、常量(static final)
- ❌ 危险的:private int count、private List<String> cache 等可变成员变量
- 解决方案:用ThreadLocal、方法局部变量、或改为@Scope("prototype")
5. 请求如何使用单例 —— 全链路视角
去公司打印:1. 你拿着文件去打印(HTTP请求)→ 2. 前台指引你去3楼(DispatcherServlet路由)→ 3. 你找到打印机(从单例池取Bean)→ 4. 按打印按钮(调用方法)→ 5. 打印完成(返回响应)。
关键:不管谁来打印,用的都是同一台打印机,打印机不会因为人多就多出来。
图5:HTTP请求 → Tomcat线程 → Spring单例池 → 方法调用全链路
6. @Scope 五种作用域
singleton = 公司1台打印机(默认)
prototype = 每人发1台(每次@Autowired都new)
request = 每个快递单配1台(Web专用,一个请求一个)
session = 每个客户配1台(Web专用,一个会话一个)
application = 整栋楼1台(ServletContext级别)
| Scope | 创建时机 | 数量 | 类比 | 使用场景 |
| singleton | 容器启动时 | 1个/容器 | 公司公共打印机 | 无状态Service/Controller/Mapper |
| prototype | 每次获取时 | N个 | 每人发一台 | 有状态的Bean |
| request | 每次HTTP请求 | 1个/请求 | 每个快递单配一台 | 请求上下文信息 |
| session | 每次HTTP会话 | 1个/会话 | 每个客户配一台 | 购物车、登录信息 |
| application | ServletContext创建时 | 1个/ServletContext | 整栋楼1台 | 全局共享配置 |
OrderService.java — prototype示例
@Service
@Scope("prototype") // 每次@Autowired都创建新对象
public class OrderService {
// 这个Bean有可变状态,不能用单例!
private List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) {
items.add(item);
}
// 每次获取都是全新的对象,互不干扰
// 但注意:prototype Bean不会被Spring自动销毁!
// 需要手动管理生命周期
}
singleton中注入prototype会怎样?只会在singleton创建时获取1次prototype!之后prototype不会重新创建。解决方案:
1. ObjectFactory/ObjectProvider:每次调用getObject()获取新实例
2. @Lookup方法:Spring用CGLIB重写方法,每次调用返回新Bean
3. 改为方法参数注入:不@Autowired,改为方法参数让Spring每次传入
7. @Lazy 延迟加载
默认(eager) = 公司开业前就买好打印机放着 → 启动慢,但首次请求快
@Lazy = 等有人要打印才去买 → 启动快,但第一次请求稍慢
LazyConfig.java
@Configuration
public class LazyConfig {
@Lazy // 不在启动时创建,第一次使用时才创建
@Bean
public HeavyService heavyService() {
// 这个Bean初始化很慢(比如连接外部系统)
return new HeavyService();
}
}
@Service
public class NormalService {
@Lazy // 也可以加在注入点
@Autowired
private HeavyService heavyService;
// 注入的是代理对象,首次调用方法时才真正创建
}
8. 面试速答
Spring的Bean默认是单例的吗?为什么?
是的,默认scope=singleton。因为大部分Bean是无状态的Service/Controller/Mapper,不需要每次请求都创建新对象,单例可以节省内存、提高性能。
Spring单例和GoF设计模式的单例有什么区别?
GoF单例靠private构造器+static自己保证全JVM只有1个;Spring单例靠IoC容器管理,每个Spring容器里只有1个。如果有2个Spring容器(如父子上下文),同一个Bean可以有2个实例。Spring单例的构造器可以是public的。
Spring单例Bean线程安全吗?
取决于Bean是否有可变状态。无状态Bean(只有方法内局部变量和注入的只读引用)是线程安全的;有可变成员变量的Bean不是线程安全的。解决方案:①不定义可变成员变量 ②使用ThreadLocal ③改为prototype scope ④加synchronized(不推荐,影响并发)
singleton Bean里注入prototype Bean会怎样?
prototype Bean只会在singleton创建时注入1次,之后不会重新创建。因为singleton只创建1次,@Autowired也只执行1次。解决方案:①用ObjectProvider<T>,每次getObject()获取新实例 ②用@Lookup方法注入 ③改为方法参数注入
Spring什么时候创建单例Bean?
默认在容器启动时(ApplicationContext.refresh()阶段)就创建所有单例Bean(pre-instantiation),不是第一次请求时。这样启动慢但运行快。加@Lazy可以改为首次使用时才创建。
为什么Controller/Service/Mapper都可以用单例?
因为它们都是无状态的:Controller调用Service,Service调用Mapper,方法内的局部变量在栈上是线程独立的,注入的依赖引用是只读的。真正有状态的数据(请求参数、数据库连接)不在Bean的成员变量里,而是通过方法参数传递或由MyBatis的SqlSession管理。
Spring怎么保证单例只创建1个?
DefaultSingletonBeanRegistry内部维护一个ConcurrentHashMap<String, Object>叫singletonObjects。创建Bean前先get检查是否存在,不存在才创建,创建后put进去。而且创建过程用synchronized加锁,保证不会并发创建。
@Lazy有什么用?有什么坑?
@Lazy让Bean在首次使用时才创建,而不是启动时。好处是加快启动速度,特别是有些Bean初始化很重(连接外部系统)。坑:①首次请求会慢 ②如果Bean有配置错误,启动时不会报错,到运行时才暴露 ③循环依赖时@Lazy可以打破循环(注入代理对象)
单例Bean销毁时会怎样?
Spring容器关闭时会调用所有单例Bean的@PreDestroy方法和DisposableBean.destroy(),释放资源(关闭连接池、清理缓存等)。但prototype Bean Spring不负责销毁,需要自己管理。
循环依赖和单例有什么关系?
Spring只对singleton Bean解决循环依赖(三级缓存),prototype Bean循环依赖会直接报错。因为singleton是容器管理完整生命周期,可以通过"提前暴露半成品"来打破循环;而prototype每次都创建新的,无法缓存。
📋 速记口诀
Spring单例 = 公司公共打印机
🔹 只创建1个,放singletonObjects池子里
🔹 谁用给谁引用(@Autowired),永远是同一个
🔹 无状态Bean安全(局部变量+只读引用)
🔹 有可变成员变量 = 危险!多线程抢改
🔹 GoF单例 ≠ Spring单例(private构造器 vs 容器管理)
🔹 singleton里注入prototype = 只注入1次!
🔹 @Lazy = 等有人用才创建(加快启动)
🔹 循环依赖只对singleton解决(三级缓存)
核心三原则速记
Spring三原则:① Bean默认单例,不要在Bean中存请求级状态 → ② @Transactional不能在类内部调用中生效 → ③ 循环依赖能用但不推荐