前言
为什么需要单例模式?
想象一个场景:一个应用需要读取配置文件,如果每次读配置都 new 一个配置管理器出来,就会出现两个问题:
- 浪费资源:配置数据都一样,创建多个对象毫无意义
- 数据不一致:A 模块改了配置,B 模块的配置对象还是旧的
这时候需要的就是单例模式------保证一个类在整个应用中只有一个实例,并提供一个全局访问点。
一句话总结:有些东西,整个应用有一份就够了。
概念
单例的意思就是单个实例。
单例模式是一种创建型设计模式 , 它的核心思想是保证一个类只有一个实例,并提供一个全局访问点来访问这个实例。
- 只有一个实例:在整个应用程序中,只存在该类的一个实例对象,而不是创建多个相同类型的对象。
- 全局访问点:为了让其他类能够获取到这个唯一实例,该类提供了一个全局访问点(通常是一个静态方法),通过这个方法就能获得实例。
实现
实现单例,必须满足三个条件:
- 构造函数私有化 :禁止外部
new对象 - 自己持有自己的实例:用一个静态变量保存
- 提供公开的获取方法:通过静态方法返回这个实例
饿汉式(最简单)
在类加载的时候就创建好实例,不管会不会被使用。
饿汉的意思是:等不及被调用,先把实例创建好。像一个饿汉一样,不管好不好吃,先填饱肚子再说。
csharp
public class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
懒汉式
只有在请求实例时才会创建,如果在首次请求时还没有创建,就创建一个新的实例,如果已经创建,就返回已有的实例。
懒汉的意思是:被调用时才创建实例,不调用就不创建。像一个懒人一样,不催就不做。
csharp
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
懒汉式+双重检查锁(最常用)
懒汉式写法会有一个问题------多线程下可能创建出多个实例。当有多个请求同时调用Singleton.getInstance()的时候,此时instance为空,则每个请求都可能new一个实例出来。
为了避免这种情况,可以通过加锁来确保只会有一个请求创建出实例:
csharp
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免每次都加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查,防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
}
为什么要两次 if 检查?
- 外层
if:实例已创建就直接返回,不用排队等锁,提高性能 - 内层
if:多个线程同时过了外层检查,排队进入锁,第一个创建后,第二个进来发现已经有了就不再创建
扩展:为什么加
volatile?
new Singleton()这一行不是原子操作,实际分三步:
- 分配内存
- 初始化对象
- 引用指向内存
JVM可能把顺序重排为1→3→2。线程A执行到第3步但还没初始化完,线程B进来发现instance != null,直接返回了一个没初始化完的对象 ,程序就崩了。volatile禁止这种重排序。
静态内部类(推荐)
结合了饿汉式和懒汉式的优点:线程安全 + 懒加载,而且没有锁的性能损耗。像把东西放在保险箱(内部类)里,不开箱东西就不会被拿出来。需要的时候一开箱,发现东西早就准备好了,而且只有一份。
csharp
public class Singleton {
private Singleton() {}
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
原理 :SingletonHolder 这个内部类只有在调用 getInstance() 时才会被JVM加载,才会创建Singleton实例,而JVM保证类加载过程是线程安全的。所以既实现了懒加载,又保证了线程安全,还不用加锁。
枚举(最安全)
为什么枚举最安全?
- 天然防止反射攻击(枚举不允许通过反射创建实例)
- 天然支持序列化(反序列化不会产生新实例)
- 代码最简洁,JVM保证唯一性
像身份证号,天生就唯一,不需要额外做任何保证。
csharp
public enum Singleton {
INSTANCE;
public void doSomething() {
// 业务方法
}
}
// 使用
Singleton.INSTANCE.doSomething();
总结
优缺点
| 说明 | |
|---|---|
| 优点 | |
| 节省资源 | 全局只有一个实例,避免重复创建和内存浪费 |
| 数据一致 | 多处代码共享同一份状态,不会出现数据不同步的问题 |
| 全局访问 | 提供统一的访问入口,使用方便 |
| 缺点 | |
| 难以测试 | 全局状态会污染测试环境,单测时需要特殊处理(如重置实例) |
| 隐藏依赖 | 调用方通过 getInstance() 直接获取,依赖关系不透明,违反依赖注入原则 |
| 并发风险 | 单例的成员变量被多线程修改时,需要额外保证线程安全 |
实现方式对比
| 实现方式 | 线程安全 | 懒加载 | 防反射 | 防序列化 | 优点 | 缺点 |
|---|---|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 否 | 否 | 实现最简单,没有锁开销 | 类加载就创建,浪费资源(如果用不到) |
| 懒汉式+双重检查锁 | 是 | 是 | 否 | 否 | 懒加载,资源利用率高 | 写法复杂,需要 volatile,有锁开销 |
| 静态内部类 | 是 | 是 | 否 | 否 | 懒加载 + 无锁,写法简洁 | 无法传参给构造函数 |
| 枚举 | 是 | 否 | 是 | 是 | 最安全,天然防反射和序列化 | 不能继承其他类,写法不够直观 |
怎么选
- 日常开发 → 静态内部类(简单、安全、懒加载)
- 需要序列化 → 枚举(最安全)
- Spring 项目 → 直接用
@Service,框架管理单例
简单记忆 :不确定就用静态内部类,它是综合最优的选择。
练习题目
银行叫号系统
题目描述:某银行大厅有一台叫号机,两个窗口共用这台叫号机为客户取号。请用单例模式设计叫号机,确保所有窗口取到的号码连续且不重复。
输入描述:第一行输入一个整数n,表示客户数量。接下来n行,每行包含窗口编号(1或2)和客户姓名,用空格隔开。
输出描述:为每个客户分配排队号码,输出格式为"客户姓名 号码"。
输入示例:
5
1 张三
2 李四
1 王五
2 赵六
1 钱七
输出示例:
张三 1
李四 2
王五 3
赵六 4
钱七 5
解题思路 :两个窗口各自获取叫号机实例,但必须是同一个实例------否则各拿各的计数器,号码就会重复。这就是单例模式解决的真正问题:多处代码需要共享同一个状态时,确保只有一个实例存在。
ini
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = Integer.parseInt(sc.nextLine());
// 两个窗口各自获取叫号机
TicketMachine window1 = TicketMachine.getInstance();
TicketMachine window2 = TicketMachine.getInstance();
for (int i = 0; i < n; i++) {
String[] parts = sc.nextLine().split(" ");
int window = Integer.parseInt(parts[0]);
String name = parts[1];
int number;
if (window == 1) {
number = window1.getNextNumber();
} else {
number = window2.getNextNumber();
}
System.out.println(name + " " + number);
}
}
}
class TicketMachine {
private int currentNumber;
private TicketMachine() {
currentNumber = 0;
}
private static class Holder {
private static final TicketMachine INSTANCE = new TicketMachine();
}
public static TicketMachine getInstance() {
return Holder.INSTANCE;
}
public int getNextNumber() {
return ++currentNumber;
}
}
扩展:实际项目中的单例
学完了实现方式,更重要的是知道在真实项目中什么时候会用到。以下场景大概率已经接触过,只是没意识到它们是单例。
Spring中的Bean(最常见)
Spring容器中的Bean默认就是单例 (@Service、@Component 等注解标记的类,默认 scope = "singleton")。整个应用只有一个实例,所有请求共享,所以Spring应用处理请求很快------不用每次都创建新对象。
注意 :单例Bean如果有成员变量被多线程修改,需要用 ThreadLocal 或加锁保证线程安全。
数据库连接池
数据库连接创建很慢(TCP握手、认证、分配资源),所以项目一定会用连接池(如 HikariCP、Druid),而连接池本身就是单例------整个应用只维护一个池子,所有模块共用。
本地缓存管理器
高频查询的数据(如字典表、省市区、热门商品)缓存在本地,减少数据库压力。缓存管理器通常用单例,保证整个应用共享一份缓存,避免内存浪费。
线程池管理器
线程池创建成本高,整个应用通常只维护几个线程池。通过单例统一管理,异步发邮件、推送消息、导出报表等耗时操作都通过同一个线程池执行,避免每个模块各自创建线程池导致资源浪费。
配置中心客户端
微服务中从 Nacos、Apollo 等配置中心拉取配置,这个客户端通常用单例------整个应用只需要一个连接来拉取和监听配置变更。
日志管理器
每天用的 LoggerFactory.getLogger() 背后也是单例。日志框架的核心组件(LoggerContext)是单例的,所有 Logger 共享同一个输出器,日志级别、格式等配置全局统一。