单线程懒汉模式

原始方法是这样的:

// 单线程懒汉模式实现
public static Singleton getInstance() {
    if (INSTANCE == null) {
          INSTANCE = new Singleton();
    }
      return INSTANCE;
}

多线程情况下的优化

单线程懒汉模式的问题

上面这段代码在单线程环境下没有问题,但是在多线程的情况下会产生线程安全问题。

在多个线程同时调用 getInstance() 时,由于没有加锁,可能会出现以下情况

  1. 这些线程可能会创建多个对象
  2. 某个线程可能会得到一个未完全初始化的对象

为什么会出现以上问题?

对于1的情况解释如下:

由于没有加锁,当线程A刚执行完if判断INSTANCE为null后还没来得及执行INSTANCE = new Singleton()此时线程B进来,if判断后INSTANCE为null,且执行完INSTANCE = new Singleton()然后,线程A接着执行,由于之前if判断INSTANCE为null,于是执行INSTANCE = new Singleton()重复创建了对象。

对于2的情况解释如下:

由于没有加锁,当线程A刚执行完if判断INSTANCE为null后开始执行 INSTANCE = new Singleton()但是注意,new Singleton()这个操作在JVM层面不是一个原子操作。

(具体由三步组成:1.为INSTANCE分配内存空间;2.初始化INSTANCE;3.将INSTANCE指向分配的内存空间,而且这三步在JVM层面有可能发生指令重排,导致实际执行顺序可能为1-3-2)。

因为new操作不是原子化操作,因此,可能会出现线程A 执行new Singleton()时发生指令重排,导致实际执行顺序变为1-3-2,当执行完1-3还没来及执行2时(虽然还没执行2,但是对象的引用已经有了,只不过引用的是一个还没初始化的对象),此时线程B进来进行if判断后INSTANCE不为null,然后直接把线程A new到一半的对象返回了。

解决问题:加锁

为了解决问题1,我们可以对 getInstance() 这个方法加锁。

public static synchronized Singleton getInstance() {  // 加锁
    if (INSTANCE == null) {
        INSTANCE = new Singleton();
    }
    return INSTANCE;
}

仔细看,这里是粗暴地对整个 getInstance() 方法加锁,这样做代价很大,因为,只有当第一次调用 getInstance() 时才需要同步创建对象,创建之后再次调用 getInstance() 时就只是简单的返回成员变量,而这里是无需同步的,所以没必要对整个方法加锁。

由于同步一个方法会降低上百倍甚至更高的性能, 每次调用获取和释放锁的开销似乎是可以避免的:一旦初始化完成,获取和释放锁就显得很不必要。

所以我们可以只对方法的部分代码加锁:

public static Lock2Singleton getSingleton() {
    // 因为INSTANCE是静态变量,所以给Lock2Singleton的Claa对象上锁
    synchronized(Lock2Singleton.class) {        // 加 synchronized
        if (INSTANCE == null) {
            INSTANCE = new Lock2Singleton();
        }
    }
    return INSTANCE;
}

优化后的代码选择了对 if (INSTANCE == null)INSTANCE = new Lock2Singleton()加锁

这样,每个线程进到这个方法中之后先加锁,这样就保证了 if (INSTANCE == null)INSTANCE = new Lock2Singleton() 这两行代码被同一个线程执行时不会有另外一个线程进来,由此保证了创建的对象是唯一的。

对象的唯一性保证了,也就是解决了问题1,但是如何解决问题2呢?虽然加了 synchronized,但是 synchronized 是不能禁止指令重排的,也就是说,INSTANCE = new Lock2Singleton(); 这行代码在 JVM 层面还是有可能发生 1-3-2 的现象,那要怎么保证绝对的1-2-3顺序呢,也就是禁止指令重排序,答案是加 volatile。

Java synchronized 能防止指令重排序吗
结论:synchronized 块里的非原子操作依旧可能发生指令重排。

这样总可以解决问题1和2了吧,然而你以为这就结束了吗?NO!这段代码从功能层面来讲确实是已经结束了,但是性能呢?是不是还有可以优化的地方。

值得优化的地方就在于 synchronized 代码块这里。每个线程进来,不管三七二十一,都要先进入同步代码块再说,如果说现在 INSTANCE 已经不为null了,那么,此时当一个线程进来,先获得锁,然后才会执行 if 判断。我们知道加锁是非常影响效率的,所以,如果 INSTANCE 已经不为null,是不是就可以先判断,再进入 synchronized 代码块。如下

public static Lock2Singleton getSingleton() {
    if (INSTANCE == null) {                         // 双重校验:第一次校验
        synchronized(Lock2Singleton.class) {        // 加 synchronized
            if (INSTANCE == null) {                 // 双重校验:第二次校验
                INSTANCE = new Lock2Singleton();
            }
        }
    }
    return INSTANCE;
}

在 synchronized 代码块之外再加一个 if 判断,这样,当 INSTANCE 已经存在时,线程先判断不为null,然后直接返回,避免了进入 synchronized 代码块。

那么可能又有人问,好了,我明白了在 synchronized 代码块外加一个 if 判断,是不是就意味着里面的那个 if 判断可以去掉。

当然不可以!如果把里面的 if 判断去掉,就相当于只对 INSTANCE = new Lock2Singleton() 这一行代码加了个锁,只对一行代码加锁,那你岂不是加了个寂寞,结果还是会引起问题1。

注:这一行不是原子操作,当然可以加锁,其实我对双重检查单例的理解也是基于对这一行的加锁的举动。

所以,两次校验,一次都不能少。

我的理解

对于问题2阻止单行语句的指令重排需要volatile这一点没有疑义。

首先回顾一下单线程懒汉模式实现,由于synchronized 加在最外层并不合适,我们希望加锁的范围最小化,因此可以对INSTANCE = new Lock2Singleton();这一行加锁。因为这一行代码既不是原子的,也会发生指令重排。说它不是原子的是因为如上所说,这行代码执行时有三个过程,不同的线程同时执行时可能线程1在刚开始执行时,线程2已经执行完了,导致线程1又进行了一次初始化。

注:这是我的个人理解,对于这行语句既不原子也会发生指令重排倒是没什么问题。多线程下会不会发生重复初始化有明白的朋友可以告诉我下。

放在if里加锁是因为创建新对象只有第一次才会执行,后续就不涉及了,因此没必要每次一走到if就加锁。但是这样会带来一个问题,就是从走到if里后到进入synchronized里还有一段时间,这段时间内有可能别的线程已经完成了初始化,但是本线程已经过了检测那一步,于是又进行了一次初始化,因此需要在synchronized里也要进行一次判断,判断是不是其它线程已经完成了一次初始化,如果已经完成了就不用再创建新对象了。

这个想法和上面的解释有一些不同,主要是我之前在思考为什么要做两次判断时想到的,不一定完全正确。

参考

https://blog.csdn.net/weixin_44471490/article/details/108929289

标签: none

添加新评论