我用 DCL 写出了单例模式,结果阿里面试官不满意!

简介: 我用 DCL 写出了单例模式,结果阿里面试官不满意!

前言



单例模式可以说是设计模式中最简单和最基础的一种设计模式了,哪怕是一个初级开发,在被问到使用过哪些设计模式的时候,估计多数会说单例模式。但是你认为这么基本的”单例模式“真的就那么简单吗?或许你会反问:「一个简单的单例模式该是咋样的?」哈哈,话不多说,让我们一起拭目以待,坚持看完,相信你一定会有收获!


饿汉式



饿汉式是最常见的也是最不需要考虑太多的单例模式,因为他不存在线程安全问题,饿汉式也就是在类被加载的时候就创建实例对象。饿汉式的写法如下:


public class SingletonHungry {
    private static SingletonHungry instance = new SingletonHungry();
    private SingletonHungry() {
    }
    private static SingletonHungry getInstance() {
        return instance;
    }
}
复制代码


  • 测试代码如下:


class A {
    public static void main(String[] args) {
        IntStream.rangeClosed(1, 5)
                .forEach(i -> {
                    new Thread(
                            () -> {
                                SingletonHungry instance = SingletonHungry.getInstance();
                                System.out.println("instance = " + instance);
                            }
                    ).start();
                });
    }
}
复制代码


网络异常,图片无法展示
|


优点:线程安全,不需要关心并发问题,写法也是最简单的。


缺点:在类被加载的时候对象就会被创建,也就是说不管你是不是用到该对象,此对象都会被创建,浪费内存空间


懒汉式



以下是最基本的饿汉式的写法,在单线程情况下,这种方式是非常完美的,但是我们实际程序执行基本都不可能是单线程的,所以这种写法必定会存在线程安全问题


public class SingletonLazy {
    private SingletonLazy() {
    }
    private static SingletonLazy instance = null;
    public static SingletonLazy getInstance() {
        if (null == instance) {
            return new SingletonLazy();
        }
        return instance;
    }
}
复制代码


演示多线程执行


class B {
    public static void main(String[] args) {
        IntStream.rangeClosed(1, 5)
                .forEach(i -> {
                    new Thread(
                            () -> {
                                SingletonLazy instance = SingletonLazy.getInstance();
                                System.out.println("instance = " + instance);
                            }
                    ).start();
                });
    }
}
复制代码


网络异常,图片无法展示
|


结果很显然,获取的实例对象不是单例的。也就是说这种写法不是线程安全的,也就不能在多线程情况下使用


DCL(双重检查锁式)



DCL 即 Double Check Lock 就是在创建实例的时候进行双重检查,首先检查实例对象是否为空,如果不为空将当前类上锁,然后再判断一次该实例是否为空,如果仍然为空就创建该是实例;代码如下:


public class SingleTonDcl {
    private SingleTonDcl() {
    }
    private static SingleTonDcl instance = null;
    public static SingleTonDcl getInstance() {
        if (null == instance) {
            synchronized (SingleTonDcl.class) {
                if (null == instance) {
                    instance = new SingleTonDcl();
                }
            }
        }
        return instance;
    }
}
复制代码


测试代码如下:


class C {
    public static void main(String[] args) {
        IntStream.rangeClosed(1, 5)
                .forEach(i -> {
                    new Thread(
                            () -> {
                                SingleTonDcl instance = SingleTonDcl.getInstance();
                                System.out.println("instance = " + instance);
                            }
                    ).start();
                });
    }
}
复制代码


网络异常,图片无法展示
|


相信大多数初学者在接触到这种写法的时候已经感觉是「高大上」了,首先是判断实例对象是否为空,如果为空那么就将该对象的 Class 作为锁,这样保证同一时刻只能有一个线程进行访问,然后再次判断实例对象是否为空,最后才会真正的去初始化创建该实例对象。一切看起来似乎已经没有破绽,但是当你学过JVM后你可能就会一眼看出猫腻了。没错,问题就在 instance = new SingleTonDcl(); 因为这不是一个原子的操作,这句话的执行是在 JVM 层面分以下三步:


1.给 SingleTonDcl 分配内存空间 2.初始化 SingleTonDcl 实例 3.将 instance 对象指向分配的内存空间( instance 为 null 了)


正常情况下上面三步是顺序执行的,但是实际上JVM可能会「自作多情」得将我们的代码进行优化,可能执行的顺序是1、3、2,如下代码所示


