1. 什么是单例模式?
单例模式 是一种创建型设计模式,它的核心思想是保证一个类只有一个实例,并提供一个全局访问点来访问这个唯一的实例。
你可以把它想象成一个国家的国王或一个公司的CEO。在任何时候,都只能有一个人担任这个角色,所有人都通过统一的渠道(如朝廷、董事会)来向他/她汇报或请求指示。
主要目的:
- 控制资源访问:确保某个资源(如数据库连接池、线程池、配置管理器)在整个应用程序中只有一个实例,避免资源浪费和冲突。
- 保证数据一致性:当多个地方需要共享和操作同一份数据时,单例可以确保数据的一致性。
- 方便管理:提供一个统一的入口来访问该实例,便于集中管理和控制。
2. 单例模式的 UML 类图
单例模式的结构非常简单,通常只包含一个角色:单例类。
+---------------------+
| Singleton |
+---------------------+
| - instance: Singleton| // 私有的静态实例
+---------------------+
| - Singleton() | // 私有的构造函数
| + getInstance(): | // 公有的静态获取实例方法
| Singleton |
+---------------------+
关键要素:
- 私有静态成员变量 (
instance
):用于存储单例类的唯一实例。由于是静态的,它属于类本身,而不是类的某个对象。 - 私有构造函数 (
Singleton()
) :这是实现单例的"防火墙"。将构造函数设为私有,可以防止外部代码通过new Singleton()
的方式创建新的实例。 - 公有静态方法 (
getInstance()
):这是全局访问点。外部代码通过调用这个方法来获取单例实例。该方法负责控制实例的创建过程,确保只创建一次。
3. 单例模式的多种实现方式(Java 示例)
单例模式有多种实现方式,每种方式在线程安全 、性能 和实现复杂度上各有优劣。
方式一:饿汉式
思想:在类加载时就立即创建实例,就像一个"饿汉"一样,不管你需不需要,我先创建好再说。
public class EagerSingleton {
// 1. 私有静态成员变量,在类加载时就初始化
private static final EagerSingleton instance = new EagerSingleton();
// 2. 私有构造函数,防止外部 new
private EagerSingleton() {
// 防止通过反射攻击
if (instance != null) {
throw new IllegalStateException("Singleton already initialized");
}
}
// 3. 公有静态方法,直接返回已创建好的实例
public static EagerSingleton getInstance() {
return instance;
}
}
- 优点 :
- 实现简单:代码最简洁。
- 线程安全 :由于实例是在类加载时创建的,由 JVM 保证了其线程安全性。
static final
关键字确保了实例的唯一性和不可变性。
- 缺点 :
- 可能造成资源浪费:如果这个单例对象非常大,并且在整个应用程序运行期间都没有被使用,那么它就会一直占用内存,造成不必要的资源开销。
方式二:懒汉式(线程不安全)
思想 :延迟加载,只有在第一次调用 getInstance()
方法时才创建实例。
public class LazySingleton {
// 1. 私有静态成员变量,先不初始化
private static LazySingleton instance;
// 2. 私有构造函数
private LazySingleton() {}
// 3. 公有静态方法,获取实例时才创建
public static LazySingleton getInstance() {
if (instance == null) { // 第一次检查
instance = new LazySingleton();
}
return instance;
}
}
- 优点 :
- 延迟加载:避免了饿汉式可能造成的资源浪费,实现了按需创建。
- 缺点 :
- 线程不安全 :在多线程环境下,如果多个线程同时通过
if (instance == null)
检查,并且都发现instance
为null
,那么它们就会各自创建一个实例,从而破坏了单例模式。这种方式在生产环境中绝对不能使用!
- 线程不安全 :在多线程环境下,如果多个线程同时通过
方式三:懒汉式(同步方法,线程安全)
思想 :为了解决方式二的线程安全问题,在 getInstance()
方法上加上 synchronized
关键字,使其成为同步方法。
public class SynchronizedLazySingleton {
private static SynchronizedLazySingleton instance;
private SynchronizedLazySingleton() {}
// 使用 synchronized 关键字修饰方法
public static synchronized SynchronizedLazySingleton getInstance() {
if (instance == null) {
instance = new SynchronizedLazySingleton();
}
return instance;
}
}
- 优点 :
- 线程安全 :
synchronized
确保了同一时间只有一个线程能进入该方法,保证了实例的唯一性。 - 延迟加载:保留了懒汉式的优点。
- 线程安全 :
- 缺点 :
- 性能低下 :每次调用
getInstance()
方法都会进行同步,即使实例已经被创建。同步操作会带来性能开销,而这个锁只有在第一次创建实例时才是必要的。之后每次获取实例都需要等待锁,效率很低。
- 性能低下 :每次调用
方式四:双重检查锁定 - 推荐
思想:这是对方式三的优化。我们只在第一次创建实例时进行同步,后续获取实例时无需同步,从而兼顾了线程安全和性能。
public class DoubleCheckedLockingSingleton {
// 使用 volatile 关键字修饰,防止指令重排序
private static volatile DoubleCheckedLockingSingleton instance;
private DoubleCheckedLockingSingleton() {}
public static DoubleCheckedLockingSingleton getInstance() {
// 第一次检查(无锁),如果实例已存在,直接返回
if (instance == null) {
synchronized (DoubleCheckedLockingSingleton.class) {
// 第二次检查(加锁),确保只有一个线程能创建实例
if (instance == null) {
instance = new DoubleCheckedLockingSingleton();
}
}
}
return instance;
}
}
-
为什么需要
volatile
?
instance = new Singleton()
这行代码并非原子操作,它大致分为三步:memory = allocate();
// 分配对象的内存空间ctorInstance(memory);
// 调用构造函数,初始化对象instance = memory;
// 将instance
指向分配的内存地址
由于 JVM 的指令重排序优化,步骤 2 和 3 的顺序可能颠倒。如果线程 A 执行完 1 和 3,但还没执行 2,此时
instance
已经不为null
。线程 B 在第一次检查时发现instance
不为null
,就会直接返回一个尚未初始化完成 的实例,导致程序出错。volatile
关键字可以禁止指令重排序,确保 2 和 3 按顺序执行。 -
优点:
- 线程安全:DCL 机制保证了多线程环境下的正确性。
- 延迟加载:保留了懒加载的优点。
- 性能高:只有在第一次创建实例时才需要同步,后续调用无需加锁,性能几乎与饿汉式相当。
-
缺点:
- 实现稍复杂 :需要理解
volatile
和 DCL 的原理。
- 实现稍复杂 :需要理解
方式五:静态内部类 - 强烈推荐
思想:利用 Java 的类加载机制来保证线程安全和延迟加载。这是目前公认的最佳实现方式之一。
public class StaticInnerClassSingleton {
// 私有构造函数
private StaticInnerClassSingleton() {}
// 静态内部类
private static class SingletonHolder {
// 静态内部类中的静态成员变量
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
// 公有静态方法,返回内部类的实例
public static StaticInnerClassSingleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
-
工作原理:
- 延迟加载 :
StaticInnerClassSingleton
类被加载时,其静态内部类SingletonHolder
并不会 被立即加载。只有当第一次调用getInstance()
方法时,SingletonHolder
类才会被加载。 - 线程安全 :JVM 在加载类时,会保证其静态成员变量的初始化是线程安全的。因此,
INSTANCE
的创建过程由 JVM 保证,不会有线程安全问题。
- 延迟加载 :
-
优点:
- 线程安全:由 JVM 保证,无需任何同步代码。
- 延迟加载:完美实现了按需加载。
- 实现简单 :代码比 DCL 更简洁,没有
volatile
和同步块,可读性高。 - 性能高:没有同步开销。
方式六:枚举
思想:利用 Java 枚举类型的特性来实现单例。这是《Effective Java》作者 Josh Bloch 极力推荐的方式。
public enum EnumSingleton {
// 这个枚举元素本身就是单例
INSTANCE;
// 可以添加你需要的任何方法
public void doSomething() {
System.out.println("Singleton is doing something.");
}
}
// 如何使用
public class Main {
public static void main(String[] args) {
EnumSingleton singleton = EnumSingleton.INSTANCE;
singleton.doSomething();
}
}
- 优点 :
- 绝对线程安全:枚举的实现同样由 JVM 保证。
- 防止反射攻击:普通单例可以通过反射调用私有构造函数来创建新实例,但枚举不行,JVM 会阻止这种操作。
- 防止序列化/反序列化破坏单例:普通单例在序列化再反序列化后,会创建一个新对象。而枚举在序列化时,JVM 只是保证了枚举实例的唯一性。
- 代码极其简洁:是所有实现方式中最简单的。
- 缺点 :
- 不够灵活:由于枚举的特性,这种方式不太适用于需要继承父类或实现特定接口的场景(虽然可以实现接口,但感觉上有些奇怪)。对于大多数场景,这不是问题。
4. 单例模式的优缺点
优点
- 内存占用少:只有一个实例,避免了频繁创建和销毁对象带来的性能开销。
- 访问方便:提供了全局唯一的访问点,方便统一管理。
- 资源共享:适合需要共享资源的场景,如配置文件、日志工具、数据库连接池等。
缺点
- 扩展性差:由于构造函数私有,单例类通常不能被继承,这限制了它的扩展性。
- 职责过重:单例模式既承担了业务逻辑,又承担了对象创建管理的职责,在一定程度上违背了"单一职责原则"。
- 测试困难:在单元测试中,如果单例对象持有状态,那么测试之间可能会相互影响,因为单例是全局共享的。需要特别注意测试的隔离性。
- 滥用风险:因为使用方便,容易被滥用。在不适合的场景下使用单例,可能会导致程序结构混乱、耦合度增高。
5. 单例模式的适用场景
- 需要频繁实例化然后销毁的对象:如线程池、缓存、日志对象等。
- 创建对象时耗时过多或者耗资源过多,但又经常用到的对象:如数据库连接池。
- 工具类对象:如配置文件管理器、JSON/Xml 解析工具等。
- 需要频繁访问数据库或文件的对象:如数据访问层(DAO)的实例。
- 系统中只需要一个对象来协调行为:如 Windows 的任务管理器、回收站。
6. 最佳实践与总结
实现方式 | 线程安全 | 延迟加载 | 性能 | 推荐度 | 备注 |
---|---|---|---|---|---|
饿汉式 | ✅ | ❌ | 高 | ⭐⭐⭐⭐ | 简单,但可能浪费资源 |
懒汉式(不安全) | ❌ | ✅ | 高 | ⭐ | 绝对禁止使用 |
懒汉式(同步方法) | ✅ | ✅ | 低 | ⭐⭐ | 性能差,不推荐 |
双重检查锁定 | ✅ | ✅ | 高 | ⭐⭐⭐⭐⭐ | 强烈推荐,兼顾了所有优点 |
静态内部类 | ✅ | ✅ | 高 | ⭐⭐⭐⭐⭐ | 强烈推荐,代码更优雅 |
枚举 | ✅ | ❌ | 高 | ⭐⭐⭐⭐⭐ | 最佳实现,最安全,但不够灵活 |
如何选择?
- 如果单例对象占用资源不大,且希望实现简单 :饿汉式是一个不错的选择。
- 如果需要延迟加载,并且对代码简洁性要求高 :静态内部类是你的首选。它在绝大多数情况下都是完美的解决方案。
- 如果需要延迟加载,并且单例类需要继承或实现复杂的逻辑 :双重检查锁定是最佳选择。
- 如果对安全性要求极高,且不介意使用枚举 :枚举是理论上最完美的实现方式,能抵御反射和序列化攻击。
在现代 Java 开发中,静态内部类 和双重检查锁定是最常用和最受推崇的实现方式。选择哪种取决于你的具体需求和偏好。