【多线程】单例模式

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

文章目录

  • [1. 单例模式的初识](#1. 单例模式的初识)
  • [2. 单例模式的含义](#2. 单例模式的含义)
  • [3. 单例模式实现的两种方式](#3. 单例模式实现的两种方式)
  • [4. 面试题 ------ 单例模式的线程安全问题](#4. 面试题 —— 单例模式的线程安全问题)

1. 单例模式的初识

何为单例模式呢~

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

这里又有一个新的概念,设计模式是什么?

设计模式

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

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

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

2)工厂模式

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

2. 单例模式的含义

从字面理解,单例就是单个实例(instance),即在一个程序中,某一个类,只能创建出一个实例 (一个对象),不能创建多个对象! (回顾JavaSE中所学的知识,类的实例就是对象~)

这里不是说多 new 几次,就可以创建多个对象,在语法上有办法禁止多 new,在Java中的单例模式,借助Java语法,保证某个类只能创建出一个实例,而不能new多次,不能创建多个对象

单例模式能保证某个类在程序中只存在唯一一个实例,而不会创建出多个实例

场景需要】有些场景就需要某个概念是单例的,比如在生活中,一夫一妻制,再比如,JDBC中的 DataSource 实例就只需要一个

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

在Java语法中,实现单例模式有很多种写法 ,本文主要介绍以下两种实现方式:

1)饿汉模式(急迫版)

2)懒汉模式(从容版)

3.1 饿汉模式

在生活中,吃完饭后去洗碗,饿汉模式 ------ 吃完之后立刻去洗碗,超急迫的~

对应计算机中的栗子,打开一个硬盘上的文件,读取文件内容,并显示出来,饿汉模式 ------ 把文件所有内容都读到内存中并显示出来

但是假设文件非常大,比如10G,饿汉模式文件打开可能都要半天,内存够不够我们都不清楚~

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

c 复制代码
// 把这个类设置为单例
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 懒汉模式(单线程版)

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

c 复制代码
//懒汉模式实现单例模式
//懒汉模式实现单例模式
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 对象操作是一个原子操作

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

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

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

在前面也讲过,加锁是一个比较低效的操作 ,因为加锁就可能涉及到阻等待,需坚持非必要,不加锁 的原则,在上述加锁方式中存在一个问题:在任何时候调用getInstance()方法都会触发锁竞争!

事实上,此处的线程不安全问题只是出现在首次创建对象 这里,一旦对象 new 好了,后续调用 getInstance()方法时,仅仅就是读操作了,不涉及到修改,也就没有线程安全问题了,就没必要加锁了!!!

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

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

问题 】到底什么时候需要加锁?

回答 】上述分析可得,在首次 new 对象时候需要加锁

措施】需要再加一层 if 判断,用来判断需要加锁的情况

c 复制代码
 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可禁止指令重排序!

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

c 复制代码
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 禁止指令重排序,保证后续线程拿到的肯定是一个完整的对象

💛💛💛本期内容回顾💛💛💛

✨✨✨本期内容到此结束啦~

相关推荐
憨子周37 分钟前
2M的带宽怎么怎么设置tcp滑动窗口以及连接池
java·网络·网络协议·tcp/ip
霖雨2 小时前
使用Visual Studio Code 快速新建Net项目
java·ide·windows·vscode·编辑器
SRY122404192 小时前
javaSE面试题
java·开发语言·面试
Fiercezm3 小时前
JUC学习
java
无尽的大道3 小时前
Java 泛型详解:参数化类型的强大之处
java·开发语言
ZIM学编程3 小时前
Java基础Day-Sixteen
java·开发语言·windows
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
P.H. Infinity3 小时前
【RabbitMQ】07-业务幂等处理
java·rabbitmq·java-rabbitmq
爱吃土豆的程序员3 小时前
java XMLStreamConstants.CDATA 无法识别 <![CDATA[]]>
xml·java·cdata