您的位置:首页 > 编程语言

如何书写一手优雅的代码之3:不得不该面对的单例模式(设计模式)

2018-02-28 22:00 711 查看
       单例模式从刚接触Java起就有听说,到后来在工作中慢慢发现,好像并不像当初所了解的那样,现在就让我们来揭开单例模式这神秘的面纱吧!
       我也一直在想怎么样才能让大家更好地掌握设计模式,可能最最通俗易懂的说明和适量的代码是我能表达的最多的。
       单例模式说白了就是一个实例,换句话说就是无论你实例化多少个,其实都一样,更重要的是,当存在两个或者两个以上的实例时,程序会产生错误或者与现实相违背。
       这样的话,如果我们不将这个类控制成单例的结构,应用中就会存在很多一模一样的类实例,这样会非常浪费系统的内存资源,而且容易导致错误甚至一定会产生错误,所以我们单例模式所期待的目标或者说使用它的目的,是为了尽可能的节约内存空间,减少无谓的GC消耗,并且使应用可以正常运作。
       下面,我们来看一下做成单例模式的几种方式。
       第一种方式,我们来看一下最最标准的也是最原始的单例模式。package com.effective.roles;

public class Singleton {
//静态实例
private static Singleton singleton;
//私有化构造方法
private Singleton(){}
//公共静态方法
public static Singleton getInstance(){
if (singleton == null){
singleton = new Singleton();
}
return singleton;
}
}      
       这是在不考虑并发访问的情况下标准的单例模式的构造方式,这种方式通过几个地方来限制了我们取到的实例是唯一的。
       1.静态实例,带有static关键字的属性在每一个类中都是唯一的。
       2.限制客户端随意创建实例,即私有化构造方法,此为保证单例的最重要的一步。
       3.给一个公共的获取实例的静态方法,注意,是静态的方法,因为这个方法是在我们未获取到实例的时候就要提供给客户端调用的,所以如果是非静态的话,那就变成一个矛盾体了,因为非静态的方法必须要拥有实例才可以调用。
       4.判断只有持有的静态实例为null时才调用构造方法创造一个实例,否则就直接返回。
       假如你去面试一家公司,给了你一道题,让你写出一个单例模式的例子,那么如果你是刚出大学校门的学生,你能写出上面这种示例,假设我是面试官的话,满分100分,我会给你90分,剩下的那10分是给更优秀的人,但如果你是一个有过两三年工作经验的人,如果你写出上面的示例,我估计最多给你30分,甚至心情要是不好可能一份也不给你。
       为什么同样的示例放到不同的人身上差别会这么大,就是因为前面我提到的那个情况,在不考虑并发访问的情况下,上述示例是没有问题的。
       至于为什么在并发情况下上述的例子是不安全的呢,我在这里给各位制造一个并发的例子,用来说明上述情况的单例模式,是有可能造出来多个实例的,测试了约100次左右,最多的一次,竟然造出了3个实例。代码呈现。(非本人原创代码)package com.effective.roles;

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TestSingleton {
boolean lock;
public boolean isLock(){
return lock;
}
public void setLock(boolean lock){
this.lock = lock;
}

public static void main(String[] args)throws InterruptedException{
final Set<String> instanceSet = Collections.synchronizedSet(new HashSet<String>());
final TestSingleton lock = new TestSingleton();
lock.setLock(true);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 100; i++){
executorService.execute(new Runnable(){
public void run(){
while(true){
if(!lock.isLock()){
Singleton singleton = Singleton.getInstance();
instanceSet.add(singleton.toString());
break;
}
}
}
});
}
Thread.sleep(5000);
lock.setLock(false);
Thread.sleep(5000);
System.out.println("--------------并发情况下我们取到的实例-------------------");
for (String instance : instanceSet){
System.out.println(instance);
}
executorService.shutdown();
}
}
       我们在程序中同时开启了100个线程,去访问getInstance方法,并且把获得实例的toString方法获得的实例字符串装入一个同步的set集合,set集合会自动去重,所以看结果如果输出了两个以上的实例字符串,就说明我们在并发访问的过程中产生了多个实例。
       程序当中让main线程睡眠了两次,第一次是为了给足够的时间让100个线程全部开启,第二个是将锁打开以后,保证所有的线程都已经调用了getInstance方法。
       好了,这下我们用事实说明了,上述的单例写法,我们可以创造出多个实例的。
       造成这种情况的原因是因为,当并发访问的时候,第一个调用getInstance方法的线程A,在判断完singleton是null的时候,线程A就进入了if块准备创造实例,但同时另一个线程B在线程A未创建出实例之前,就又进行了singleton是否为null的判断,这时singleton依然为null,所以线程B也会进入if去创造实例,这时问题就来了,有两个线程都进入了if块去创造实例,结果就造成单例模式并非单例。
       为了避免这种情况,我们就要考虑并发的情况了,我们最容易想到的方式应该是下面这样的方式,直接将整个方法同步。
package com.effective.roles;

