Java设计模式详谈(一):单例

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/liuyaorong/article/details/78591617

         经过一段时间的工作历练和学习,会慢慢接触到开发六大原则和二十三种设计模式,虽然有时候并不一定全部都会用到,不过对于在今后的学习和工作当中会有很大帮助。六大原则本章暂时不进行讨论,本章就开始一一学习GOF当中的二十三中设计模式。

        相信在此之前,网络上会有很多关于设计模式此类的文章,我也一直在想该如何去诠释如此多的设计模式,只能说我想分享一下我自己对于它们的认识与理解,如果存在问题,还希望读者能够留下宝贵的意见,我们共同学习。

        遵循着通俗易懂的理念,我们就来回顾一下单例模式为何要出现,又或者说什么样的类可以做成单例的。

        在我工作的过程中,会发现所有可以使用单例的类都会有一个共性,就是无论我实例化多少次,结果都是一样的。另外,当出现两个或多个实例的时候,程序会因此产生程序与现实相违背的逻辑错误。这样的情况下,如果不对该类使用单例结构的话,程序中会存在很多一样的类实例,这往往会造成内存的浪费。所以我们在使用单例的目的就是尽可能的节约内存空间,减少不需要的GC消耗,并且能够保证程序正常运行。

        下面,先介绍一种最基本最原始的单例模式构造方式。

/**
 * 常用:普通的单例(不考虑并发)
 * @author lyr
 * @date 2017年11月21日
 */

public class SimpleSingleton {

    private static SimpleSingleton simpleSingleton;
    
    private SimpleSingleton(){};
    
    public static SimpleSingleton getInstance(){
        if(simpleSingleton==null){
            simpleSingleton = new SimpleSingleton();
        }
        return simpleSingleton;
    }
}

这是在不考虑并发的情况下最基本的单例模式,这种方式在几个地方限制我们获取到的实例是唯一的。

(1)静态实例,带有static关键字的属性在每个类都是唯一的;
(2)私有化构造方法,限制客户端随意创建实例,作为最重要的一环;
(3)给出一个公共的获取实例的静态方法,主要是在我们未获取到实例的时候提供给客户端进行调用,当然,这里肯定不能是非静态方法,因为非静态方法必须要有实例才能调用;
(4)经过if判断只有在静态实例为null时才去创建,否则直接返回。

至于为何这种方式在并发的情况不能使用,下面直接给出了一个例子:

/**
* 测试
* @author  lyr
* @date 2017年11月21日
*/
public class TestSingleton {
    
