玩转单例模式
# 玩转单例模式
# 相关信息
# 定义
单例模式(Singleton Pattern)是一种常用的软件设计模式,用于确保一个类只有一个实例,并提供一个全局访问点。在 Java 中,单例模式有多种实现方式,包括懒汉式、饿汉式、双重检查锁定(DCL)、静态内部类等。
# 优点
- 唯一性:
- 确保一个类只有一个实例,这对于需要频繁创建和销毁的对象非常有用,可以节省系统资源。
- 全局访问:
- 提供了一个全局访问点,方便在整个应用程序中使用同一个对象实例。
- 资源控制:
- 由于只有一个实例,可以更好地控制对资源的访问,例如数据库连接、线程池等。
- 性能优化:
- 避免了频繁创建和销毁对象带来的性能开销,特别是在对象创建成本较高的情况下。
# 劣势
以下是单例模式的主要劣势:
- 过度使用导致高耦合
- 高耦合:单例模式通常提供一个全局访问点,这会导致其他类对单例类的高度依赖。这种高度依赖使得代码的耦合度增加,降低了模块的独立性和可重用性。
- 难于替换:一旦某个类依赖于单例,替换这个单例类变得非常困难,因为需要修改所有依赖它的代码。
- 单元测试困难
- 全局状态:单例模式维护的是全局状态,这使得在单元测试中很难模拟不同的实例或状态。每次测试都需要重置单例的状态,增加了测试的复杂性。
- 难以模拟:在单元测试中,模拟单例的行为比模拟普通对象更复杂,因为单例的生命周期和全局状态使得依赖注入和模拟变得更加困难。
- 多线程问题
- 线程安全:如果不正确地实现单例模式,可能会导致线程安全问题。例如,懒汉式单例在多线程环境下可能会创建多个实例,除非使用同步机制(如
synchronized
或volatile
)。 - 性能影响:为了保证线程安全,通常需要使用同步机制,这可能会引入额外的性能开销,尤其是在高并发场景下。
- 扩展性差
- 单一实例:单例模式限制了一个类只能有一个实例,这在某些需要多个实例的场景下是不合适的。例如,如果需要根据不同配置创建多个实例,单例模式就不适用。
- 灵活性低:单例模式缺乏灵活性,一旦确定了单例类的行为,后续的修改和扩展变得困难。
- 内存泄漏
- 长时间占用资源:单例对象的生命周期与应用程序的生命周期相同,这可能导致资源长时间占用,尤其是在单例对象持有大量资源的情况下。
- 垃圾回收困难:由于单例对象不会被垃圾回收器回收,可能会导致内存泄漏,特别是在单例对象持有大量内存或资源时。
# 应用场景
所谓的场景就是,在哪些情况下,可以将它的优势最大限度的发挥出来,尽可能的避免它的劣势带来的问题。
以下是一些常见的应用场景:
- 数据库连接:
- 数据库连接池通常使用单例模式,以确保整个应用程序使用同一个连接池,减少资源消耗。
- 日志记录:
- 日志记录器通常使用单例模式,以确保所有的日志记录操作都通过同一个实例进行。
- 配置管理:
- 配置管理类通常使用单例模式,以确保配置信息在整个应用程序中一致。
- 线程池:
- 线程池通常使用单例模式,以确保整个应用程序使用同一个线程池,提高线程的复用率。
# 不同的实现模式
# 懒汉式
懒汉模式,顾名思义,就是在需要的时候才进行创建。
public class Singleton {
private static Singleton instance;
// 将构造方法私有化 避免被调用,目的实现 单一实例
private Singleton() {}
// 构造方法被私有化了 因此获取实例的方法 只能为静态方法
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
只有需要的时候,才进行调用,因此需要判断是否为空。
但上述代码存在线程安全问题。当多个线程同时调用 getLazyMode()
方法时,可能会导致多次创建 LazyMode
实例,从而破坏单例的唯一性。
因此我们给其加上锁。比如sychronized
。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
# 饿汉式
饿汉模式,顾名思义,就是一开始就进行创建。
public class Singleton {
// 因此 对象要加static
private static final Singleton instance = new Singleton();
// 方法私有化
private Singleton() {}
// 因为无法通过构造方法 构造,因此获得方法只能为 静态方法
public static Singleton getInstance() {
// 静态方法 不能控制实例方法 因此instance 也要为静态对象
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
在上述代码中加了一个final
,它的作用是什么?必须加吗?
它不是必须的,即使不加,它也是线程安全的。
原因:因为它对应的方法和对象都是静态的,也就是说在类被加载的过程中创建,类加载是一个由 JVM 负责的过程,当类第一次被加载到 JVM 中时,类的静态变量会被初始化。这个过程是线程安全的(类加载的过程是线程安全的
),因为 JVM 会确保每个类只会被加载一次。
而final
关键字确保 instance
引用在初始化后不能被改变,进一步保证了单例的唯一性。进一步提高了实例对象的可用性。
同时,如果不加 final
,理论上可以通过反射或其他手段改变 instance
的引用,这可能会破坏单例的唯一性。
因此强烈建议,虽然不加 final
也可以实现线程安全,但加 final
是更好的选择。
# 双检测锁模式
懒汉模式比饿汉对资源的利用更优秀,因为它只在需要的时候才创建,减少了一段时间的浪费。但上述加了锁的懒汉式还可以继续优化。
它其实存在一些性能问题。调用instance
都需要获取锁,这会导致性能下降。这无可避免,但实际上,你仔细一些就会发现,我们只需要锁住创建对象的代码即可,对于检测对象是否为null,并没有必要。
于是我们将锁的部分代码进行优化:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
如此一来,锁只会在创建对象的时候生效,性能得到了进一步提高。但是改的似乎不彻底,这次改动带来了线程安全问题。
# 加第二道锁
假设有两个线程 T1 和 T2 同时调用 getInstance
方法:
- T1 进入
getInstance
方法:- T1 检查
instance == null
,结果为true
。 - T1 准备进入
synchronized
块,但还没有进入。
- T1 检查
- T2 进入
getInstance
方法:- T2 检查
instance == null
,结果也为true
。 - T2 准备进入
synchronized
块,但还没有进入。
- T2 检查
- T1 进入
synchronized
块:- T1 创建
Singleton
实例并赋值给instance
。
- T1 创建
- T2 进入
synchronized
块:- T2 也创建
Singleton
实例并赋值给instance
,此时instance
被覆盖,导致两个线程创建了两个不同的实例。
- T2 也创建
为了解决上述问题,我们还需要在锁的代码中加一个检测。判断当前对象是否为空,并实现,T1线程创建对象后,T2线程在外面能够及时感知到变化。于是又进行了一番优化:
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;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
volatile
这个关键字的作用:
确保了
instance
的可见性,即当一个线程修改了instance
的值,其他线程能够立即看到这个变化。这防止了指令重排序问题,确保
instance
的初始化是原子性的。
关于volatile在双检锁中起到的作用,这里就不展开了,更多的请大家自行查阅资料。
以上代码就是著名的懒汉模式的双检锁模式(Double-Checked Locking, DCL)模式。
# 枚举实现单例
除了上述实现单例的方式,其实还有一种方式,就是通过枚举
。
通过枚举是一种非常有效和推荐的方式,主要是因为它具有以下优势:
简洁性
- 枚举实现单例模式的代码非常简洁,易于理解和维护。
- 不需要手动处理线程安全问题,也不需要使用
synchronized
关键字或volatile
变量。
线程安全
- 枚举的实例化由 JVM 保证线程安全。枚举的实例在类加载时创建,并且只创建一次,因此天然支持线程安全。
- 不需要额外的同步机制来确保线程安全。
防止反射攻击
- 枚举的构造方法是私有的,并且不能被外部访问。即使通过反射也无法创建新的枚举实例,从而防止了反射攻击。
- 这一点对于单例模式非常重要,因为反射攻击可能会破坏单例的唯一性。
序列化和反序列化安全
枚举类型默认实现了
Serializable
接口,因此可以轻松地进行序列化和反序列化。枚举在序列化和反序列化过程中是安全的。JVM 会确保枚举的实例在反序列化时仍然是同一个实例,不会创建新的实例。
这一点对于需要在网络中传输或持久化的单例对象非常重要。
自动实现 Cloneable
- 枚举类型默认实现了
Cloneable
接口,但调用clone
方法会抛出CloneNotSupportedException
异常,从而防止了克隆攻击。
public enum Singleton {
INSTANCE;
// 可以在这里添加方法和属性
public void doSomething() {
System.out.println("Doing something...");
}
public static void main(String[] args) {
Singleton singleton = Singleton.INSTANCE;
singleton.doSomething();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
枚举在序列化和反序列化时是安全的,主要是因为 JVM 对枚举类型的特殊处理。
# 序列化
- 唯一标识符:每个枚举常量在序列化时都会被赋予一个唯一的标识符(通常是枚举常量的名字)。这个标识符在反序列化时用于恢复原始的枚举常量。
- 序列化机制:枚举类型的序列化机制由 JVM 自动管理,确保每个枚举常量在序列化时都被正确地表示。
# 反序列化
- 唯一性保证:在反序列化时,JVM 会根据枚举常量的唯一标识符查找并返回对应的枚举常量。如果找不到对应的枚举常量,会抛出
InvalidObjectException
。- 防止新实例创建:JVM 确保在反序列化过程中不会创建新的枚举实例,只会返回已存在的枚举常量。