悟空模式-java-单例模式

【那座山,正当顶上,有一块仙石。其石有三丈六尺五寸高,有二丈四尺围圆。三丈六尺五寸高,按周天三百六十五度;二丈四尺围圆,按政历二十四气。上有九窍八孔,按九宫八卦。四面更无树木遮阴,左右倒有芝兰相衬。盖自开辟以来,每受天真地秀,日精月华,感之既久,遂有灵通之意。内育仙胞,一日迸裂,产一石卵,似圆球样大。因见风,化作一个石猴,五官俱备,四肢皆全。便就学爬学走,拜了四方。目运两道金光,射冲斗府。】

上面这段文字,描述了悟空出生时的场景。孙悟空只有一个,任何程序要使用孙悟空这个对象,都只能使用同一个实例。

所以,单例模式非常好理解,单例模式确保一个类只有一个实例,且这个类自己创建自己的唯一实例并向整个系统提供这个实例,这个类叫做单例类。

其实,这个设计模式与抽象思维或者业务架构设计没有太多关系,更多要求的是对Java内存模型以及并发编程的理解,所以在介绍单例模式之前,需要先介绍一下JMM(Java Memory Model)相关的基础知识,然后再理解单例模式就会简单得多。

1.重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序又包括编译器优化的重排序、指令级并行的重排序以及内存系统的重排序。

比如下面一段代码:

int a = 1;//A
int b = 2;//B

A并不一定是比B先执行的,它们的执行顺序可能是A-B,也可能是B-A,甚至有可能是一同执行的。

2.happens-before与as-if-serial

as-if-serial保证单线程内程序的执行结果不被改变,它给程序员一个幻觉:单线程程序是按程序的顺序来执行的;

happens-before保证正确同步的多线程程序的执行结果不被改变,它给程序员一个幻觉:正确同步的多线程程序是按happens-before指定的顺序来执行的。

程序员其实并不关心两个指令是否真的被重排序了,我们只关心程序执行的语义不能被改变,也就是程序的执行结果不能改变。

比如上面那段代码的A与B顺序颠倒过来,对程序的结果并没有影响,我们还是可以获得两个赋值正确的int变量。但如果是下面这段代码,就有问题了:

int x = 1;//A
int x = 2;//B

如果这两行代码的执行顺序发生了改变,那么我们最终得到的x的值可能不是2,而是1,那样程序的执行结果就发生了改变了。好在JMM对于这种有数据依赖性(两个指令都是对同一个变量进行的)的重排序已经禁止了,所以我们并不需要担心。

3.类初始化锁

Java语言规范规定,对于每一个类或者接口A,都有一个唯一的初始化锁LA与之对应。从A到LA的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。这个锁可以同步多个线程对同一个类的初始化

4.volatile的内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存;当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

5.JSR-133内存模型

JDK5开始,java升级了内存模型,开始使用JSR-133内存模型。JSR-133对旧内存模型的修补主要有两个:增强volatile的内存语义,严格限制volatile变量与普通变量的重排序(旧内存模型允许volatile变量与普通变量重排序);增强final的内存语义,使final具有初始化安全性,在旧的内存模型中,多次读取同一个final变量的值可能会不同。

 

下面我们再来开始看单例模式的各种实现方式,也许你还对上面这些概念不是很熟悉,但结合具体的代码,相信会加深你的理解。

饿汉模式

package com.tirion.design.singleton;

public class WuKong {
    private static WuKong wuKong = new WuKong();

    private WuKong() {
    }

    public static WuKong getWuKong() {
        return wuKong;
    }
}

static变量会在类装载的时候完成初始化,这里注意构造方法也被声明为private,我们只能通过WuKong.getWuKong()来获取WuKong的唯一实例wuKong静态变量。

因为单例的实现是在类装载的时候完成的,并且无论后面对象实例是否被真正用到(WuKong.getWuKong()会不会得到执行),对象实例都已经被创建了,所以把这种以空间换时间的方式成为饿汉模式。

饿汉模式的优缺点也非常明显,它不必等到用到的时候再创建实例,节省了程序的运行时间,但在某些情况下也可能创建了不必要的对象,导致空间被浪费。

懒汉模式

package com.tirion.design.singleton;

public class WuKong {
    private static WuKong wuKong = null;

    private WuKong() {
    }

    public static synchronized WuKong getWuKong() {
        if (wuKong == null) {
            wuKong = new WuKong();
        }
        return wuKong;
    }
}

懒汉模式与饿汉模式的不同之处在于把实例对象的创建放到了静态工厂方法内部,当调用WuKong.getWuKong()时,会判断实例是否已经被创建,如果没有创建则进行实例对象的初始化工作,已经创建则直接返回。

懒汉模式为了实现多线程环境下的线程安全,在创建实例的方法上增加了synchronized同步控制,顺便说一下synchronized是编译器通过插入monitorenter和monitorexit指令来进行同步控制的,所有调用synchronized方法的线程都要在monitorenter处等待获取monitor对象锁,所以导致懒汉模式在线程竞争环境下效率非常低,这也是称之为懒汉模式的原因。

基于volatile的DCL双重检查锁机制的单例

 1 package com.tirion.design.singleton;
 2 
 3 public class WuKong {
 4     private static volatile WuKong wuKong = null;
 5 
 6     private WuKong() {
 7     }
 8 
 9     public static WuKong getWuKong() {
10         if (wuKong == null) {
11             synchronized (WuKong.class) {
12                 if (wuKong == null) {
13                     wuKong = new WuKong();
14                 }
15             }
16         }
17         return wuKong;
18     }
19 }

我们发现,双重锁检查机制相比于懒汉模式,又有几个细节被改动:

