深入单例设计模式
1.前言
单例设计模式(Singleton)应该是开发者最熟悉的设计模式了,并且好像也是最容易实现的,基本每一个开发者都能够随手写出来的,但是真的是这样吗?
作为一个Java开发者,也许你觉得自己对单例设计模式的了解已经最够了。我并不危言耸听说一定还有你不知道的--毕竟我自己的了解也的确有限,但究竟你自己了解的程度到底怎么样呢?往下看,我们一起来聊聊看~~~
2.什么是单例?
单例对象的类必须保证只有一个实例存在----这是维基百科的上对单例的定义,这也可以作为对意图实现单例模式的代码进行验证的标准。
对单例的实现可以分为两大类:懒汉式和饿汉式,它们的区别是:
懒汉式:指全局的单例实例在第一次被使用时构建。
饿汉式:指全局的单例实例在类加载时构建。
从它们的区别也能看出来,日常我们使用的较多的应该是懒汉式的单例,毕竟按需加载才能做到资源的最大化利用。。。
3.懒汉式单例
3.1 简单版本
看最简单的写法Version1.0
public class Single
{
//先定义对象的引用
private static Single instance;
//用作初始化的构造器,定为私有,防止外部的类调用
private static Single(){};
//获取对象
public static Single getInstance()
{
//如果对象的引用为空
if(instance == null)
//创建一个对象
instance = new Single();
//返回这个对象
return instance;
}
}
缺点:当多线程工作的时候,如果有多个线程同时运行到if(instance == null),都判断为空,那么这两个线程各自都会创建一个实例,这样一来,就不是单例了。
3.2 synchronized版本
那既然可能会因为多线程导致的问题,那么就加一个同步锁。(Version 2.0)
public class Single
{
private static Single instance;
private static Single(){};
//使用同步函数,加一个同步锁
public static synchronized Single getInstance()
{
if(instance == null)
instance = new Single();
return instance;
}
}
优点:加上了synchronized关键字之后,getInstance方法就会锁上了,如果有两个线程(T1,T2)同时执行到这个方法时,会有其中一个线程T1获得同步锁,得意继续执行,而另一个线程T2则需要等待,当T1执行完毕getInstance之后(完成了null判断、对象创建、获得返回值之后),T2才会执行。
缺点:但是,这种写法也有一个问题:给getInstance方法加锁,虽然避免了可能出现的多个实例问题,但是会强制除T1以外的所有线程等待,实际上会对程序的执行效率造成负面影响。
3.3 双重检查版本(Double-Check)
就是在锁的外面在做一次判断,如果是空,才进入锁内。(Version 3.0)
public class Single
{
private static Single instance;
private static SIngle(){};
public static Single getInstance()
{
//如果instance为空,才会让此线程进入锁内
if(instance == null)
{
//使用同步代码块,由于此方法是静态的,同步锁是类名.class
synchronized(Single.class)
{
if(instance == null)
instance = new Single();
}
}
return instance;
}
}
注意:这两次的if的判断,每次判断都是有什么作用?
第一次的if(instance == null),其实是为了解决Version2中的效率问题,只有instance为null的时候,才进入synchronized的代码块,大大减少了进入锁的几率。
第二个if(instance == null),则是跟version2中的一样,是为了防止可能出现多个实例的情况。
但是以上的方法并不是很好的方式,还需要了解两个知识点[指令重排]和[原子操作]。
知识点一:指令重排
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就会给程序员带来问题。
不同的指令间可能存在数据依赖。比如下面计算圆的面积的语句:
double r = 2.3d;//(1)
double pi =3.1415926; //(2)
double area = pi* r * r; //(3)
area的计算依赖于r与pi两个变量的赋值指令。而r与pi无依赖关系。
as-if-serial语义是指:不管如何重排序(编译器与处理器为了提高并行度),(单线程)程序的结果不能被改变。这是编译器、Runtime、处理器必须遵守的语义。
虽然,(1) - happensbefore -> (2),(2) - happens before -> (3),但是计算顺序(1)(2)(3)与(2)(1)(3) 对于r、pi、area变量的结果并无区别。编译器、Runtime在优化时可以根据情况重排序(1)与(2),而丝毫不影响程序的结果。
指令重排序包括编译器重排序和运行时重排序。
知识点二:原子操作
当多个线程访问临界区(数据共享的区域)的数据时,如果使用锁来进行并发控制,
当某一个线程(T1)抢占到锁之后,那么其他线程再尝试去抢占锁时就会被挂起,当T1释放锁之后,下一个线程(T2)再抢占到锁后并且重新恢复到原来的状态大约需要经过8W个时钟周期。而假设我们业务代码本身并不具备很复杂的操作,执行整个操作可能就花费3-10个时钟周期左右,那么当我们使用无锁操作时,线程T1和线程T2对共享变量进行并发的CAS操作,假设T1成功了,T2最多再执行一次,它执行多次的所消耗的时间远远小于由于线程所挂起到恢复所消耗的时间,它基本不可能运气差到要执行几千次才能完成操作,因此无锁的CAS操作在性能上要比同步锁高很多。
3.4 volatile版本
只需要给instance的声明加上volatile关键字即可。(Version 4.0)
public class Single
{
//在声明instance的时候加上volatile
private static volatile Single instance;
private static SIngle(){};
public static Single getInstance()
{
//如果instance为空,才会让此线程进入锁内
if(instance == null)
{
//使用同步代码块,由于此方法是静态的,同步锁是类名.class
synchronized(Single.class)
{
if(instance == null)
instance = new Single();
}
}
return instance;
}
}
volatile关键字的一个作用是禁止指令重排,把instance声明为volatile之后,对它的写操作就会有一个内存屏障(什么是内存屏障?),这样,在它的赋值完成之前,就不会调用读操作了。
注意:volatile阻止的不是instance = new Single();这句话内部[1-2-3]的指令重排,而是保证了在一个写操作[1-2-3]完成之前,不会调用读操作(if(instance == null))。
4.饿汉式单例
正如上面所说,饿汉式单例是指:指全局的单例实例在类加载时构建的实现方式。由于类加载的过程是有类加载器(ClassLoader)来执行的,这个过程也是由JVM来保证同步的,所以这种方式先天有一个优势,能够避免许多由多线程引起的问题。
4.1饿汉式单例的实现
public class Single
{
//在类加载的时候就创建一个对象
private static final Single instance = new Single();
//初始化构造器
private static Single(){}
//获取单例的对象
public static Single getInstance()
{
//返回一个单例对象
return instance;
}
}
缺点:就是饿汉式单例本身的缺点所在,由于instance的初始化时在类加载时进行的,而类的加载是由ClassLoader来做的,所以开发者本来对于它初始化的时机就很难去准确把握:
1.可能由于初始化的过早,造成了资源的浪费。
2.如果初始化本身依赖于一些其他的数据,那么本身也就很难保证其他数据会在它初始化之前准备好。
知识点三:什么时候是类加载时?
不严格的说,大致有这几个条件会触发一个类被加载:
1. new一个对象时
2. 使用反射创建它的实例时
3. 子类被加载时,如果父类还没被加载,就先加载父类
4. JVM启动时执行的主类会首先被加载