浅学设计模式之单例模式(13/23)附带volatile修饰的详解

由于单例模式用的多,对我们来说可能会很简单,所以这篇会省去很多篇幅。

1. 单例模式的概念

单例模式(Singleton),保证一个类仅有一个实例,并提供一个访问它的全局访问点

通常我们可以让一个全局变量使得一个对象被访问,但它不能防止你实例化多个对象。
一个最好的办法就是,让类自身负责保存他的唯一实例。这个类可以保证没有其他实例可以被创建,并且它可以提供一个访问该实例的方法。 这就是单例模式。

2. UML图

在这里插入图片描述

对于单例来说,instance和构造方法都是private,提供一个public的getInstance方法来访问这个单例。

3. 代码示例

单例模式的核心是 私有的构造方法,和静态公有的getter方法,单例模式的代码在这个基础上演化出多种版本。
比如“饿汉式”、“懒汉式”,名字是第一批研究单例模式写法的人起的,其区别就只是实例在什么时候被构造出来而已。
还有线程安全版本的DCL单例写法。
下面分别看看他们的写法。

3.1 饿汉式

饿汉式突出一个饿,即你要马上给对方提供一个实例。在对方想要的时候立马return给他,所以写法是这样的:

// 单例类
public class Singleton {
    private static Singleton instance = new Singleton();  // 静态变量在类加载时直接初始化。

    //构造函数是private
    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }
}

上述单例类在 静态变量instance在Singleton类加载时直接初始化。
当客户端想使用instance时,调用 getInstance()就能直接拿到instance实例。

这样做的优点是:

  • 在获取的时候能马上拿到instance实例,速度快,这就是空间换时间
  • 线程安全,这是因为程序运行后,每个类只会加载一次,所以饿汉式的单例只要被加载,其instance在单个进程的实例永远只有一个。在不同线程来抢夺时,他们抢的instance永远是同一个instance,所以不会出现instance会被实例化多次

缺点是:

  • 如果不用的话,可能会出现资源的浪费。(虽然完全不用的话,类是不会被加载的)
    举个例子:你没有用到getInstance方法,但是你通过了 class.forname()反射的方式调用了它的某个方法,或者执行了这个单例类其他的静态方法,就会触发类被加载。如果之后不用到getInstance(),那么instance的初始化就是没有意义的,而且因为instance是静态变量的关系,如果不手动置null,它会存活很久(在一般情况下,进程没死掉,GC就不会回收静态变量)。

为了解决饿汉式的缺点,懒汉式就诞生了。接下来看看懒汉式。

PS:其实我认为饿汉式瑕不掩瑜,我们平时开发的时候如果不用Singleton,我们干嘛会通过那些方式去让它加载呢?

3.2 懒汉式

即懒加载,在第一次用到的时候,才去初始化这个instance实例。

public class Singleton {
    private static Singleton instance;

    //构造函数是private
    private Singleton() {
    }

//    public static Singleton getInstance() {
//        return instance;
//    }

    public static Singleton getInstance() {
        if(instance == null) {  //在使用的时候去加载
            instance = new Singleton();  
        }
        return instance;
    }
}

可以看到,在类加载的时候不会去初始化instance。在 getInstance()的时候,会判断instance是否会null,如果为null,去调用其构造方法初始化。

从上可以看出懒汉式的优点:

  • 将静态实例的初始化提后,这样可以保证如果不用到 getInstace()方法,静态实例是不会初始化,所以不会造成资源浪费。避免了饿汉式的缺点。

缺点:

  • 时间换空间,(但是我认为空间换时间,时间换空间的做法并无优劣之分,他们都是在特定场合使用的,都是非常好的策略)
  • 多线程不安全

先不考虑多线程的问题。
在单线程的情况下,懒汉式的写法就是业界通用的、推荐的写法,单线程下,这种写法没有没有任何缺点。

在来考虑多线程的情况, 为什么会产生线程不安全的问题呢?
我们把getInstance()分解一下 :

    public static Singleton getInstance() {
        if(instance == null) {   // 1 if语句
            // 2 进入if语句
            instance = new Singleton(); // 3 new出实例  
        }
        return instance; 
    }

我把getInstance方法分成了上面的三步。接下来模拟一下 A、B两个线程调用 getInstance()的场景,Cpu时间片先A后B:
开始:类加载,instance为null
A线程:执行1,发现instance为null,进入if语句内,即到2, 在准备执行3的时候------------Cpu切片,线程切换
B线程:执行1,发现instance为null,进入if语句内,即到2,执行3,成功new出instance -----------Cpu切换,线程切换
A线程:因为JVM程序计数器存放刚刚执行代码的语句,所以这个时候继续执行3

