设计模式与范式 --- 创建型模式(单例模式)

简介: 设计模式与范式 --- 创建型模式(单例模式)

写在前:创建型模式主要是解决对象对的创建过程,封装复杂的创建过程。解耦对象的【创建代码和使用代码】。


1.概述



对于系统中的某些类来说,只有一个实例很重要,例如,一个系统中可以存在多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。


如何保证一个类只有一个实例并且这个实例易于被访问呢?定义一个全局变量可以确保对象随时都可以被访问,但不能防止我们实例化多个对象。一个更好的解决办法是让类自身负责保存它的唯一实例。这个类可以保证没有其他实例被创建,并且它可以提供一个访问该实例的方法。这就是单例模式的模式动机。


单例模式(Singleton Pattern):一个类只允许创建一个实例(仅有一个实例),而且自行实例化并向整个系统提供这个实例。这个类称为单例类,它提供全局访问的方法。


单例是一种创建型设计模式,需要注意三个要点:


  • 一个类只能有一个实例
  • 必须自行创建这个实例(不能通过反射创建,会实例化对象)
  • 必须自行向整个系统提供这个实例


2.应用场景



那为什么需要单例这种设计模式呢,正如概述中提到的问题,它能解决哪些问题?


案例1:处理资源访问冲突

/**
    自定义实现:往文件打印日志的Logger类
**/
public class Logger {
  private FileWriter writer;
  public Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  public void log(String message) {
    writer.write(mesasge);
  }
}
// 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/wangzheng/log.txt 中。在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。


为什么会出现互相覆盖呢?我们可以这么类比着理解。在多线程环境下,如果两个线程同时给同一个共享变量加 1,因为共享变量是竞争资源,所以,共享变量最后的结果有可能并不是加了 2,而是只加了 1。同理,这里的 log.txt 文件也是竞争资源,两个线程同时往里面写数据,就有可能存在互相覆盖的情况。


那如何来解决这个问题呢?因为FileWriter 本身就是线程安全的,它的内部实现中本身就加了对象级别的锁。我们最先想到的就是通过加类别级锁的方式:让所有的对象都共享同一把锁。这样就避免了不同对象之间同时调用 log() 函数,而导致的日志覆盖问题。具体的代码实现如下所示:

public class Logger {
  private FileWriter writer;
  public Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  public void log(String message) {
    synchronized(Logger.class) { // 类级别的锁
      writer.write(mesasge);
    }
  }
}


其他解决方法还有:分布式锁、并发队列等。相对来说,单例模式方案更加简单。单例模式相对于之前类级别锁的好处是,不用创建那么多 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("/Users/wangzheng/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());
  }
}


  • 案例2:表示全局唯一类


从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。再比如,唯一递增 ID 号码生成器,如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。

import java.util.concurrent.atomic.AtomicLong;
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();
  }
}


实际上,两个代码实例(Logger、IdGenerator),设计的都并不优雅,还存在一些问题。


可以总结一下:以下情况可以考虑使用单例:


  • 系统只需要一个实例对象,如系统要求提供一个唯一的系列号生成器或资源管理器,或者需要考虑资源消耗太大而且只允许创建一个对象
  • 客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他实例访问该实例。


3.实现方案



要实现一个单例,我们的关注重点:

  1. 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例,确保全局只有一个类的实例;
  2. 考虑对象创建时的线程安全问题;
  3. 考虑是否支持延迟加载(懒加载);
  4. 考虑 getInstance() 性能是否高(是否加锁)。

单例模式的基本特征:

  1. 一个单例类只能拥有一个对象;
  2. 只能自己来创建这个唯一的对象(反射创建会实例化对象);
  3. 必须提供方法给外部访问这个对象;

简言之,保证一个类仅有一个实例,自行创建并提供一个访问它的全局访问点。


(1)饿汉式


  • 在类加载的时候就创建并初始化实例。避免了多线程同步问题,线程安全。但是缺点是不支持延迟加载,若占用资源多或初始化时间长,提前初始化浪费资源。


实现代码:

