多线程——单例模式

发布于:2024-10-18 ⋅ 阅读:(13) ⋅ 点赞:(0)

目录

·前言

一、设计模式

二、饿汉模式

三、懒汉模式

1.单线程版

2.多线程版

·结尾


·前言

        前面的几篇文章中介绍了多线程编程的基础知识,在本篇文章开始,就会利用前面的多线程编程知识来编写一些代码案例,从而使大家可以更好的理解运用多线程来编写程序,本篇文章会用多线程来实现设计模式中的“单例模式”,这里实现“单例模式”的方式主要介绍两种:“饿汉模式”和“懒汉模式”,下面进行本篇文章的重点内容吧。

一、设计模式

        本篇文章介绍的单例模式属于设计模式中的一种,那么什么是设计模式呢?设计模式和象棋中的“棋谱“”比较类似,比如“红方当头炮,黑方马来跳”,针对红方的一些走法,黑方应招也有一些固定的套路,按照这种套路来下,局势就不会吃亏,按照棋谱下棋,下出来的棋不会太差,因为棋谱会兜住我们下棋的下限,设计模式也是如此,按照设计模式来写代码同样可以兜住我们的下限。

        单例模式,是设计模式的一种,它可以保证某个类在程序中只存在唯一的一份实例,而不会创建出多个实例,这点需求在很多场景都需要,比如在我们前面 MySql 篇章 JDBC 编程中的 DataSource 实例就只需要一个。使用单例模式,就可以对我们的代码进行一个更严格的校验和检查,不会像口头约定那样还可以创建多个实例。

        单例模式的具体实现有很多种,本篇文章就来介绍两种实现方式:“饿汉模式”和“懒汉模式”。

二、饿汉模式

        饿汉模式下实现的单例模式,在类加载时就会创建好对象实例,具体的代码已经运行示例如下所示,通过代码中的注释对代码再进一步介绍:

// 希望这个类在进程中只有一个实例
class Singleton{
    private static Singleton instance = new Singleton();
    // get 方法设为静态方法,这样其他代码想要使用这个类的实例就需要通过这个方法来获取
    // 不应该在其他代码中重新 new 这个对象,而是使用这个方法获取现成的对象
    public static Singleton getInstance() {
        return instance;
    }
    // 将构造方法设为 private 这样其他代码中就无法通过构造方法再进行实例化一个新对象
    private Singleton() {}
}

public class ThreadDemo1 {
    public static void main(String[] args) {
        // 利用"饿汉模式"实现的单例模式创建两个对象,观察这两个对象是否相同
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        System.out.println(s1==s2);
    }
}

        上述的代码就是“饿汉模式”单例模式中一种简单的实现方式,这里实例是在类加载的时候就创建了,创建的时机非常早,这就相当于程序一启动,实例就创建好了,就使用“饿汉”来形容“创建实例非常迫切,非常早”。

三、懒汉模式

        懒汉模式下实现的单例模式,在类加载的时候不创建实例,在第一次使用的时候才创建实例。这样的设计方式可以节省一些不必要的开销,以生活中的肯德基疯狂星期四为例,只有在星期四时,肯德基的点餐小程序上才会出现疯狂星期四的特价餐品,此时使用懒汉模式,不是星期四时就不会加载疯狂星期四的特价餐品,就会节省一些开销。

1.单线程版

        下面来以懒汉模式来实现一个单线程版的单例模式,示例代码及运行结果如下所示:

// 懒汉模式---单线程版
class SingletonLazy{
    // 这个引用指向唯一实例,初始化为 null,而不是立即创建实例
    private static SingletonLazy instance = null;
    private SingletonLazy() {}
    public static SingletonLazy getInstance() {
        if (instance == null){
            // 首次调用 getInstance 方法,创建实例
            instance = new SingletonLazy();
        }
        // 如果不是第一次调用 getInstance 方法,直接返回之前创建好的实例
        return instance;
    }
}

