设计模式之美笔记8 柔光的暖阳◎ 2022-11-26 07:52 153阅读 0赞 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 ### 文章目录 ### * * 单例模式 * * 1. 为什么要使用单例 * 2. 应用场景 * * 实战案例1:处理资源访问冲突 * 实战案例2:表示全局唯一类 * 3. 如何实现单例 * * 1. 饿汉式 * 2. 懒汉式 * 3. 双重检测 * 4. 静态内部类 * 5. 枚举 * 4. 单例存在的问题 * * 1. 对OOP特性的支持不友好 * 2. 单例会隐藏类之间的依赖关系 * 3. 单例对代码的扩展性不友好 * 4. 单例对代码的可测试性不友好 * 5. 单例不支持有参构造函数 * 5. 有什么替代解决方案 * 6. 如何理解单例模式中的唯一性 * 7. 如何实现线程唯一的单例 * 8. 如何实现集群环境下的单例 * 9.如何实现一个多例模式 ## 单例模式 ## 问题: * 为什么要使用单例 * 单例存在哪些问题 * 单例与静态类的区别 * 有什么替代的解决方案 ### 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文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。 **单例类对象的唯一性前提是必须保证该类被同一个类加载器加载。**
相关 设计模式之美笔记16 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 解释器模式 解释器模式的原理和实现 深藏阁楼爱情的钟/ 2022年12月01日 11:53/ 0 赞/ 136 阅读
相关 设计模式之美笔记15 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 访问者模式 访问者模式的诞生 我就是我/ 2022年12月01日 05:16/ 0 赞/ 143 阅读
相关 设计模式之美笔记14 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 状态模式 背景 什么 水深无声/ 2022年11月30日 15:51/ 0 赞/ 146 阅读
相关 设计模式之美笔记13 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 策略模式 策略模式的原理和实现 忘是亡心i/ 2022年11月30日 12:27/ 0 赞/ 145 阅读
相关 设计模式之美笔记12 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 观察者模式 原理及应用场景剖析 深碍√TFBOYSˉ_/ 2022年11月30日 04:18/ 0 赞/ 174 阅读
相关 设计模式之美笔记11 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 门面模式 门面模式的原理和实现 ゞ 浴缸里的玫瑰/ 2022年11月28日 13:41/ 0 赞/ 155 阅读
相关 设计模式之美笔记10 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 序言 代理模式 桥接模式 柔情只为你懂/ 2022年11月28日 10:36/ 0 赞/ 149 阅读
相关 设计模式之美笔记9 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 工厂模式 1. 简单工厂 待我称王封你为后i/ 2022年11月28日 00:41/ 0 赞/ 148 阅读
相关 设计模式之美笔记8 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 单例模式 1. 为什么要使用单例 柔光的暖阳◎/ 2022年11月26日 07:52/ 0 赞/ 154 阅读
相关 设计模式之美笔记7 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 实战1:id生成器的重构 1. 需求背景 女爷i/ 2022年11月25日 13:19/ 0 赞/ 186 阅读
还没有评论,来说两句吧...