在项目开发中经常能遇见的设计模式就是单例模式了,而实现的方式最常见的有两种:饿汉和饱汉(懒汉)。由于日常接触较多而研究的不够深入,导致面试的时候被询问到后有点没底,这里记录一下学习的过程。
饿汉实现
饿汉的名字由来就是因为很饿很着急,所以在类加载时即创建实例对象,实现如下:
public class Singleton {
private static final Singleton singleton = new Singleton();
private Singleton(){
}
public static Singleton getInstance(){
return singleton;
}
饿汉模式本身就是线程安全的,为什么是线程安全的呢?原因是这样的,JVM虚拟机在执行类加载的初始化阶段,能保证一个类的<clinit>方法在多线程环境下能够被正确的加锁,同步,如果多线程初始化一个类,那么只有一个线程会去执行这个类的<clinit>方法,其他需要阻塞,更何况我们还加入了final关键字,如果某个成员是final的,JVM规范做出如下明确的保证:一旦对象引用对其他线程可见,则其final成员也必须正确的赋值了。
因此居于上述两点能够保证饿汉单例正确的在多线程环境下运行。
饱汉实现
饱汉的实现跟饿汉不同,饱汉只在调用获取实例的时候才会进行new对象的过程,简单的实现如下:
public class Singleton {
private static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
在单线程的环境中,使用该模式是完全没有问题的,不会涉及到临界问题,而在多线程模式下,那么就不能保证了。假设有两个线程A和B,A线程判断singleton==null了,这时候进行singleton = new Singleton()操作,在该步还没有完成时,线程B进入了方法体中,判断singleton==null,由于A还没有实例化完成Singleton,导致singleton==null成立,B线程也执行了singleton = new Singleton()的操作,那么就不能保证在只有单次赋值的情况了,也就不能保证每个线程中的Singleton对象是一样的。
那么改进方式也很简单,既然有临界问题,那么我们就加个锁来保证线程的安全性问题:
public class Singleton {
private static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
return singleton;
}
}
这个方式就能保证单例模式的正常使用了,但是由于我们每次调用getInstance()的时候都要进行加锁/解锁的操作,在多线程中,在CPU调度切换不同线程时候会发生上下文切换,上下文切换时候,JVM需要去保存当前线程对应的寄存器使用状态,以及代码执行的位置等等,那么肯定是会有一定的开销的。而且当线程由于等待某个锁而被阻塞的时候,JVM通常将该线程挂起,挂起线程和恢复线程都是需要转到内核态中进行,频繁的进行用户态到内核态的切换对于操作系统的并发性能来说会造成不小的压力。因此上面的写法实际上相对来说较为低效,那么,这个时候我们进行优化变成如下代码:
public class Singleton {
private static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {//1
synchronized (Singleton.class) {
if (singleton == null) {//2
singleton = new Singleton();
}
}
}
return singleton;
}
}
在调用synchronized前提前判断一步是否singleton == null,如果不等于null,那么说明已经赋值成功,如果等于null,那么在执行加锁操作就可以了。所以加两次判空的主要原因就是因为避免重复加/解锁的操作,浪费系统资源。
那么上面的实现还会不会有问题呢?首先分析一下singleton = new Singleton()
这句话底层执行的过程:
在堆中分配Singleton对象内存
填充Singleton对象的必要信息+具体数据初始化+末位填充
把singleton引用指向这个对象的堆内地址
本身singleton = new Singleton()
不是一个原子操作,实例化过程会经过上面的三个步骤,而且JVM在遵守as-if-serial语义的情况下,允许进行指令重排序的过程,也就是可以执行1-3-2的操作的。
那么在一些极端的情况就可能会出现问题:
- 线程A和线程B同时访问getInstance()方法,首先A先访问步骤1,由于第一次访问,所以肯定会走到
singleton = new Singleton()
中,这时候JVM进行了重排序优化1-3-2的过程。 - 线程B在线程A实例化single的时候恰巧走到了步骤1当中,同时线程A中在执行3,即把singleton引用指向这个对象的堆内地址,由于这时候在锁外访问的步骤1,不遵循happen-before原则,线程B看到singleton引用不为空了,那么就直接返回singleton引用了,那么代码就出不符预期的问题。
解决方式也简单,使用volatile,通过volatile的语义禁止指令重排序功能,那么就解决了上面的问题了,正确代码如下:
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}