深入解析单例模式的七种实现

什么是单例模式

什么是单例模式呢? 我们引用一下维基百科:

单例模式,也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。还有就是我们经常使用的servlet就是单例多线程的。使用单例能够节省很多内存。

如何实现单例模式呢?

我们引用一下维基百科:

实现单例模式的思路是:一个类能返回对象一个引用(永远是同一个)和一个获得该实例的方法(必须是静态方法,通常使用getInstance这个名称);当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用;同时我们还将该类的构造函数定义为私有方法,这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例。

好了,我们知道了单例模式的定义和如何使用单例的描述,接下来,就引用Linux Torvalds 的话:

Talk is cheap. Show me the code

让我们来看看单例模式的7种实现方式

单例模式的七种实现

第一种:懒汉式加载

懒汉式加载:最简单的单例模式:2步,1.把自己的构造方法设置为私有的,不让别人访问你的实例,2.提供一个static方法给别人获取你的实例.

懒汉式加载版本单例模式

我们可以看到,这是一个简单的获取单例的一个类,首先我们定义一个静态实例 single, 如何将构造方法变成私有的。并且给外界一个静态获取实例的方法。如果对象不是null,就直接返回实例,从而保证实例。也可以保证不浪费内存。这是我们的第一个实现单例模式的例子。很简单。但是有问题,我们后面再讲。

第二种:饿汉式加载

饿汉式加载版本单例模式

我们看到第二种单例模式,代码量比第一个少了很多,而为什么叫饿汉式呢?我们看代码,我们定义了一个静态的final的实例,并且直接new了一个对象,这样就会导致Single2 类在加载字节码到虚拟机的时候就会实例化这个实例,当你调用getInstance方法的时候,就会直接返回,不必做任何判断,这样做的好处是代码量明显减少了,坏处是,在你没有使用该单例的时候,该单例却被加载了,如果该单例很大的话,将会浪费很多的内存。

我们停下来思考一下

我们如何选择这两种实现方式呢?如果你的项目对性能没有要求,那么请直接使用饿汉式方法实现单例模式,既简单又方便。但是,大部分程序员都是有追求的,岂能不追求性能。那么我们看第一种方式,就是懒汉式,我们刚刚说过,懒汉式既保证了单例,又保证了性能。但是,他真的能保证单例吗?可以确定的是:在单线程模式下,毫无问题,但在复杂的多线程模式下,会怎么样呢?show me code .

测试用例:我们测试一下测试用例

我们分析一下上面的代码,首先,我们验证的是什么呢?我们想验证多线程下获取懒汉式单例会不会出现错误。也就是出现一个以上的单例,我们如何做呢?首先我们定义一个Set对实例进行去重,然后创建1000个线程(Windows每个进程最多1000个线程,Linux每个进程最多2000个线程),每个线程都去获取实例,并添加到set中,实际上,我们应该使用Collections.synchronizedSet(set)获取一个线程安全的set,但是,这里为了方便,就直接使用HashSet了,然后main线程等待10秒,让1000个线程尽量都执行完毕。最后循环打印set的内容。在某些情况下,会出现2个实例,注意,是某些情况下,一定要多测试几次。下面是我们测试的结果:
测试结果

我们停下来思考一下:

我们通过测试用例发现:高并发情况下,我们的懒加载确实存在bug。为什么会这样呢?我们假设第一个线程进入getInstance方法,判断实例为null,准备进入if块内执行实例化,这时线程突然让出时间片,第二个线程也进入方法,判断实例也为null,并且进入if块执行实例化,第一个线程唤醒也进入if块进行实例化。这时就会出现2个实例。所以出现了bug。So, 我们想要性能(避免上面说的消耗不需要的内存),又要线程安全。那我们该怎么办呢?有点经验的同学心里肯定有数了。show me code.

第三种方式:synchronized 同步式

