• 2023.10.19 关于设计模式 —— 单例模式


    目录

    引言

    单例模式

    饿汉模式

    懒汉模式

    懒汉模式线程安全问题 

    分析原因


    引言

    • 设计模式为编写代码的 约定 和 规范

    阅读下面文章前建议点击下方链接明白 对象 和 类对象

    对象和类对象


    单例模式

    • 单个实例(对象)
    • 在某些场景中有特定的类,其只能被创建出一个实例,不应该被创建多个实例
    • 而 单例模式 就针对上述的需求场景进行更强制的保证
    • 通过巧用 java 的现有语法,实现了某个类只能被创建出一个实例的效果
    • 从而当程序员不小心创建出多个实例时,会编译报错

    实例理解:

    • JDBC 编程中的 DataSource 类,我们仅连接一个数据库时
    • DataSource 类描述数据的来源,用于获取数据库连接,因为数据只来源于一个数据库,所以我们仅创建一个实例即可,无需创建多个实例
    • 从而该场景适合使用单例模式

    具体思路:

    •  static 关键字可以将成员变量或方法声明为静态的,让它们属于类级别而不是实例级别
    • 因此我们可以将 单例对象 声明为静态变量,让其可以在任何地方直接通过类名来访问,无需创建类的实例
    • 从而这样便可以方便地获取单例对象,并且对于多个调用者来说,始终返回同一个实例

    简单理解:

    •  static 关键字将 单例对象 转为 静态变量
    • 因此 单例对象 便从 与实例相关联 转为 与类相关联
    • 又因为 在一个 java 进程中,对于同一个类,只会存在一个对应的类对象
    • 所以该 类对象内部的类属性也仅会存在一份,也就是作为类属性的 单例对象 也仅会存在一份

    饿汉模式

    1. // 饿汉模式的 单例模式 实现
    2. // 此处保证 Singleton 这个类只能创建出一个实例
    3. class Singleton {
    4. // 在此处,先把这个实例给创建出来了
    5. // 使用 private 修饰是为了防止在类外对 Singleton 实例 instance 进行修改
    6. private static Singleton instance = new Singleton();
    7. // 如果需要使用这个唯一实例,统一通过 Singleton.getInstance() 方式来获取
    8. public static Singleton getInstance() {
    9. return instance;
    10. }
    11. // 为了避免 Singleton 类不小心被复制出来多份
    12. // 把构造方法设为 private ,在类外面就无法通过 new 的方式来创建这个 Singleton 实例了
    13. private Singleton() {}
    14. }
    15. public class ThreadDemo19 {
    16. public static void main(String[] args) {
    17. Singleton s = Singleton.getInstance();
    18. Singleton s2 = Singleton.getInstance();
    19. System.out.println(s == s2);
    20. }
    21. }
    • 该模式表示在类加载阶段,就已经把实例创建出来了
    • 所以 "饿汉" 一词便体现出创建该实例的急迫感

    懒汉模式

    1. class SingletonLazy {
    2. private static SingletonLazy instance = null;
    3. public static SingletonLazy getInstance() {
    4. if(instance == null) {
    5. instance = new SingletonLazy();
    6. }
    7. return instance;
    8. }
    9. private SingletonLazy(){}
    10. }
    11. public class ThreadDemo20 {
    12. public static void main(String[] args) {
    13. SingletonLazy s1 = SingletonLazy.getInstance();
    14. SingletonLazy s2 = SingletonLazy.getInstance();
    15. System.out.println(s1 == s2);
    16. }
    17. }
    • 该模式在创建实例时并非是在类加载阶段,就已经把实例创建出来了
    • 而是当真正第一次使用的时候才创建实例
    • 所以相比于 "饿汉" 模式创建实例的急切感,"懒汉" 模式则显得没那么着急

    阅读下面文章之前建议点击下方链接了解清楚线程安全问题

    线程安全问题详解


    懒汉模式线程安全问题 

    • 相比于 饿汉模式 仅涉及到读操作
    • 懒汉模式 则既涉及到 写操作 又涉及到 读操作

    • 显然 懒汉模式 有着线程安全问题

    分析原因

    • 懒汉模式线程安全问题的本质为 读操作、比较操作、写操作 这三个操作并不是原子的
    • 从而便会导致线程t2 读到的 instance 值可能是线程t1 还没来得及写的
    • 这也就是我们常说的 脏读

    • 此时我们便可以利用 synchronized 关键字来进行加锁,使得上图中的指令变为原子的
    1. public static SingletonLazy getInstance() {
    2. synchronized (SingletonLazy.class) {
    3. if(instance == null) {
    4. instance = new SingletonLazy();
    5. }
    6. }
    7. return instance;
    8. }
    • 加锁的对象是 SingletonLazy.class 类对象
    • 该锁是基于类的

    • 虽然对 SingletonLazy.class 类对象进行加锁能解决多线程之间脏读的问题
    • 但是也导致了每次调用 getInstance 方法时都需要先进行加锁,才能进入方法内部进行判断 instance 是否为空,非空则触发 return 直接返回单例对象
    • 我们要清楚的一点是 加锁操的开销还挺大,会涉及到用户态到内核态之间的切换,这样切换成本的成本是很高的
    • 要注意到的是 在 new 完单例对象之后,后续再调用 getInstance 方法时,我们仅会直接返回单例对象,即仅涉及到读操作,这是没有线程安全问题的
    • 所以在 new 出对象之前有加锁操作,这是十分有必要的,即任意线程第一次调用getInstance 方法
    •  在 new 完单例对象之后,我们无需再进行加锁操作,这样便可以很大程度上提高效率
    1. public static SingletonLazy getInstance() {
    2. if (instance == null){
    3. synchronized (SingletonLazy.class) {
    4. if(instance == null) {
    5. instance = new SingletonLazy();
    6. }
    7. }
    8. }
    9. return instance;
    10. }
    • 我们便可以在 加锁操作 的外层再加上个 if 判断,判断 instance 对象是否已经被创建出来了
    • 从而该代码只会在任意线程第一次调用 getInstance 方法时,才会进行加锁操作
    • 从而此处不再是无脑加锁,而是满足了特定条件之后,才真正加锁

    • 我们需要理解此处为什么会有两个相同的 if 判断
    • 首先如果这两个 if 判断之间没有加锁操作,那么写两个一模一样 if 判断是毫无意义的
    • 但是正因为这两个 if 判断之间有加锁操作,而加锁操作就可能会引起线程阻塞,当线程竞争到锁之后,再执行到第二个 if 判断的时候,可能与第一次执行 if 判断之前隔了很长一段时间

    举例理解:

    • 线程A 第一次调用 getInstance 方法,读取到 instance 为 null,通过第一次 if 判定,并成功为锁对象进行加锁操作,然后再次读取到 instance 为 null,通过第二次 if 判定,进而直接 new 出一个 instance 对象,最后再将锁释放
    • 可能线程B 比线程A 晚一点点的调用了 getInstance 方法,可能此时线程A 并未修改完instance 的值,从而线程B 读取到 instance 为 null,通过了第一次 if 判定,然后进行阻塞等待线程A 释放锁,但正是在线程B 等待锁的在这段时间里,线程A 已经将 instance 对象给创建出来了,此时线程B 再获取到锁时,instance 的值已经发生改变了,线程B 再次读取 instance 的值,此时 instance 不为 null,从而未通过第二次 if 判断,直接返回 instance 的值,这就意味着第二次 if 判断成功阻止了线程B 再创建一个新的 instance 对象
    • 根据上述例子,深入理解 图中第一个 if 负责判定是否要加锁,解决了每次调用getInstance 方法时都需要引入无意义的加锁操作,很大程度上减少了开销,第二个 if 负责判定是否要创建对象,是最初为了保障单例模式,引入的必要条件
    • 这两 if 判断的目的是完全不相同的,只是碰巧代码是一样的!

    • 上述仅解决了多线程之间 脏读 的问题,但是还可能会有 内存可见性问题
    • 假设有很多线程,都去执行 getInstance 方法,这个时候便可能存在被优化的风险,即只有第一次读才是真正读了内存,后续都是读寄存器或 cache 
    • 同时还可能涉及到 指令重排序问题
    • 编译器为了提高程序的效率,调整代码执行顺序
    • 即 我们可以将 instance = new Singleton(),拆分为三个步骤
    • 步骤 1:申请内存空间
    • 步骤 2:调用构造方法,把这个内存空间初始化成一个合理的对象
    • 步骤 3:把内存空间的地址赋值给 instance 引用
    • 编译器可能将步骤的执行顺序由 1、2、3,优化重排序为 1、3、2
    • 如果仅是在单线程场景下,执行步骤的调换是没有任何影响的
    • 但是如果是在多线程环境下,我们举一个简单例子来理解指令重排序所带来的问题

    举例理解:

    • 假设编译器优化指令重排序,线程A 的步骤执行顺序变为 1、3、2,如果线程A 执行完步骤 1、3,正当要执行步骤 2 时,被切出 CPU,CPU 调度执行线程B
    • 我们要注意到的是,此时线程A 执行完步骤 1、3 后会创建出一个非法对象,即该对象仅分配了内存,其数据是无效的,只有执行完步骤 2 才会把这个内存空间初始化成一个合理的对象
    • 那么当 CPU 调度执行线程B 时,线程B 又正好调用 getInstance 方法,此刻便会进入第一个 if 判断,获取 instance 对象的值,来判断是否为 null
    • 因为 instance 对象 已经被分配好了内存空间,所以线程B 获取到的 instance 对象值并不会为 null
    • 所以线程B 将会直接返回该 instance 对象
    • 注意此处线程B 返回的 instance 对象 是上述讲的非法对象,即仅分配了内存,其数据是无效的
    • 所以之后 线程B 拿着这个非法对象,来进行使用便将会出现许多问题和错误

    解决方法:

    • 引入 volatile 关键字
    • volatile 关键字的功能正好能解决 内存可见性 和 指令重排序
    1. class SingletonLazy {
    2. private volatile static SingletonLazy instance = null;
    3. public static SingletonLazy getInstance() {
    4. if (instance == null){
    5. synchronized (SingletonLazy.class) {
    6. if(instance == null) {
    7. instance = new SingletonLazy();
    8. }
    9. }
    10. }
    11. return instance;
    12. }
    13. private SingletonLazy(){}
    14. }
    15. public class ThreadDemo20 {
    16. public static void main(String[] args) {
    17. SingletonLazy s1 = SingletonLazy.getInstance();
    18. SingletonLazy s2 = SingletonLazy.getInstance();
    19. System.out.println(s1 == s2);
    20. }
    21. }
    • 以上完整的代码便是 线程安全的懒汉模式 完全体
  • 相关阅读:
    编译器优化记录(Mem2Reg+SSA Destruction)
    编程入门之学哪种编程语言?
    通过Redis实现一个异步请求-响应程序
    手動安裝wordpress方法
    nginx,域名绑定ipv6,本地能访问,但远程无法访问,如何解决?
    Java实战:Spring Boot热部署DevTools使用
    Ajax 实战
    SW的stp文件转成CAD格式文件学习笔记
    MySQL之char、varchar类型简析
    计算机毕业设计(60)php小程序毕设作品之共享充电桩小程序系统
  • 原文地址:https://blog.csdn.net/weixin_63888301/article/details/133894342