概述
设计原则是指导我们代码设计的一些经验总结,也就是“心法”;面向对象就是我们的“武器”;设计模式就是“招式”。
以心法为基础,以武器运用招式应对复杂的编程问题。
表妹:哥啊,为什么电脑上只能打开一个任务管理器呢?
我:首先,如果可以打开多个窗口,但这些窗口显示的内容是完全一致的,这是一种资源浪费;其次,如果这些窗口显示的内容不一致的话,就意味着某一瞬间系统有多个状态,这与实际不符,也会给用户带来误解。
表妹:原来是这样子~
我:这就用到了设计模式中的单例模式。
确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。
为什么要使用单例模式?
避免资源访问冲突
比如,往文件中打印日志的Logger类:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new file("D:/Log/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 name, String password) {
// 省略业务逻辑代码
logger.log(name + " logined!");
}
}
public class OrderController {
private Logger logger = new Logger();
public void create(OrderVo order) {
// 省略业务逻辑代码
logger.log("Created an order: " + order.toString());
}
}
从上面的代码中,我们可以看到,所有的日志都是写到同一个文件D:/Log/log.txt中的。很明显,在并发写入的时候,就会出现日志信息相互覆盖的情况。如下图所示:
可能有同学会说,可以加锁来解决并发问题。
是的,确实可以。但是,其实日志文件只有一份,没必要创建那么多Logger对象,毕竟系统的文件句柄是有限的资源。
所以,我们将Logger设计成一个单例类,程序中只允许创建一个Logger对象,所有的线程共享使用的Logger对象,共享一个FileWriter对象,而且FileWriter本身是对象级别线程安全的,也就避免了多线程情况下写日志会互相覆盖的问题。
public class Logger {
private FileWriter writer;
private static final Logger instance = new Logger();
private Logger() {
File file = new File("");
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 name, String password) {
// 省略业务逻辑代码
Logger.getInstance().log(name + " logined!");
}
}
public class OrderController {
private Logger logger = new Logger();
public void create(OrderVo order) {
// 省略业务逻辑代码
Logger.getInstance().log("Created a order: " + order.toString());
}
}
表示全局唯一
从业务概念上,有些数据在系统中只需要保存一份,这种就比较适合设计成单例类。
比如系统在运行的时候,需要加载一些配置和属性,这些配置和属性是共享的,且在整个生命周期内都存在的,所以只需要存在一份就够了。
如果每次都在需要用到的时候重新new一个出来,再进行赋值,显然是浪费资源的,再赋值也没有意义,所以应该将其设计成单例类,全局只维护一份。
单例模式的实现方式
理解了在开发中使用单例设计模式的原因以后,现在来看看到底如何实现单例模式。
要实现一个单例,我们需要关注的点有如下几个:
- 构造函数需要是private,这样才能避免外部通过new创建实例;
- 考虑对象创建时的线程安全问题;
- 考虑是否支持延迟加载;
- 考虑getInstance()性能是否高(是否加锁)。
还是以递增的ID生成器为例子:
- 饿汉式
这种方式在类加载的时候,instance静态实例就已经创建并初始化好了,而不是在真正用到该类的时候,再创建实例。所以,这种方式的单例创建过程是线程安全的。
import java.util.concurrent.atomic.AtomicLong;
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的原则(有问题尽早暴露),那我们也希望在程序启动的时候就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错,可以立即去修复。避免在程序运行过程中触发报错,影响系统的可用性。
- 懒汉式
可能有同学会说,饿汉式提前初始化实例,是一种浪费资源的行为,应该需要用到的时候,再去初始化。那么,懒汉式这种方式就支持延迟加载。
import java.util.concurrent.atomic.AtomicLong;
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()这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。如果这个单例类偶尔被调用到,该方式还是可以接受的。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。
- 双重检测
饿汉式不支持延迟加载,懒汉式的并发度较低。那我们来看一种既支持延迟加载,又避免并发度较低的单实例实现方式,也就是双重检测实现方式。
import java.util.concurrent.atomic.AtomicLong;
public class IDGenertor{
private AtomicLong ID = new AtomicLong(0);
// 这里加上volatile关键字的原因:
// 禁止指令重排序优化,保证instance变量被赋值的时候对象已经是初始化过的。
private static volatile IDGenerator instance = null;
private IDGenerator() {}
public static IDGenerator getInstance() {
if (instance == null) {
synchronized(IDGenerator.class) { // 类级别锁
if (instance == null) {
instance = new IDGenerator();
}
}
}
return instance;
}
public long getID() {
return ID.incrementAndGet();
}
}
这种实现方式,只要instance被创建以后,即便再调用getInstance()函数也不会再进入加锁逻辑中了。所以,避免了并发度较低的问题。
- 静态内部类
这是一种比双重检测更加简单的实现方式。
import java.util.concurrent.atomic.AtomicLong;
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来保证。所以,这种方式既保证了线程安全,又能做到延迟加载。
- 枚举
这是一种最简单的实现方式,通过Java枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。
import java.util.concurrent.atomic.AtomicLong;
public enum IDGenerator {
// 枚举元素本身就是单例
INSTANCE;
private AtomicLong ID = new AtomicLong(0);
public long getID() {
return ID.incrementAndGet();
}
}
这种实现方式可以天然的防止反射和反序列化调用,但是不支持延迟加载。
以上几种实现方式,怎么选择呢?
- 如果单例对象占用资源少,不需要延迟加载,那么,枚举好于饿汉式;
- 如果单例对象占用资源多,需要延迟加载,静态内部类好于懒汉式。
单例模式的优缺点
设计模式是前辈们对代码开发经验的总结,是解决特定问题的一系列套路,也就是我们与“敌人”交手时用的“招式”。我们来看一下,单例设计模式有哪些优点:
- 单例模式在内存中只有一个实例,减少了内存开销。
- 对于一个对象需要频繁创建、销毁时,而且创建或销毁时性能又无法优化的时候,单例模式能够减少系统的性能开销。
- 单例模式可以避免对资源的多重占用,例如上面的Logger类,由于使用单例模式后,只有一个实例存在,避免对同一个资源文件的同时写操作。
- 单例模式可以在系统设置全局的访问点,优化和共享资源访问,比如,可以设计一个单例类,负责所有数据表的映射处理。
虽然它是我们前辈们总结出来的经验,但并不代表该设计模式就是完美的。我们来看一下,单例设计模式的一些缺点:
1、单例设计模式对抽象、继承、多态都支持得不好。我们还是通过递增ID生成器的例子来解释一下:
public class Order {
public void create(...) {
// 省略一些业务逻辑代码
long ID = IDGenerator.getInstance().getID();
// 省略一些业务逻辑代码
}
}
public class User {
public void create(...) {
// 省略一些业务逻辑代码
long ID = IDGenerator.getInstance().getID();
// 省略一些业务逻辑代码
}
}
现在IDGenerator的使用方式是违背了基于接口而非实现编程的原则。假如有一天,我们希望订单ID和用户ID分别采用不同的ID生成方法,那么需要修改所有用到IDGenerator类的地方,这样的代码改动是比较大的。
public class Order {
public void create(...) {
// 省略一些业务逻辑代码
long ID = IDGenerator.getInstance().getID();
// 需要将上面一行代码,改成下面一行代码
long ID = OrderIDGenerator.getInstance().getID();
// 省略一些业务逻辑代码
}
}
public class User {
public void create(...) {
// 省略一些业务逻辑代码
long ID = IDGenerator.getInstance().getID();
// 需要将上面一行代码,改成下面一行代码
long ID = UserIDGenerator.getInstance().getID();
// 省略一些业务逻辑代码
}
}
你看,这不就违背了我们之前学习过的设计原则之【开放封闭原则】吗?所以,对扩展性不友好。
以上是针对抽象而言的,至于继承和多态,虽然单例类可以做到,但是会让代码的可读性降低。
2、单例会隐藏类之间的依赖关系。我们知道,如果通过构造函数、参数传递等方式声明的类之间的依赖关系,可以通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。在阅读代码的时候,就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
3、单例对代码的可测试性不友好。如果单例类依赖比较重的外部资源,比如DB,我们在写单元测试的时候,希望能通过mock的方式将其替换掉,但是单例类这种硬编码的方式,导致无法实现mock替换;其次,单例类中如果持有成员变量的话(比如IDGenerator中的成员变量ID),它实际上就相当于一种可变的全局变量,这也会影响代码的可测试性的。
4、在一些情况下,单例模式可能与单一职责原则有冲突。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,使得单例类的职责过重。
尽管如此,并不是说,单例设计模式就是一种反模式,毕竟,设计模式是前辈们经过无数的实践总结出来的“招式”。这个“招式”适不适合应用在这里?应用在这里会不会违背设计原则?会不会破坏面向对象编程风格?又会不会降低代码质量?这些都是要相互权衡的,没有一种设计模式是完美的。
其实,类对象的全局唯一性还可以通过其他方式来保证,单例模式只是其中一种。
接下来,我们来看看,单例设计模式一般有哪些应用场景。
适用场景
- 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器或资源管理器。
- 在整个项目中需要一个共享访问点或共享数据,比如一个Web页面上的计数器,可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的。
- 创建一个对象需要消耗的资源过多,如要访问IO和数据库等资源。
- 需要定义大量的静态常量和静态方法(如工具类)的环境,可以采用单例模式(当然,也可以直接声明为static的方式)。
总结
设计模式没有对错,关键看你怎么用。
参考
极客时间专栏《设计模式之美》
《设计模式之禅》
最后
以上就是拼搏火龙果为你收集整理的设计模式之【单例模式】的全部内容,希望文章能够帮你解决设计模式之【单例模式】所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复