专栏首页Java进阶指南面试官看完我手写的单例直接惊呆了!

面试官看完我手写的单例直接惊呆了!

前言

单例模式应该算是 23 种设计模式中,最常见最容易考察的知识点了。经常会有面试官让手写单例模式,别到时候傻乎乎的说我不会。

之前,我有介绍过单例模式的几种常见写法。还不知道的,传送门看这里:

设计模式之单例模式

本篇文章将展开一些不太容易想到的问题。带着你思考一下,传统的单例模式有哪些问题,并给出解决方案。让面试官眼中一亮,心道,小伙子有点东西啊!

以下,以 DCL 单例模式为例。

DCL 单例模式

DCL 就是 Double Check Lock 的缩写,即双重检查的同步锁。代码如下,

public class Singleton {

    //注意,此变量需要用volatile修饰以防止指令重排序
    private static volatile Singleton singleton = null;

    private Singleton(){

    }

    public static Singleton getInstance(){
        //进入方法内,先判断实例是否为空,以确定是否需要进入同步代码块
        if(singleton == null){
            synchronized (Singleton.class){
                //进入同步代码块时再次判断实例是否为空
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

乍看,以上的写法没有什么问题,而且我们确实也经常这样写。

但是,问题来了。

DCL 单例一定能确保线程安全吗?

有的小伙伴就会说,你这不是废话么,大家不都这样写么,肯定是线程安全的啊。

确实,在正常情况,我可以保证调用 getInstance 方法两次,拿到的是同一个对象。

但是,我们知道 Java 中有个很强大的功能——反射。对的,没错,就是他。

通过反射,我就可以破坏单例模式,从而调用它的构造函数,来创建不同的对象。

public class TestDCL {
    public static void main(String[] args) throws Exception {
        Singleton singleton1 = Singleton.getInstance();
        System.out.println(singleton1.hashCode()); // 723074861
        Class<Singleton> clazz = Singleton.class;
        Constructor<Singleton> ctr = clazz.getDeclaredConstructor();
        //通过反射拿到无参构造,设为可访问
        ctr.setAccessible(true);
        Singleton singleton2 = ctr.newInstance();
        System.out.println(singleton2.hashCode()); // 895328852
    }
}

我们会发现,通过反射就可以直接调用无参构造函数创建对象。我管你构造器是不是私有的,反射之下没有隐私。

打印出的 hashCode 不同,说明了这是两个不同的对象。

那怎么防止反射破坏单例呢?

很简单,既然你想通过无参构造来创建对象,那我就在构造函数里多判断一次。如果单例对象已经创建好了,我就直接抛出异常,不让你创建就可以了。

修改构造函数如下,

再次运行测试代码,就会抛出异常。

有效的阻止了通过反射去创建对象。

那么,这样写单例就没问题了吗?

这时,机灵的小伙伴肯定就会说,既然问了,那就是有问题(可真是个小机灵鬼)。

但是,是有什么问题呢?

我们知道,对象还可以进行序列化反序列化。那如果我把单例对象序列化,再反序列化之后的对象,还是不是之前的单例对象呢?

实践出真知,我们测试一下就知道了。

// 给 Singleton 添加序列化的标志,表明可以序列化
public class Singleton implements Serializable{ 
    ... //省略不重要代码
}
//测试是否返回同一个对象
public class TestDCL {
    public static void main(String[] args) throws Exception {
        Singleton singleton1 = Singleton.getInstance();
        System.out.println(singleton1.hashCode()); // 723074861
        //通过序列化对象,再反序列化得到新对象
        String filePath = "D:\\singleton.txt";
        saveToFile(singleton1,filePath);
        Singleton singleton2 = getFromFile(filePath);
        System.out.println(singleton2.hashCode()); // 1259475182
    }

    //将对象写入到文件
    private static void saveToFile(Singleton singleton, String fileName){
        try {
            FileOutputStream fos = new FileOutputStream(fileName);
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(singleton); //将对象写入oos
            oos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //从文件中读取对象
    private static Singleton getFromFile(String fileName){
        try {
            FileInputStream fis = new FileInputStream(fileName);
            ObjectInputStream ois = new ObjectInputStream(fis);
            return (Singleton) ois.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }
}

可以发现,我把单例对象序列化之后,再反序列化之后得到的对象,和之前已经不是同一个对象了。因此,就破坏了单例。

那怎么解决这个问题呢?

我先说解决方案,一会儿解释为什么这样做可以。

很简单,在单例类中添加一个方法 readResolve 就可以了,方法体中让它返回我们创建的单例对象。

然后再次运行测试类会发现,打印出来的 hashCode 码一样。

是不是很神奇。。。

readResolve 为什么可以解决序列化破坏单例的问题?

我们通过查看源码中一些关键的步骤,就可以解决心中的疑惑。

我们思考一下,序列化和反序列化的过程中,哪个流程最有可能有操作空间。

首先,序列化时,就是把对象转为二进制存在 ``ObjectOutputStream` 流中。这里,貌似好像没有什么特殊的地方。

其次,那就只能看反序列化了。反序列化时,需要从 ObjectInputStream 对象中读取对象,正常读出来的对象是一个新的不同的对象,为什么这次就能读出一个相同的对象呢,我猜这里会不会有什么猫腻?

应该是有可能的。所以,来到我们写的方法 getFromFile中,找到这一行ois.readObject()。它就是从流中读取对象的方法。

点进去,查看 ObjectInputStream.readObject 方法,然后找到 readObject0()方法

再点进去,我们发现有一个 switch 判断,找到 TC_OBJECT 分支。它是用来处理对象类型。

然后看到有一个 readOrdinaryObject方法,点进去。

然后找到这一行,isInstantiable() 方法,用来判断对象是否可实例化。

由于 cons 构造函数不为空,所以这个方法返回 true。因此构造出来一个 非空的 obj 对象 。

再往下走,调用,hasReadResolveMethod 方法去判断变量 readResolveMethod是否为非空。

我们去看一下这个变量,在哪里有没有赋值。会发现有这样一段代码,

点进去这个方法 getInheritableMethod。发现它最后就是为了返回我们添加的readResolve 方法。

同时我们发现,这个方法的修饰符可以是 public , protected 或者 private(我们当前用的就是private)。但是,不允许使用 static 和 abstract 修饰。

再次回到 readOrdinaryObject方法,继续往下走,会发现调用了 invokeReadResolve 方法。此方法,是通过反射调用 readResolve方法,得到了 rep 对象。

然后,判断 rep 是否和 obj 相等 。obj 是刚才我们通过构造函数创建出来的新对象,而由于我们重写了 readResolve 方法,直接返回了单例对象,因此 rep 就是原来的单例对象,和 obj 不相等。

于是,把 rep 赋值给 obj ,然后返回 obj。

所以,最终得到这个 obj 对象,就是我们原来的单例对象。

至此,我们就明白了是怎么一回事。

一句话总结就是:当从对象流 ObjectInputStream 中读取对象时,会检查对象的类否定义了 readResolve 方法。如果定义了,则调用它返回我们想指定的对象(这里就指定了返回单例对象)。

总结

因此,完整的 DCL 就可以这样写,

public class Singleton implements Serializable {

    //注意,此变量需要用volatile修饰以防止指令重排序
    private static volatile Singleton singleton = null;

    private Singleton(){
        if(singleton != null){
            throw new RuntimeException("Can not do this");
        }
    }

    public static Singleton getInstance(){
        //进入方法内,先判断实例是否为空,以确定是否需要进入同步代码块
        if(singleton == null){
            synchronized (Singleton.class){
                //进入同步代码块时再次判断实例是否为空
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

    // 定义readResolve方法,防止反序列化返回不同的对象
    private Object readResolve(){
        return singleton;
    }
}

另外,不知道细心的读者有没有发现,在看源码中 switch 分支有一个 case TC_ENUM 分支。这里,是对枚举类型进行的处理。

感兴趣的小伙伴可以去研读一下,最终的效果就是,我们通过枚举去定义单例,就可以防止序列化破坏单例。

本文分享自微信公众号 - 烟雨星空(mistyskys),作者:烟雨星空

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-09-25

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 推荐一款 Python 数据分析报告开发与分享神器

    呆鸟云: """ Python 的数据分析能力已经被大家充分认可了。处理数据的 Pandas,绘制可视图的 Matplotlib,生成交互图的 Bokeh,实现...

    周萝卜
  • 自定义微信分享链接的图片标题描述等

    使用微信自定义分享,可设置个性化的分享图片、标题、描述等,从而使分享的内容更生动有趣,以获得更好的传播效果。

    许坏
  • Android 使用Kotlin自定义View的方法教程

    随着google宣布kotlin作为官方开发语言,在Android中使用kotlin的趋势也越来越明显,最近被kotlin的文章轰炸了,所以决定上手试一下,试过...

    砸漏
  • GitHub 新出的功能!可以帮我们自动写代码

    今天逛 GitHub 的时候发现了 GitHub 出了一个新的 Feature,叫做 GitHub Copilot,说可以帮我们自动写代码!

    崔庆才
  • 如何用30行代码,打造一个微信群聊助手

    这是一位Python爱好者的投稿,业务时间自己编码了一个黑科技,让场主分享给大家~

    养码场
  • 跟“老大爷”学习数据分析

    缜密的思维,耳观六路,眼看八方,不放过任何细节,这就是我们平常所说的数据敏感度或数据分析直觉,具备这样直觉的人不一定是一个好的侦探,但却不妨碍他成为一个好的数据...

    CDA数据分析师
  • 别在看不起女程序媛了,一个高颜值女程序媛的日常

    今天这篇文,意义特殊,是我的一个迷妹程序媛-祈澈姑娘写的,她发给我后,我看了通篇,感觉写的很真实,而且又是记录女程序媛的日常,比较少见,所以我很有兴趣,相信大家...

    一个程序员的成长
  • LeetCode笔记:Weekly Contest 249(补发)

    当前最优的算法实现耗时68ms,采用的方式是使用内置的extend函数,不过本质上没啥区别。

    codename_cys
  • 2018秋招面经-网易Java面试经历

    毕业季刚过,又到了秋季校招的时候,很多人已经准备了迎接秋季的校招,本文是EakonZhao的在本号授权发布的第二遍文章,分享亲自去网易面试的切身经历,希望本文能...

    开发者技术前线
  • 【干货】100家硅谷IT公司技术博客-吐槽篇(上)

    之前笔者写过一篇“这些硅谷创业的公司,哪一家惊艳了你”,算是处女作吧,还写过一篇介绍“美国大数据创业公司”。其实介绍硅谷公司有不同的角度,有参观访问的角度,有从...

    新智元
  • 程序员一周做了一个游戏,微软25亿美元买下 微软:赚大发了

    谈到游戏业的传奇人物,相信所有人的答案都会包括 Markus Persson(马库斯·佩尔松)。他创作的《我的世界(Minecraft)》自 2009 年发布以...

    一墨编程学习
  • 个案报道写作神器,目标NEJM和Lancet...

    如果你现在还宅在家,因为做不了实验而懊恼,那么除了之前说的发泄一下投稿新英格兰,你也可以回想下,之前临床轮转碰到的一些特殊病例,不管是少见的,还是发人深省的,你...

    百味科研芝士
  • 小程序项目之再填坑记

      是的,真的,你没有看错,我就是上次那个加薪的,但是现在问题来了,最近又搞了个小程序的需求,又填了不少坑,其中的辛酸就不说了,说多了都是泪??,此处省略三千字...

    苏南
  • Python爬虫自学系列(四)

    上一篇讲的是爬虫中的缓存,相对来说比较难一点,而且不是直接面向网页的,所以可能会比较无聊一点吧。

    看、未来
  • 高斯模糊效果的几种实现方案及性能对比

    现在越来越多的 app 在背景图中使用高斯模糊效果,如 yahoo 天气,效果做得很炫。 我们5.2个性资料卡的标签模版也需要使用高斯模糊,这里就用一个 dem...

    胡力
  • 个人的前端面经,回馈社会

    酷家乐(10-20k) 电话一面 三十五分钟 如何学习前端,看了什么书 谈实习经历 谈项目,问为什么用那么多插件,有没有想过自己写 position有几个属性 ...

    牛客网
  • 动动手——LeetCode题目14:最长公共前缀

    编写一个函数来查找字符串数组中的最长公共前缀。如果不存在公共前缀,返回空字符串 ""。

    二环宇少
  • 云币网及KYC【区块链生存训练】

    李笑来在7月5日发布了Press.One即将 ICO 的消息,大批小白开始在云币网注册开户,我的“区块链生存训练”饭团也在一夜之间加入了40多人。有位新人在饭团...

    申龙斌
  • 糟糕了!这次新版微信,要干死所有小游戏了!

    (微信官微升级内容截图) 今天上午,大家肯定都收到了微信官微通知,没错!微信爸爸又升级了,这次升级内容不多,主要有三点: 01.新增小程序任务栏功能(界面变了,...

    企鹅号小编

扫码关注云+社区

领取腾讯云代金券