设计模式基础(一)——单例模式
对于单例的概念,我觉得没必要解释太多,你一看就能明白。我们所说的单例模式,一般特指JVM进程内的单例,确切的说是限定在ClassLoader范围内的单例。
我们在程序中使用单例模式(Singleton Design Pattern),目的一般是处理资源访问的冲突,或者从业务概念上,有些数据在系统中只应保存一份,那也比较适合设计为单例类,比如配置类、全局流水号生成器等。
一、实现方式
我们先来看看有哪几种方式可以实现单例模式。要实现一个单例,无外乎关注下面几个点:
- 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
- 考虑对象创建时的线程安全问题;
- 考虑是否支持延迟加载;
- 考虑 getInstance() 性能如何(是否加锁)。
1.1 饿汉式
饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。
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();
}
}
有人说,这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为,最好的方法应该在用到的时候再去初始化。
我个人反对这种观点,并且认为如果在生产环境确实需要使用单例模式,应该采用饿汉式,理由如下:
- 如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时);
- 如果实例占用资源多,按照 fail-fast 原则,那我们也希望在程序启动时就将这个实例初始化好,如果资源不够,就会在程序启动时报错(比如 Java 中的 PermGen Space OOM),及早发现问题。
1.2 懒汉式
懒汉式相对于饿汉式的优势是支持延迟加载,比如下面这种方式,是采用了双重锁检测的懒汉式,提升了获取单例对象的性能:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static IdGenerator getInstance() {
if (instance == null) {
synchronized(IdGenerator.class) { // 此处为类级别的锁
if (instance == null) {
instance = new IdGenerator();
}
}
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
有人说,上面这种方式可能因为指令重排序,导致 IdGenerator 对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化就被使用了。只有很低版本(JDK1.5以下)的 Java 才会有这个问题,高版本的 JDK 已经解决了这个问题(把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。
1.3 静态内部类方式
还有一种方式,是利用 Java 的静态内部类,它有点类似饿汉式,但又能做到延迟加载:
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();
}
}
SingletonHolder 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。
instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。
1.4 枚举方式
最后,还有一种很简单的实现方式,就是基于枚举类型来实现。这种实现方式通过 Java 枚举类型本身的特性,保证了对象创建的线程安全性和唯一性:
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() {
return id.incrementAndGet();
}
}
二、模式种类
2.1 线程内单例
我们上述将的单例模式是指ClassLoader内的单例,如何要实现线程内单例呢?所谓线程内单例,指的是线程内唯一,线程间可以不唯一。我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。
实际上,Java 语言本身提供了 ThreadLocal 工具类,可以更加轻松地实现线程唯一单例。
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final ConcurrentHashMap<Long, IdGenerator> instances
= new ConcurrentHashMap<>();
private IdGenerator() {}
public static IdGenerator getInstance() {
Long currentThreadId = Thread.currentThread().getId();
instances.putIfAbsent(currentThreadId, new IdGenerator());
return instances.get(currentThreadId);
}
public long getId() {
return id.incrementAndGet();
}
}
2.2 集群单例
还有一种单例模式,就是所谓的集群内单例。集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。
实现集群内单例的核心思路,其实跟利用分布式锁控制访问共享资源是一个道理,只是将创建/销毁单例对象的过程用分布式锁加以控制,保证每次只有一个节点能做创建/销毁的操作:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private static SharedObjectStorage storage = FileSharedObjectStorage();
private static DistributedLock lock = new DistributedLock();
private IdGenerator() {}
public synchronized static IdGenerator getInstance()
if (instance == null) {
lock.lock();
instance = storage.load(IdGenerator.class);
}
return instance;
}
public synchroinzed void freeInstance() {
storage.save(this, IdGeneator.class);
instance = null; //释放对象
lock.unlock();
}
public long getId() {
return id.incrementAndGet();
}
}
// IdGenerator使用举例
IdGenerator idGeneator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance();
上面是一段伪代码,我们把单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。多个不同进程之间的访问通过分布式锁来控制。
三、开源示例
在Netty开源框架中,NioEventLoop 通过 select() 不断轮询注册的 I/O 事件,Netty 提供了选择策略 SelectStrategy 控制 select 循环行为,包含CONTINUE
、SELECT
、BUSY_WAIT
三种策略。
SelectStrategy 对象的默认创建,就是采用饿汉式单例,源码如下:
final class DefaultSelectStrategy implements SelectStrategy {
static final SelectStrategy INSTANCE = new DefaultSelectStrategy();
private DefaultSelectStrategy() { }
@Override
public int calculateStrategy(IntSupplier selectSupplier, boolean hasTasks) throws Exception {
return hasTasks ? selectSupplier.get() : SelectStrategy.SELECT;
}
}
四、总结
本章,我介绍了设计模式中最为人熟悉的单例模式,并介绍了开源框架Netty中对单例模式的运用。生产环境如需使用单例模式,建议采用饿汉式。另外,我们也必须认识到,单例模式存在以下的问题:
- 单例对 OOP 特性的支持不友好;
- 单例会隐藏类之间的依赖关系;
- 单例对代码的扩展性不友好;
- 单例对代码的可测试性不友好;
- 单例不支持有参数的构造函数
所以,我们需要根据项目的实际情况,评估是否使用单例模式,建议优先考虑通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证。
感谢赞赏~