专栏名称: 码农翻身
工作15年的前IBM架构师分享好玩有趣的编程知识和职场的经验教训, 不容错过。
目录
相关文章推荐
程序员小灰  ·  什么是 “财商” ? ·  昨天  
程序员的那些事  ·  NPM 作者推出全新的 ... ·  3 天前  
码农翻身  ·  可惜,美国人又领先了我们一步...... ·  3 天前  
OSC开源社区  ·  国行版“苹果牌AI”有望使用腾讯、字节大模型 ·  1 周前  
51好读  ›  专栏  ›  码农翻身

Java帝国之单例设计模式

码农翻身  · 公众号  · 程序员  · 2017-02-08 20:16

正文

前言:本文原作者是王钦誉,原文链接:https://xiaoqinyu0000.github.io/Java/JavaSingleton/

之前王钦誉还写过《Java帝国之拨云见日识回调》。 


这篇文章很长,讲了单例模式的方方面面, 建议认真读完。

1

背景

在Java帝国,有一个隐蔽的村庄叫IO村,村里每个人都身怀绝技。其中,SocketIO、HttpIO、FileIO更是专注于某个领域的高手。


FileIO,它十余年苦练文件存储技术,雄心壮志,决定走出村庄,去外面闯一闯。


FileIO到了城里,成功的通过了面试,进入一家IO科技责任有限公司,专门负责文件存储等工作。


2

懒汉式


FileIO刚进入公司,工作不久,就接到3、4次投诉,投诉理由是“在系统中使用FIleIO之后,频繁地发生内存抖动,导致内存吞吐量骤减”。


FIleIO大急,连忙查看日志,原来在系统运行中,自己的实例被频繁地创建与销毁。


FileIO找到主管老张: “咱们能不能给客户说一下, 让客户对我的实例进行缓存,别这么频繁地创建啊”。


老张,“我们很难去规定客户怎么用,不过我们可以做一些引导。我看你也不需要创建多个实例,你可以创建一个实例给客户使用,不对外开放创建实例的权限就行”。


FileIO眼睛一亮,赶紧请教老张具体该怎么做。


老张说:“其实很容易。

第一、把自己的构造方法设置为private的,不让别人new你的实例;

第二、提供一个static方法给别人获取你的实例,你在这个方法里面返回你自己创建的实例就行”。


FileIO按照老张的思路做了修改:


以后别人在调用FIleIO的时候就不再使用new的方式去创建一个FIleIO的实例,而是调用static方法getInstance()获取FileIO的实例,例:

这样一来,FileIO就不会频繁的被创建了,

后来,FileIO才知道自己的这种实现机制就是“单例设计模式”,并且被人称为“懒汉式”, 可能是因为在需要的时候才创建吧, 显得很“懒”。


3

饿汉式


FileIO使用单例模式修改后解决了问题,在公司例会上被技术总监大大地表扬了一次。


FileIO非常傲娇,在村里的微信群里向小伙伴们炫耀这件事。


村里的HttpIO自己也存在类似的问题,也需要自修一下,于是它按照了FileIO在微信群中分享的思路进行修改:


实现的方式虽有所不同,但调用方式和效果都是一样的(都能实现单例)。


HttpIO总觉得自己的实现方式更好, FileIO则说自己的Lazy方式更流行, 毕竟不调用FileIO的话对象就不会创建。


两人争执不下, 于是就去请教经验丰富的老张。 


老张说,你们这都是单例设计模式的实现方式,HttpIO的实现方式在单例设计模式中被称为饿汉式”(可以能由于太饥饿, 一上来就创建了对象)两者的执行顺序有所不同:

FileIO的实现在第一次调用的时候先执行了getInstance()方法,再执行构造方法。


HttpIO的实现在第一次调用的时候先执行了构造方法,再执行getInstance()方法。


而且,饿汉式是一种线程安全的写法。


4