59{(V}0%M`G546FRI`F4(_9.png

这是我们的第三种方式,我们分析一下代码,我们可以看到,我们仅仅是在第一种懒汉式中加入了一个关键字,synchronized, 使用synchronized保证线程同步,保证同时只有一个进程进入此方法。从而保证并发安全。但是这样做完美吗?我们思考一下我们的代码:我们使用synchronized关键字,相当于每个想要进入该方法的获取实例的线程都要阻塞排队,我们仔细思考一下:需要吗?当实例已经初始化之后,我们还需要做同步控制吗?这对性能的影响是巨大的。是的,我们只需要在实例第一次初始化的时候同步就足够了。我们继续优化。

第四种方式:双重检验锁:

双重检验锁

我们继续分析一下代码:首先看getInstance方法,我们在方法声明上去除了synchronized关键字,多线程进入方法内部,判断是否为null,如果为null,多个线程同时进入if块内,此时,我们是用Single4 Class对象同步一段方法。保证只有一个线程进入该方法。并且判断是否为null,如果为null,就进行初始化。我们想象一下,如果第一个线程进入进入同步块,发现该实例为null,于是进入if块实例化,第二个线程进入同步内则发现实例已经不是null,直接就返回 了,从而保证了并发安全。那么这个和第三种方式又什么区别呢?第三种方式的缺陷是:每个线程每次进入该方法都需要被同步,成本巨大。而第四种方式呢?每个线程最多只有在第一次的时候才会进入同步块,也就是说,只要实例被初始化了,那么之后进入该方法的线程就不必进入同步块了。就解决并发下线程安全和性能的平衡。虽然第一次还是会被阻塞。但相比较于第三种,已经好多了。

我们还对一个东西感兴趣,就是修饰变量的volatile关键字,为什么要用volatile关键字呢?这是个有趣的问题。我们好好分析一下:
首先我们看,Java虚拟机初始化一个对象都干了些什么?总的来说,3件事情:

  1. 在堆空间分配内存
  2. 执行构造方法进行初始化
  3. 将对象指向内存中分配的内存空间,也就是地址

但是由于当我们编译的时候,编译器在生成汇编代码的时候会对流程进行优化(这里涉及到happen-before原则和Java内存模型和CPU流水线执行的知识,就不展开讲了),优化的结果式有可能式123顺序执行,也有可能式132执行,但是,如果是按照132的顺序执行,走到第三步(还没到第二步)的时候,这时突然另一个线程来访问,走到if(single4 == null)块,会发现single4已经不是null了,就直接返回了,但是此时对象还没有完成初始化,如果另一个线程对实例的某些需要初始化的参数进行操作,就有可能报错。使用volatile关键字,能够告诉编译器不要对代码进行重排序的优化。就不会出现这种问题了。

我们看到,小小的单例模式被我们弄得很复杂。但这就是一个程序员的追求,追求最好的性能,追求最好的代码。

那还有没有别的更好的办法呢?这个代码也太多了,代码可读性也不好。而且线程第一次进入还会阻塞,还能更完美吗?

第五种方式:既要懒汉式加载,又要线程安全:静态内部类。

我们来分析一下代码:相比较饿汉式(也就是第二种),我们增加了一个内部类,内部类中有一个外部类的实例,并且已经初始化了。我们回忆一下饿汉式有什么问题,饿汉式的问题是:在你没有使用该单例的时候,该单例却被加载了,如果该单例很大的话,将会浪费很多的内存.但是,我们现在引入了内部类的方式,虚拟机的机制是,如果你没有访问一个类,那么是不会载入该类进入虚拟机的。当我们使用外部类的时候其他属性的时候,是不会浪费内存载入内部类中的单例的。从而也就保证了并发安全和防止内存浪费。
但是,这样就能完美了吗?

第六种方式:反射和反序列化破坏单例

我们知道Java的反射几乎是什么事情都能做,管你什么私有的公有的。都能破坏。我们是没有还手之力的。精心编写的代码就被破坏了,而反序列化也很厉害,但是稍微还有点办法遏制。什么办法呢?重写readResolve方法。show me code。

我们看到:我们重写了readResolve方法,在该方法中直接返回了我们的内部类实例。重写readResolve() 方法,防止反序列化破坏单例机制,这是因为:反序列化的机制在反序列化的时候,会判断如果实现了serializable或者externalizable接口的类中包含readResolve方法的话,会直接调用readResolve方法来获取实例。这样我们就制止了反序列化破坏我们的单例模式。那反射呢?我们有办法吗?

第七种方式:最后一招,使用枚举

为什么使用枚举可以呢?枚举类型反编译之后可以看到实际上是一个继承自Enum的类。所以本质还是一个类。 因为枚举的特点,你只会有一个实例。我们看一下反编译的枚举类。
反编译的class字节码

我们看到,我们的hello包下的Single7枚举继承了java.lang.Enum<> 类。事实上就是一个类,但是我们这样就能防止反射破坏我们辛苦写的单例模式了。因为枚举的特点,而他也能保证单例。堪称完美!!!

总结

回到开始,我们引用了一些维基百科的话,我们再看看维基百科关于并发是怎么说的:

单例模式在多线程的应用场合下必须小心使用。如果当唯一实例尚未创建时,有两个线程同时调用创建方法,那么它们同时没有检测到唯一实例的存在,从而同时各自创建了一个实例,这样就有两个实例被构造出来,从而违反了单例模式中实例唯一的原则。 解决这个问题的办法是为指示类是否已经实例化的变量提供一个互斥锁(虽然这样会降低效率).

我们看到维基百科还是靠谱的。告诉了我们可以使用互斥锁来防止并发出现的问题。

而单例模式带来了什么好处呢?

  1. 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销;
  2. 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。

小小的一个单例模式也是如此的复杂,耗费了我们很多的精力去写,去读一篇文章。可是,这就是我们的乐趣。任何一段小小的代码,我们都要精益求精。总有一天,我们会写出千万人用的优良代码。加油!!


深入解析单例模式的七种实现
http://thinkinjava.cn/2017/10/23/2017/2017-10-23-深入解析单例模式的七种实现/
作者
莫那·鲁道
发布于
2017年10月23日
许可协议