• Design Pattern —— 创建型 —— 单例模式(上) ——概念特点、面试常问、具体实践案例、源码解读


    📖 单例模式 —— 实现以及常见问题

    ❓ 本章知识内容

    这里非常感谢 极客时间 —— 王争 老师 的 《设计模式之美》—— 非常好的文章

    1️⃣ 单例模式概念以及特点

    2️⃣ 单例模式的饿汉式与懒汉式具体实现

    3️⃣ 针对懒汉式可能出现的线程安全问题优化

    4️⃣ 针对懒汉式可能因 Java 反射机制破坏的单例模式进行优化

    5️⃣ 比较懒汉式与饿汉式二者区别

    6️⃣ 单例模式常见面试考察点 —— 会在每个模块以❓的方式出现

    7️⃣ 实战案例理解并应用 —— 会在每个模块以 🌰 的方式出现

    📑 单例模式概念以及特点

    什么是单例设计模式?

    单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。

    单例模式的特点

    • 在 Java 运行的应用当中,单例模式可以保证在同一个 JVM 中,该对象只有一个对应实例存在
    • 构造器私有化,同时对外提供一个公共的 setInstance 方法获取对应单例对象实例
    • 没有对应的公共的 set 方法,防止外部调用 set 方法创建该实例对象

    单例模式的优点

    • 使得当前 JVM 中只存在一个实例对象,避免不必要的系统开销,防止资源重复占用空间
    • 减少了使用 new 关键字来创建对象实例,降低了对应的系统内存使用的频率,减轻了 GC垃圾回收的系统回收压力

    ❓ 为什么要使用单例模式 ?

    🌰 实战案例一:处理资源访问冲突

    实战案例一:处理资源访问冲突

    /***
     * @author: Alascanfu
     * @date : Created in 2022/8/16 14:34
     * @description: SingletonDesignPattern.Logger
     * @modified By: Alascanfu
     **/
    public class Logger {
        private FileWriter writer ;
        
        public Logger() throws IOException {
            File file = new File("/Users/log/log.txt");
            // true 表示追加写入
            writer = new FileWriter(file , true );
        }
        
        public void log(String msg ) throws IOException {
            writer.write(msg);
        }
    }
    
    /***
     * @author: Alascanfu
     * @date : Created in 2022/8/16 14:38
     * @description: Logger 类的应用示例
     * @modified By: Alascanfu
     **/
    public class UserController {
        private Logger logger ;
    
        public void login(String username , String password) throws IOException {
            // ... 省略业务逻辑代码...
            logger.log(username + " login!");
        }
    }
    
    /***
     * @author: Alascanfu
     * @date : Created in 2022/8/16 14:40
     * @description: 业务订单处理类
     * @modified By: Alascanfu
     **/
    public class OrderController {
        private Logger logger ;
        
        public void create(OrderVo order) throws IOException {
            // ... 省略 业务逻辑代码 ...
            logger.log("Created an order: " + order.toString());
        }
    }
    
    
    /***
     * @author: Alascanfu
     * @date : Created in 2022/8/16 14:41
     * @description:
     * @modified By: Alascanfu
     **/
    public class OrderVo {
        public String orderName ;
        
        public String orderId ;
        
        public String getOrderName() {
            return orderName;
        }
        
        public void setOrderName(String orderName) {
            this.orderName = orderName;
        }
        
        public String getOrderId() {
            return orderId;
        }
        
        public void setOrderId(String orderId) {
            this.orderId = orderId;
        }
        
        @Override
        public String toString() {
            return "OrderVo{" +
                "orderName='" + orderName + '\'' +
                ", orderId='" + orderId + '\'' +
                '}';
        }
    }
    
    • 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
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    可能出现的问题

    可能出现的问题

    我们注意到,所有的日志都写入到同一个文件/Users/log/log.txt 中。在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况

    为什么会出现互相覆盖呢?

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

    在这里插入图片描述

    方式一:尝试加锁,真的能解决么?

    如何解决该问题?

    方式一:尝试加锁,真的能解决么?

    我们最先想到的就是通过加锁的方式:给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字),同一时刻只允许一个线程调用执行 log()函数。具体的代码实现如下所示:

    /***
     * @author: Alascanfu
     * @date : Created in 2022/8/16 14:34
     * @description: SingletonDesignPattern.Logger
     * @modified By: Alascanfu
     **/
    public class Logger {
        private FileWriter writer ;
        
        public Logger() throws IOException {
            File file = new File("/Users/log/log.txt");
            // true 表示追加写入
            writer = new FileWriter(file , true );
        }
        
        public void log(String msg ) throws IOException {
            synchronized(this){
                writer.write(msg);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    你仔细想想,这真的能解决多线程写入日志时互相覆盖的问题吗?

    答案是否定的。这是因为,这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用 log() 函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行 log() 函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。

    在这里插入图片描述

    那么到底如何解决呢?

    因为这是个对象级别锁引起的可能锁不住的现象,那我们如果采用 类级别的锁自然而然就可以锁住对应的类了,所有对象共享一把锁咩,这样就避免了不同对象之间同时调用 log() 函数,而导致的日志覆盖问题。具体的代码实现如下所示:

    /***
     * @author: Alascanfu
     * @date : Created in 2022/8/16 14:34
     * @description: SingletonDesignPattern.Logger
     * @modified By: Alascanfu
     **/
    public class Logger {
        private FileWriter writer ;
        
        public Logger() throws IOException {
            File file = new File("/Users/log/log.txt");
            // true 表示追加写入
            writer = new FileWriter(file , true );
        }
        
        public void log(String msg ) throws IOException {
            synchronized(Logger.class){
                writer.write(msg);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    除了使用类级别锁之外,实际上,解决资源竞争问题的办法还有很多,分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无 bug、高性能的分布式锁,并不是件容易的事情。除此之外,并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。


    方式二:单例设计模式解决

    如果将 Logger 设计成一个单例类,程序中只允许创建一个 Logger 对象,所有的线程共享使用的这一个 Logger 对象,共享一个 FileWriter 对象,而 FileWriter 本身是对象级别线程安全的,也就避免了多线程情况下写日志会互相覆盖的问题。

    相对于这两种解决方案,单例模式的解决思路就简单一些了。单例模式相对于之前类级别锁的好处是不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)

    /***
     * @author: Alascanfu
     * @date : Created in 2022/8/16 14:34
     * @description: SingletonDesignPattern.Logger
     * @modified By: Alascanfu
     **/
    public class Logger {
        private FileWriter writer;
        private static final Logger instance = new Logger();
        
        private Logger() {
            File file = new File("/Users/log/log.txt");
            // true 表示追加写入
            try {
                writer = new FileWriter(file, true);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        
        public static Logger getInstance(){
            return instance;
        }
        
        public void log(String msg) throws IOException {
            writer.write(msg);
        }
    }
    
    • 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
    • 28

    🌰 实战案例二:表示全局唯一类

    从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。

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

    再比如,唯一递增 ID 号码生成器,如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。

    /***
     * @author: Alascanfu
     * @date : Created in 2022/8/16 15:05
     * long id = IdGenerator.getInstance.getId();
     * @description: 自增 ID 生成器
     * @modified By: Alascanfu
     **/
    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();
        }
    }
    
    • 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

    📑 如何实现一个单例?

    如何实现一个单例模式的文章已经有很多了,但为了保证内容的完整性,我这里还是简单介绍一下几种经典实现方式。概括起来,要实现一个单例,我们需要关注的点无外乎下面几个:

    • 构造函数需要时 private 访问权限的 ,这样才能避免外部通过 new 来创建实例
    • 考虑对象创建时线程的安全问题
    • 考虑是否延迟加载
    • 考虑 getInstance() 性能是否高(是否加锁)

    🔖 饿汉式

    饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator 的时候,再创建实例),从名字中我们也可以看出这一点。 具体的代码实现如下所示:

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

    有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存 多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点。

    原因如下:

    如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作, 会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作, 提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。

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

    🔖 懒汉式

    有饿汉式,对应地,就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示:

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

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

    🔖 双重检验 DCL 懒汉式

    饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。

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

    public class IdGenerator {
        private AtomicLong id = new AtomicLong(0);
        private static 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

    这种实现方式有些问题。因为指令重排序,可能会导致 IdGenerator 对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻 辑),就被另一个线程使用了。

    ==> 解决办法:可以给对应的竞争资源变量添加 volatile 关键字来添加内存屏障禁止指令重排,保证数据可见性,非原子性。

    要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁止指令重排序才 行。实际上,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作 设计为原子操作,就自然能禁止重排序)。

    🔖 静态内部类

    我们再来看一种比双重检测更加简单的实现方法,那就是利用 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();
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

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

    🔖 枚举

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

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

    ⭐ 源码中单例模式体现

    spring源码 如 ReactiveAdapterRegistry。

    JDK 源码 如 AbstractQueuedSynchronizer。

    ❓单例模式中为什么要用局部变量来接受对象实例

    ❓ 单例模式中为什么要用局部变量来接受对象实例

    public  class Singleton {
        private static volatile Singleton instance=null; 
        private Singleton() {
        } 
    
        public static Singleton getInstance() {//  
            Singleton temp=instance;  //  为什么要用局部变量来接收
            if (null == temp) { 
                synchronized (Singleton.class) { 
                    temp=instance;
                    if (null == temp) { 
                        temp=new Singleton(); 
                        instance=temp;
                    }
                }
            }
            return instance; 
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    性能上的优化

    Using localRef, we are reducing the access of volatile variable to just one for positive usecase. If we do not use localRef, then we would have to access volatile variable twice - once for checking null and then at method return time. Accessing volatile memory is quite an expensive affair because it involves reaching out to main memory.

    使用局部变量来接受主要是为了减少访问 volatile 变量到只有一个正实例,如果我们不使用局部变量进行接收,那么我们将不得不访问 volatile 变量两次,一次是检查是否为 null ,另一次是在方法返回时。访问 volatile 变量内存空间是非常消耗资源的,因为其涉及到了主内存的访问。

    参考链接:https://www.javacodemonk.com/threadsafe-singleton-design-pattern-java-806ad7e6

  • 相关阅读:
    【矩阵分析】求 史密斯标准形,求不变因子 (利用初等变换求 不变因子,史密斯标准形) || 行列式因子 || 利用 行列式因子 求史密斯标准形
    (最优化理论与方法)第三章优化建模-第二节:回归分析
    [Linux]------动静态库的模拟实现和简单使用
    vector与list的区别与应用?
    Towards Interpretable Video Anomaly Detection 论文阅读
    10天学完React——03、组件之间的props通信
    leetcode日记(38)字母异位词分组
    AQS解读
    git-使用命令笔记
    Linux常用命令
  • 原文地址:https://blog.csdn.net/fuijiawei/article/details/126368193