记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步
文章目录
单例模式
问题:
- 为什么要使用单例
- 单例存在哪些问题
- 单例与静态类的区别
- 有什么替代的解决方案
1. 为什么要使用单例
单例设计模式singleton design pattern,一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫单例设计模式。
2. 应用场景
为什么需要单例模式?能解决哪些问题?
实战案例1:处理资源访问冲突
public class Logger {
private FileWriter writer;
public Logger(){
File file = new File("xxx/xx/log.txt");
writer = new FileWriter(file,true);//true表示追加写入
}
public void log(String message){
writer.write(message);
}
}
// logger类的应用示例:
public class UserController {
private Logger logger = new Logger();
public void login(String username,String password){
//...省略业务逻辑代码...
logger.log(username+" logined!");
}
}
public class OrderController {
private Logger logger = new Logger();
public void create(OrderVo order){
//...省略业务逻辑代码...
logger.log("Created an order: "+order.toString());
}
}
上述代码中,所有的日志都写入到同一个文件中。在UserController和OrderController中,分别创建两个Logger对象。在web容器的servlet多线程环境下,如果两个servlet线程同时执行login()和create()两个方法,并同时写日志到log.txt中,可能存在日志信息相互覆盖的情况。
为什么相互覆盖?可以类比理解。多线程下,如果两个线程同时给共享变量加1,最后结果可能并不是加2,而是只加了1,因为是竞争资源。同理,这里的log.txt文件也是竞争资源,可能存在相互覆盖。
如何解决?首先想到加锁。给log()方法加互斥锁(java的synchronized关键字)。同一时刻只允许一个线程调用执行log()方法。
public void log(String message){
synchronized(this){
writer.write(message);
}
}
但是这样真的能解决多线程写入日志时互相覆盖的问题吗?不能,因为锁是对象级别的锁,不同的对象之间不共享这把锁。不同线程下,通过不同的对象调用执行log()方法,锁不起作用。
其实,FileWriter本身就是线程安全的,内部实现本身就加了对象级别的锁。再加对象锁多此一举。
那如何解决呢?只需要把对象锁换成类级别的锁即可。
public void log(String message){
synchronized(Logger.class){//类级别的锁
writer.write(message);
}
}
此外,解决资源竞争问题的方法还有很多,分布式锁就是常听到的解决方案。不过,实现一个安全可靠、无bug、高性能的分布式锁,不容易。此外,并发队列(如Java的BlockingQueue)也可解决该问题。多个线程同时往并发队列写日志,一个单独的线程负责将并发队列的数据,写入到日志文件。也稍微复杂。
相较来说,单例模式就简单多了。相对于类级别的锁的好处:不用创建那么多Logger对象,一方面节省内存空间,另一方面节省文件句柄(对OS说,文件句柄也是资源,不能随便浪费)。
将Logger设计为单例类,程序中只允许创建一个Logger对象,所有的线程共享使用的这个Logger对象,共享一个FileWriter对象。而FileWriter本身就是对象级别的线程安全的,避免多线程下写日志互相覆盖。
重新设计后:
public class Logger {
private FileWriter writer;
private static final Logger instance = new Logger();
public Logger(){
File file = new File("xxx/xx/log.txt");
writer = new FileWriter(file,true);//true表示追加写入
}
public static Logger getInstance(){
return instance;
}
public void log(String message){
writer.write(message);
}
}
// logger类的应用示例:
public class UserController {
public void login(String username,String password){
//...省略业务逻辑代码...
Logger.getInstance().log(username+" logined!");
}
}
实战案例2:表示全局唯一类
从业务上说,如果有些数据在系统中只应保存一份,比较适合设计为单例类,如配置信息类。在系统中,只有一个配置文件,被加载到内存后,以对象的形式存在,理应只有一份。还有,唯一递增ID号码生成器,如果程序有两个对象,会存在生成重复ID的情况,应设计为单例。
public class IdGenerator {
//AtomicLong是一个java并发库提供的原子变量类型
//将一些线程不安全需要加锁的复合操作封装为线程安全的原子操作,如下面的incrementAndGet()
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();
}
}
//使用举例
long id = IdGenerator.getInstance().getId();
当然,这两个代码实例Logger、IdGenerator设计的都并不优雅,如何改造留到之后处理。
3. 如何实现单例
有几种简单的经典方式,要实现一个单例,需要关注的无非几个方面:
- 构造函数是要private权限的,才能避免外部new创建实例
- 考虑对象创建时的线程安全问题
- 考虑是否支持延迟加载
- 考虑getInstance()性能是否高(是否加锁)
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的设计原则,也希望在程序启动时,就将实例初始化好。如果资源不够,尽早报错,可以立即去修复。
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();
}
}
缺点是给getInstance()加了锁,导致并发度很低。如果该单例被频繁使用,会导致频繁的加锁、释放锁,不可取。
3. 双重检测
既支持延迟加载,又支持高并发。只要instance被创建后,即使再调用getInstance()方法也不会再进入加锁逻辑。
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator(){}
public static synchronized 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后,还没来得及初始化(执行构造函数的代码逻辑),就被另一个线程使用。
解决这个问题,需要给instance成员变量加上volatile关键字,禁止指令重排。当然,只有低版本的java才有该问题,高版本的java已经在jdk内部解决该问题。
4. 静态内部类
比双检锁更简单的方法,利用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保证。既保证线程安全,又能延迟加载。
5. 枚举
最简单,就是枚举,保证实例创建的线程安性和实例的唯一性。
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId(){
return id.incrementAndGet();
}
}
4. 单例存在的问题
1. 对OOP特性的支持不友好
OOP的四大特性:封装、抽象、继承和多态。单例对于抽象、继承和多态的支持都不好。
IdGenerator的使用方式违背了基于接口而非实现的设计原则。也就违背了广义的OOP的抽象特性。如果之后我们希望对不同的业务采用不同的ID生成算法。如订单ID和用户ID采用不同的ID生成器来生成。为应对这个需求变化,需要修改所有用到IdGenerator的地方,改动很大。
此外,对于继承、多态支持也不友好。单例类理论上可以被继承、实现多态,但实现很奇怪,导致代码可读性变差。因此,如果选择将某个类设计为单例类,意味着放弃继承和多态,损失了应对未来需求变化的扩展性。
2. 单例会隐藏类之间的依赖关系
代码可读性非常重要,通过构造方法、参数传递等方式声明的类之间的依赖关系,通过查看方法的定义,很容易识别。但是,单例类不需要显式创建、不需要依赖参数传递,在方法中直接调用即可。如果代码复杂,调用关系非常隐蔽。
3. 单例对代码的扩展性不友好
单例只有一个对象实例,如果有天想要创建两个实例或多个实例,对代码有较大的改动。
可能会说,会有这种需求吗?既然大部分情况下都用来表示全局类,怎么会需要两个或多个实例呢?
实际需求并不少见,以数据库连接池为例。设计初期,觉得应该只有一个数据库连接池,方便控制对数据库连接资源的消耗。设计为单例类。之后发现有些SQL语句运行很慢,执行时长期占用连接资源,导致其他事情了请求无法响应。希望将慢SQL和其他SQL隔离开,需要创建两个数据库连接池,避免慢SQL影响其他SQL的执行。
如果设计为单例类,显然无法适应这样的需求变更。所以,数据库连接池、线程池这类资源池,最好不要设计为单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计为单例类。
4. 单例对代码的可测试性不友好
如果单例类依赖比较重的外部资源,如DB,写单元测试时,希望mock的方式替换,但单例这种硬编码的使用方式,无法mock替换。
此外,如果单例类持有成员变量(如IdGenerator的id成员变量),实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是个可变全局变量,也就是说,它的成员变量可被修改,编写单元测试时,还要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,导致测试结果相互影响的问题。
5. 单例不支持有参构造函数
如创建一个连接池的单例对象,没法通过参数指定连接池的大小。这种有哪些解决方案呢?
第一种:创建完实例后,再调用init()方法传递参数。需要注意,在使用这个单例类时,先调用init()方法,才能调用getInstance()方法,否则代码会抛异常。
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton(int paramA,int paramB){
this.paramA = paramA;
this.paramB = paramB;
}
public static Singleton getInstance(){
if (instance==null){
throw new RuntimeException("Run init() first.");
}
return instance;
}
public synchronized static Singleton init(int paramA,int paramB){
if (instance==null){
throw new RuntimeException("Singleton has been created.");
}
instance = new Singleton(paramA,paramB);
return instance;
}
}
Singleton.init(10,50);//先init,再使用
Singleton singleton = Singleton.getInstance();
第二种:将参数放到getInstance()方法中
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton(int paramA,int paramB){
this.paramA = paramA;
this.paramB = paramB;
}
public synchronized Singleton getInstance(int paramA,int paramB){
if (instance==null){
instance = new Singleton(paramA,paramB);
}
return instance;
}
}
Singleton singleton = Singleton.getInstance(10,50);
不过,上面代码稍有点问题,如果如下执行两次getInstance()方法,获取到的singleton1和singleton2的paramA和paramB都是10和50,也就是第二次的参数没有起作用。
Singleton singleton = Singleton.getInstance(10,50);
Singleton singleton = Singleton.getInstance(20,30);
第三种:将参数放到另一个全局变量中,具体代码如下。Config是一个存储了paramA和paramB值的全局变量。里面的值既可以像下面的代码通过静态常量定义,也可从配置文件加载得到。这种方式最值得推荐。
public class Config {
public static final int PARAM_A = 123;
public static final int PARAM_B = 123;
}
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton(){
this.paramA = Config.PARAM_A;
this.paramB = Config.PARAM_B;
}
public synchronized Singleton getInstance(){
if (instance==null){
instance = new Singleton();
}
return instance;
}
}
5. 有什么替代解决方案
为了表示全局唯一,除了使用单例,还可以使用静态方法实现。如
public class IdGenerator {
private static AtomicLong id = new AtomicLong(0);
public static long getId(){
return id.incrementAndGet();
}
}
//使用举例
long id = IdGenerator.getId();
不过静态方法这种实现,更不灵活,还有另外一种方法:
//1. 老的使用方式
public demoFunction(){
//...
long id = IdGenerator.getInstance().getId();
//...
}
//2. 新的使用方式:注入依赖
public demoFunction(IdGenerator idGenerator){
long id = idGenerator.getId();
}
//外部调用demoFunction()时,传入idGenerator
IdGenerator idGenerator = IdGenerator.getInstance();
demoFunction(idGenerator);
新的使用方式,将单例生成的对象,作为参数传递给方法(也可通过构造方法传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。不过对于单例的其他问题如OOP特性支持、扩展性、可测试性不好等问题,还是无法解决。
实际上,要解决这些问题,需要从根本上,寻找其他方式实现全局唯一类。实际上,类对象的全局唯一性可通过多种不同的方式来保证。既可以通过单例模式强制保证,也可通过工厂模式、IOC容器(如spring IOC容器)保证,还可通过程序员自己保证(自己编写代码时,保证不创建两个类对象)。类似于java的内存对象的释放由JVM负责,而C++由程序员自己负责一样。
6. 如何理解单例模式中的唯一性
单例的定义:一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫单例模式。
对象的唯一性的作用范围是什么呢?是线程还是进程唯一?答案是后者。
单例类中对象的唯一性的作用范围是进程内,在进程间不唯一。
7. 如何实现线程唯一的单例
什么是线程唯一的单例,线程唯一和进程唯一的区别
进程唯一指的是进程内唯一,进程间不唯一。线程唯一指的线程内唯一,线程间可以不唯一。而进程唯一还代表了线程内、线程间都唯一。
线程唯一的单例的代码实现很简单。通过一个HashMap存储对象,key是线程ID,value是对象。这样就能不同线程对应不同的对象,同一个线程只能对应一个对象。实际上,java语言本身提供了ThreadLocal工具类,可更轻松的实现线程唯一单例。ThreadLocal底层实现原理也是基于HashMap
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();
}
}
8. 如何实现集群环境下的单例
什么是集群唯一的单例
集群相当于多个进程构成的一个集合,集群唯一就相当于进程内唯一,进程间也唯一。不同的进程间共享同一个对象,不能创建同一个类的多个对象。
如果严格按照不同的进程间共享同一个对象来实现,那集群唯一的单例实现起来有点难度。
具体说,需要把这个单例对象序列化并存储到外部共享存储区(如文件),进程在使用该单例对象时,先从外部共享存储区将其读取到内存,并反序列化为对象,再使用,用完后再存储回外部共享存储区。
为保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象后,需要对对象加锁,避免其他进程再将其获取,用完后,显式的把对象从内存中删除,并释放对象的锁。
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 synchronized void freeInstance(){
storage.save(this,IdGenerator.class);
instance = null;//释放对象
lock.unlock();
}
public long getId(){
return id.incrementAndGet();
}
}
//使用举例
IdGenerator idGenerator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance();
9.如何实现一个多例模式
多例,一个类可以创建多个对象,但是个数是有限制的,如智能创建3个对象,如下:
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.134.11.111:8080"));
serverInstances.put(2L,new BackendServer(2L,"192.134.11.112:8080"));
serverInstances.put(3L,new BackendServer(3L,"192.134.11.113:8080"));
}
private BackendServer(long serverNo,String serverAddress){
this.serverNo = serverNo;
this.serverAddress = serverAddress;
}
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);
}
}
实际上,对于多例,还有一种理解:同一类型的只能创建一个对象,不同类型的可创建多个对象。这里的“类型”如何理解?
举例,在下面代码中,logger name就是刚才说的类型,同一个logger name获取到的对象实例是相同的,不同的logger name获取到的对象实例是不同的。
public class Logger {
private static final ConcurrentHashMap<String,Logger> instances = new ConcurrentHashMap<>();
private Logger(){}
public 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");
这种多例模式的理解方式类似工厂模式。不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的对象是不同子类的对象。此外,枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可创建多个对象。
对于java语言,单例类对象的唯一性的作用范围不是进程,而是类加载器class loader。为什么?
java中两个类来源于同一个class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。
单例类对象的唯一性前提是必须保证该类被同一个类加载器加载。