面试官:请写一个你认为比较“完美”的单例

2023-11-04

单例模式是保证一个类的实例有且只有一个,在需要控制资源(如数据库连接池),或资源共享(如有状态的工具类)的场景中比较适用。如果让我们写一个单例实现,估计绝大部分人都觉得自己没问题,但如果需要实现一个比较完美的单例,可能并没有你想象中简单。本文以主人公小雨的一次面试为背景,循序渐进地讨论如何实现一个较为“完美”的单例。本文人物与场景皆为虚构,如有雷同,纯属捏造。

小雨计算机专业毕业三年,对设计模式略有涉猎,能写一些简单的实现,掌握一些基本的JVM知识。在某次面试中,面试官要求现场写代码:请写一个你认为比较“完美”的单例。

简单的单例实现

凭借着对单例的理解与印象,小雨写出了下面的代码

public class Singleton {
    private static Singleton instance;

    private Singleton(){}

    public static final Singleton getInstance(){
        if(instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

写完后小雨审视了一遍,总觉得有点太简单了,离“完美”貌似还相差甚远。对,在多线程并发环境下,这个实现就玩不转了,如果两个线程同时调用 getInstance() 方法,同时执行到了 if 判断,则两边都认为 instance 实例为空,都会实例化一个 Singleton 对象,就会导致至少产生两个实例了,小雨心想。嗯,需要解决多线程并发环境下的同步问题,保证单例的线程安全。

线程安全的单例

一提到并发同步问题,小雨就想到了锁。加个锁还不简单,synchronized 搞起,

public class Singleton {
    private static Singleton instance;

    private Singleton(){}

    public synchronized static final Singleton getInstance(){
        if(instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

小雨再次审视了一遍,发现貌似每次 getInstance() 被调用时,其它线程必须等待这个线程调用完才能执行(因为有锁锁住了嘛),但是加锁其实是想避免多个线程同时执行实例化操作导致产生多个实例,在单例被实例化后,后续调用 getInstance() 直接返回就行了,每次都加锁释放锁造成了不必要的开销。

经过一阵思索与回想之后,小雨记起了曾经看过一个叫 Double-Checked Locking 的东东,双重检查锁,嗯,再优化一下,

public class Singleton {
    private static volatile Singleton instance;

    private Singleton(){}

    public static final Singleton getInstance(){
        if(instance == null) {
            synchronized (Singleton.class){
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

单例在完成第一次实例化,后续再调用 getInstance() 先判空,如果不为空则直接返回,如果为空,就算两个线程同时判断为空,在同步块中还做了一次双重检查,可以确保只会实例化一次,省去了不必要的加锁开销,同时也保证了线程安全。并且令小雨感到自我满足的是他基于对JVM的一些了解加上了 volatile 关键字来避免实例化时由于指令重排序优化可能导致的问题,真是画龙点睛之笔啊。 简直——完美!

Tips: volatile关键字的语义

  1. 保证变量对所有线程的可见性。对变量写值的时候JMM(Java内存模型)会将当前线程的工作内存值刷新到主内存,读的时候JMM会从主内存读取变量的值而不是从工作内存读取,确保一个变量值被一个线程更新后,另一个线程能立即读取到更新后的值。
  2. 禁止指令重排序优化。JVM在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,使用 volatile 可以禁止进行指令重排序优化。

JVM创建一个新的实例时,主要需三步:

  1. 分配内存
  2. 初始化构造器
  3. 将对象引用指向分配的内存地址

如果一个线程在实例化时JVM做了指令重排,比如先执行了1,再执行3,最后执行2,则另一个线程可能获取到一个还没有完成初始化的对象引用,调用时可能导致问题,使用volatile可以禁止指令重排,避免这种问题。

小雨将答案交给面试官,面试官瞄了一眼说道:“基本可用了,但如果我用反射直接调用这个类的构造函数,是不是就不能保证单例了。” 小雨挠挠头,对哦,如果使用反射就可以在运行时改变单例构造器的可见性,直接调用构造器来创建一个新的实例了,比如通过下面这段代码

 Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
 constructor.setAccessible(true);
 Singleton singleton = constructor.newInstance();

小雨再次陷入了思考。

反射安全的单例

怎么避免反射破坏单例呢,或许可以加一个静态变量来控制,让构造器只有从 getInstance() 内部调用才有效,不通过 getInstance() 直接调用则抛出异常,小雨按这个思路做了一番改造,

public class Singleton {
    private static volatile Singleton instance;
    private static boolean flag = false;

    private Singleton(){
        synchronized (Singleton.class) {
            if (flag) {
                flag = false;
            } else {
                throw new RuntimeException("Please use getInstance() method to get the single instance.");
            }
        }

    }

    public static final Singleton getInstance(){
        if(instance == null) {
            synchronized (Singleton.class){
                if(instance == null) {
                    flag = true;
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

使用静态变量 flag 来控制,只有从 getInstance() 调用构造器才能正常实例化,否则抛出异常。但马上小雨就发现了存在的问题:既然可以通过反射来调用构造器,那么也可以通过反射来改变 flag 的值,这样苦心设置的 flag 控制逻辑不就被打破了吗。看来也没那么“完美”。虽然并不那么完美,但也一定程度上规避了使用反射直接调用构造器的场景,并且貌似也想不出更好的办法了,于是小雨提交了答案。

面试官露出迷之微笑:“想法挺好,反射的问题基本解决了,但如果我序列化这个单例对象,然后再反序列化出来一个对象,这两个对象还一样吗,还能保证单例吗。如果不能,怎么解决这个问题?”

SerializationSafeSingleton s1 = SerializationSafeSingleton.getInstance();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(s1);
oos.close();

ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
SerializationSafeSingleton s2 = (SerializationSafeSingleton) ois.readObject();
ois.close();

s1 == s2 吗? 答案是否,如何解决呢。

序列化安全的单例

小雨思考了一会,想起了曾经学习序列化知识时接触的 readResolve() 方法,该方法在ObjectInputStream已经读取一个对象并在准备返回前调用,可以用来控制反序列化时直接返回一个对象,替换从流中读取的对象,于是在前面实现的基础上,小雨添加了一个 readResolve() 方法,

public class Singleton {
    private static volatile Singleton instance;
    private static boolean flag = false;

    private Singleton(){
        synchronized (Singleton.class) {
            if (flag) {
                flag = false;
            } else {
                throw new RuntimeException("Please use getInstance() method to get the single instance.");
            }
        }

    }

    public static final Singleton getInstance(){
        if(instance == null) {
            synchronized (Singleton.class){
                if(instance == null) {
                    flag = true;
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    /**
     * 该方法代替了从流中读取对象
     * @return
     */
    private Object readResolve(){
        return getInstance();
    }
}

通过几个步骤的逐步改造优化,小雨完成了一个基本具备线程安全、反射安全、序列化安全的单例实现,心想这下应该足够完美了吧。面试官脸上继续保持着迷之微笑:“这个实现看起来还是显得有点复杂,并且也不能完全解决反射安全的问题,想想看还有其它实现方案吗。”

其它方案

小雨反复思考,前面的实现是通过加锁来实现线程安全,除此之外,还可以通过类的加载机制来实现线程安全——类的静态属性只会在第一次加载类时初始化,并且在初始化的过程中,JVM是不允许其它线程来访问的,于是又写出了下面两个版本

1.静态初始化版本

public class Singleton {
    private static final Singleton instance = new Singleton();

    private Singleton(){}

    public static final Singleton getInstance() {
        return instance;
    }
}

该版本借助JVM的类加载机制,本身线程安全,但只要 Singleton 类的某个静态对象(方法或属性)被访问,就会造成实例的初始化,而该实例可能根本不会被用到,造成资源浪费,另一方面也存在反射与序列化的安全性问题,也需要进行相应的处理。

2.静态内部类版本

public class Singleton {
    private Singleton(){}

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

    private static class SingletonHolder {
        private static final Singleton instance = new Singleton();
    }
}

该版本只有在调用 getInstance() 才会进行实例化,即延迟加载,避免资源浪费的问题,同时也能保障线程安全,但是同样存在反射与序列化的安全性问题,需要相应处理。

这貌似跟前面版本的复杂性差不多啊,依然都需要解决反射与安全性的问题,小雨心想,有没有一种既简单又能避免这些问题的方案呢。

“完美”方案

一阵苦思冥想之后,小雨突然脑中灵光闪现,枚举!(这也是《Effective Java》的作者推荐的方式啊)

public enum Singleton {
    INSTANCE;

    public void func(){
        ...
    }
}

可以直接通过 Singleton.INSTANCE 来引用单例,非常简单的实现,并且既是线程安全的,同时也能应对反射与序列化的问题,面试官想要的估计就是它了吧。小雨再次提交了答案,这一次,面试官脸上的迷之微笑逐渐消失了……

Tips:为什么枚举是线程、反射、序列化安全的?

  1. 枚举实际是通过一个继承自Enum的final类来实现(通过反编译class文件可看到具体实现),在static代码块中对其成员进行初始化,因此借助类加载机制来保障其线程安全
  2. 枚举是不支持通过反射实例化的,在Constructor类的newInstance方法中可看到
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
     throw new IllegalArgumentException("Cannot reflectively create enum objects");
  3. 枚举在序列化的时候仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。并且,编译器是不允许任何对这种序列化机制的定制的,禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。枚举通过这种机制保障了序列化安全。

总结

枚举方案近乎“完美”,但实际中,大部分情况下,我们使用双重检查锁方案或静态内部类方案基本都能满足我们的场景并能很好地运行。并且方案从来没有“完美”,只有更好或更合适。本文只是从单例实现的不断演进的过程中,了解或回顾如反射、序列化、线程安全、Java内存模型(volatile语义)、JVM类加载机制、JVM指令重排序优化等方面的知识,同时也是启示我们在设计或实现的过程中,多从各个角度思考,尽可能全面地考虑问题。或者,在相关面试中能更好地迎合面试官的“完美”期望。


作者:雨歌,一枚仍在学习路上的IT老兵 欢迎关注作者公众号:半路雨歌,一起学习成长
qrcode

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

面试官:请写一个你认为比较“完美”的单例 的相关文章

随机推荐

  • 记录springboot+vue+fastdfs实现简易的文件(上传、下载、删除、预览)操作

    前言说明 springboot vue FastDFS实现文件上传 支持预览 升级版 FASTDFS部分 FASTDFS安装过程 基于centos 7安装FastDFS文件服务器 SpringBoot部分 springboot源码实现 pa
  • 何以解忧,唯有程序员硬核段子

    大家好 我是五月 前言 程序员应该是最喜欢自黑的职业了 因为黑程序员这个门槛有点高 外行人根本黑不到点子上 程序员们看不下去了 只好提起裤子自己上了 而且程序员的段子大多比较硬核 一般人可能会有点难以理解其笑点 下面找了几个程序员专属的段子
  • RDP远程桌面协议

    1 概况 目前常用的远程桌面协议有VNC SPICE RDP三种 2 RDP Remote Desktop Protocol 远程桌面协议 是一个多通道 multi channel 的协议 让用户 客户端或称 本地电脑 连上提供微软终端机服
  • IT网络时代文章读后感2011年3月10日星期四 晴 【大明原创,转载必须标明出处】

    我宿舍规矩开学来的第一天基本都是座谈会 是坐在一起说说各自的新鲜事 和老金侃侃而谈说点保定的新鲜事 和晨阳吹吹牛 和小新说说家里的热闹事 和老刘交流一下NBA赛事 和小白调侃一下 逗乐一下 然后就是老金和晨阳说起了网络游戏情节 就表示散会了
  • taro发版的时候,微信提示wx.getLocation 接口未配置在app.json文件中

    如何解决这个问题呢 taro的开发者找到app config ts文件 原生小程序的开发者找到app json文件就可以 uinapp的开发这找到manifest json文件 找到文件了以后呢 在window的同级添加requiredPr
  • 什么都不懂的人怎么样可以建立自己的网站

    网站建设教程 新手如何自己建网站 很多从来没有做过网站的新手都很想知道 如何建设自己的网站 需要准备什么 步骤有哪些 在讲解之前 需要先明白网站主要由什么组成 一个完整的网站必须具备三元素 域名 网站空间 服务器 网站程序 域名就是网址 空
  • 牛逼了,这21款游戏项目,只一行代码即可上手—开玩【附源码】

    导语 随着时代的不同 社会的改变 伴随着一起长大的游戏 逐渐淡出我们的视线 却一直铭刻在我们心中 还记得你小时候都玩过什么游戏吗 超级玛丽 坦克大战 魂斗罗 贪吃蛇 植物大战僵尸 咳咳咳 有点儿暴露年龄了哈 今天就分享一个私藏的项目 fre
  • JS判断时间上传格式

    MimeType 文件类型 application msword word doc application vnd ms powerpoint powerpoint ppt application vnd ms excel
  • 链表——一种线性数据结构

    链表 链表中的每个元素实际上是一个单独的对象 而所有对象都通过每个元素中的引用字段链接在一起 线性数据结构 与数组一样 链表也是线性数据结构 他们的区别在于存储方式不同 顺序存储结构 数组 快速的存和取 逻辑上相邻 物理上也相邻 链式存储结
  • 深入leveldb-初步认识leveldb

    文章参考http blog chinaunix net uid 26575352 id 3245476 html 1 leveldb简介 leveldb是google两位工程师实现的单机版k v存储系统 具有以下几个特点 1 key和val
  • 数组环形队列(FIFO)

    数组环形队列 FIFO 一 说明 环形队列的关键是判断队列为空 还是为满 当tail追上head时 队列为满 当head追上tail时 队列为空 二 代码实现 define ElemType uint8 t 定义数据类型 define Qu
  • python的小作业

    目录 python安装 2 打印0 100 遇到19的时候跳出循环 3 只打印90以内的奇数 4 对发送邮件代码进行修改 换成自己的邮箱并截图保存 5 对指导书中海龟画图和TCP UDP的案例进行学习并运行结果 python小项目 pyth
  • 高德地图-根据IP定位显示当前所在城市

    1 绘制地图 构造参数中的container为添加的地图容器的id let map new AMap Map container 2 IP定位 高德地图定位文档 自建图层 参考手册 地图 JS API 高德地图API 创建AMap Map对
  • ARCore:ARCore开发的起步

    已经是第7章节了 终于可以开始正式接触到我们梦寐以求的ARCore了 相信大家应该都有一点点小激动了吧 那么下面我们就开始讲解如何进行ARCore的开发 一 Session简介 初看到Session 大家都摸不到头脑 最直接的解释 它是AR
  • ChatGPT实用用法10大场景

    之前的文章中 我们提到了ChatGPT的一些局限性 比如它会一本正经地胡说八道 所以如果使用方法不对 反而会耽误时间甚至被误导 但要是用对了 真的会事半功倍 让我们褪去ChatGPT无所不知无所不能的光环 看看现阶段的它有哪些靠谱的用法吧
  • java jar 创建_java生成jar包

    Java编写的application程序是否可以终于形成一个类似于exe一样的可执行文件 难道就仅仅能用命令行执行 通常有两种 一种是制作一个可运行的JAR文件包 然后就能够像 chm文档一样双击运行了 而还有一种是使用JET来进行编译 可
  • 2014百度校招笔试

    1 ISO七层说明 2 用百度地图查询 百度大厦 到 北京大学 得到路线不太稳定是怎么回事 分析可能的原因 测试开发唯一区别于软件开发的一题 3 TCP UDP协议的区别 举出上一层的应用协议 二 算法 1 写出a0 a1 a2 an的所有
  • Web框架 Flask 之 请求方式 & 会话

    常见的请求方式 GET 页面的默认请求方式 请求的数据是以明文的形式放在路由上面 以 开头的键值对 中间以 连接多个参数 POST 请求的数据隐藏发送的 安全系数高 通常用来向服务器提交数据 请求对象 Flask 中由全局对象 reques
  • Java为什么一个源文件只能有一个public类,而且源文件名必须要与public类名相同

    看了几个解答 都不能让我很满意 以后有机会再深究一下 真要是被问起 就这么答 java和jvm的设计需要 是一个规定 假如一个源文件中没有public类 那么你想定义几个类就定义几个 都没有问题 而且每个类的名字都随便起 完全和文件名没有关
  • 面试官:请写一个你认为比较“完美”的单例

    单例模式是保证一个类的实例有且只有一个 在需要控制资源 如数据库连接池 或资源共享 如有状态的工具类 的场景中比较适用 如果让我们写一个单例实现 估计绝大部分人都觉得自己没问题 但如果需要实现一个比较完美的单例 可能并没有你想象中简单 本文