设计模式-单例模式
《巫师3》中,陪着主人公南征北战的坐骑,不管你何时何地召唤它,它永远只有一个名字——萝卜。
面试开始
HR :来了一个面试Java的,我让他在小会议室等着了。
面试官 :好的,我就来。
面试官用一次性纸杯倒了杯水,夹着Mac,进了小会议室。看见一个20出头的精神小伙,带着黑框眼镜,发量诱人,像极了N年前的自己,风华正茂,书生意气。
面试官 :你好,先喝杯水吧。(不给应聘者倒水的公司都是不靠谱的)我看你简历上写着精通设计模式,要不我们就聊聊设计模式吧。
应聘者 :可以呀。
一句轻描淡写的“可以呀”,但经验丰富的面试官还是发现了平静面容下,应聘者的一丝丝窃喜,好像很胸有成竹的样子。
面试官 :那就说说,你平时都用了哪些设计模式吧?
应聘者 :(内心狂喜ing)我平时使用最多的设计模式有单例模式。单例模式属于23种设计模式中的创建型的设计模式。23种设计模式可以分为3种:创建型、结构型和行为型。单例模式确保了一个类只有一个实例。单例模式有5种实现方式:懒汉式、饿汉式、Double-Check方式、静态内部类方式、枚举方式。
面试官 :嗯,你对单例模式了解的不错嘛。你先说下为什么要使用单例模式吧。
应聘者 :单例模式其实很简单,就是一个类只能创建一个实例。在程序中,有一些对象只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。还有些业务上就只会有一个,比如公司主体等。
面试官 :那如何实现一个单例呢?
应聘者 :实现单例有好几种方式,有饿汉式、懒汉式、静态内部类,或者使用枚举来实现。使用单例模式,一般把类的构造函数设置为private,避免通过new创建多个示例。我先来说下饿汉方式吧。
应聘者喝了口水,似乎准备开始表演了。
应聘者 :饿汉式实现比较简单。类有一个静态的实例,一般取名为instance。在类加载的时候,就会创建并初始化好instance实例。所以,饿汉式是线程安全的。
面试官 :你能写一下具体的实现代码吗?
应聘者很快就在纸上写出了饿汉式的代码实现:
public static Singleton{
private static final Singleton = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return instance;
}
}
看的出来,应聘者对饿汉式的代码实现很熟悉,编码风格和命名也很不错。我在面试的时候,就有几位应聘者不知道如何给类命名,有的使用Danli,有的使用Single或One。
面试官 :嗯,很不错。你平时都使用这种方式吗?
应聘者 :哦,不是的。饿汉式虽然简单,但是有个问题是,它不支持延迟加载,或者叫按需加载。在系统启动时,就必须要创建实例。
面试官 :这样会有什么问题吗?
应聘者 :如果实例占用资源多,比如内存占用高,或者初始化耗时长(比如需要加载各种配置文件),提前初始化就会造成浪费。应该在用到的时候再去初始化。
面试官 :如果初始化耗时长,等用到的时候再初始化。就可能在用户请求接口的时候,触发了这个初始化过程,会导致请求的响应时间很长,甚至超时。对用户造成影响。所以,究竟是启动时初始化好,还是延迟初始化好呢?
应聘者 :啊,这个。。。(这个面试官不按套路出牌呀)网上说的都是要延迟加载。
面试官 :还有,如果实例占用资源多,比如内存使用高。如果延迟加载,可能会出现在程序运行一段时间后,因为初始化实例,占用资源多,出现了OOM,程序崩溃。根据Fast Fail原则,是不是就应该在启动时初始化实例,如果资源不够,我们就能快速发现问题,尽快进行修复,而不会让问题在生产环境中才暴露。
应聘者 :嗯,好像有道理。但我看网上的文章都说这种方式不好。
面试官 :那你觉得哪种方式好呢?
应聘者 :(内心有些摇摆,有些凌乱)
面试官 :那我们再聊聊延迟加载的单例?
应聘者 :嗯嗯,好呀。延迟加载就是在使用的时候才进行初始化,它的代码实现是这样的:
public class Singleton{
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance(){
if (instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
面试官 :嗯,不错嘛。我看getInstance方法中,有多个null判断,还有个synchronized锁,能不能解释一下。
应聘者 :这个叫双重检查(Double Check)。加synchronized是为了保证线程安全。null判断是为了提升性能。如果不在前面先判断instance是否为null,就需要在每次使用时,先获取锁,然后释放锁,会导致性能瓶颈。
应聘者 :所以使用了双重检查,只要instance被创建后,即使再调用getInstance,也不会再加锁了。解决了性能问题。
应聘者 :网上有人说,这种实现方式也有问题。因为指令重排,可能会导致Singleton被new出来后,被赋值给了instance,还没来得及初始化,就被另一个线程使用了,可能会出现NPE错误。要解决这个问题,我们需要给instance成员变量添加volatile关键字,禁止指令重排。
面试官 :嗯,你对Java指令重排也有了解呀,不错。关于线程安全,我们稍后再仔细聊聊吧。
应聘者 :(不要啊,我就只记住了这一段。待会儿一聊就露馅了啊。。。)
面试官:你知道还有其它实现单例的方式吗?
应聘者 :还有个静态内部类方式。它比双重检查更加简单。就是利用Java的静态内部类。代码实现是这样的:
public class Singleton{
private Singleton(){}
private static class SingletonHolder{
private static final Singleton instance = new Singleton();
}
public static Singleton getInstance(){
return SingletonHolder.instance;
}
}
应聘者 :SingletonHolder
是一个内部静态类,当外部Singleton
被加载时,并不会创建SingletonHolder
实例对象。只有当调用getInstance
方法时,SingletonHolder
才被加载,这个时候才会创建instance
。instance
的唯一性、创建过程的线程安全有JVM虚拟机来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。
应聘者 :还有一种使用枚举创建单例的方式。
面试官 :哇,还有嘛。那你再说说吧。
应聘者 :使用枚举应该是最简单的。它利用了Java枚举类型本身的特点,保证了实例创建的线程安全和实例唯一性。代码如下:
public enum Singleton{
INSTANCE;
}
面试官 :你平时都是使用这个方式吗?
应聘者 :没有呢。这种方式的确简单,而且也是《Effective Java》作者推荐的。但是我觉得用枚举来表达一个单例,这种方式比较奇怪。总觉得是一样投机取巧的方式。
面试官 :哈哈哈。。的确是这样,开源项目中也很少会使用这种方式,是比较怪。你对单例模式的理解很深入呀,说出了这么多种实现,不错不错。刚才看你对线程安全也挺了解的,那我们接下来再聊聊Java多线程吧。
应聘者 :(狠狠抽了几下耳巴子。。。叫你多嘴。。。)
重点回顾
单例模式是面试中经常出现的话题。单例模式本身比较简单,就是一个类只有一个实例。大部分面试者在面试准备时,都会阅读单例的相关知识点,比如单例模式的多种实现。
但是,希望大家不要仅仅是背诵,还应该多去理解。本文的面试中,面试官问了一个问题,到底是启动时初始化好,还是延迟加载好呢?这个问题,大家可以自己思考一下。
大家好,我是左耳朵梵高,北理工毕业,本是导弹专业,意外进入了IT行业。现任某金融咨询公司首席架构师,曾在阿里巴巴中间件团队任职。沉浸软件行业十余年,热衷于软件架构、研发效能、分布式、云原生等领域,相信技术能改变世界。译有《你真的会写代码吗?》
坚持输出技术干货,职场心得和读书感悟。欢迎关注「左耳朵梵高」,和我一起持续学习,终生成长。