• 【设计模式】单例模式


    【设计模式】单例模式

    1 为什么要用单例?

    单例设计模式(Singleton Design Pattern):一个类只允许创建一个对象(或实例)。

    1.1 处理资源访问冲突

    例如,我们需要实现一个往文件中打印日志的日志类,定义方式如下:

    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);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    在业务中,有两块功能需要使用 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());
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    这样的代码是存在问题的!那就是 在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login()create() 两个函数,并且同时写日志到 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("/Users/wangzheng/log.txt");
        writer = new FileWriter(file, true); //true表示追加写入
      }
      
      // 只能通过暴露出的方法进行静态成员变量访问
      public static Logger getInstance(){
        return instance;
      }
      
      public void log(String message){ 
        writer.write(mesasge);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    随后,其他业务代码如果需要访问该资源,需要通过公开的方法进行访问。

    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 an order: " + order.toString());
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    1.2 表示全局唯一类

    从业务概念上,如果一个数据在系统中至应保留一份(全局唯一),那就比较适合设计为单例类。

    比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。

    2 如何实现一个单例?

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

    • 构造函数需要是 private 的,防止外部随意 new 类对象
    • 考虑对象创建时的线程安全问题
    • 考虑是否支持延迟加载
    • 考虑 getInstance() 性能是否高(是否加锁)

    接下来,我们实现五种单例实现。

    2.1 饿汉单例

    饿汉就是特别饿,想要 立刻 有吃的。

    饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载。

    public class IdGenerator{
      private AtomocLong 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();
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。

    如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性

    结论:饿汉式不支持延迟加载

    2.2 懒汉单例

    懒汉就是特别懒,要用的时候才创建,能拖就拖。

    懒汉式相对于饿汉式的优势就是支持 延迟加载

    public class IdGenerator{
      private AtomocLong id = new AtomicLong(0);
      
      // 静态实例私有化,但一开始不创建
     	private static final IdGenerator instance;
      
      // 构造器私有化
      private IdGenerator(){}
      
      // 创建访问方法,加锁是为了防止高并发时创建多个 instance 对象
      public static synchronized IdGenerator getInstance(){
        if(instance == null){
          // 能拖就拖,用的时候才创建
          instance = new IdGenerator();
        }
        return instance;
      }
      
     	public long getId(){
        return id.incrementAndGet();
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

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

    结论:懒汉式不支持高并发

    2.3 双重检测

    双重检测,是一种既支持延迟加载、又支持高并发的单例实现方式

    本质上对懒汉单例进行改进。因为加锁是为了防止创建多个实例,那么实际上 只需要在创建的时候加锁,访问的时候就不需要锁了

    在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下所示:

    public class IdGenerator{
      private AtomocLong id = new AtomicLong(0);
      
      // 静态实例私有化,但一开始不创建
     	private static final IdGenerator instance;
      
      // 构造器私有化
      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();
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    2.4 静态内部类

    我们再来看一种比双重检测更加简单的实现方法,那就是利用 Java 的 静态内部类。它有点类似饿汉式,但又能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现。

    public class IdGenerator{
      private AtomocLong id = new AtomicLong(0);
      
      // 构造器私有化
      private IdGenerator(){}
      
      // 静态内部类
      private static class SingletonHolder{
        private static final IdGenerator instance = new IdGenerator();
      }
      
      public static IdGenerator getInstance() {
        // 返回静态内部类中的 instance
        return SingletonHolder.instance;
      }
      
      public long getId(){
        return id.incrementAndGet();
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    SingletonHolder 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。insance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

    通过静态初始化来初始化 IdGenerator 为什么不需要额外的同步?

    在初始器中采用了特殊的方式来处理 静态域(或者在静态初始化代码块中初始化的值),并提供了额外的线程安全性保证。静态初始化器是由 JVM 在类的初始化阶段执行,即在类被加载后并且被线程使用之前。由于 JVM 将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然而,这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步来确保随后的修改操作是可见的,以及避免数据破坏。

    2.5 枚举

    这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示:

    public enum IdGenerator {
      INSTANCE; 
      private AtomicLong id = new AtomicLong(0);
      public long getId() {
        return id.incrementAndGet();
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    很简洁的一种实现方式,提供了序列化机制,保证线程安全,绝对防止多次实例化,即使是在面对复杂的序列化或者反射攻击的时候。

    总结

    方式优点缺点
    饿汉线程安全、效率高不可延迟加载
    懒汉线程安全、可延迟加载效率低
    双重检测线程安全、可延迟加载、效率高
    静态内部类线程安全、可延迟加载、效率高
    枚举线程安全、效率高不可延迟加载
  • 相关阅读:
    IntelliJ IDEA 创建 Java 工程,运行 HelloWorld
    Ajax同源和跨域和节流防抖
    Android中常用的几种容器视图的使用
    阳光能源,创造永远:光模块的未来”:随着大数据、区块链、云计算和5G的发展,光模块成为满足不断增长的数据流量需求的关键技术
    最小步数
    《无限可能-快速唤醒你的学习脑》阅读笔记
    安卓中轻量级数据存储方案分析探讨
    微擎模块 拍图取字 1.7.0 | 智慧农场 1.5.5后台模块+前端小程序
    Java ”框架 = 注解 + 反射 + 设计模式“ 之 注解详解
    父类和子类
  • 原文地址:https://blog.csdn.net/weixin_41960890/article/details/127779888