线程安全(synchronized)


FileIO表示不服, 老张说,我给你举个例子,

当有多个线程并发执行getInstance()的时候,可能会出现以下的情况而导致FileIO产生多个实例。”


线程一 : FileIO.getInstance()

(FileIO:判断fileIO为null,进行fileIO实例的初始化)

线程二: FileIO.getInstance()

(FileIO:  fileIO还没初始化完,依然为null, 于是进行另外一个fileIO实例的初始化)


等到两个线程都返回的时候,其实是创建了两个FileIO的实例。

(点击看大图)

FileIO恍然大悟,线程安全可得好好重视,好在以前研究过一点线程安全的问题,直接加上synchronized:

这样一来,当有两个线程同时执行getInstance()方法的时候,一旦线程一获取到FileIO.class锁,线程二只能在外面等待着


在线程一执行完getInstance()的逻辑后释放FileIO.class锁,其他线程才能获取这个锁进入getInstance()方法中。

这样就避免了创建两个FileIO的实例


流程如下:

(点击看大图)

5

线程安全-双重检验锁


老张锊一锊胡须,道:“这种做法简单明了,确实保证了线程安全, 还能更优化呢!”


FileIO不服气,“我不信,难道还有比我只加一个synchronized更简单的做法?”。


老张笑了一下,“我说的优化并不是指简单化。你想想啊,如果fileIO实例不为空时,还需要使用synchronized来限制执行时只能一个线程进入吗?来来来,老哥给你露一手。”


“我这招江湖人称”双重检验锁“,够酷吧!”。


“老司机就是复杂,好端端的,被你弄了那么多个fileIO == null的判断,还用volatile关键字修饰fileIO,这样真的能提升性能吗?”


“you look,当有多有线程调用getInstance()方法的时候,不管三七二十一,先让他们进来。如果fileIO实例不为空,那最好了,直接return实例fileIO,跟synchronized一点都扯不上关系,所以也不会影响到性能。这是双重检验中的第一次检验。”


“oh,I know,如果fileIO是null的,就进入synchronized语句块,在synchronized语句块里面初始化对象。但为什么在synchronized语句中需要再次检查fileIO实例是否为null?”


“这就是第二次检验了,当有多个线程通过第一次检验时,假设线程拿到锁进入synchronized语句块,对fileIO实例进行初始化,释放FileIO.class锁之后,线程二持有这个锁进入synchronized语句块,此时又对fileIO对象就行初始化。所以在这里进行第二次检验防止这种意外发生。”


“我理解了,但我不明白fileIO为什么要用volatile关键字修饰?”


“我们假设线程一进入第二次检验之后就执行FileIO fileIO = new FIleIO()操作,在这个操作中,JVM主要干了三件事

1、在堆空间里分配一部分空间;

2、执行FileIO的构造方法进行初始化;

3、把fileIO对象指向在堆空间里分配好的空间。


但是,当我们编译的时候,编译器在生成汇编代码的时候会对流程顺序进行优化。优化的结果是有可能按照1-2-3顺序执行,也可能按照1-3-2顺序执行。


我们知道,执行完3的时候就fileIO对象就已经不为空了,如果是按照1-3-2的顺序执行,恰巧在执行到3的时候(还没执行2),突然跑来了一个线程,进来getInstance()方法之后判断fileIO不为空就返回了fileIO实例。


此时fileIO实例虽不为空,但它还没执行构造方法进行初始化。又恰巧构造方法里面需要对某些参数进行初始化。后来闯进来的线程糊里糊涂对那些需要初始化的参数进行操作就有可能报错奔溃了。”


6

线程安全-静态内部类


“弄一个单例模式要这么麻烦,写这么多代码,我还是使用饿汉式单例模式算了!”,FileIO抱怨说。


老张笑着说,“那也不能这么想呀,其实我们可以根据使用场景不同来使用不一样的单例模式。如果我们需要在getInstance()方法的时候传入参数进来辅助构造方法初始化,那就得用懒汉式了,比如:“