这个时候就会问题-------A、B线程都创建了实例。这就不符合单例模式的定义。
出现这个问题的本质是 我们是永远不可能知道Cpu什么时候切换线程,这是操作系统决定的。

所以为了解决懒汉式在多线程下可能会被实例多次的问题,大佬们写出了线程安全的版本

3.3 线程安全式

在Java中,synchronizedlock()/unlock()都可以用来加锁。因为JDK1.7后,JVM对synchronized优化了很多,比起 lock/unlock开发者自己去找机会开锁和解锁。所以更推崇使用 synchronized关键字。

所以就有了这么一个线程安全的写法:

    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
// 等价于:
    public static Singleton getInstance() {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
        return instance;
    }

直接对方法进行修饰。这样的做法一定能保证instance只会被初始化一次。
但是这样的做法挺暴力的。因为直接给方法加锁或者方法的所有代码加锁,这是因为加锁是重量级操作。
如果一个instance已经被实例化了,那么别的线程都要通过加锁才能拿到它,这没有必要。

对比 HashTableConcurrentHashMap,我们知道,我们可以在关键代码再加锁。

这个时候,就有了最经典的优化版本----DCL写法。

3.4 DCL双重检验锁

如果instance已经被实例化了,那么我们的目标是不用获取锁就能拿到它。如果没有别实例化,我们就要求第一个线程对它进行实例化。
它的代码刚开始看是这样的:

    public static Singleton getInstance() {
        if (instance == null) { // 1
            // 2
            synchronized (Singleton.class) { //3
                instance = new Singleton();  
            }
        }
        return instance;
    }

这样写确实能保证如果instance被实例化,线程进来能直接拿到instance,但是这样的写法有个问题,我依旧把代码拆成上面3个部分,有这么一个场景:
开始:类被初始化,instance为null
线程A:进入到1,发现instance为null,进入2,在准备执行3的时候------------Cpu切片,线程切换
线程B:进入到1,发现instance为null,进入2,执行3成功,拿到锁,通过new成功对instance实例化---------------Cpu切片,线程切换
线程A:执行3,又执行了一次 instance的实例化。