public class ThreadDemo2 {
    public static void main(String[] args) {
        // 利用"懒汉模式"实现的单例模式创建两个对象,观察这两个对象是否相同
        SingletonLazy b = SingletonLazy.getInstance();
        SingletonLazy b2 = SingletonLazy.getInstance();
        System.out.println(b==b2);
    }
}

        由运行结果可以看出,上述的代码写法仍然可以保证该类的实例是唯一一个,与此同时,创建实例的时机就不是程序启动时了,而是第一次调用 getInstance 方法的时候。 

2.多线程版

        通过上面单线程版的懒汉模式实现单例模式,我们可以来分析一下上述的代码是否是线程安全的呢?结论一定是不安全的,不然也不会再创建一个多线程版的懒汉模式实现单例模式,那么以上代码在哪里会涉及到线程安全问题呢?这里出现问题的核心代码就是 getInstance 方法,下面通过画图的方式来对这里的线程安全问题进行讲解:

        如上图所示,在线程 t1 判断完成,当前是第一次执行 getInstance 方法后进入 if 语句内,没等创建实例就被调度走去执行线程 t2 ,此时 t2 虽然是第二次调用 getInstance 方法,但是由于线程 t1 调用 getInstance 方法还没有创建实例,所以线程 t2 执行 if 语句显示 instance 仍然为 null,此时线程 t2 开始创建实例,并返回实例,然后又跳转回线程 t1 ,t1 继续执行创建实例,这时,该进程中就会出现两个实例,也就出现了线程安全问题。  

        如何改进单线程的懒汉模式,使它也成为线程安全的代码呢?这就需要我们进行加锁操作,想要使这里的代码执行正确,其实只需把 if 和 创建实例的两个操作打包成原子的(不可拆分),这样就可以解决单线程的懒汉模式中的线程安全的问题,加锁逻辑如下图所示:

        如上图两个线程在加锁后的执行流程所示,此时就可以确保,一定是 t1 执行完实例(new)操作修改了 instance 之后再回到 t2 执行 if 语句了,这时 if 的条件就不会成立了,t2 就会直接返回 instance 了。

        但是这样加锁之后还有一个问题,如果 instance 已经创建过实例了,此时后续再调用 getInstance 方法就都是直接返回 instance 实例了,这时调用 getInstance 方法就属于纯粹的读操作了,就不会有线程安全问题了,不过,按照上图中的代码逻辑,即使创建完 instance 实例后是线程安全的代码,仍然每次调用都会先加锁再释放锁,此时效率就会变低(加锁意味着产生阻塞,一旦阻塞解除时间就不确定了)。

        为了解决上述加锁引入的新问题,我们可以在每次加锁前再进行一次判断,仍然是判断当前 instance 的值是否为 null ,为 null 就继续加锁,不为 null 就可以直接返回 instance 对象,不用再进行加锁操作了,具体代码如下图所示:

        如上图所示的代码中,synchronized 上下两条 if 语句中判断的内容是一样的,这里虽然 if 中进行的判断相同,但是所判断的含义还是有所差别:

  1. 第一个 if 判断当前是否要加锁;
  2. 第二个 if 判断的是当前是否要创建实例 

        上面代码很凑巧的 if 中的判断条件相同了,但是一个是为了保证“线程安全”一个是“保证“执行效率”,这也就形成了双重校验锁。

        代码改到此处,还是存在一个问题,那就是由指令重排序引起的线程安全问题,指令重排序是一种编译器的优化方式,调整原有的代码执行顺序,保证逻辑不变的前提下提高程序的效率,但是在多线程中,这种优化就很可能带来线程安全问题,上面代码中,创建 instance 实例的过程就很可能会被指令重排序,创建 instance 实例代码如下:

