模式实践
单例模式的一些实际应用场景。处理资源访问冲突问题,处理全局唯一类问题。
设计一个文件写入无冲突的日志工具
当我们使用日志类去写日志时,自定义实现了一个往文件中打印日志的 Logger 类。具体的代码实现如下所示:
public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/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()); } }
所有的日志都写入到同一个文件 /Users/log.txt
中。在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。
我们最先想到的就是通过加锁的方式:给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字),同一时刻只允许一个线程调用执行 log() 函数。具体的代码实现如下所示
public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/log.txt"); writer = new FileWriter(file, true); //true表示追加写入 } public void log(String message) { synchronized(this) { writer.write(mesasge); } } }
但是这并不能解决相互写入的问题,这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用 log() 函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行 log() 函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题
1 使用类锁解决资源冲突问题
要想解决这个问题有如下几种思路,首先就是将对象锁升级为类锁。
- 给日志类的实现加类锁,让所有的对象都共享同一把锁。这样就避免了不同对象之间同时调用 log() 函数,而导致的日志覆盖问题
public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/log.txt"); writer = new FileWriter(file, true); //true表示追加写入 } public void log(String message) { synchronized(Logger.class) { // 类级别的锁 writer.write(mesasge); } } }
- 使用分布式锁,但是实现一个安全可靠、无 bug、高性能的分布式锁,并不是件容易的事情
- 并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂
2 使用单例模式解决资源冲突问题
抛开以上复杂的设计,最简单的实现方式就是单例模式,让Logger类只有一个对象,再加上 FileWriter 本身就是线程安全的,它的内部实现中本身就加了对象级别的锁:
因为唯一的 Logger 对象有持有唯一的 FileWriter 对象,所以,FileWriter 对象级别的锁也解决了数据写入互相覆盖的问题。那么对象锁的作用就等同于类锁了:饿汉式单例实现
public class Logger { private FileWriter writer; private static final Logger instance = new Logger(); private Logger() { File file = new File("/Users/log.txt"); writer = new FileWriter(file, true); //true表示追加写入 } public static Logger getInstance() { return instance; } public void log(String message) { writer.write(mesasge); } } // Logger类的应用示例: public class UserController { public void login(String username, String password) { // ...省略业务逻辑代码... Logger.getInstance().log(username + " logined!"); } } public class OrderController { public void create(OrderVo order) { // ...省略业务逻辑代码... Logger.getInstance().log("Created a order: " + order.toString()); } }
从这我们也能看的出,单例模式并不能解决方法调用的并发问题,只是能保证一个类有一个实例,如果要解决并发问题,还需要配合对象锁使用,单例模式+对象锁=类锁,这很好理解,当一个类只有一个对象的时候,当然对象锁和类锁的作用就想同了。单例模式相对于之前类级别锁的好处是,不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)
设计一个网站访问数自增器
从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。
再比如,唯一递增 ID 号码生成器,如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例,饿汉式单例实现
package com.example.springboot.test; import java.util.concurrent.atomic.AtomicLong; /** * * @Name MainTest * * @Description * * @author tianmaolin * * @Date 2022/4/7 */ public class MainTest { public static void main(String[] args) { // IdGenerator使用举例 long id = IdGenerator.getInstance().getId(); System.out.println(id); } } 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(); } }
如果不是单例的,那么两个线程分别调用自增器对象的getId方法同时依据现有值自增,本来应该累加2,就成了累加1了。当只有一个自增器对象的时候,即使多个线程调用,因为自增方法是线程安全的原子操作,所以不会有冲突的情况。这和上边的日志覆写例子类似,单例保证只有一个对象,再加上对象操作方法本身是线程安全的。
当然全局唯一类也能通过:工厂模式、IOC 容器(比如 Spring IOC 容器)来保证
模式对比
单例模式有几种全局唯一实现的替代方案:静态方法,工厂模式、IOC 容器(比如 Spring IOC 容器),我们重点关注静态方法和单例模式的对比
单例模式与静态方法
要想实现和单例模式一样的效果,实际上我们通过静态方法也可以,这样就不用考虑类实例的创建过程了,直接用类的静态方法。例如:
// 静态方法实现方式 public class IdGenerator { private static AtomicLong id = new AtomicLong(0); public static long getId() { return id.incrementAndGet(); } } // 使用举例 long id = IdGenerator.getId();
其它的使用大同小异。有以下几点区别:
- 单例模式某些实现方式(懒汉式单例)支持延迟加载,但静态方法不支持延迟加载。
- 静态方法是面向过程的,而非面向对象的编程思想,单例是一个对象,能够实现接口或者继承一个父类,但类的静态方法不行。静态方法只能使用静态成员变量,单例可以动态加载一些内容,比如属性有其他类的对象(组合)。
- 单例模式是利用唯一的实例保存系统的状态,提供的实例方法也是为了对这个唯一的实例进行操作,而静态方法多是一些工具方法,Math 类中的静态方法就是一个典型的例子,如果仅仅是想不自己创建类的实例就可以调用到某些方法来完成一定的操作,那完全没必要也不应该使用单例模式
- 从生命周期上来看,静态方法的类会在代码编译的时候就被加载,静态方法中产生的对象句柄,会随着静态方法执行完毕而释放掉,对象也会在不再有引用的时候消失,而且执行类中的静态方法时,不会实例化静态方法所在的类。如果用单例模式, 产生的那一个唯一的实例,会一直在内存中,不会被GC清除的(原因是静态的属性变量不会被GC清除),除非整个应用退出了JVM
其实只要明确一点:静态方法是类方法,类方法面向过程;而单例模式是唯一实例对象,面向对象;以上的几点区别都基于此。所以静态方法一般适用于做一些Util类,例如JsonUtil,StringUtil。而单例适合做唯一实例管理器-就像简单工厂模式中提到的单例+简单工厂模式、日志处理器、自增序号生成器。
模式扩展
扩展了解下单例模式的唯一性的范围以及多例模式的概念,理论层面上打开一下眼界。
单例唯一性范围
单例类中对象的唯一性的作用范围是进程唯一的。
- 集群唯一指的是进程内唯一、进程间也唯一,进程内、进程间都唯一
- 进程唯一指的是进程内唯一、进程间不唯一,线程内、线程间都唯一
- 线程唯一指的是线程内唯一、线程间不唯一。
对于 Java 语言来说,单例类对象的唯一性的作用范围并非仅仅是进程,而是类加载器(Class Loader),因为不同的类加载器也会生成不同的单例类,进而生成不同的单例对象。
集群唯一单例实现方式
要想创建集群内唯一单例,我们需要通过借助外部文件的方式
- 把单例对象序列化并存储到外部共享存储区(比如文件)
- 进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用。一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁
- 使用完成之后还需要再存储回外部共享存储区
以上条件的伪代码实现如下:懒汉式单例实现
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();
线程唯一单例实现方式
我们通过一个 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() {} //ConcurrentHashMap保证线程安全 public static IdGenerator getInstance() { Long currentThreadId = Thread.currentThread().getId(); instances.putIfAbsent(currentThreadId, new IdGenerator()); return instances.get(currentThreadId); } public long getId() { return id.incrementAndGet(); } }
多例模式的概念
单例指的是一个类只能创建一个对象。对应地,多例指的就是一个类可以创建多个对象,但是个数是有限制的,比如只能创建 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.22.138:8080")); serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080")); serverInstances.put(3L, new BackendServer(3L, "192.134.22.140: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); } }
这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象
总结一下
今天总结了一套学习设计模式的模版来不断套用学习模式,并且对历史学习的单例进行了一个整合性学习,加深了单例的概念内容探究。重新回顾了饿汉式单例实现、懒汉式单例实现、双检锁单例实现、静态内部类单例实现以及常规单例的反射和序列化破坏,最终祭出大杀器:枚举单例,算是一篇大汇总吧,同时也发散了一下自己的思维,单例的唯一性在什么限定范围下?多例模式是什么?很多看起来单例的应用场景是如何甄别为不适用于单例,单例的反模式反在哪里?以及单例的最佳应用场景:对于一个类判定其后续不扩展且不依赖外部并且试图全局唯一最好使用单例模式