public static SingleTonDcl getInstance() {
    if (null == instance) {
        synchronized (SingleTonDcl.class) {
            if (null == instance) {
                1. 给 SingleTonDcl 分配内存空间
                3.将 instance 对象指向分配的内存空间( instance 不为 null 了)
                2. 初始化 SingleTonDcl 实例
            }
        }
    }
    return instance;
}
复制代码


假设现在有两个线程 t1, t2


  1. 如果 t1 执行到以上步骤 3 被挂起
  2. 然后 t2 进入了 getInstance 方法,由于 t1 执行了步骤  3,此时的 instance 已经不为空了,所以 if (null == instance) 这个条件不为空,直接返回 instance, 但由于 t1 还未执行步骤 2,导致此时的 instance 实际上是个半成品,会导致不可预知的风险!


该怎么解决呢,既然问题出在指令有可能重排序上,不让它重排序不就行了,volatile 不就是干这事的吗,我们可以在 instance 变量前面加上一个 volatile 修饰符


画外音:volatile 的作用
1.保证的对象内存可见性
2.防止指令重排序
复制代码


优化后的代码如下


public class SingleTonDcl {
    private SingleTonDcl() {
    }
    //在对象前面添加 volatile 关键字即可
    volatile private static SingleTonDcl instance = null;
    public static SingleTonDcl getInstance() {
        if (null == instance) {
            synchronized (SingleTonDcl.class) {
                if (null == instance) {
                    instance = new SingleTonDcl();
                }
            }
        }
        return instance;
    }
}
复制代码


到这里似乎问题已经解决了,双重锁机制 + volatile 实际上确实基本上解决了线程安全问题,保证了“真正”的单例。但真的是这样的吗?继续往下看


静态内部类



先看代码


public class SingleTonStaticInnerClass {
    private SingleTonStaticInnerClass() {
    }
    private static class HandlerInstance {
        private static SingleTonStaticInnerClass instance = new SingleTonStaticInnerClass();
    }
    public static SingleTonStaticInnerClass getInstance() {
        return HandlerInstance.instance;
    }
}
复制代码


  • 测试代码如下:


class D {
    public static void main(String[] args) {
        IntStream.rangeClosed(1, 5)
                .forEach(i->{
                    new Thread(()->{
                        SingleTonStaticInnerClass instance = SingleTonStaticInnerClass.getInstance();
                        System.out.println("instance = " + instance);
                    }).start();
                });
    }
}
复制代码


网络异常,图片无法展示
|


静态内部类的特点:


这种写法使用 JVM 类加载机制保证了线程安全问题;由于 SingleTonStaticInnerClass 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本;


但是,它依旧不是完美的。


不安全的单例



上面实现单例都不是完美的,主要有两个原因


1. 反射攻击


首先要提到 java 中让人又爱又恨的反射机制, 闲言少叙,我们直接边上代码边说明,这里就以 DCL 举例(为什么选择 DCL 因为很多人觉得 DCL 写法是最高大上的....这里就开始去”打他们的脸“)


将上面的 DCl 的测试代码修改如下:


class C {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class<SingleTonDcl> singleTonDclClass = SingleTonDcl.class;
        //获取类的构造器
        Constructor<SingleTonDcl> constructor = singleTonDclClass.getDeclaredConstructor();
        //把构造器私有权限放开
        constructor.setAccessible(true);
        //反射创建实例   注意反射创建要放在前面,才会攻击成功,因为如果反射攻击在后面,先使用正常的方式创建实例的话,在构造器中判断是可以防止反射攻击、抛出异常的,
        //因为先使用正常的方式已经创建了实例,会进入if
        SingleTonDcl instance = constructor.newInstance();
        //正常的获取实例方式   正常的方式放在反射创建实例后面,这样当反射创建成功后,单例对象中的引用其实还是空的,反射攻击才能成功
        SingleTonDcl instance1 = SingleTonDcl.getInstance();
        System.out.println("instance1 = " + instance1);
        System.out.println("instance = " + instance);
    }
}
复制代码


网络异常,图片无法展示
|


居然是两个对象!内心是不是异常平静?果然和你想的不一样?其他的方式基本类似,都可以通过反射破坏单例。


2. 序列化攻击


我们以「饿汉式单例」为例来演示一下序列化和反序列化攻击代码,首先给饿汉式单例对应的类添加实现 Serializable 接口的代码,