instance = new SingletonLazy();

        上面这段代码,可以拆分成三个大的步骤:

  1. 申请一段内存空间;
  2. 在这个内存空间上调用构造方法,创建出这个实例;
  3. 把这个内存地址赋值给 instance 引用变量。 

        正常的情况下,会按 1,2,3 的顺序来执行上面这段代码,但是编译器可能会将上面代码优化成 1,3,2 的顺序来执行,这时就可能会出现问题,如下图所示的情况: 

        如上图的线程调度过程,t2 线程执行完 getInstance 方法后得到的是一个各个属性都未初始化“全0”值的 instance 实例,此时如果使用 t2 线程如果使用了 instance 里面的属性或者方法就会出现错误。

        这种错误出现的原因是由于线程 t1 在创建实例执行完了 1,3 后,被调度走,此时 instance 指向的是一个非 null 的,但是未初始化的对象,这时 t2 线程就会判定 instance==null 不成立,直接 return ,得到一个各个属性都未初始“全0”值的 instance 实例,此时使用这个实例就会出现问题,但是如果创建实例的代码按照 1,2,3 的顺序来执行,就不会出现上述的问题了,所以解决这个问题的方法就是阻止编译器对这段代码的指令重排序,这就需要使用到我们前面文章介绍的关键字 volatile 了。

        这里还是再介绍一下 volatile 关键字的功能把,主要有两个:

  1. 保证内存可见性:每次访问变量都必须要重新读取内存,而不会优化成到寄存器或缓存中读取变量;
  2. 禁止指令重排序:针对这个 volatile 关键字修饰的变量的读写操作相关指令是不能被重排序的。 

        代码中需要进行指令重排序的地方是为 instance 创建实例的时候,所以我们可以直接针对这个变量加上 volatile 关键字进行修饰,这样,针对这个变量再进行读写操作就不会出现重排序了,此时,创建实例的顺序一定是 1,2,3 也就预防了上述的问题。

        代码修改到这里就算没有问题了,那么正确懒汉模式实现单例模式多线程版的代码就可以写出来了,代码及一些详细注释如下所示:

// 懒汉模式---多线程版
class SingletonLazy{
    // 这个引用指向唯一实例,初始化为 null,而不是立即创建实例
    private volatile static SingletonLazy instance = null;
    private static Object locker = new Object();
    private SingletonLazy() {}
    public static SingletonLazy getInstance() {
        // 如果 instance 为 null, 说明是首次调用,首次调用就需要考虑线程安全问题,需要加锁
        if (instance == null) {
            synchronized (locker) {
                if (instance == null){
                    // 首次调用 getInstance 方法,创建实例
                    instance = new SingletonLazy();
                }
            }
        }
        // 如果不是第一次调用 getInstance 方法,直接返回之前创建好的实例
        return instance;
    }
}

public class ThreadDemo2 {
    public static void main(String[] args) {
        // 利用"懒汉模式"实现的单例模式创建两个对象,观察这两个对象是否相同
        SingletonLazy b = SingletonLazy.getInstance();
        SingletonLazy b2 = SingletonLazy.getInstance();
        System.out.println(b==b2);
    }
}

·结尾

        文章到这里就要结束了,本篇文章利用前面文章介绍的多线程基础知识来实现了一个小案例——单例模式的实现,这里介绍的两种实现方式:饿汉模式与懒汉模式,由于饿汉模式从类加载时就已经创建好实例,后续获取实例都是读操作不涉及线程安全问题,所以饿汉模式下的单例模式代码天生就是线程安全的,反观,懒汉模式在多线程与单线程下就有很大的差别了,此时单线程版的懒汉模式在多线程中就会引发线程安全问题,上面文章详细介绍了每个会出现线程安全问题的地方,希望能够给大家讲解清楚,最后在基于单线程版的懒汉模式代码下,修改出了多线程版的懒汉模式代码,理解清楚这里相信会对你理解线程安全问题有很大的帮助,如果对文章哪里感到疑惑,欢迎在评论区进行留言讨论哦~我们下一篇文章再见~~~