引言
单例模式是一种常见的设计模式,如果一个类只希望实例化生成一个对象,那我们就应使用单例模式。比如Servlet、Spring中的Bean、RunTime等等都应用到了单例模式。但一个新手往往会存在的疑惑是:我们为什么不可以在代码中只new一次这个对象,并给予它全局变量的作用域,让所有需要用到它的变量共享使用它呢?
原因是这种方式不安全。如果在这个类的定义中,完全不设置任何实例化对象相关的限制条件,那么一旦将该类打成jar包,供给第三方客户端调用时就会产生安全问题,即可以new出超过一个实例。因为对于客户端来说jar包中的内容完全是一个黑盒,如果jar包中该类对new的数量没有作出限制处理,客户端则可以随意new出>1个的对象。
因此一个类要想是单例,不能靠客户端生成实例去控制唯一性,而要从服务端对这个类的定义进行唯一性控制,这才能从根本上达到单例效果。那么如何去定义单例类呢?下面我们就介绍几种常用写法,并比较他们之间的异同和使用场景。
懒汉式写法
1 非线程安全
最简单粗暴的写法是下面这种,基本的单例模板有了,客户端只需调用静态方法getInstance(),并在内部判断instance是否实例化过,若实例化过则返回之前的那个对象,若未实例化过则现生成一个新对象返回。
public class Singleton { private static Singleton instance; private Singleton (){} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
可以看出这种方式是懒加载的,只有在第一次使用到该单例的时候才会生成对象。这种写法在单线程场景下效率很高,但在多线程场景下很显然是非线程安全的,比如这个情况:线程A执行到if语句块内部,刚要执行instance = new Singleton();的时候,线程B抢占了CPU时间片并且顺利地进入了if语句块内部,同时生成了instance对象返回,此时线程A再继续往下执行,便会又生成一个新对象并覆盖原来B生成的那个。本例给出的这个单例很简单,只是个示例,后果并不严重,因为它没有包含重要的成员变量,但假设包含了一些成员变量,线程B在返回对象后,客户端向这个单例加入了一些实际的配置参数,那么线程A一覆盖之前对象中的参数就清空了,整个程序也就一团乱麻了。
2 线程安全
那么其实只要对上述代码稍加改进,便可以得到一种线程安全的单例:
public class Singleton { private static Singleton instance; private Singleton (){} public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
对单例获取方法体加上synchronized关键字即可保证同一时刻进入getInstance()的只有一个线程,尽管如果有线程抢占了CPU时间片,由于它没有单例类的锁,因此会阻塞等待,然后交由有锁的那个线程处理完毕后,再进行执行,这样就保证了线程安全。但该方法也有弊端,那就是效率很低,因为其实只要第一次生成了单例对象,那么之后所有对象访问该单例,都不需要加锁就可以直接return instance了,而加锁是非常耗费时间的,因此做了很多无用功。
饿汉式写法
所谓饿汉,就是在不需要吃饭的时候也吃饭。在单例模式中的含义就是,在不需要单例的时候也去生成单例先储备在那里。那么很自然想到的方式就是利用JVM的classloader机制,在类装载的时候就实例化,如下面代码所示:
public class Singleton { private static Singleton instance = new Singleton(); private Singleton (){} public static Singleton getInstance() { return instance; } }
这种方式简单又安全,但显然没有达到按需取用的效果,如果这个单例非常的庞大耗费内存空间,那么势必会对系统运行造成不小的压力。
静态内部类写法
下面这种方式是《Effective Java》中推荐的,它的主要思想是采用静态内部类达到懒汉式加载的效果,利用了一个类中的内部类是延时加载的这一点去实现,如下所示:
public class Singleton { private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } private Singleton (){} public static final Singleton getInstance() { return SingletonHolder.INSTANCE; } }
这种方式与上面的方式不同,饿汉式加载只要Singleton类加载了,那么instance就会被实例化,而这种方式Singleton类加载了instance也不会实例化,只有在显式地通过getInstance()获取实例时,才会进一步装在SingletonHolder类,并初始化其静态变量,返回Singleton的实例。
枚举类写法
下面介绍的这种写法是Effective Java作者Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象,可谓是很坚强的壁垒啊,不过在实际工作中,其实很少看见有人这么写过:
public enum Singleton { INSTANCE; private Resource instance; Singleton() { instance = new Resource(); } public Resource getInstance() { return instance; } }
双重校验锁写法
这种写法信息量看起来就很大,虽然我们不一定使用它,但它所蕴含的思想还是非常值得学习的:
public class Singleton { private static Singleton instance; private Singleton (){} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
仔细分析一下这种写法的好处:首先还是判断单例是否存在,如果存在就直接返回。继续往下看就会发现:synchronized类锁并没有加在方法体上,而是缩减到了第一重if判空语句内部,很显然这样缩减锁区的方式较好地避免了“单例对象生成后再访问时盲目加锁造成的效率影响”。那么再往下看又出现了第二重if判空语句,它和第一次判断有什么区别呢?其实第一次判断只是为了判断对象是否生成过,如果生成了就直接return;而第二次判断是为了防止重复生成对象用的,因为很可能多个线程都在等着Singleton.class这把类锁,一旦进入同步块便会重复生成并覆盖instance实例。
然而上述代码虽然看似已经非常完美了,但还是有瑕疵,主要在于:instance = new Singleton();这句,因为这句赋值语句并非原子操作,在JVM中它是由如下3步原子操作组合而成的:
- 为instance分配一片孤立的内存空间
- 调用Singleton的构造函数来初始化成员变量
- 将instance引用指向1中分配的内存空间
但是在 JVM 的即时编译器中存在指令重排序的优化。即上述组合并不一定是按照1-2-3的顺序执行的,也可能是1-3-2,那么一旦3先于2执行完,那么instance就已经指向了一片并未初始化参数的空间了,此时线程B如果在正要执行第一重if判空语句时抢占到了CPU时间片(注意:线程A当前持有类锁并不意味着线程B不能抢占CPU时间片去执行,尽管线程B在线程A未在同步块中执行完之前是无法获得类锁,但试探的权利还是有的!),那么很自然instance不为空,就会马上return instance,而这个instance的成员参数都是空值,是个无法使用的异常单例。
而解决这个问题最好的办法就是使用volatile关键字,就像下面这样:
public class Singleton { private static volatile Singleton instance; private Singleton (){} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
volatile关键字最出名的的优势就是其可见性,比如多个线程都要对一个共享变量进行读、写操作,这个时候尽管对写操作的同步块加了锁,也不能保证在线程A修改了该变量后,线程B就能马上读到修改后的变量值,而很可能读到的还是它之前的值!这是因为每个线程都有个工作内存,线程建立的时候,会事先把共享变量保存一个副本在工作内存中,对共享变量的修改先发生在工作内存,再将其回写至共享变量真正所在的主内存中。因此一旦线程B没在变量回写到主内存之后访问,取到的就是之前的值!而只要加上volatile关键字,读写操作都将直接发生在主内存,这样就能保证正确性了。
但可见性并不意味着同步性(这里多说一点与本文无关的内容,因为它的确挺重要),比如现在i是个被volatile修饰的共享变量,然后要执行i++这种非原子的操作:
- 从主内存读取i的值
- 执行i+1操作得到结果
- 将结果回写至主存中的i
假如线程A在依次执行完1-2步后,线程B抢占了CPU时间片,并对i的值进行了修改,由于volatile的可见性,i在主内存的值很快得到改变,而线程A现在得到CPU时间片继续执行第3步将i回写至主存,那么很显然刚才线程B修改的i的值就被覆盖了,这就导致了线程不安全。因此同步性还是需要依靠sychronized或AtomicXXX包装类来保证。
扯得有点远了,回到本文,volatile的可见性的确很好很强大,但这并不是这个单例写法要用到它的原因,它的另一个特性才是根本原因:禁止指令重排序优化。即instance = new Singleton();中对instance的读操作必须要等1-2-3或1-3-2写操作执行完成后才能进行,从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。
结语
上述给出了多种单例写法,其实就一般使用而言,通常采用饿汉式写法,因为利用JVM本身的机制保证了线程安全,只要生成该单例不是特别耗费资源的话,大多数情况均优先考虑它。而如果是遇到要满足懒加载的情况的话,通常采用静态内部类写法。