最近由于课程作业的缘故,使用Scala语言的次数较多。在Scala中,类型还是挺丰富的,其中有一种类型称为Obejct
,可以说对应着Java中的单例模式,今天正好来详细说一下自己一直没有搞清楚的这个东西,同时也是面试中经常出现的一个知识点。
单例模式是一种比较特殊的设计模式(Design Pattern),在Java中,它表示一个Class只有一个实例化的Object。单例模式分为两种,一种叫饿汉模式(Object的new操作发生在Class创建时);一种叫懒汉模式(Object的new操作发生在getInstance时)。这两种的区别就在于单例模式实例化Object的早晚。
其中,饿汉模式是一种标准的写法,这也是《Effective Java》一书中推荐的,其具体代码如下:
class Singleton { |
这第一种写法是最基础的,在之后的代码中如果想使用单例,就直接调用Singleton.INSTANCE
即可;另外还有一种写法,使用Static Factory Method,使得代码更加灵活。
class Singleton { |
不管是是哪一种写法,都是属于饿汉模式,即在Singleton Class创建的时候就完成Object的new操作。这其中有两点最为关键:
第一点是声明构造函数(Constructor)为private
,防止在其他Class的代码中new出新的Singleton Object;
第二点是提供一个public static
的member/function或直接,或间接地让外部用户获得这个单例。
另外一种设计思路就是懒汉模式,它通常与多线程问题一起考察(含锁的双检查),主要是为了面试能够多引出一些知识点来,我个人认为实际的代码中一般不会用到这种,不过我们还是可以梳理一下面试思考问题的思路。
首先是最基础的版本:
class Singleton { |
它的问题在于多线程,当Thread1,Thread2同时检查到if (instance == null)
时,此时都进入if
语句块,假设此时Thread1先获得了CPU的控制权,那么它创建(new)了一个Singleton对象,然后返回;紧接着Thread2获得CPU,也会创建一个Singleton对象,那么相当于此时内存堆中拥有两个不同的Singleton Object,违反了单例模式。
解决的方案有两种,第一种是最简单的,直接对getInstance
函数加锁(synchronized
),不过这样是一种简单粗暴的互斥访问,会造成并发量的下降(关于synchronized
底层实现的本质和具体的性能表现我尚未有特别详细的研究)
public static synchronized Singleton getInstance() { |
第二种方法就稍微复杂些,那就是含锁的双检查:
public static Singleton getInstance() { |
这里为什么是if (instance == null)
检查两次呢?第一次的检查非常好理解(看看是不是已经创建了instance,有的话就直接返回,节省进入synchronized
的开销),第二次的检查是为了避免创建两个不同的单例对象,违反单例模式的原则。具体来说是这样的:
如果Thread1,Thread2同时处于synchronized(Singleton.class)
这一临界区间(Critical Section)等待获取锁。此时,如果Thread1先进入,那么Thread2就会因获得不到锁而进入BLOCKED
状态,再也无法和RUNNABLE
状态的Thread1竞争CPU的时间片,它只能被动等待synchronized锁的释放并获得锁,然后再进入RUNNABLE
状态。
Thread1 | Thread2 |
---|---|
获取synchronized锁 | |
再次判断instance为空 | |
创建并完成instance的初始化 | |
释放synchronized锁 | |
返回已初始化的instance | |
获取synchronized锁 | |
判断instance不为空 | |
(如果没有第二次检查,那么就会再创建一个Singleton对象) | |
返回已初始化的instance |
含锁的双检查模式已经是足够好了,但是还不是最好的。这是因为实例化对象的代码instance = new Singleton();
实际上包含了3个操作:
- 分配对象的内存空间
- 初始化对象
- 将
instance
引用指向刚分配的内存空间
在Java中编译器存在指令的重排序,所以以上的3个操作不一定会依次按步骤进行,第2步和第3步有可能会进行重排序,此时这一行代码的执行操作是:
- 分配对象的内存空间
- 将
instance
引用指向刚分配的内存空间 - 初始化对象
此时对于Thread1,Thread2来说,就存在以下的一种多线程执行顺序:
Thread1 | Thread2 |
---|---|
首次判断instance为空 | |
获取synchronized锁 | |
再次判断instance为空 | |
分配instance的内存空间 | |
将instance引用指向刚分配的内存空间 | |
判断instance不为空(第一次判断) | |
返回未初始化的instance | |
初始化instance | |
释放syncrhonized锁 | |
返回已初始化的instance |
注意,这里尽管Thread1运行在synchronized代码块中,但是依然存在CPU时间片的线程切换,这是因为在Java线程模型中,将OS中的Ready(就绪),Running(运行)都认为是RUNNABLE
的状态。也就是说,所有处于RUNNABLE
状态的线程,都有争夺CPU时间片的资格。Thread2并没有因为执行到synchronized
代码块前没有获得锁而进入BLOCKED
状态,所以就可以和Thread1争夺CPU控制权。
因此,我们说synchronized
关键字能保证在它划定的临界区间(Critical Section)中最多只有一个线程能够运行;但是它不能保证当线程在临界区间上运行时,不会被其他RUNNABLE
状态的线程切换。
此时,Thread2就会提前返回一个(内存堆中)没有完成初始化的Singleton instance对象,虽然说没有在内存堆中生成两个以上的对象,违反单例原则,但是没有完成初始化的对象会造成后续操作的不可预见性。
正确的避免指令重排序的方法就是对instance
使用volatile
关键字,所以最后的正确版本是:
class Singleton { |