public class SingletonHungry implements Serializable {
    private static SingletonHungry instance = new SingletonHungry();
    private SingletonHungry() {
    }
    private static SingletonHungry getInstance() {
        return instance;
    }
}
复制代码


然后看看如何使用序列化和反序列化进行攻击


SingletonHungry instance = SingletonHungry.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file")));
// 序列化【写】操作
oos.writeObject(instance);
File file = new File("singleton_file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file))
// 反序列化【读】操作
SingletonHungry newInstance = (SingletonHungry) ois.readObject();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
复制代码


来看下结果


网络异常,图片无法展示
|


果然出现了两个不同的对象!这种反序列化攻击其实解决方式也简单,重写反序列化时要调用的 readObject 方法即可


private Object readResolve(){
    return instance;
}
复制代码


这样在反序列化时候永远只读取 instance 这一个实例,保证了单例的实现。


真正安全的单例: 枚举方式



public enum SingleTonEnum {
    /**
     * 实例对象
     */
    INSTANCE;
    public void doSomething() {
        System.out.println("doSomething");
    }
}
复制代码


调用方法


public class Main {
    public static void main(String[] args) {
        SingleTonEnum.INSTANCE.doSomething();
    }
}
复制代码


枚举模式实现的单例才是真正的单例模式,是完美的实现方式


有人可能会提出疑问:枚举是不是也能通过反射来破坏其单例实现呢?


试试呗,修改枚举的测试类


class E{
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class<SingleTonEnum> singleTonEnumClass = SingleTonEnum.class;
        Constructor<SingleTonEnum> declaredConstructor = singleTonEnumClass.getDeclaredConstructor();
        declaredConstructor.setAccessible(true);
        SingleTonEnum singleTonEnum = declaredConstructor.newInstance();
        SingleTonEnum instance = SingleTonEnum.INSTANCE;
        System.out.println("instance = " + instance);
        System.out.println("singleTonEnum = " + singleTonEnum);
    }
}
复制代码


网络异常,图片无法展示
|


没有无参构造?我们使用 javap 工具来查下字节码看看有啥玄机


网络异常,图片无法展示
|


好家伙,发现一个有参构造器 String Int ,那就试试呗


//获取构造器的时候修改成这样子
Constructor<SingleTonEnum> declaredConstructor = singleTonEnumClass.getDeclaredConstructor(String.class,int.class);
复制代码


网络异常,图片无法展示
|


好家伙,抛出了异常,异常信息写着: 「Cannot reflectively create enum objects」

源码之下无秘密,我们来看看 newInstance() 到底做了什么?为啥用反射创建枚举会抛出这么个异常?


网络异常,图片无法展示
|


真相大白!如果是枚举,不允许通过反射来创建,这才是使用 enum 创建单例才可以说是真正安全的原因!


结束语



以上就是一些关于单例模式的知识点汇总,你还真不要小看这个小小的单例,面试的时候多数候选人写不对这么一个简单的单例,写对的多数也仅止于 DCL,但再问是否有啥不安全,如何用 enum 写出安全的单例时,几乎没有人能答出来!有人说能写出 DCL 就行了,何必这么钻牛角尖?但我想说的是正是这种钻牛角尖的精神能让你逐步积累技术深度,成为专家,对技术有一探究竟的执著,何愁成不了专家?