public class Singleton {
    // 饿汉式(线程安全,静态常量方式)
    private static Singleton instance = new Singleton();
    private Singleton() {}
    public static Singleton getInstance() {
        return instance;
    }
}
public class Singleton {
    // 饿汉式(线程安全,静态代码块的方式)
    private static Singleton instance = null;
    static {
        instance = new Singleton();
    }
    private Singleton() {};
    public static Singleton getInstance() {
        return instance;
    }
}


针对上述的缺点,可以采用以下方法解决:


  1. 如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。
  2. 如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。


(2)懒汉式


  • 在调用的时候,判断实例是否存在。如果有就直接返回,否则加锁


(synchronized)创建实例。支持延迟加载 。但是缺点:并发度低,效率低。


代码实现:

public class Singleton {
    // 懒汉式(线程安全)
    private static Singleton instance;
    private Singleton() {};
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}


观察上述代码,我们给 getInstance() 这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是缺点是:如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。


(3)双重检测


  • 饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。
  • 只要实例被创建之后,加类级别的锁,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了。解决了懒汉式并发度低的问题,且支持延时加载。


代码实现:

public class Singleton {
    // DCL(双重校验所)
    private static volatile Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}


为什么给给instance加一个volatile关键字?


  • 主要是防止指令重排序instance = new Singleton()这个操作也会被解析成字节码的多步操作,可能会导致对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了(未初始化的对象)。
  • 实际上,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。


为什么双重检验?


  • 如果只是锁住创建单例对象的部分,但是如果多个线程都判空,还是会创建多个对象,只不过创建的顺序变成的串行而已。还需要判断是否创建对象的个数(内层判断)。


(4)静态内部类


  • 一种比双重检测更加简单的实现方法,那就是利用 Java 的静态内部类。它有点类似饿汉式,但又能做到了延迟加载。具体是怎么做到的呢?
  • SingletonHolder 是一个静态内部类,当外部类被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。


代码实现如下:

public class Singleton {
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton () {}
    public static final Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}


(4)枚举


  • 使用枚举类可以防止反射(私有构造器不保险),也可以让单例对象在序列化和反序列化后返回同一个对象,而且代码非常简单,所以枚举的方式实现单例模式,是一种很好的方式。
  • 这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。


代码如下:

public enum Singleton {
    INSTANCE;
    public Singleton getInstance(){
        return INSTANCE;
    }
}