这就出现和懒汉式类似的场景,为了解决这个问题,大佬们研究出了 DCL写法。
DCL的全称是Double-Check Locking(双重检验锁定)。双重检验就是检验两次instance是否为null,在上面的基础上又加了一次if(instance == null)

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {  // 1
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

可以看到,注释1,线程在拿到锁喉还要检查一遍instance是否为null,如果为null才去实例化。
这就解决了上面的线程不安全的问题。

DCL写法是业界推荐的多线程下的单例模式写法。它在多线程下几乎没有任何缺点。
但是如果我们的单线程的操作,就没有必要用到这种写法,会消耗很多性能。

3.4 volatile修饰instance实例

首先先说下 volatle,它是Java保护线程安全的最轻量的机制。它有两个作用:

  1. 可以保证修饰变量在操作的时候的原子性但是必须要满足下面两个条件
    ①:这个操作的运算结果不依赖当前的值 (最直观的就是直接赋值,例如,x = 5, user = otherUser)
    ②:修饰的变量不需要与其他的状态变量共同参与不变约束 (就是变量不能被其他变量影响)
  2. 禁止修饰的变量在JVM指令进行优化重排。

针对于第二点说一下:
真正执行代码的地方是操作系统,我们的Java代码会经过 JVM -> 操作系统,在操作系统形成机器码后一条一条的读。

JVM有个特别强大的地方,它可以进行 指令优化,这是针对机器级的优化,即Java字节码在进入JVM后,先会解释成一条一条的JVM指令码,接着对这些指令码重新排序,排序在不扰乱程序正常运行的前提下,打乱指令的排序,然后给机器码执行。

看到这里可能会有觉得很神奇,为什么打乱了程序的指令,程序还能正常运行?举个例子,指令int a = 0, int b = 1
JVM可能会优化成 int b = 0, int a = 0,JVM会认为这样执行会快一点或者对存储好一点。这个已经牵扯到汇编的知识了。
再讲的本质一点,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说,指令任意排序,Cpu需要能够正确处理指令依赖情况以保障程序能得出正确的执行结果。

指令重排序会导致下面两点:

  • ①:所以对于单线程来说,就算指令重排,单线程下看到的现象,所有的操作也都是有序的。(此处的有序是指语义有序,不是指令顺序有序)
  • ②:而对于多线程来说,因为指令重排,B线程看A线程的操作是无序的,根本原因是B线程不知道A线程的语义。

上面两点体现的是Java线程三个特性:原子性、有序性、可见性中的有序性。

这里我们先不讲volatile怎么去禁止指令重排序的,我们先看看在不加volatile的情况下,上面的DCL会产生什么问题:
由于我们已经深入到了JVM执行码的层级,所以我们就要以这个层级做的事来看待这个问题,就是 instance = new Singleton()这句,在JVM执行的时候,它这句要拆成三个部分,它的伪代码是这样的:

// 正常顺序下
A线程拿到锁
if(instance == null) {
    malloc(tmp);  //分配空间
    init(tmp);    //初始化
    instance = tmp;  //赋值
}

由于可能会发生指令重排,就会出现这么一个情况:

// 重新排序
A线程拿到锁
if(instance == null) {
    malloc(tmp);  //分配空间
    instance = tmp;  //1 赋值 
    init(tmp);    //初始化
}

我们假定这就是指令优化后的结果(现实也会出现这样的情况)。
这时候开始走我们的场景,从getInstance()方法开始:
A线程:执行if(instance == null) 因为instance为null,所以往if语句走
A线程:执行 synchronized(Singleton.class),发现可以拿到锁,拿到锁后,往锁里方法走
A线程:执行 if(instance==null),第二次检查,发现instance还是空的, 所以往if语句里面走
A线程:执行 malloc(tmp), 执行 instance=tmp,当要执行init(tmp)时----------CPU切片,线程切换
B线程:执行if(instance == null),诶!这个时候发现instance不为null!所以return 这个instance。
B线程:拿到instance执行方法,结果报 NullPointExpection,instance为空。

对B线程来说,就已经遭重了… 这就是指令重排的潜在危险。

而volatile为什么能够解决这个问题呢?我们不妨不上面的例子,A和B的执行顺序再来写一遍,这次我们写在一起,变成真正的“机器指令码”,当然这里写的是伪代码:

机器码执行顺序:
....
MALLOC(tmp);   // A线程执行
instance=tmp;   //A线程执行
if instance==null  // B线程执行 
instance != null //B线程执行
return instance  //B线程执行
NULLPOINTEXPECTION //B线程报错
init(tmp);    //A线程执行

这就是事故发生的现场,而volatile如果修饰了instance,它的执行顺序就变成这样了:

机器码执行顺序:
....
----------------------
//这里是关于A线程对instance的操作
MALLOC(tmp);   // A线程执行
instance=tmp;   //A线程执行
init(tmp);    //A线程执行
lock         // volatile发挥作用
-----------------------
if instance==null  // B线程执行 
return instance  //B线程执行

我们看到,对于A线程来操作 instance时,最后会立马加一条 lock,lock是一条指令。
lock指令的作用:CPU将修改好的instance数据(在Cache中)写入到主内存,别的CPU的Cache中的这个instance数据直接无效。lock后面的指令不能排序到lock前面,lock就像一个屏障一样(Memory Barrier)。
所以对B线程来说,发生了lock,就意味着之前所有的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。

线程B,我们假定认为是它别的Cpu上工作,那么它去看instance时,因为Cache中的数据已经无效,那么他就有必要再去主存中拿一次instance的值,这时的instance一定是正确的值。

这也说明了volatile是怎么保证数据的可见性的。

最后根据:
在这里插入图片描述
特点,我们就能确定了 instance = new Singleton()原子性操作
所以 instance如果是null就一定是null,如果不是null就一定不是null。

最终版的DCL代码如下:

public class Singleton {
    private volatile static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

4. 总结

单例模式保证了一个类在一个进程中的实例仅有唯一一个。 单例模式因为Singleton类封装了它的唯一实例,这样它可以严格地控制客户怎样访问它及何时访问它,简单的说就是对唯一实例的受控访问。

它在单线程中的写法有饿汉式和懒汉式:

  • 饿汉式(预加载式)
    空间换时间方案,在类加载的时候初始化实例。在获取时O(1)时间拿到实例。并且可以保证线程安全。
    缺点是:如果类被加载了,但是又不使用 getInstance(),因为instance是静态实例的原因,会产生资源的浪费。
  • 懒汉式(懒加载式)
    时间换空间的方案,在第一次调用 getInstance()时才去初始化实例。
    优点是避免资源消耗。缺点是线程不安全,多线程竞争时,可能会产生多个实例。
    单线程下,是最推荐的写法。

在多线程下,出现了DCL–双重检验锁写法:

  • 在懒汉式的基础下,在 getInstance()中检查两次实例。第一次检查是在没拿到锁的时候,第二次检查是在拿到锁的时候。

因为JVM会优化指令,对指令进行重排,指令重排会阻扰Java的并发。
DCL时多线程场景下可能会导致 线程在调用 getInstance()拿到空的实例,所以需要使用 volatile修饰,保证了实例在初始化的原子性,和通知其他线程修改的可见性。

展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 像素格子 设计师: CSDN官方博客
应支付0元
点击重新获取
扫码支付

支付成功即可阅读