什么是单例模式
单例对象的类必须保证只有一个实例存在,这也可以作为对意图实现单例模式的代码进行检验的标准。
单例的实现可以分为两大类–懒汉模式和饿汉模式,他们区别在于:
- 懒汉式:指全局的单例实例在第一次被使用时构建。
- 饿汉式:指全局的单例实例在类装载时构建,
从他们的区别也能看出来,日常我们使用的较多的是懒汉式的单例,毕竟按需加载才能做到资源的最大化利用
懒汉式单例
懒汉式单例的实现方式。
- 1 简单版本
//Version 1
public class Single1{
private static Single1 instance;
public static Single1 getInstance(){
if(instance==null){
instance=new Single1();
}
return instance;
}
}
或者再进一步,把构造器改为私有的,这样能够防止被外部的类调用
//Version 1.1
public class Single1{
private static Single1 instance;
private Single1(){
if(instance==null){
instance=new Single1();
}
return instance;
}
}
每次获取instance之前先进性判断,如果instance为空就new一个出来,否则就直接返回已存在的instance。
这种写法在大多数的时候也是没什么问题的。问题在于,当多线程工作的时候,如果有多个线程同时运到if(instance==null)
,都判断为null
,那么两个线程就各自会创建一个实例,这样一来,就不是单例了。
- 2
synchronized
版本
既然可能会因为多线程导致问题,那么加上一个同步锁来试一下!
//Version 2
public class Single2{
private static Single2 instance;
private Single2(){}
public static synchronized Single2 getInstance(){
if(instance==null){
instance=new Single2();
}
return instance;
}
}
在加上synchronized
关键字之后,getInstance
方法就会上锁了,如果有两个线程(S1、S2)同时执行到这个方法时,会有其中一个线程S1获得同步锁,得以继续执行,而另一个线程S2这需要等待,当S1执行完毕getInstance
之后(完成了null
判断、对象创建、获得返回值之后),S2线程才会继续执行。这段代码也就避免了Version 1中,可能出现以为多线程导致多个实例的情况。
但是,这种写法也有一个问题:给getInstance
方法枷锁,虽然避免了可能会出现的多个实例问题,但是会强制除S1之外的所有线程等待,实际上会对线程的执行效率造成负面影响。
- 3 双重检查(Double-Check)版本
Version 2代码相对于Version 1代码的效率问题,其实只是为了解决1%的几率问题,而实用了一个100%出现的防护盾。做一个优化思路,就是把100%出现的防护盾,也改为1%的几率出现,使之只出现在可能会导致多个实例出现的地方。
//Version 3
public class Single3{
private static Single3 instance;
private Single3(){}
public static Single3 pugetInstance(){
if(instance==null){
synchronized(Single3.class){
if(instance==null){
instance=new Single3();
}
}
}
return instance;
}
}
这个版本的代码看起来有点复杂,注意其中两次if(instance==null)
的判断,这个叫做 双重检查 Double - Check。
-
第一个
if(instance==null)
,其实是为了解决Version 2中的效率问题,只有instance
为null
的时候,才进入synchronized
的代码段,大大减少了几率。 -
第二个
if(instance==null)
,则是跟Version 2一样,是为了防止可能出现多个实例的情况。 -
这段代码看起来已经完美无瑕了,当然,只是“看起来”,还是有小概率出现问题的。
为什么这里可能还会出现问题,我们就得弄清楚几个概念:原子操作、指令重拍。-
知识点: 什么是原子操作?
简单来说,原子操作(atomic)就是不可分割的操作,在计算机中,就是指不会因为线程调度被打断的操作。
比如,简单的赋值是一个原子操作:
m=6;//这是个原子操作
假如m原先的值为0,那么对于这种操作,要么执行成功,m
变成了6
,要么是没执行,m
还是0
,而不会出现诸如m=3
这种中间态–即使是在并发的线程中。
而声明并赋值就不是一个原子操作:
int n=6;//这不是一个原子操作
对于这个语句,至少有两个操作:
①、声明一个变量n
②、给n赋值为6
这样就会有一个中间状态:变量n已经被声明了但是还没有被赋值的状态。
这样,在多线程中,由于线程执行顺序的不确定性,如果两个线程都使用m,就可能会导致不稳定的结果出现。 -
知识点:什么是指令重排?
简单来说,就是计算机为了提高执行效率,会做一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。
比如:
int a;//语句 1
a=8;//语句 2
int b=9;//语句 3
int c=a+b;//语句4
正常来说,对于顺序结构,执行的顺序是自上到下,也即语句1、语句2、语句3、语句4。
但是,由于指令重排的原因,因为不能影响最终的结果,所以,实际执行的顺序可能会变成3124或是1324。
由于语句3和4没有原子性的操作,在不影响最终结果的情况下,其拆分成的原子操作可能会被重新排列执行顺序。
在了解了原子操作和指令重排的概念之后,我们继续砍Version 3代码的问题。
主要在于instance=new Single3()
这句,这并非是一个原子操作,事实上在JVM中这句话大概做了下面3件事。- 给
instance
分配内存 - 调用
instance
的构造函数来初始化成员变量,形成实例 - 将
instance
对象指向分配的内存空间(执行完这步instance
才是非null
了)但是在JVM的即时编译器中存在指令重排的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是1-2-3-4也可能是1-3-2-4。如果是后者,则在3执行完毕、2未执行之前,被线程S2抢占了,这时instance
已经是非null
了(但却没有初始化),所以线程S2会直接返回instance
,然后使用,然后顺理成章的报错。
由于有一个
instance
已经不为null
但是仍没有完全初始化的中间状态,而这个时候,如果有其他线程刚好运行到第一层if(instance==null)
这里,这里读取到的instance
已经不为null
了,所以就直接把这个中间状态的instance
拿去用了,就会产生问题。
这里的关键在于–线程S1对instance
的写操作没有完成,线程S2就执行了读操作。 -
- 4 终极版本:volatile
对于Version 3中可能出现的问题(当然这种概率已经非常小了,但毕竟还是有的嘛),解决方案是:只需要给instance
的声明加上volatile
关键字即可,`Version 4版本:
//Version 4
public class Single4{
private static volatile Single4 instance;
private Single4(){}
public static Single4 getInstance(){
if(instance==null){
synchronized(Single4.class){
if(instance==null){
instance=new Single4();
}
}
}
return instance;
}
}
volatile
关键字的一个作用是禁止指令重排,把instance
声明为volatile
之后,对它的写操作就会有一个内存屏障(什么是内存屏障?),这样,在它的赋值完成之前,就不会调用读操作。
注意:volatile
阻止不了instance=new Single4()
这句话内部[1-2-3-4]的指令重排,而是保证了在一个写操作([1-2-3-4])完成之前,不会调用读操作if(instance==null)
。
也就彻底防止了Version 3中的问题发生。
饿汉式单例
由于类装载的过程时由类加载器ClassLoader
来执行的,这个过程也是由JVM来保证同步的,所以这种方式先天就有一个优势–能够免于许多由多线程引起的问题。
- 1 饿汉式单例的实现方式
//饿汉式实现
public class SingleB{
private static final SingleB INSTANCE = new SingleB();
private SingleB(){}
public static SingleB getInstance(){
return INSTANCE;
}
}
对于一个饿汉式单例的写法来说,它基本上是完美的。
所以他的缺点也只是饿汉式单例本身的缺点,由于INSTANCE
的初始化是在类加载时进行的,而类的加载时由ClassLoader
来做的,所以开发者本来对于它初始化的时机就很难去准确把握:
-
- 1.可能由于初始化的太早,造成资源的浪费
-
- 2.如果初始化本身依赖于一些其他数据,那么也就很难保证其他数据会在它初始化之前准备好。
当然,如果所需的单例占用的资源很少,并且也不依赖于其他数据,那么这种实现方式也是很好的。
- 知识点:什么时候是类装载时?
前面提到了单例在类装载时被实例化,那究竟什么是“类装载时”呢?
不严格的说,大致有这么几个条件会触发一个类被加载:- new一个对象时
- 使用反射创建它的实例时
- 子类被加载时,如果父类还没被加载,就先加载父类
- JVM启动时执行的主类会首先被加载
- 一些其他的实现方法:
5.1 静态内部类
《Effective Java》一书的第一版中推荐了一种写法:
//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;
}
}
-
这种写法非常巧妙:
- 对于内部类
SingletonHolder
,它是一个饿汉式的单例实现,在SingletonHolder
初始化的时候ClassLoader
来保证同步,使INSTANCE
是一个真·单例。 - 同时,由于
SingletonHolder
是一个内部类,只在外部类的Singleton的getInstance()
中被使用,所以它被加载的时机也就是在getInstance()
方法第一次被调用的时候。 - 他利用了
ClassLoader
来保证了同步,同时又能让开发者控制类加载的时机。从内部看是一个饿汉式的单例,但是从外部来看,又的确是懒汉式的实现。
5.2 枚举
- 对于内部类
//Effective Java 第二版推荐写法
public enum SingleInstance{
INSTANCE;
public void fun1(){
// do something
}
}
//使用
SingleInstance.INSTANCE.fun1();
创建枚举实例的过程是线程安全的,在功能上与共有域方法相近,但是它更简介,无偿的提供了序列化机制,绝对防止对此实例化,即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法