4.存在的问题



  • 单例对OOP特性中抽象、继承、多态的支持不友好
  • 通过 IdGenerator 这个例子来讲解,生成 ID 的调用处这样写 long id = IdGenerator.getInstance().getId()。如果我们希望针对不同的业务采用不同的 ID 生成算法,我们需要修改所有用到 IdGenerator 类的地方,比如修改成 long id = UserIdGenerator.getIntance().getId(),这样代码的改动就会比较大。
  • 单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。
  • 单例会隐藏类之间的依赖关系
  • 单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
  • 单例对代码的扩展性不友好
  • 单例类只能有一个对象实例,如果我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。以数据库连接池来举例解释一下。在系统设计初期,我们觉得系统中只应该有一个数据库连接,但之后我们发现,系统中有些 SQL 语句运行得非常慢,我们希望将慢 SQL 与其他 SQL 隔离开来执行。如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。
  • 单例对代码的可测试性不友好
  • 如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
  • 单例类持有成员变量(比如 IdGenerator中 的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。
  • 单例不支持有参数的构造函数
  • 第一种解决思路是:创建完实例之后,先调用 init() 函数传递参数,再调用 getInstance() 方法。
  • 第二种解决思路是:将参数放到 getIntance() 方法中,比如 Singleton singleton = Singleton.getInstance(10,50)。这里有个问题,因为代码中对 instance == null 有判断,那么第一次实例化后,第二次传入的参数是不起作用的。
  • 第三种解决思路是:将参数放到另外一个全局变量中。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。


5.解决方案



  • 为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。
  • 单例除了我们之前讲到的使用方法之外,还有另外一种依赖注入的使用方法。比如 IdGenerator idGenerator = IdGenerator.getInsance();demofunction(idGenerator)。基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。
  • 不过,如果要完全解决OOP特性、扩展性、可测性不友好等问题,我们可能要从根上,寻找其他方式来实现全局唯一类。以通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。


6:问题补充



Q1:如何理解单例模式的唯一性?


  • 单例类中对象的唯一性的作用范围是“进程内唯一”的,进程间不唯一。


Q2:如何实现线程唯一的单例?


  • “进程唯一”是指线程内、线程间都唯一(进程间不唯一),这也是“进程唯一”和“线程唯一”的区别之处。
  • 假设 IdGenerator 是一个线程唯一的单例类。在线程A内,我们可以创建一个单例对象 a。因为线程内唯一,在线程 A 内就不能再创建新的 IdGenerator 对象了,而线程间可以不唯一,所以,在另外一个线程 B 内,我们还可以重新创建一个新的单例对象 b。
  • 代码中,我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。


Q3:如何实现集群环境下的单例?


  • 集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。
  • 我们需要把这个单例对象序列化并存储到外部共享储区(比如文件)。为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。


Q4:进程与线程的区别?


进程和线程都是一个时间段的描述,是CPU工作时间段的描述,不过是颗粒大小不同。


  • 进程就是包换上下文切换的程序执行时间总和 = CPU加载上下文+CPU执行+CPU保存上下文
  • 线程是共享了进程的上下文环境,的更为细小的CPU时间段。


Q4:总结一下单例模式的优缺点


主要优点:


  • 单例模式提供了对唯一实例的受访控制。因为单例类封装了它的唯一实例,所以他可以严格控制客户怎样以及何时访问它。
  • 系统内存中只存有一个对象,节约了系统资源,对于一些需要频繁创建和销毁的对象单例模式无疑可以提高系统的性能。
  • 允许可变数目的实例。基于单例模式我们可以进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例,既节省系统资源,又解决了单例单例对象共享过多有损性能的问题。


主要缺点:


  • 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
  • 单例类的职责过重,在一定程度上违背了“单一职责原则”。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。


Q5:使用场景总结


  • 频繁的实例化然后销毁对象
  • 创建资源耗时,但不经常使用的对象
  • 有状态的工具类对象
  • 平凡访问数据库或文件对象
相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
1天前
|
设计模式 安全 Java
【JAVA】Java 中什么叫单例设计模式?请用 Java 写出线程安全的单例模式
【JAVA】Java 中什么叫单例设计模式?请用 Java 写出线程安全的单例模式
|
1天前
|
设计模式
【设计模式】单例模式的三种实现方式
【设计模式】单例模式的三种实现方式
5 1
|
1天前
|
设计模式 安全 Java
【设计模式学习】单例模式和工厂模式
【设计模式学习】单例模式和工厂模式
|
1天前
|
设计模式 安全 Java
|
1天前
|
设计模式 存储 安全
Java 设计模式:深入单例模式的理解与应用
【4月更文挑战第27天】单例模式是一种常用的设计模式,在 Java 开发中扮演着重要角色。此模式的主要目的是保证一个类只有一个实例,并提供一个全局访问点。
20 0
|
1天前
|
设计模式 安全 Java
[设计模式Java实现附plantuml源码~创建型] 确保对象的唯一性~单例模式
[设计模式Java实现附plantuml源码~创建型] 确保对象的唯一性~单例模式
|
1天前
|
设计模式 安全 测试技术
[设计模式 Go实现] 创建型~单例模式
[设计模式 Go实现] 创建型~单例模式
|
1天前
|
设计模式 API
【设计模式】适配器和桥接器模式有什么区别
【设计模式】适配器和桥接器模式有什么区别
7 1
|
1天前
|
设计模式
【设计模式】张一鸣笔记:责任链接模式怎么用?
【设计模式】张一鸣笔记:责任链接模式怎么用?
10 1
|
1天前
|
设计模式 uml
【设计模式】建造者模式就是游戏模式吗?
【设计模式】建造者模式就是游戏模式吗?
9 0

热门文章

最新文章