public class BadSynchronizedSingleton {
//静态实例
private static BadSynchronizedSingleton badSynchronizedSingleton;
//私有构造方法
private BadSynchronizedSingleton(){}
//公共静态方法
public synchronized static BadSynchronizedSingleton getInstance(){
if (badSynchronizedSingleton == null){
badSynchronizedSingleton = new BadSynchronizedSingleton();
}
return badSynchronizedSingleton;
}
}

       上面的做法很简单,就是将整个获取实例的方法同步,这样在一个线程访问这个方法时,其它所有的线程都要处于挂起等待状态,倒是避免了刚才同步访问创造出多个实例的危险,但是我只想说,这样的设计实在是糟糕透了,这样会造成很多无谓的等待,所以为了表示我的气愤,我在类名上加入了Bad。
       其实我们同步的地方只是需要发生在单例的实例还未创建的时候,在实例创建以后,获取实例的方法就没有必要再进行同步控制了,所以我们将上面的示例改为很多教科书中标准的单例模式,也成为双重加锁。
package com.effective.roles;

public class SynchronizedSingleton {
//静态实例
private static SynchronizedSingleton synchronizedSingleton;
//私有构造方法
private SynchronizedSingleton(){}
//公共静态方法
public static SynchronizedSingleton getInstance(){
if (synchronizedSingleton == null){
synchronized (SynchronizedSingleton.class){
if (synchronizedSingleton == null){
synchronizedSingleton = new SynchronizedSingleton();
}
}
}
return synchronizedSingleton;
}
}

       这种做法与上面那种最无脑的同步做法相比就要好很多了,因为我们只是在当前实例为null,也就是实例未创建时才进行同步,否则就直接返回,这样就节省了很多无谓的线程等待时间,值得注意的是在同步块中,我们再次判断了synchronizedSingleton是否为null,解释下为什么这样做。
       假设我们去掉同步块中的是否为null的判断,有这样一种情况,假设A线程和B线程都在同步块外面判断了synchronizedSingleton为null,结果A线程首先获得了线程锁,进入了同步块,然后A线程会创造一个实例,此时synchronizedSingleton已经被赋予了实例,A线程退出了同步块,直接返回了第一个创造的实例,此时B线程获得线程锁,也进入了同步块,此时A线程其实已经创建好了实例,B线程正常情况应该直接返回了,但是因为同步块里没有判断是否为null,直接就是一条创建实例的语句,所以B线程也会创造一个实例返回,此时就造成了多个实例的情况。
       经过刚才的分析,貌似上述双重加锁的实例看起来是没有问题了,如果再进入深入考虑,其实仍然有问题。

       如果我们深入到JVM中去探索上面这段代码,它就有可能出现问题。
       因为虚拟机在执行创建实例的这一步操作的时候,其实是分了好几步去进行的,也就是说创建了一个新的对象并非是原子性操作,在有些JVM中上述做法是没有问题的,但是有些情况下会造成莫名的错误。
       首先要明白JVM创建新的对象时,主要要经过三步:
       1.分配内存
       2.初始化构造器
       3.将对象指向分配的内存的地址
       
       这种顺序在上述双重加锁的方式是没有问题的,因为这种情况下JVM是完成了整个对象的构造才将内存的地址交给了对象,但是如果2和3步骤是相反的,就会出问题。
       因为这时将会先将内存地址赋给对象,针对上述的双重加锁,就是说先将分配好的内存地址指给synchronizedSingleton,然后再进行初始化构造器,这时候后面的线程去请求getInstance方法时,会认为synchronizedSingleton对象已经实例化了,直接返回一个引用。如果在初始化构造器之前,这个线程使用了synchronizedSingleton,就会产生莫名的错误。
       所以我们在语言级别无法完全避免错误的发生,我们只有将该任务交给JVM,所以有一种比较标准的单例模式,如下所示。package com.oneinstance;

public class InnerClassSingleton {

public static Singleton getInstance(){
return Singleton.singleton;
}

private static class Singleton{

protected static Singleton singleton = new Singleton();

}
}
       首先来说一下,这种方式为何会避免了上面莫名的错误,主要是因为一个类的静态属性只会在第一次加载类时初始化,这时JVM帮我们保证的,所以我们无需担心并发访问的问题,所以在初始化进行一半的时候,别的线程是无法使用的,因为JVM会帮我们强行同步这个过程,另外由于静态变量只初始化一次,所以singleton仍然是单例的。       更改为更符合我们习惯的写法,如下形式。
public class Singleton {

private Singleton(){}

public static Singleton getInstance(){
return SingletonInstance.instance;
}

private static class SingletonInstance{

static Singleton instance = new Singleton();

}
}

        好了,到这里,单例模式应该算是已经完成了,最终的产物就是如上述所示,上述形式保证了一下几点:
        1.Singleton最多只有一个实例,在不考虑反射强行突破访问限制的情况下。
        2.保证了并发访问的情况下,不会发生由于并发而产生多个实例。
        3.保证了并发访问的情况下,不会由于初始化动作未完成而造成使用了尚未正确初始化的实例。

        当然,LZ在文章中尚未提及饿汉式加载,而只是提及了懒汉式,由于简单就不一一列举了。

        值得一提的是饿汉式与我们最后给出的方式类似,只不过没有经过内部类处理,这种方式最主要的缺点就是一旦我访问了Singleton的任何其他的静态域,就会造成实例的初始化,而事实是可能我们从始而终就没有使用这个实例,造成了内存的浪费。
        LZ写到这,腰已经疼得不行了,要下班了,溜了溜了................................
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  设计模式