相关文章
|
6天前
|
消息中间件 存储 canal
阿里面试:canal+MQ,会有乱序的问题吗?
本文详细探讨了在阿里面试中常见的问题——“canal+MQ,会有乱序的问题吗?”以及如何保证RocketMQ消息有序。文章首先介绍了消息有序的基本概念,包括全局有序和局部有序,并分析了RocketMQ中实现消息有序的方法。接着,针对canal+MQ的场景,讨论了如何通过配置`canal.mq.partitionsNum`和`canal.mq.partitionHash`来保证数据同步的有序性。最后,提供了多个与MQ相关的面试题及解决方案,帮助读者更好地准备面试,提升技术水平。
阿里面试:canal+MQ,会有乱序的问题吗?
|
2天前
|
消息中间件 架构师 Java
阿里面试:秒杀的分布式事务, 是如何设计的?
在40岁老架构师尼恩的读者交流群中,近期有小伙伴在面试阿里、滴滴、极兔等一线互联网企业时,遇到了许多关于分布式事务的重要面试题。为了帮助大家更好地应对这些面试题,尼恩进行了系统化的梳理,详细介绍了Seata和RocketMQ事务消息的结合,以及如何实现强弱结合型事务。文章还提供了分布式事务的标准面试答案,并推荐了《尼恩Java面试宝典PDF》等资源,帮助大家在面试中脱颖而出。
|
5天前
|
SQL 关系型数据库 MySQL
阿里面试:MYSQL 事务ACID,底层原理是什么? 具体是如何实现的?
尼恩,一位40岁的资深架构师,通过其丰富的经验和深厚的技術功底,为众多读者提供了宝贵的面试指导和技术分享。在他的读者交流群中,许多小伙伴获得了来自一线互联网企业的面试机会,并成功应对了诸如事务ACID特性实现、MVCC等相关面试题。尼恩特别整理了这些常见面试题的系统化解答,形成了《MVCC 学习圣经:一次穿透MYSQL MVCC》PDF文档,旨在帮助大家在面试中展示出扎实的技术功底,提高面试成功率。此外,他还编写了《尼恩Java面试宝典》等资料,涵盖了大量面试题和答案,帮助读者全面提升技术面试的表现。这些资料不仅内容详实,而且持续更新,是求职者备战技术面试的宝贵资源。
阿里面试:MYSQL 事务ACID,底层原理是什么? 具体是如何实现的?
|
5天前
|
Kubernetes 架构师 算法
阿里面试:全国14亿人,统计出重名最多的前100个姓名
文章介绍了如何解决“从全国14亿人的数据中统计出重名人数最多的前100位姓名”的面试题,详细分析了多种数据结构的优缺点,最终推荐使用前缀树(Trie)+小顶堆的组合。文章还提供了具体的Java代码实现,并讨论了在内存受限情况下的解决方案,强调了TOP N问题的典型解题思路。最后,鼓励读者通过系统化学习《尼恩Java面试宝典》提升面试技巧。
阿里面试:全国14亿人,统计出重名最多的前100个姓名
|
11天前
|
存储 缓存 NoSQL
阿里面试题:缓存的一些常见的坑,你遇到过哪些,怎么解决的?
阿里面试题:缓存的一些常见的坑,你遇到过哪些,怎么解决的?
|
6天前
|
存储 Kubernetes 架构师
阿里面试:JVM 锁内存 是怎么变化的? JVM 锁的膨胀过程 ?
尼恩,一位经验丰富的40岁老架构师,通过其读者交流群分享了一系列关于JVM锁的深度解析,包括偏向锁、轻量级锁、自旋锁和重量级锁的概念、内存结构变化及锁膨胀流程。这些内容不仅帮助群内的小伙伴们顺利通过了多家一线互联网企业的面试,还整理成了《尼恩Java面试宝典》等技术资料,助力更多开发者提升技术水平,实现职业逆袭。尼恩强调,掌握这些核心知识点不仅能提高面试成功率,还能在实际工作中更好地应对高并发场景下的性能优化问题。
|
1月前
|
缓存 监控 NoSQL
阿里面试让聊一聊Redis 的内存淘汰(驱逐)策略
大家好,我是 V 哥。粉丝小 A 面试阿里时被问到 Redis 的内存淘汰策略问题,特此整理了一份详细笔记供参考。Redis 的内存淘汰策略决定了在内存达到上限时如何移除数据。希望这份笔记对你有所帮助!欢迎关注“威哥爱编程”,一起学习与成长。
|
3月前
|
SQL 安全 Java
Android经典面试题之Kotlin中object关键字实现的是什么类型的单例模式?原理是什么?怎么实现双重检验锁单例模式?
Kotlin 单例模式概览 在 Kotlin 中,`object` 关键字轻松实现单例,提供线程安全的“饿汉式”单例。例如: 要延迟初始化,可使用 `companion object` 和 `lazy` 委托: 对于参数化的线程安全单例,结合 `@Volatile` 和 `synchronized`
48 6
|
3月前
|
设计模式 安全 Java
Java面试题:设计模式如单例模式、工厂模式、观察者模式等在多线程环境下线程安全问题,Java内存模型定义了线程如何与内存交互,包括原子性、可见性、有序性,并发框架提供了更高层次的并发任务处理能力
Java面试题:设计模式如单例模式、工厂模式、观察者模式等在多线程环境下线程安全问题,Java内存模型定义了线程如何与内存交互,包括原子性、可见性、有序性,并发框架提供了更高层次的并发任务处理能力
68 1
|
2月前
|
JavaScript
给原始数据类型加属性和方法为什么不会报错?包装类——阿里面试题
给原始数据类型加属性和方法为什么不会报错?包装类——阿里面试题