    public static void main(String[] args) throws InterruptedException {
        
        final Set<String> set = Collections.synchronizedSet(new HashSet<String>());
        final CountDownLatch cdl = new CountDownLatch(1);
         
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < 100; i++) {
            executor.execute(new Runnable() {
                public void run() {
                    try {
                        cdl.await();
                         
                        SimpleSingleton singleton = SimpleSingleton.getInstance();
                        set.add(singleton.toString());
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
         
         
        Thread.sleep(1000);
        cdl.countDown();
        Thread.sleep(1000);
         
        System.out.println("一共有" + set.size() + "个实例");
        for (String str : set) {
            System.out.println(str);
        }
         
        executor.shutdown();
         
    }
}

在上面的例子当中我同时开启了100个线程去访问getInstance(),并且将获取到的实例的toSting()之后的字符串装入一个同步的set集合当中,set会自动去重,所以看结果输出两个或两个以上的实例字符串,就说明在并发访问的情况下能够出现多个实例。
在程序当中添加了两次睡眠时间,第一次是为了留有足够的时间等待100个线程全部开启,第二次是为了锁打开之后,保证所有的线程都已经调用了getInstance()。
那么问题来了,究竟是什么情况导致的在并发的情况下会出现多个实例的呢?当第一个去访问getInstance的线程A,在判断完singleton是null的情况下,进入if判断准备创建实例,而此时另一个线程B在A还未创建实例之前,又进行了判断singleton是否为null的判断,而判断的结果依然是null,那么此时线程B也会进入到if的判断环节准备创建实例,当两个线程都去if判断块创建实例的时候,此时的单例模式就不再是单例了。


那在并发的情况下该如何正确的使用单例呢,不多BB,直接来下面的例子:

/**
 * 常用:并发情况下的单例(双重加锁)
 * @author lyr
 * @date 2017年11月21日
 */
public class ConcurrentSingleton {

	private static ConcurrentSingleton concurrentSingleton;
	
	private ConcurrentSingleton(){};
	
	public static ConcurrentSingleton getInstance(){
		if(concurrentSingleton==null){
			synchronized (ConcurrentSingleton.class) {
				if(concurrentSingleton==null){
					concurrentSingleton = new ConcurrentSingleton();
				}
			}
		}
		return concurrentSingleton;
	}
}

        可能会有很多人有疑问,为何不直接在方法上进行同步,先来看看如果直接在前一个例子的方法上直接加同步,是可以避免出现多个实例的情况,但是,当一个线程进入的时候,其它所有的线程都被挂起处于等待状态。
        其实我们只需要在实例创建之前进行同步就OK了,实例创建后再同步无意义。此时,也就出现了上面的这个教科书版的单例模式,同时也叫双重加锁。
        这种做法比上一个的例子在方法上加同步好多了,保证了在实例未创建之前才开始同步,否则就直接返回。这样节省了大量无谓的等待时间,当然在这种方式中,我们再一次的判断concurrentSingleton是否为null,这样的做的好处是什么呢?这样去想,如果我们把同步块中的if判断去掉,让A、B线程又再次来临,A线程先进入第一层if判断当前的实例为null,进入同步块之后创建实例,此时concurrentSingleton被赋予了一个实例,A线程退出同步块;B线程进入,由于此时没有存在第二层if判断,B线程又去创建了一个实例,此时就会出现多个实例的状况。
       这样看起来目前的这种方式应该没有什么问题了,真的没有吗?还是会有的。问题会在哪,问题就有可能出在JVM那里(这里只是有可能,并不一定会出现)。

       这个情况下就要考虑JVM创建对象的过程了,主要经历三步:(1) 分配内存;(2) 初始化构造器;(3) 将对象指向分配好的内存地址。一般情况下使用上面的单例模式不会出现问题,如果此时出现JVM对字节码进行调优,而其中的一项就是对指令顺序的调整,此时问题就出现了。
       因为这个时候JVM会先将内存地址赋值给对象,针对上面的双重加锁,会先将分配好的内存地址指给concurrentSingleton,然后再执行初始化构造器,这个时候后面的线程去请求getInstance()的时候,会认为concurrentSingleton对象已经实例化了,直接返回一个引用。如果在初始化构造器之前,这个线程使用了concurrentSingleton,就会产生莫名的错误。针对这一问题,可以考虑在concurrentSingleton变量前添加volatile关键字,该关键字出现在Java 5以后,以此来保证:(1)在对volatile变量进行写操作时,不允许和它之前的读写操作打乱顺序;(2)在对volatile变量进行读操作时,不允许和它之后的读写操作打乱顺序。

当然,还有一种比较标准的单例模式,如下:

/**
 * 常用:完整的单例(最终的版本):
 * 	(1)考虑到JVM针对字节码调优时,一般会先把创建好的对象指向分配号的内存地址,然后再去执行初始化;
 * 	(2)静态属性只会在类第一次加载的时候进行初始化,所以不用去考虑并发问题,在初始化的过程中,别的线程是无法使用的;
 * 	(3)由于静态变量只初始化一次,所以仍然是单例
 * @author lyr
 * @date 2017年11月21日
 */
public class FinalSingleton {

	private FinalSingleton(){};
	
	public static FinalSingleton getInstance(){
		return FinalSingletonClass.instance;
	}
	
	private static class FinalSingletonClass{
		static FinalSingleton instance = new FinalSingleton();
	}
}

进行到这里,单例模式算是结束了,以上就是最终的单例模式的形式,上述形式保证了以下几点:
(1)Singleton最多只会存在一个实例,在不考虑反射强行冲破访问限制的情况下;
(2)保证了并发的情况下,不会出现由于并发而出现多个实例,不会出现由于初始化未完全完成而造成了未正确初始化的实例。

  上述的实现方式都是比较常用的单例模式,当然也会有其它的不常用的一些单例模式,一种就是俗称饿汉模式:

/**
 * 不常用:饿汉单例模式(一般用于项目启动加载配置文件)
 * (1)一旦访问该类的其它静态域,就会造成实例的初始化,而可能我们自始至终都没有使用过该实例,容易造成内存浪费
 * @author lyr
 * @date 2017年11月21日
 */
public class HungerSingleton {

	private static HungerSingleton hunger = new HungerSingleton();
	
	private HungerSingleton(){};
	
	public static HungerSingleton getInstance(){
		return hunger;
	}
}

   这一种会在程序启动加载配置文件的时候用到,其它情况不建议使用。
  单例模式就分享到这里,感谢各位的阅读。

猜你喜欢

转载自blog.csdn.net/liuyaorong/article/details/78591617