单例模式引发的思考
单例是个老生常谈的话题,有很多种写法,每一种写起来代码都比较简单,但是每一种背后蕴含的知识可谓是一层套一层,套路很深呀。。。
本篇主要是记录下我对单例的理解。
熟悉单例的人都知道单例一般会有五种写法,分别为懒汉法(线程非安全)、线程安全的懒汉法、饿汉法、内部类法和枚举法。
饿汉法
先说个intellij idea中单例模板代码(饿汉法):
1 | public class SingletonStatic { |
IDE中推荐的是饿汉法,代码是不是很简单。别看代码简单,但功能齐全,此代码是线程安全的。
线程安全是什么?
线程安全是指在多线程中,当多条语句在操作同一个线程的共享数据时,一个线程对多条语句只执行了一部分,还没执行完,另一个线程参与进来执行,导致共享数据出错。
解决方法:保证多条共享数据的语句,在一个线程完全执行完之前,其他线程不参与执行过程,也就是加锁或者叫同步。
此方式的线程安全是依赖类的加载和初始化实现的。
类加载与初始化
一个类在JVM中被实例化成一个对象,需要经历三个过程:加载、链接和初始化。
- 加载 类在jvm中的加载都是动态加载的,在被_首次调用_时加载到jvm中,由类加载器将
.class
文件加载jvm中。 - 链接 链接简单地说,就是将已经加载的
.class
组合到JVM运行状态中去。包括_验证、准备和解析_ - 初始化 执行类的static块和初始化类内部的静态属性(static块和静态属性是按照声明的顺序初始化的,且仅执行一次),然后是类内部属性,最后是类构造方法。
明白了类的加载和初始化再看上面的代码是不是感觉有点感觉了。
当SingletonStatic.getSingletonThink()
在jvm中被第一次调用时,SingletonStatic.class
被加载到jvm中,然后进行初始化,由于singletonStatic
是静态属性,则先被初始化,此时singletonStatic就被实例化,通过getSingletonThink返回。
在多线程中,假如thread1调用SingletonStatic.getSingletonThink()
,在其未返回时,thread2也调用SingletonStatic.getSingletonThink()
,此时,因为thread1先调用SingletonStatic
,被加载到jvm中,初始化之后singletonStatic
被实例化,此刻thread2也调用SingletonStatic.getSingletonThink()
,jvm发现SingletonStatic
已被加载,并且singletonStatic
是静态属性,只能被初始化一次并且已在thread1调用时被初始化,则thread2调用时并不会对singletonStatic进行再次初始化,thread1和thread2使用的singletonStatic对象都是同一个,所以线程安全。
代码验证如下:
1 | public class MainThread { |
执行结果如下:
1 | Thread-1 |
其结果显示构造方法只被调用了一次,也就是说singletonStatic被初始化了一次。
懒汉式
上面的代码短小精悍,但其是饿汉式的,单例singletonStatic会在加载SingletonStatic类一开始就被初始化,即使客户端没有调用getSingletonThink()方法,也会被初始化,这就有点不太高效。其实我们是想在用的时候才加载,下面就说下懒汉式。
懒汉式代码:
1 | public class SingletonLazy { |
代码也挺短的啊。饿汉式是线程安全的,那我们也来个多线程来测试下,它是否安全。测试代码如下:
1 | public class MainThread { |
结果如下
1 | Thread-0 |
构造方法被调用了两次,非线程安全。那么可以加锁来实现线程安全。代码如下:
1 | public class SingletonLazy { |
这样能保证线程安全,但是在每次调用getSingletonLazy
时,都会加个锁去判断singletonLazy是否为null,有点不高效呀。
你说每次判断是否为null都加锁,不高效,那就把synchronized放到if里面,这样就不用每次多加锁去判断是否为null了,但是这样并不是线程安全的,有可能thread1和thread2同时判断singletonLazy为null,然后分别对singletonLazy进行实例化。
还有一种方法是在synchronized外面再加个if判断语句,这样每次判断singletonLazy是否为null就不用加锁了,而且当thread1和thread2同时判断singletonLazy为null,而进去最外层的if语句时,当thread1拿到锁之后,会再次判断此时singletonLazy是否依然为null,为null则进行实例化,待singletonLazy实例化之后,释放锁,thread2拿到锁,判断singletonLazy是否为null,singletonLazy已在thread1中被实例化,非null,则在thread2中不会进行实例化,所以线程安全了。此种方法也叫双重校验锁,代码如下:
1 | public static SingletonLazy getSingletonLazy(){ |
此时就万事大吉了吗?非也!
singletonLazy = new SingletonLazy();
这句语义并不是一个原子操作,大概包括3件事,分别为:
- 给singletonLazy分配内存
- 调用singletonLazy的构造函数来初始化成员变量
- 将singletonLazy对象指向分配的内存空间(执行完这步singletonLazy就为非null了)
但是在JVM的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是1-2-3也可能是1-3-2。如果是后者,则thread1在3执行完毕、2 未执行之前,被thread2抢占了,这时singletonLazy已经是非null了(但却没有初始化),所以thread2会直接返回singletonLazy,然后使用,但此时singletonLazy并没有被初始化成功,不能正常使用。
可以在声明singletonLazy静态变量时,使用volatile
关键字,volatile是一个轻量的synchronized,具有两重语义,第一是可见性,是指共享变量被修改之后,立马会从本地缓存中写入主内存,以对其它线程可见。第二是禁止指令重排序优化。(这里其实有点以偏概全的意思,当对volatile写时,无论前面的操作是什么,都不能重排序,有的场景中是可以重排序的,具体在这里就不展开了,只是对volatile的写时,前面的代码禁止重排序。)这里使用的就是第二种语义,禁止重排序。
线程安全懒汉式需要注意的几点:
- synchronized声明为静态属性,且用volatile修饰
- 双重校验锁(先if判断是否为null,然后加锁,最后再次判断是否为null)
- 构造方法是私有的
内部类
将懒汉式的代码修补成线程安全之后,发现代码也不短了,而且还比较绕,那么饿汉法能不能优化为懒汉式的呢?答案肯定是可以的,那就是使用内部类,之所以要改成懒汉式,是因为某些场景饿汉式不能使用,如singletonStatic实例的创建是依赖参数或者配置文件的,在getSingletonStatic()之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。代码如下:
1 | public class SingletonInner { |
代码是不是依然很短,而且思路也比较清晰。将singletonInner
放在Singleton
内部类中,使其不会在SingletonInner初始化时,被实例化,以达到懒加载的效果。
多线程测试代码如下:
1 | Thread-0 |
由结果可以推断,SingletonInner被加载并初始化时,singletonInner并没有被实例化,singletonInner的实例化是在调用getSingletonInner之后才调用构造方法进行的实例化,确实是懒加载模式。并且是线程安全的。
枚举式
枚举应该是最简单的,代码如下:
1 | public enum EasySingleton{ |
枚举还是线程安全的,因为默认枚举实例的创建是线程安全的,但是在枚举中的其他任何方法由程序员自己负责。
总结
实现单例的途径这么多,那么应该首选哪个呢?我比较推荐饿汉式(也是IDE默认推荐的),如果使用场景中需要懒加载,那么可以使用内部类来实现单例。但是线程安全的懒汉式也应该记住。
参考
https://www.oschina.net/question/2273217_217864
http://www.cnblogs.com/yahokuma/p/3668138.html
http://wuchong.me/blog/2014/08/28/how-to-correctly-write-singleton-pattern/