深入分析单例模式

来源:互联网 发布:两组数据相似性分析 编辑:程序博客网 时间:2024/06/03 19:14

单例模式,是设计模式中最常见的一种设计模式,也是面试官最喜欢考察的模式。那什么是单例模式,这里我就不在赘述了,百度一大堆。

那么如何写一个好的单例模式呢?网上最经典的就是那七种单例模式。但是基本都是浅显地解释为什么要那样写单例,如何保证线程安全。但是对于单例的编写,我们不仅仅需要知道可以那样写,还应该知道为什么可以那样写,还应该知道怎么想出可以那样写的。这样我相信才会对童鞋们帮助更大。

第一层就是最简单的懒汉模式

public class Singleton {      private static Singleton instance;      private Singleton (){}      public static Singleton getInstance() {          if (instance == null) {        ---------------------1                        instance = new Singleton();---------------------2        }          return instance;    }  }

这种写法是普遍都知道的入门级。这种写法的目的是延迟加载,当instance没有初始化的时候才进行初始化。但是毫无疑问,无法保证线程安全的。为什么? 当然是因为现在假如有两个线程A,B。线程A执行代码1的同时,线程B执行代码2。这会出现什么问题?

实例的创建,主要分三步:

  1. 分配对象的内存空间
  2. 初始化对象,进行一下赋值操作
  3. 将实例的引用指向刚才分配的内存地址

JMM在进行实例创建的时候,只会保证intra-thread-semantics,即只保证在单线程中,不改变程序的执行结果(as-if-serial)。我们知道,为了进一步优化程序的执行,编译器或者处理器会进行指令的重排。也就说在上面的三个步骤中,2,3两个步骤可能会发生重排,可能具体执行的顺序为3,2。intra-thread-semantics只保证单线程执行正确,所以会允许这样的重排。

那么这样就带来了问题。假如进行了重排,先3再2,那么当线程B在执行了2,先给instance赋值后,然后执行3的时候,此时线程A看到instance已经被赋值了,那么就会认为对象已经创建完成,然后使用该instance。但是实际上,该instance还没完成初始化。这就造成了问题。

因为指令的重排,这种写法无法保证线程安全。那么我们从指令重排的角度来考虑怎么解决。既然是因为2,3步骤重排,那么可以用两个方法解决:1.即使发生了重排,那我们只要保证在执行期间,其他线程看不到这个重排。2.既然是因为重排导致的问题,那就禁止2,3重排好了。

解决思路一:禁止重排

重排不可见,解决方法很多。

法一

直接在方法层上加锁。

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

这样就保证了线程安全。但是,假如getInstance的调用很频繁,每次都会去竞争锁。那就很尴尬了。因为锁的竞争是需要开销的。虽然说synchronized经过优化,在java8里开销已经比较小了,在高并发场景下吞吐相对于lock也小了。但是仍然是会有开销的。

因此机智的人们就想到了很经典的双重检查锁,来减少锁的竞争。
于是就有了这样的代码:

public class Singleton {      private  static Singleton singleton;      private Singleton (){}      public static Singleton getSingleton() {          if (singleton == null) {              synchronized (Singleton.class) {                  if (singleton == null) {                      singleton = new Singleton();                  }              }          }          return singleton;      }  }  

ok,这样的话,只有在还没有创建实例之前需要竞争锁去创建。但,这样的修改,线程仍然是安全的吗?大家看看先代码想一下。好吧,我知道大部分人不会想,那就直接说。

虽然上面的代码减少了锁的竞争,但却也把原来的线程安全又给搞得不安全了。仍然是实例还没有初始化结束,另一个线程就能读到。原因和上面讲的一样。不过有的童鞋会问:“你不是已经用了synchronized (Singleton.class) 吗?那这个synchronized还存在的意义是啥”?

因为(需要待求证)

那怎么办?

法二

那我们的解决方法,就是在instance上加volatile。需要在JDK5+上运行。代码如下

完整代码

public class Singleton {      private  volatile static Singleton singleton;      private Singleton (){}      public static Singleton getSingleton() {          if (singleton == null) {              synchronized (Singleton.class) {                  if (singleton == null) {                      singleton = new Singleton();                  }              }          }          return singleton;      }  }  

这样的话,2,3的重排序就会被禁止。

解决思路二:重排对其他线程不可见

这个思路,我们需要利用JVM的类的初始化的特性。我们知道,在初始化一个类的时候,为了防止多个线程同时对一个类做初始化,JVM会利用锁。在执行类的初始化之前呢,先获取锁,然后去初始化。那么我们做延迟加载,在类需要的时候才去初始化,就可以利用这个特性了。

不过类的初始化,是在某些场景下才会调用初始化的。比如说创建一个类的instance的时候,比如类中的静态方法被调用了,比如类中的静态方法被赋值了等等。那这里呢,我们采用静态类的形式来做。代码如下

public class Singleton {      private static class InstanceHolder{        public static Instance instance = new Instance();    }    public static Singleton getSingleton() {           return InstanceHolder.instance;      }  }  

上面的代码中,当getSingleton的时候,会调用静态成员变量,这就会触发类的初始化,转而去new 一个Instance。利用JVM初始化会加锁,就可以达到即使内部重排,也不会影响锁外面的情况。使得其他线程不会看到非法的情况。

这样,就通过两个角度,解决了单例中的线程不安全的问题了。那这两种方案其实都可以,不过基于volatile的方法更加灵活,除了可以对静态变量做延迟加载,也可以对实例对象做延迟加载。但是一般来讲,采用延迟加载可能比正常加载效率更低一些,会增加访问延迟初始化的字段的开销。因此需要谨慎使用。

原创粉丝点击