a.静态工厂方法的synchronized被去掉了,改为使用同步代码块来进行控制

b.从原先的一次判断对象实例是否为null改为了两次判断

c.对象实例增加了volatile关键词修饰

下面我们来对这几个细节一一进行分析,看看这些改动有哪些意义:

针对第一个改动,我们从懒汉模式的分析中已经可以看出,synchronized方法的效率会比较差,实际情况下,除了对象实例刚刚要被创建及正在被创建的那段时间里,后面的时间针对synchronized同步锁的竞争都是浪费的(因为对象实例已经被建立了),所以这里通过第一个判断 if (wuKong == null){synchronized...},规避了对象实例被创建后的所有对synchronized的同步锁竞争,大大节省了代码的执行时间,提高了效率;

针对第二个改动,是结合上一个改动而产生的,想象现在有两个线程A和B同时进入了Line9(代码行号)方法,由于它俩是前两个进入方法的,所以它们都通过了Line10的对象实例为空的判断,进入了Line11的同步代码块,由于同一时间只有一个线程能够进入同步代码块,所以线程A获得了监视器锁,进入了同步代码块内部并执行了对象实例的初始化工作,当线程A退出同步代码块时会释放监视器锁,这时处于Blocked状态下的线程B就会获取到监视器锁并进入到同步代码块中,如果没有第二个实例对象是否为空的判断的话,线程B就也会执行一遍对象实例的初始化,这样就违反单例模式对象实例只初始化一次的原则了;

针对第三个改动,我们先要看一下JVM是如何执行Line13的wuKong = new WuKong()这段代码的,其实,这一行代码可以分解为如下的三行伪代码:

memory = allocate();   // 1-分配对象的内存空间
ctorInstance(memory);  // 2-初始化对象
wuKong = memory;       // 3-设置wuKong指向刚分配的内存地址

在一些编译器上,上面三行代码中的2和3可能会发生重排序,因为重排序并不影响as-if-serial原则,重排序后,就是先把wuKong这个实例指向空的内存空间地址,随后再在空的内存空间上进行对象的初始化工作。

在单线程的情况下,上述重排序确实不会影响程序的执行结果,但在多线程环境下,可能会出现如下情况:

线程B刚刚进入Line10的is null判断时,线程A恰好出现了对象内存地址分配与对象初始化的重排序,这时候线程B看到的对象实例不是null(空的内存地址,但不是null),所以线程B直接绕过了同步代码块,直接返回了一个还未进行初始化的对象。

那么我们如何解决这个问题呢?一种思路是禁止对象内存地址指向和对象初始化的重排序。

在JDK5或更高版本后,Java开始使用了新的JSR-133内存模型,在这个模型中对旧内存模型做了一个重要的修补,增强了volatile关键字的内存语义,通过添加内存屏障的方式,禁止了volatile对象初始化与内存地址指向的重排序,也因此避免了上述情况可能导致的问题。

需要注意的是,这个解决方案只在JDK5及之后才能正常运作。

基于类初始化的单例

package com.tirion.design.singleton;

public class WuKong {

    private WuKong() {
    };

    private static class WuKongHolder {
        public static WuKong wuKong = new WuKong();
    }

    public static WuKong getWuKong() {
        return WuKongHolder.wuKong;
    }

}

在调用WuKong.getWuKong()时,WuKongHolder将被立即初始化,在上面我们已经介绍了类初始化时,所有线程都会去竞争一个类初始化锁,所以这个初始化动作是线程安全的。

同时,在第一个线程完成类的初始化写入工作,释放类初始化锁的之后,第二个线程会尝试获取这个类初始化锁,happens-before规则保证了一个锁的释放一定发生在同一个锁的获取之前,所以第一个线程在释放锁之前执行类的初始化的写入操作对后面获取同一个锁的线程可见。

在happens-before规则的保证下,无论WuKong wuKong = new WuKong();代码内部发生了怎样的重排序,对于后面的线程来说都不可见。

通过对比基于volatile的双重检查锁定的单例和基于类初始化的单例,我们发现基于类初始化的方案的实现代码更加简洁方便,也不需要太多的JMM知识。

但是基于volatile的DCL的单例模式有一个额外的优势,就是除了可以对静态字段实现延迟初始化之外,还可以对实例字段实现延迟初始化。所以当需要对实例字段实现延迟初始化的时候,可以选择基于volatile的双重检查机制的单例模式。

基于枚举的单例

package com.tirion.design.singleton;

public enum WuKongEnum {

    WUKONG;

    private WuKong wuKong;

    private WuKongEnum() {
        wuKong = new WuKong();
    }

    public WuKong getWuKong() {
        return wuKong;
    }
}

在理解基于枚举的单例之前,我们先要知道编译器会在创建枚举时替我们创建一个继承java.lang.Enum的类,这个创建过程我们是无法干涉的,这个类看起来像下面这样

public class WuKongEnum extends Enum{
       public static final WuKongEnum WUKONG;
       ...  
}

在调用WuKongEnum.getWuKong()时,编译器自动生成的private构造方法将得到执行,对象实例将得到初始化,另外由于对象实例是static final的,所以JVM将会保证它只会初始化一次。另外Enum实现了Serializable接口,所以它也无偿提供了序列化机制。

所以说,用枚举实现单例模式是简洁、高效且安全的。

关于单例模式的介绍就到这里,你可以将它记忆为悟空单例模式

如果你认为文章中哪里有错误或者不足的地方,欢迎在评论区指出,也希望这篇文章对你学习java设计模式能够有所帮助。转载请注明,谢谢。

更多设计模式的介绍请到悟空模式-java设计模式中查看。

posted @ 2017-09-21 18:01  tirion0510  阅读(262)  评论(0编辑  收藏  举报