单例模式
单例模式
为什么要使用单例?
- 单例设计模式(Singleton Design Pattern):一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
实战案例一:处理资源访问冲突
文中样例
- 一个往文件中打印日志的 Logger 类。
- 构造方法中,打开日志文件 log.txt,将写入文件句柄赋值给属性 writer。
- log() 方法中调用 writer.write() 写入日志内容。
- 使用此 Logger 类时,首先实例化 new Logger(),然后调用log() 方法写入日志。
样例中问题及解决方案
- Logger 类中的日志都写到同一个文件 log.txt 中,在多线程环境下,当 Logger 类被多处调用者实例化后,大家同时写 log.txt,可能会存在互相覆盖。
- 首先我们想到的是加锁方案,在 log() 方法中调用 writer.write() 前加入一个对象锁。但是在不同线程下,不同对象调用并不会共享一把锁,问题依然存在。
进一步解决方案
- 换成类级别锁,让所有对象都共享一把锁。 具体方案,在 log() 方法中调用 writer.write() 前加入一个类锁。
- 分布式锁也是一种解决方案,不过,实现一个安全可靠、无bug、高性能的分布式锁,并不是件容易的事情。
- 并发队列解决方案,多个线程同时往并发队列写日志,一个单独线程将并发队列中数据写入日志文件,也是一个稍复杂的方案。
单例模式解决方案
- 增加 getInstance() 方法,此方法中返回属性 instance。
- 而 private static final Logger instance = new Logger()。
- 调用的时候直接 Logger.getInstance().log(),这样 Logger 类就只会被实例化一次。
- 好处是可以不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄。
单例 Logger 类设计:
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
public class Logger {
private FileWriter writer;
private static final Logger instance = new Logger();
private Logger() {
File file = new File("log.txt");
try {
writer = new FileWriter(file, true);
} catch (IOException e) {
e.printStackTrace();
}
}
public static Logger getInstance() {
return instance;
}
public void log(String message) {
try {
synchronized(Logger.this) {
writer.write(message);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
Logger 类的使用:
public class OrderController {
public void create(String ordername) {
Logger.getInstance().log(ordername);
}
}
public class UserController {
public void login(String username, String password) {
Logger.getInstance().log(username + " logined!");
}
}
实战案例二:表示全局唯一类
- 业务场景:
- 有些数据在系统中只应保存一份,比如配置信息类。
- 唯一递增ID号码生成器,不能生成重复的ID。
- 文中样例:
- IdGenerator 类中 getInstance() 方法返回 instance。
- 而 private static final IdGenerator instance = new IdGenerator()。
- 获取 id 方法 getId() 中,返回 id.incrementAndGet()。
- 使用的时候,直接 IdGenerator.getInstance().getId()。
如何实现一个单例?
实现一个单例关注点:
- 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例。
- 考虑对象创建时的线程安全问题.
- 考虑是否支持延迟加载.
- 考虑 getInstance() 性能是否高(是否加锁)。
饿汉式
- 加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。这样的实现方式不支持延迟加载。
- 有的人认为,提前初始化实例是一种浪费资源的行为,最好的方法应该在用到的时候再去初始化。理由是实例占用资源多或者初始化耗时长:但是如果初始化耗时长,那我们更不能等到要用它的时候再初始化,会影响到系统的性能。如果实例占用资源多,最好在程序启动的时候就能触发报错。
- 前文的例子都是饿汉式。
饿汉式实现:
import java.util.concurrent.atomic.AtomicLong;
public class eagerIdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final eagerIdGenerator instance = new eagerIdGenerator();
private eagerIdGenerator() {}
public static eagerIdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
懒汉式
- 懒汉式相对于饿汉式的优势是支持延迟加载。
- 以前文中的 IdGenerator 类举例,在 getInstance() 方法中才会 new IdGenerator() 赋值给属性 instance。
- 不过这样的缺点显而易见,我们给 getInstance() 方法加了锁,导致并发度很低,相当于串行。如果这个单例类会被频繁用到,那么频繁加锁、释放锁会降低并发,导致性能瓶颈。
懒汉式实现:
public class lazyIdGenerator {
private AtomicLong id = new AtomicLong(0);
private static lazyIdGenerator instance;
private lazyIdGenerator() {}
public static synchronized lazyIdGenerator getInstance() {
if (instance == null) {
instance = new lazyIdGenerator();
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
双重检测
- 在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中。即支持延迟加载,又解决并发度低的问题。
- 我们在 getInstance() 方法中,new IdGenerator() 前加入类级别锁。
双重检测实现:
public class doubleCheck {
private AtomicLong id = new AtomicLong(0);
private static volatile doubleCheck instance;
private doubleCheck() {}
public static doubleCheck getInstance() {
if (instance == null) {
synchronized(doubleCheck.class) {
if (instance == null) {
instance = new doubleCheck();
}
}
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
静态内部类
- 一种比双重检测更加简单的实现方法,那就是利用 Java 的静态内部类。
- 在 IdGenerator 类内部创建一个静态内部类 SingletonHold,在此静态内部类里面添加属性 private static final IdGenerator instance = new IdGenerator()。
- 当类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。
静态内部类实现:
import java.util.concurrent.atomic.AtomicLong;
public class staticInner {
private AtomicLong id = new AtomicLong(0);
private staticInner() {}
private static class SingleHolder {
private static final staticInner instance = new staticInner();
}
public static staticInner getInstance() {
return SingleHolder.instance;
}
public long getId() {
return id.incrementAndGet();
}
}
枚举
- 一种最简单的实现方式,这种实现方式通过 Java 枚举类型本身的特性,基于枚举类型的单例实现。
- 在 IdGenerator 类中定义枚举类型 INSTANCE。
枚举实现:
import java.util.concurrent.atomic.AtomicLong;
public enum enumIdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() {
return id.incrementAndGet();
}
}
单例存在哪些问题?
1.单例对OOP特性的支持不友好
- 单例这种设计模式对于OOP四大特性中的抽象、继承、多态都支持得不好。
- 通过 IdGenerator 这个例子来讲解,生成 ID 的调用处这样写 long id = IdGenerator.getInstance().getId()。如果我们希望针对不同的业务采用不同的 ID 生成算法,我们需要修改所有用到 IdGenerator 类的地方,比如修改成 long id = UserIdGenerator.getIntance().getId(),这样代码的改动就会比较大。
- 单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。
2.单例会隐藏类之间的依赖关系
- 单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
3.单例对代码的扩展性不友好
- 单例类只能有一个对象实例,如果我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。
- 你可能会觉得不会有这样的需求,下面以数据库连接池来举例解释一下。在系统设计初期,我们觉得系统中只应该有一个数据库连接,但之后我们发现,系统中有些 SQL 语句运行得非常慢,我们希望将慢 SQL 与其他 SQL 隔离开来执行。如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。
4.单例对代码的可测试性不友好
- 如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
- 单例类持有成员变量(比如 IdGenerator中 的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。
5.单例不支持有参数的构造函数
- 单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小:
- 第一种解决思路是:创建完实例之后,先调用 init() 函数传递参数,再调用 getInstance() 方法。
- 第二种解决思路是:将参数放到 getIntance() 方法中,比如 Singleton singleton = Singleton.getInstance(10,50)。这里有个问题,因为代码中对 instance == null 有判断,那么第一次实例化后,第二次传入的参数是不起作用的。
- 第三种解决思路是:将参数放到另外一个全局变量中。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。
有何替代解决方案?
- 为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。
- 单例除了我们之前讲到的使用方法之外,还有另外一种依赖注入的使用方法。比如 IdGenerator idGenerator = IdGenerator.getInsance();demofunction(idGenerator)。基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。
- 不过,如果要完全解决OOP特性、扩展性、可测性不友好等问题,我们可能要从根上,寻找其他方式来实现全局唯一类。以通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。
如何理解单例模式中的唯一性?
- 单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。
如何实现线程唯一的单例?
- “进程唯一”还代表了线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。
- 假设 IdGenerator 是一个线程唯一的单例类。在线程A内,我们可以创建一个单例对象 a。因为线程内唯一,在线程 A 内就不能再创建新的 IdGenerator 对象了,而线程间可以不唯一,所以,在另外一个线程 B 内,我们还可以重新创建一个新的单例对象 b。
- 代码中,我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。
线程唯一单例模式实现:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public class ThreadIdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final ConcurrentHashMap<Long, ThreadIdGenerator> instances = new ConcurrentHashMap<>();
private ThreadIdGenerator() {}
public static ThreadIdGenerator getInstance() {
Long currentThread = Thread.currentThread().getId();
instances.putIfAbsent(currentThread, new ThreadIdGenerator());
return instances.get(currentThread);
}
public long getId() {
return id.incrementAndGet();
}
}
如何实现集群环境下的单例?
- 集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。
- 我们需要把这个单例对象序列化并存储到外部共享储区(比如文件)。为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。
如何实现一个多例模式?
- “多例”指的就是,一个类可以创建多个对象,但是个数是有限制的,比如只能创建3个对象。
- 对于多例模式,还有一种理解方式:同一类型的只能创建一个对象,不同类型的可以创建多个对象。
- 在代码中,logger name 就是刚刚说的“类型”,同一个 logger name 获取到的对象实例是相同的,不同的 logger name 获取到的对象实例是不同的。这种多例模式的理解方式有点类似工厂模式,它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象。
多例模式实现:
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
public class BackendServer {
private long serverNo;
private String serverAddress;
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.01.1:8080"));
serverInstances.put(2L, new BackendServer(2L, "192.168.01.2:8080"));
serverInstances.put(3L, new BackendServer(3L, "192.168.01.3:8080"));
}
private BackendServer(long serverNo, String serverAddress) {
this.serverAddress = serverAddress;
this.serverNo = serverNo;
}
public BackendServer getInstance(long serverNo) {
return serverInstances.get(serverNo);
}
public BackendServer getRandomInstance() {
Random r = new Random();
int no = r.nextInt(SERVER_COUNT)+1;
return serverInstances.get(no);
}
}