FileIO一想,也对,其他情况就可以使用饿汉式了。


老张好像看出FileIO的想法,道:”其实饿汉式也有其他弊端,比如当我们不想获取FileIO的实例而是想获取其中一个全局变量的时候,在类加载的时候还是会对fileIO实例进行初始化,导致时间比较久。举例如下:

当调用FileIO.TYPE_MP3的时候,INSTANCE实例也会被初始化,这显然不是我们需要的。所以,我们Java帝国的高手们又想出了一种叫静态内部类的单例模式,它简单又保证实例能进行懒加载。”

FileIO眼睛一亮,“这个我能理解,当执行getInstance()方法的时候就去调用FileIOHolder内部类里面的INSTANCE实例,此时FileIOHolder内部类会被加载到内存里,在类加载的时候就对INSTANCE实例进行初始化。和饿汉式一个道理,保证了只有一个实例,而且在调用getInstance()方法的时候才进行INSTANCE实例的初始化,又具有懒汉式的部分特性。”


老张满意地说,“是的,但这种写法是利用JVM的机制完成的,在其他语言不一样适用哦!”


7

黑客破坏-反射和反序列化


听了老张一堂课,FileIO收获匪浅,这些都是以前在村里老村长没讲过的一些技巧,它非常高兴,仿佛看到自己在以后踏上人生巅峰、迎娶白富美的样子。


老张决定给它泼泼冷水,“还没完呢,在我们Java帝国,有很多被称为黑客的家伙,他们经常搞破坏,你如果只按照上面介绍的写法使用单利模式,很有可能被破坏哦!”


FileIO愣住了,接着问,“那怎么办呀,他们都是怎么破坏的?”。


老张故作深沉地答,”其实他们的破坏方式无非就是这两种:

1. 反射调用构造方法初始化新的实例

FileIO fileIO = FileIO.class.newInstance();


2. 序列化和反序列化产生新的实例

对于通过反射调用构造方法的破坏方式我们可以通过在增加全局变量flag,在第一次初始化的时候就设置为true,第二次初始化的时候判断到flag为true就抛出异常。但这种办法也只能避免破坏,无法彻底阻止,因为他们可以反射flag来修改flag的值。


对于使用序列化和反序列化产生新的实例的方式就容易避免了,可以增加readResolve()方法来预防。我们使用静态内部类的方式来演示如何避免:”


FileIO不解:”为什么增加readResolve()方法并在里面返回之前创建好的实例就可以避免被反序列破坏呢?“


“这是反序列化机制决定的, 在反序列化的时候会判断如果实现了serializable 或者 externalizable接口的类中又包含readResolve()方法的话,会直接调用readResolve()方法来获取实例。”,老张解释道。


8

终极招数


FileIO叹一叹气,”虽然单例看起来简单, 但是要弄一个完美的单例模式还是比较麻烦的!“


老张眨了眨眼,“我还有终极招数呢!”


“咦!”


FileIO看着简单又陌生的自己,向老张投去疑惑又质疑的眼神。


老张解释:“别看你现在是枚举类型,但实际上反编译可知枚举实际上就是一个继承Enum的类。所以你的本质还是一个类,因为枚举的特点,你只会有一个实例,同时保证了线程安全、反射安全和反序列化安全。”


FileIO感慨,原来单例也可以这么简单,“你妹的老张,有这么简单的方法你不早告诉我。”


“我们重在过程,不在结果。”


(完)


你看到的只是冰山一角, 更多精彩文章,参见《码农翻身2016文章精华


有心得想和大家分享? 欢迎投稿 ! 我的联系方式:微信:liuxinlehan  QQ: 3340792577


公众号:码农翻身

“码农翻身”公众号由工作15年的前IBM架构师创建,分享编程和职场的经验教训。