深圳幻海软件技术有限公司 欢迎您!

[设计模式]单例模式

2023-03-13

 单例模式本章笔记的内容主要参考《设计模式之美》核心问题1.为什么要使用单例?2.单例存在的问题?3.单例与静态类的区别?4.替代方案?为什么要使用单例模式/在很多场景中,我们需要一些可以共享的对象,来统一操作一些资源。若此时,产生了多个实例,则这些原本应该共享的资源,会产生冲突或覆盖的现

单例模式

本章笔记的内容主要参考《设计模式之美》

核心问题

1.为什么要使用单例? 2.单例存在的问题? 3.单例与静态类的区别? 4.替代方案?

为什么要使用单例模式

/在很多场景中,我们需要一些可以共享的对象,来统一操作一些资源。若此时,产生了多个实例,则这些原本应该共享的资源,会产生冲突或覆盖的现象。

举个例子,比如日志记录类。一般来说,日志纪录类会像固定的文件中输出日志结果,此时若使用多个实例进行这一操作,对于文件内容的write操作可能会出现覆盖的现象。当然,这种情况下可以使用类级的锁来保证正确性,但相比而言,单例是一种更节约资源的做法。另外,在业务系统中,涉及到如配置、唯一ID生成器这样的需求,一般也会使用单例模式。实际上,在Spring中管理的Bean对象都是基于单例模式的。

几种实现单例模式的方式

1.饿汉模式

public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}

2.懒汉模式

基础版本

public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static synchronized IdGenerator getInstance() {
if (instance == null) {
instance = new IdGenerator();
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}

基础版本中使用了对方法加锁的方式,会极大的影响性能,不支持高并发。

双重检测

public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static synchronized IdGenerator getInstance() {
if (instance == null) {
instance = new IdGenerator();
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}

实际上大多数的实现中,还会给单例类上声明volatile关键字,来避免new动作非原子性导致的问题。具体的可以参考:

The "Double-Checked Locking is Broken" Declaration

实际上这个问题在高版本的JDK中已经做了相应的原子化处理,即使不使用volatile,也能保证正确性。

静态内部类

public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private IdGenerator() {}
private static class SingletonHolder{
private static final IdGenerator instance = new IdGenerator();
}
public static IdGenerator getInstance() {
return SingletonHolder.instance;
}
public long getId() {
return id.incrementAndGet();
}
}

使用了一种简单的方式,达到了懒加载和线程安全的目的。线程安全由JVM在初始化静态内部类时保证。

枚举

还可以使用枚举的方式来实现单例,在此不再赘述。

单例存在的问题

由于单例隐藏了初始化的细节,因此在初始化时,往往使用了硬编码的方式,这其实是一种反模式。在后续维护中,若业务需求发生了变化,相应的逻辑变更会相对困难。这里引用一个《设计模式之美》中的例子:

在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据 库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统 中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资 源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔 离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独 享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到 其他 SQL 的执行。

另外,单例模式的可测试性不高这也是因为代码中存在较多的硬编码,导致一些输入难以mock测试

单例的语义

在讨论单例模式时,我们会强调其唯一性。但在不同的前提条件下,唯一性的语义是不同的,在默认的语境中,单例模式指的是在同一个进程中,一个类仅有一个对象。当然这个前提条件可能会因为业务落地的实际场景发生变化。

线程中的唯一性

我们如何实现一个类在一个线程中的唯一性?实际上可以直接使用Java中的ThreadLocal类帮助我们实现,或者我们也可以自己在类中定义一个静态的ConcurrentHashMap,并使用线程id为Key,不同的实例为Value进行实现。

分布式环境下的唯一性

那么,在分布式多节点的环境下,如何保证实例的唯一性呢?通常的做法是,使用分布式文件系统,创建一个多节点共享的文件(该实例的本质是唯一的文件)。我们在创建、修改、读取实例时,我们总是从文件中反序列化得到实例,然后进行操作,最后将实例重新序列化回文件。这样就可以在不同的节点上,保证实例的唯一性。

参考文献

1.《设计模式之美》
2.《双重检测》
3. Reality Check, Douglas C. Schmidt, C++ Report, SIGS, Vol. 8, No. 3, March 1996.