概述
上一节课《设计模式与范式 - 创建型:2.单例模式(中):为什么不推荐使用单例模式?又有何替代方案?》针对单例模式,讲解了单例的应用场景、几种场景的代码实现和存在问题,并粗略给出了替换单例模式的方式,比如工厂模式、IOC 容器。今天,再进一步延伸下,讨论下下面这几个问题:
- 如何理解单例模式的唯一性?
- 如何实现线程唯一的单例?
- 如何实现集群环境的单例?
- 如何实现一个多例模式?
如何理解单例模式的唯一性?
单例的定义:一个类只允许创建一个对象,那这个类就是一个单例类,这种设计模式叫做单例设计模式,简称单例模式。
定义中提到,"一个类支援许创建唯一一个对象"。那对象的唯一性的作用范围是什么?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象? 答案是后者,也就说,单例模式创建的对象是进程唯一的。在进程间是不唯一的。
如何实现线程唯一的单例?
刚刚讲了单例类对象是进程唯一的,一个进程只能有一个单例对象。那如何实现一个线程唯一的单例呢?
先看一下,什么是线程唯一的单例,以及 "线程唯一" 和 "进程唯一" 的区别。
"进程唯一" 指的是进程内唯一,进程间不唯一。类比一下, "线程唯一" 指的是线程唯一,线程间不唯一。实际上,"进程唯一" 还代表了线程内、线程间都唯一,这也是 "进程唯一" 和 "线程唯一" 的区别之处。这段话有点像绕口令,我举个例子解释下。
假如 IdGenertaor
是一个线程唯一的单例类。在线程 A 内,可以创建一个单例对象 a。因为线程内唯一,在线程 A 内就不能再创建新的 IdGenertaor
对象了,而线程间可以不唯一,所以,在另一个线程 B 内,还可以重新创建一个新的单例对象 b。
尽管概念理解起来比较复杂,但线程唯一单例的代码实现很简单,如下所示。在代码中,我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java 本身提供了 ThreadLocal
工具类,可以更加轻松地实现线程唯一单例。不过,ThreadLocal
底层实现原理也是基于下面代码中所示的 HashMap。
java
public class IdGenerator {
private static AtomicLong id = new AtomicLong(0);
private static final Map<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 static long getId() {
return id.incrementAndGet();
}
}
如何实现集群环境的单例?
刚刚讲了 "进程唯一" 的单例和 "线程唯一" 的单例,现在再来看下, "集群唯一" 的单例。
首先,先解释下,什么是 "集群唯一" 的单例。
还是将它和 "进程唯一" "线程唯一" 做个对比。
- "进程唯一" 指的是进程内唯一、进程间不唯一。
- "线程唯一" 指的是线程内唯一、线程间不唯一。
- 集群相当于多个进程构成的一个集合, "集群唯一" 就相当于进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建一个类的多个对象。
经典的单例模式是进程内唯一的,那如何实现一个进程间也唯一的单例呢?如果严格按照不同的进程间共享同一个对象来实现,那集群唯一的单例实现起来就有点难度了。
具体来书哦,需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。
为了保证任何时刻,在进程内都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象后,还需要显式地将对象从内存中删除,并是否对象的加锁。
按照这个思路,使用伪代码实现了一下这个过程,具体如下所示:
java
public class IdGenerator {
private static AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private static SharedObjectStorage storage = new 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 synchronized void freeInstance() {
storage.save(this, IdGenerator.class);
instance = null;
lock.unlock();
}
public long getId() {
return id.incrementAndGet();
}
}
// IdGenerator使用举例
IdGenerator idGenerator = IdGenerator.getInstance();
long id = idGenerator.getId();
idGenerator.freeInstance();
如何实现一个多例模式?
跟单例模式概念相对应的还有一个多例模式。
"单例" 是指一个类只能创建一个对象。对应的,"多例" 指的就是,一个类可以创建多个对象,但是个数是有限制的,比如只能创建 3 个对象。就比如下面这样子:
java
public class BackendServer {
private long serverNo;
private String address;
private static final int SERVER_COUNT = 3;
private static final Map<Long, BackendServer> serverInstances = new HashMap<>();
static {
serverInstances.put(1L, new BackendServer(1L, "192.168.1.101:8080"));
serverInstances.put(2L, new BackendServer(2L, "192.168.1.102:8080"));
serverInstances.put(2L, new BackendServer(2L, "192.168.1.102:8080"));
}
private BackendServer(long serverNo, String address) {
this.serverNo = serverNo;
this.address = address;
}
public static BackendServer getInstance(long serverNo) {
return serverInstances.get(serverNo);
}
public static BackendServer getRandomInstance() {
Random r = new Random();
int no = r.nextInt(SERVER_COUNT) + 1;
return serverInstances.get(no);
}
}
实际上,对于多例模式,还有一种理解方式:同一类型的只能创建一个对象,不同类型的可以创建多个对象。
这里的 "类型" 如何理解?
还是通过一个例子来解释下,具体代码如下所示。在代码中, logger name
就是刚刚所说的 "类型",同一个 logger name
获取到的对象实例是相同的,不同的 logger name
获取到的对象实例是不同的。
java
public class Logger {
private static final Map<String, Logger> instances = new ConcurrentHashMap<>();
private Logger() {}
private static Logger getInstance(String loggerName) {
instances.putIfAbsent(loggerName, new Logger());
return instances.get(loggerName);
}
public void log() {
//...
}
}
// l1==l2, l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");
这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类对象。实际上,它还有点类似享元模式。此外,实际上,枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可以创建多个对象。
回顾
1.如何理解单例模式的唯一性
单例类中对象的唯一性的作用范围是 "进程唯一" 的。"进程唯一" 指的是进程内唯一,进程间不唯一;"线程唯一" 指的是线程内唯一,线程间不唯一。实际上,"进程唯一" 就意味着线程内、线程间都唯一,这也是 "进程唯一" 和 "线程唯一" 的区别之处。 "集群唯一" 指的是进程内唯一,进程间也唯一;
2.如何实现线程唯一的单例
通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java 语音本身提供了 ThreadLocal 并发工具类,可以更加轻松地实现线程唯一单例。
3.如何实现集群环境下的单例
需要把这个单例对象序列化并存储到外部共享存储区(如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区将它读取到内存,并反序列化成对象,然后再使用,使用完成后,还需要再存储回外部共享存储区。为了保证任何时刻在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用这个对象之后,需要显式地将对象从内存中删除,并释放对对象的加锁。
4.如何实现一个多例模式
"单例" 指的是一个类只能创建一个对象。对应的,"多例" 指的就是一个类可以创建多个对象,但是个数是有限制的,比如只能创建 3 个对象。多例的实现也比较简单,通过一个 Map 来存储对象类型和对象之间的关系,来控制对象的个数。