写这篇博文的目的是想把单例模式在自己目前的理解程度上解释明白,话不多说,进入正题。
什么样的对象适合做成单例?我看到最经典的回答是,能做成单例的类,在整个应用中,同一时刻,有且只能有一种状态。换句话说,这些类无论是实例化多少个,其实都是一样的,而且更重要的一点是,这个类如果有两个或者两个以上的实例的话,我的程序竟然会产生程序错误。
最原始的单例模式长这样,
public class Singleton { //一个静态的实例 private static Singleton singleton; //私有化构造函数 private Singleton(){} //给出一个公共的静态方法返回一个单一实例 public static Singleton getInstance(){ if (singleton == null) { singleton = new Singleton(); } return singleton; } }
这是在不考虑并发的情况下单例模式的写法,有几个关键点:
- 静态实例,带有static关键字的属性在每一个类中都是唯一的。
- 限制客户端随意创造实例,即私有化构造方法,此为保证单例的最重要的一步。
- 给一个公共的获取实例的静态方法,注意,是静态方法,因为这个方法是在我们未获取到实例的时候就要提供给客户端调用的,所以如果是非静态的话,那就变成一个矛盾体了,因为非静态的方法必须要拥有实例才可以调用。
- 判断只有持有的静态实例为null时才调用构造方法创造一个实例,否则直接返回。
假如你面试一家公司,让你写出单例模式的例子,你按上面的写了,那么如果你刚毕业,可能这道题就过了,但是如果你已经有两年工作经验了,那这道题肯定不及格。为什么呢,因为没有考虑并发。像上面这种写法,在并发环境中是不正确的,线程A进入getInstance方法,判断了静态实例为空,准备创建实例,这时线程B入场,在线程A没有实例化完成对象前又判断对象为空,就会再次实例化对象,导致两个线程得到两个不同实例,这是错的。
为了证明在并发条件下这样写是错误的,我写了一个例子,其中用到了两个闭锁startGate和endGate,startGate是让多个线程同时执行,endGate是等待最慢的线程执行完毕。闭锁包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞直到计数器为零,或等待中的线程中断,或等待超时。countDown和await方法是配合使用的。
import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class TestSingleton { public static void main(String[] args) throws InterruptedException { final Set<String> instanceSet = Collections.synchronizedSet(new HashSet<String>()); final Runnable task = new Runnable() { @Override public void run() { Singleton singleton = Singleton.getInstance(); instanceSet.add(singleton.toString()); } }; long time = timeTasks(100,task); System.out.println("耗时:"+time+"纳秒"); for (String instance : instanceSet) { System.out.println(instance); } } /** * 并发nThreads个线程一起执行,并等待最慢的线程执行完成,返回执行时间(纳秒) * @param nThreads 线程数 * @param task 任务 * @return 执行时间(纳秒) * @throws InterruptedException */ public static long timeTasks(int nThreads,final Runnable task) throws InterruptedException { final CountDownLatch startGate = new CountDownLatch(1); final CountDownLatch endGate = new CountDownLatch(nThreads); ExecutorService executorService = Executors.newCachedThreadPool(); for (int i = 0; i < nThreads; i++) { executorService.execute(new Runnable() { @Override public void run() { try { startGate.await(); try { task.run(); } finally { endGate.countDown(); } } catch(InterruptedException e){} } }); } long startTime = System.nanoTime(); startGate.countDown(); endGate.await(); long endTime = System.nanoTime(); return endTime - startTime; } }
LZ并发100个线程,第一次执行得到的结果就是集合中有两个不同实例。
怎么解决呢,最容易想到的方案是将getInstance做成同步方法,同一时刻只有一个线程能执行该方法。
public class BadSynchronizedSingleton { //一个静态的实例 private static BadSynchronizedSingleton synchronizedSingleton; //私有化构造函数 private BadSynchronizedSingleton(){} //给出一个公共的静态方法返回一个单一实例 public synchronized static BadSynchronizedSingleton getInstance(){ if (synchronizedSingleton == null) { synchronizedSingleton = new BadSynchronizedSingleton(); } return synchronizedSingleton; } }
直接将整个方法同步,是一种很无脑的做法,synchronized将导致性能开销,如果getInstance()方法被多个线程频繁调用,将会导致程序执行性能的下降。
在早期的JVM中,synchronized存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定,也就是双重加锁,相比之下,我觉得双重检查锁定这个词表达更直接一点。我们看实现。
public class DoubleCheckedLocking { // 1 private static Instance instance; // 2 public static Instance getInstance() { // 3 if (instance == null) { // 4:第一次检查 synchronized (DoubleCheckedLocking.class) { // 5:加锁 if (instance == null) // 6:第二次检查 instance = new Instance(); // 7:问题的根源出在这里 } // 8 } // 9 return instance; // 10 } // 11 private DoubleCheckedLocking(){} }
如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作,因为,第一次检查可以大幅度降低synchronized带来的性能开销。有疑问的地方可能在于第二次检查的意义在哪里,不是只对对象的实例化过程加锁吗,用同步代码块将instance = new Instance();包起来就可以了吧。我们想象这样一个情况,假设A线程和B线程都在同步块外面判断了synchronizedSingleton为null,结果A线程首先获得了线程锁,进入了同步块,然后A线程会创造一个实例,此时synchronizedSingleton已经被赋予了实例,A线程退出同步块,直接返回了第一个创造的实例,此时B线程获得线程锁,也进入同步块,此时A线程其实已经创造好了实例,B线程正常情况应该直接返回的,但是因为同步块里没有判断是否为null,直接就是一条创建实例的语句,所以B线程也会创造一个实例返回,此时就造成创造了多个实例的情况。
双重检查锁定看起来似乎很完美,但是这是一个错误的优化!在线程执行到第4行,代码读到instance不为null时,instance引用的对象有可能还没有实例化完成。问题的根源在第7行(instance = new Instance();),JVM创建对象的时候大概分3步:
- 分配空间
- 初始化对象
- 设置instance指向刚分配的内存地址
上面2和3之间,可能会被重排序也就是先设置instance指向刚分配的内存地址,然后再初始化对象,这种重排序是真实存在的,JVM这样做的目的是提高程序的执行性能,这块内容以后再整理,假如线程A在2和3之间发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化,问题的根源就在这里。
在了解了问题的根源后,可以想到两个办法来解决这个问题。
- 不允许2和3重排序
- 允许2和3重排序,但不允许其他线程“看到”这个重排序。
1、基于volatile的解决方案
对于上面的基于双重检查锁定来实现延迟初始化的方案,只需要做一点小的修改,把instance声明为volatile类型,就可以实现线程安全的延迟初始化。
public class SafeDoubleCheckedLocking { private volatile static Instance instance; public static Instance getInstance() { if (instance == null) { synchronized (SafeDoubleCheckedLocking.class) { if (instance == null) instance = new Instance(); // instance为volatile,现在没问题了 } } return instance; } private SafeDoubleCheckedLocking(){} }
当声明对象的引用为volatile后,2和3的重排序,在多线程环境中将会被禁止。注意,这个解决方案需要JDK5或更高版本。(因为从JDK5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义)。
2、基于类初始化的解决方案
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获得一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的单例模式(这个方案被称为Initialization On Demand Holder idiom)。
public class InstanceFactory { private static class InstanceHolder { public static Instance instance new Instance(); } public static Instance getInstance() { return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化 } private InstanceFactory(){} }
假设两个线程并发执行getInstance()方法,下面是执行的示意图。
到这里,单例模式的最终版本终于浮出水面,也就是基于类初始化的解决方案。这种写法不依赖JDK版本,在并发环境下保证万无一失。