• 【多线程】单例模式


    🥰🥰🥰来都来了,不妨点个关注叭!
    👉博客主页:欢迎各位大佬!👈

    在这里插入图片描述

    1. 单例模式的初识

    何为单例模式呢~
    单例模式
    单例模式是一种经典的设计模式,经常考的设计模式之一,所以是十分重要的

    这里又有一个新的概念,设计模式是什么?
    设计模式
    相当于软件开发中的棋谱,一些大佬们针对一些常见的场景,总结出来的代码编写套路,按照套路来写,不能说代码可以编写得多么好,但是至少不会很差,这类似于在下棋中,通过前人总结出来的一些固定下棋套路,按照这些棋谱来下,不能说下得多么漂亮,但是至少不会很差,这两者是一个道理,这种设计模式相当于兜底,给我们了一种模板

    其中,设计模式有很多种,之前有个大佬写了本书,流传很广,讨论23种设计模式,很多人就认为设计模式只有23种,并不是的,只是在这本书中讨论了这23种设计模式,其实设计模式还有很多种!!!

    在我们这个阶段,主要考察两个设计模式:
    1)单例模式
    2)工厂模式

    设计模式需要我们有一定的开发经验的积累,才好理解,尽管我们现在还没有积累一定的经验,我们还是可以尝试去理解它,在实际应用开发中,我们将会有一个更深刻的了解,下面我们一起来看看单例模式!

    2. 单例模式的含义

    从字面理解,单例就是单个实例(instance),即在一个程序中,某一个类,只能创建出一个实例(一个对象),不能创建多个对象! (回顾JavaSE中所学的知识,类的实例就是对象~)
    这里不是说多 new 几次,就可以创建多个对象,在语法上有办法禁止多 new,在Java中的单例模式,借助Java语法,保证某个类只能创建出一个实例,而不能new多次,不能创建多个对象

    单例模式能保证某个类在程序中只存在唯一一个实例,而不会创建出多个实例
    场景需要】有些场景就需要某个概念是单例的,比如在生活中,一夫一妻制,再比如,JDBC中的 DataSource 实例就只需要一个

    3. 单例模式实现的两种方式

    在Java语法中,实现单例模式有很多种写法,本文主要介绍以下两种实现方式:
    1)饿汉模式(急迫版)
    2)懒汉模式(从容版)

    3.1 饿汉模式

    在生活中,吃完饭后去洗碗,饿汉模式 —— 吃完之后立刻去洗碗,超急迫的~
    对应计算机中的栗子,打开一个硬盘上的文件,读取文件内容,并显示出来,饿汉模式 —— 把文件所有内容都读到内存中并显示出来
    但是假设文件非常大,比如10G,饿汉模式文件打开可能都要半天,内存够不够我们都不清楚~

    饿汉模式 —— 类加载的同时创建实例,代码如下:

    // 把这个类设置为单例
    class Singleton {
        private static Singleton instance = new Singleton();
        
        //获取到实例的方法
        public static Singleton getInstance() {
            return instance;
        }
        
        //禁止new 将构造方法设置为private
        private Singleton() {
        
        };
    }
    
    public class ThreadDemo14 {
        public static void main(String[] args) {
            Singleton s1 =  Singleton.getInstance();
            //此时s1和s2是同一个对象
            //Singleton s2 =  Singleton.getInstance();
            //此时不能再进行new了,外部无法创建实例
            //Singleton s3 = new Singleton();
        }
    }
    

    结果分析:
    1)s1 和 s2 获取到的其实是同一个对象
    2)运行 s3,将会报错,因为外部无法再 new 一个对象,已禁止该操作了
    在这里插入图片描述
    具体实现过程如下:
    在这里插入图片描述
    以上就是饿汉模式代码,通过Java语法来限制类实例的多次创建保证单例的特性:

    1. staic 修饰 instance,保存单例对象的唯一实例
    2. 并用 private 修饰 instance,将该实例进行封装
    3. 如果要获取该实例,通过调用 getInstance() 方法获取这个实例
    4. 将构造方法用 private 修饰,可禁止外部 new 实例操作,即不可多次 new 对象
      在这里插入图片描述

    对于 private 修饰的方法,我们会有一个疑问:反射不是可以获取到私有方法吗?
    1)反射本身就是一个非常规的手段,反射本身就是不安全的(能不用就不用)
    2)单例模式有一种实现方式可以保证反射下得安全,通过枚举即使使用反射也可以保证单例(这里不作过多介绍)

    但是饿汉模式存在一个问题,那就是实例的创建时机过早了,可以看到,实例在类内部就创建好了,只有类一加载,就会创建出这个实例,如果后面并没有用到这个实例,其实会有点浪费的意思,更好的实现方式是懒汉模式,即用的时候再创建,下面介绍懒汉模式

    3.2 懒汉模式

    在计算机中,谈到"懒",一般其实是褒义词,想想为什么我们的科技能够进步,社会能够发展,其本质动力,都是为了更便捷,源动力全靠"懒"~

    继续洗碗的栗子,懒汉模式 —— 吃完饭后,先把碗放着,等到下一顿吃饭时,需要用到碗时再去洗,超从容!
    继续打开硬盘文件的栗子,饿汉模式 —— 只把文件读一小部分,把当前屏幕填充上,如果用户翻页了,再读其它文件内容,如果不翻页,就不需要再去读
    如果文件非常大,懒汉模式就可以快速打开,毕竟不用一次都打开完,等需要某部分就打开某部分
    (尽管懒汉模式会增加硬盘的读取次数,但是和饿汉模式情况相比,其实是不值得一提的~)

    通常认为,懒汉模式更好,效率更高,核心思想:非必要,不创建,即非必要不去做某事,等到要去做某事的时候再去做

    3.2.1 懒汉模式(单线程版)

    懒汉模式(单线程版) —— 类加载的时候不创建实例,第一次使用的时候才创建实例,即需要使用这个实例的时候才创建它 ,代码如下:

    //懒汉模式实现单例模式
    //懒汉模式实现单例模式
    class SingletonLazy {
        private static SingletonLazy instance = null; //先置为空
    
        public static SingletonLazy getInstance() { //只有调用这个才会创建对象
            if (instance == null) {
                instance = new SingletonLazy();
            }
            return instance;
        }
        private SingletonLazy() { }
    }
    
    public class ThreadDemo15 {
        public static void main(String[] args) {
            SingletonLazy s1 = SingletonLazy.getInstance();
            SingletonLazy s2 = SingletonLazy.getInstance();
            System.out.println(s1 == s2);
        }
    }
    

    运行结果如下:s1 和 s2 获取到的是同一个对象,所以结果返回 true

    在这里插入图片描述

    具体实现过程如下:
    在这里插入图片描述

    1. 先将 instance 设置为 null
    2. 当需要使用 instance 的时候,调用getInstance()方法,如果 instance 为null,则需 new 一个,不为空,则说明已经有一个实例,不需要new,直接使用该实例(单例模式就是一个类只有一个实例)
    3. 使用单例,调用getInstance()方法

    以上就是懒汉模式代码,与饿汉模式代码的实现方式类似,最大的区别在于懒汉模式只有在需要使用实例时才会创建,所以要将创建实例写在getInstance()方法里面,懒汉模式通过Java语法来限制类实例的多次创建保证单例的特性与饿汉模式一致,这里就不再赘述啦~

    3.2.2 懒汉模式(多线程版)

    1)多线程情况下为什么只讨论懒汉模式而不讨论饿汉模式呢?

    上述的两个代码,是否线程安全呢?即多个线程下调用 getInstance()方法,是否会出现问题?
    在这里插入图片描述
    结论
    饿汉模式天然线程就是安全的,因为只是读数据
    懒汉模式是线程不安全的,因为有读有写

    所以,为什么讨论多线程下懒汉模式,是因为懒汉模式在多线程下,可能无法保证创建对象的唯一性,会出现问题,我们需要一定的措施去解决这个问题以保证它是线程安全的,而饿汉模式本身则是线程安全的~

    2)懒汉模式线程不安全的原因

    回顾线程不安全的原因:线程不安全原因

    1. 抢占式执行
    2. 修改共享数据
    3. 修改操作不是原子的
    4. 内存可见性
    5. 代码顺序性(指令重排序)

    懒汉模式线程不安全的最直接原因 —— 多个线程修改同一个变量
    在这里插入图片描述
    分析
    在饿汉模式中,getInstance()方法直接进行返回,没有涉及到改的操作
    而在懒汉模式中,getInstance()方法需要先判断 instance 是否为 null,如果是的,就需要对 instance 进行修改, new 一个实例,再返回,如果不是则直接返回

    通过上述分析,可以知道在懒汉模式中涉及到修改的操作,在多线程下,由于有多个线程,可能会创建出多个实例,无法保证创建对象的唯一性!下面进行进一步分析:
    在这里插入图片描述

    严重性
    如果是N个线程一起调用 getInstance()方法,可能创建出N个对象,我们可能会想,这不就是 多 new 些对象的事情嘛,有什么大不了的嘛,其实并不是这样的,对象是有大有小的,有些对象可能会很大,管理的内存数据可能会特别多,如果这个对象管理特别多的内存数据,多 new 几次,内存根本不够呀!所以,线程不安全带来的问题是很严重的!!!

    3)解决方式

    回顾之前的内容,线程安全问题的措施 如下:

    1. 使用 synchronized 关键字进行加锁,保证操作原子性
    2. 使用 volatile 关键字,可保证内存可见性和禁止指令重排序

    通过之前的分析可知:懒汉模式线程不安全是因为多个线程修改同一个变量!进一步分析,引起上述问题的原因是 if判定操作和修改操作是分开的,并不是原子的,显而易见,可以通过加锁来解决这个问题~

    这就有一个问题了,锁要加在哪里? 这是值得我们深入思考的,要知道多线程代码是很复杂的,并不说加锁就一定可以解决问题,必须要具体问题具体分析,下面举一个错误的加锁:
    在这里插入图片描述
    将锁加在了 new 对象的操作上,以类对象为锁对象,这样的加锁方式可行吗?显然是不行的,我们加锁需要保证 if 判定操作和 new 对象操作作为一个整体的,是一个原子操作!才能解决上述问题,而仅把锁加在 new 对象的操作上,仍然不能保证原子性,所以这是错误的加锁方式!!!

    1)将 if 操作也放到锁里,保证 if 判定操作和 new 对象操作是一个原子操作

    public static SingletonLazy getInstance() { //只有调用这个才会创建对象
            synchronized (SingletonLazy.class) {
                if (instance == null) {
                    instance = new SingletonLazy();
                }
            }
            return instance;
        }
    

    2)或者直接将锁加在方法上,保证整个方法都是原子的

      synchronized public static SingletonLazy getInstance() { //只有调用这个才会创建对象
            if (instance == null) {
                instance = new SingletonLazy();
            }
            return instance;
        }
    

    在前面也讲过,加锁是一个比较低效的操作,因为加锁就可能涉及到阻等待,需坚持非必要,不加锁的原则,在上述加锁方式中存在一个问题:在任何时候调用getInstance()方法都会触发锁竞争!
    在这里插入图片描述
    事实上,此处的线程不安全问题只是出现在首次创建对象这里,一旦对象 new 好了,后续调用getInstance()方法时,仅仅就是读操作了,不涉及到修改,也就没有线程安全问题了,就没必要加锁了!!!

    因此,需要对加锁的位置进行优化,下面具体来介绍如何进行优化的~

    a. 优化一:修改锁的位置解决效率问题

    问题】到底什么时候需要加锁?
    回答】上述分析可得,在首次 new 对象时候需要加锁
    措施】需要再加一层 if 判断,用来判断需要加锁的情况

     public static SingletonLazy getInstance() { //只有调用这个才会创建对象
     		//这个if判断是用于判断是否要加锁,如果对象已经有了,此时无需加锁,本身就是线程安全的
            if(instance == null) {
                synchronized (SingletonLazy.class) {
                //这个if判断是如果为空则创建对象
                    if (instance == null) {
                        instance = new SingletonLazy();
                    }
                }
            }
            return instance;
        }
    

    1)在初心(目的)上,这两个 if 条件看起来是一样的,但是这两个条件的初心即目的是不同的,只是巧了,正好是一模一样的代码
    第一个 if 语句目的:判断是用于判读是否要加锁,如果对象已经有了,此时无需加锁,本身就是线程安全的
    第二个 if 语句目的:判断 instance 是否为空,如果为空则创建对象
    在这里插入图片描述

    2)在执行时机上,这两个 if 条件紧挨着,实际上,这两个 if 语句的执行时机有着巨大的差别!

    按照我们之前的理解,在单线程代码中,如果两行代码紧挨着,在执行的时候,这两行代码会被迅速执行完,可以近似地看作这两个 if 语句是"同一时机"被执行的

    但是在多线程中,上述两个 if 语句中还间隔着一个 synchronized 的情况下,就不能简单地这样理解了
    因为加锁就可能导致线程阻塞,而啥时候解除阻塞,无从知晓,可能过了很久才解除阻塞,那么这两行代码虽然看起来是相邻且相同的,但如果调用的时间间隔长了,判断结果也可能会不同!

    就比如在一个线程执行时,一开始 instance 为 null,第一个 if 判定成立,进入第一个 if 中,但接下来获取锁时却发现,锁已经被其它线程获取了,那么这个线程此时就只能阻塞等待,等到这个线程结束阻塞,获取到锁的时候,再继续往下执行,发现 instance 已经被别的线程创建好了,不再为 null,第二 if 判断就不成立,此时该线程不会进入到第二个 if 中去,也就不会重复再 new 一个对象

    b. 优化二:使用volatile修饰解决 new 操作引发指令重排序

    注意!!! 优化后的代码,仍然还存在一个很重要的问题!!! —— 指令重排序,指令重排序也可能导致线程不安全问题
    这是怎么一回事呢?回顾之前指令重排序的案例(有些遗忘的,可回顾这期内容)我们一起来分析分析这个代码~

    new 的操作大体包括以下3个步骤:

    1)申请内存空间
    2)调用构造方法,即初始化内存的数据
    3)把对象引用赋值给 instance,即内存地址的赋值

    在这里插入图片描述这就可能存在指令重排序问题,其中在单线程下步骤2) 和 3) 可以互换顺序,但是在多线程下,如果按照1) 3) 2)的顺序,则可能会出现问题!

    假如 instance 为 null,当线程 t1 执行完 1) 和 3) 这两个步骤后,被线程 t2 调度,t2 线程再进入 if 判断时,由于 t1 线程已经申请内存空间并将对象引用赋值给 instance 了,instance 已经不为 null,此时条件不成立,t2 线程中的getInstance()方法则直接返回 instance,实际上 instance 指向的对象还没调用构造方法,即 t2 拿到的是一个没装修过的毛坯房,如果 t2 线程继续往下执行,调用后续的方法,可能就都是将错就错了 !
    在这里插入图片描述

    尽管上述过程是一个极端小概率情况,但在高并发、大数据的情况下,一旦出现上述问题,后果是十分严重的,不容小视!

    解决方式volatile 修饰 instance即可,volatile可禁止指令重排序!

    最后懒汉模式整体代码如下:

    class SingletonLazy {
        volatile private static SingletonLazy instance = null; //先置为空
    
        public static SingletonLazy getInstance() { //只有调用这个才会创建对象
            if(instance == null) {
                synchronized (SingletonLazy.class) {
                    if (instance == null) {
                        instance = new SingletonLazy();
                    }
                }
            }
            return instance;
        }
        private SingletonLazy() { }
    }
    
    public class ThreadDemo15 {
        public static void main(String[] args) {
            SingletonLazy s1 = SingletonLazy.getInstance();
            SingletonLazy s2 = SingletonLazy.getInstance();
            System.out.println(s1 == s2);
        }
    }
    
    

    【Q】我们知道 volatile 关键字除了禁止指令重排序,还有保证内存可见性的效果,那么在上述代码中,有没有内存可见性问题呢?
    【A】暂时保留疑问,在此不作定论
    这个代码与之前内存可见性的案例代码差别很大,内存可见性问题发生在由于频繁读,编译器优化掉寄存器从内存读取数据到CPU寄存器的操作,使每一次读数据并没有真正从内存中读取,在上述代码中是否存在频繁读问题,假设 N 个线程一起调用,是否就相当于读了 N 次,触发优化到寄存器中的操作?
    这其实是不一定的!!! 每个线程都有自己的一套寄存器,这会不会出现上述问题,无法确定~

    4. 面试题 —— 单例模式的线程安全问题

    其实就是本期后半部分内容的小结~ 知识点都讲完啦!

    【饿汉模式】天然就是线程安全的,因为只是进行读操作
    【懒汉模式】是线程不安全的,因为既有读操作,也有写操作

    保证懒汉模式线程安全问题的措施:

    1. 加锁,把 if 操作 和 new 操作 变成原子操作
    2. 双重 if,减少不必要的加锁操作,坚持非必要,不加锁的原则
    3. 使用 volatile 禁止指令重排序,保证后续线程拿到的肯定是一个完整的对象

    💛💛💛本期内容回顾💛💛💛
    在这里插入图片描述
    ✨✨✨本期内容到此结束啦~

  • 相关阅读:
    二阶段day2
    品牌百度百科词条创建方法是什么?品牌百科怎么创建?
    MySQL (2)
    Android 12.0 修改系统默认字体的大小
    Unity导表工具Luban插件的数据加载原理与优化
    【Azure 云服务】Azure Cloud Service (Extended Support) 云服务开启诊断日志插件 WAD Extension (Windows Azure Diagnostic) 无法正常工作的原因
    阿里P7,一个女测试经理的成长之路
    解决 Can‘t connect to MySQL server on ‘x‘ (10048) 问题
    迁移学习怎么用
    Linux网络编程---Socket编程
  • 原文地址:https://blog.csdn.net/m0_61814277/article/details/140314895