文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

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

2024-12-03 11:44

关注

前言

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

饿汉式

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

  1. public class SingletonHungry { 
  2.     private static SingletonHungry instance = new SingletonHungry(); 
  3.  
  4.     private SingletonHungry() { 
  5.     } 
  6.  
  7.     private static SingletonHungry getInstance() { 
  8.         return instance; 
  9.     } 
  1. class A { 
  2.     public static void main(String[] args) { 
  3.         IntStream.rangeClosed(1, 5) 
  4.                 .forEach(i -> { 
  5.                     new Thread( 
  6.                             () -> { 
  7.                                 SingletonHungry instance = SingletonHungry.getInstance(); 
  8.                                 System.out.println("instance = " + instance); 
  9.                             } 
  10.                     ).start(); 
  11.                 }); 
  12.     } 

结果

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

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

懒汉式

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

  1. public class SingletonLazy { 
  2.     private SingletonLazy() { 
  3.     } 
  4.  
  5.     private static SingletonLazy instance = null
  6.  
  7.     public static SingletonLazy getInstance() { 
  8.         if (null == instance) { 
  9.             return new SingletonLazy(); 
  10.         } 
  11.         return instance; 
  12.  
  13.     } 

演示多线程执行

  1. class B { 
  2.     public static void main(String[] args) { 
  3.         IntStream.rangeClosed(1, 5) 
  4.                 .forEach(i -> { 
  5.                     new Thread( 
  6.                             () -> { 
  7.                                 SingletonLazy instance = SingletonLazy.getInstance(); 
  8.                                 System.out.println("instance = " + instance); 
  9.                             } 
  10.                     ).start(); 
  11.                 }); 
  12.     } 

结果

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

DCL(双重检查锁式)

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

  1. public class SingleTonDcl { 
  2.     private SingleTonDcl() { 
  3.     } 
  4.  
  5.     private static SingleTonDcl instance = null
  6.  
  7.     public static SingleTonDcl getInstance() { 
  8.         if (null == instance) { 
  9.             synchronized (SingleTonDcl.class) { 
  10.                 if (null == instance) { 
  11.                     instance = new SingleTonDcl(); 
  12.                 } 
  13.             } 
  14.         } 
  15.         return instance; 
  16.     } 

测试代码如下:

  1. class C { 
  2.     public static void main(String[] args) { 
  3.         IntStream.rangeClosed(1, 5) 
  4.                 .forEach(i -> { 
  5.                     new Thread( 
  6.                             () -> { 
  7.                                 SingleTonDcl instance = SingleTonDcl.getInstance(); 
  8.                                 System.out.println("instance = " + instance); 
  9.                             } 
  10.                     ).start(); 
  11.                 }); 
  12.     } 

结果

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

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

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

  1. public static SingleTonDcl getInstance() { 
  2.     if (null == instance) { 
  3.         synchronized (SingleTonDcl.class) { 
  4.             if (null == instance) { 
  5.                 1. 给 SingleTonDcl 分配内存空间 
  6.                 3.将 instance 对象指向分配的内存空间( instance 不为 null 了) 
  7.                 2. 初始化 SingleTonDcl 实例 
  8.             } 
  9.         } 
  10.     } 
  11.     return instance; 

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

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

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

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

优化后的代码如下

  1. public class SingleTonDcl { 
  2.     private SingleTonDcl() { 
  3.     } 
  4.  
  5.     //在对象前面添加 volatile 关键字即可 
  6.     volatile private static SingleTonDcl instance = null
  7.  
  8.     public static SingleTonDcl getInstance() { 
  9.         if (null == instance) { 
  10.             synchronized (SingleTonDcl.class) { 
  11.                 if (null == instance) { 
  12.                     instance = new SingleTonDcl(); 
  13.                 } 
  14.             } 
  15.         } 
  16.         return instance; 
  17.     } 

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

静态内部类

先看代码

  1. public class SingleTonStaticInnerClass { 
  2.     private SingleTonStaticInnerClass() { 
  3.  
  4.     } 
  5.  
  6.     private static class HandlerInstance { 
  7.         private static SingleTonStaticInnerClass instance = new SingleTonStaticInnerClass(); 
  8.     } 
  9.  
  10.     public static SingleTonStaticInnerClass getInstance() { 
  11.         return HandlerInstance.instance; 
  12.     } 
  1. class D { 
  2.     public static void main(String[] args) { 
  3.         IntStream.rangeClosed(1, 5) 
  4.                 .forEach(i->{ 
  5.                     new Thread(()->{ 
  6.                         SingleTonStaticInnerClass instance = SingleTonStaticInnerClass.getInstance(); 
  7.                         System.out.println("instance = " + instance); 
  8.                     }).start(); 
  9.                 }); 
  10.     } 

静态内部类的特点:

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

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

不安全的单例

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

1. 反射攻击

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

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

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

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

2. 序列化攻击

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

  1. public class SingletonHungry implements Serializable { 
  2.     private static SingletonHungry instance = new SingletonHungry(); 
  3.  
  4.     private SingletonHungry() { 
  5.     } 
  6.  
  7.     private static SingletonHungry getInstance() { 
  8.         return instance; 
  9.     } 

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

  1. SingletonHungry instance = SingletonHungry.getInstance(); 
  2. ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"))); 
  3. // 序列化【写】操作 
  4. oos.writeObject(instance); 
  5. File file = new File("singleton_file"); 
  6. ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file)) 
  7. // 反序列化【读】操作 
  8. SingletonHungry newInstance = (SingletonHungry) ois.readObject(); 
  9. System.out.println(instance); 
  10. System.out.println(newInstance); 
  11. System.out.println(instance == newInstance); 

来看下结果图片

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

  1. private Object readResolve(){ 
  2.     return instance; 

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

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

  1. public enum SingleTonEnum { 
  2.      
  3.     INSTANCE; 
  4.     public void doSomething() { 
  5.         System.out.println("doSomething"); 
  6.     } 

调用方法

  1. public class Main { 
  2.     public static void main(String[] args) { 
  3.         SingleTonEnum.INSTANCE.doSomething(); 
  4.     } 

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

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

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

  1. class E{ 
  2.     public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { 
  3.         Class singleTonEnumClass = SingleTonEnum.class; 
  4.         Constructor declaredConstructor = singleTonEnumClass.getDeclaredConstructor(); 
  5.         declaredConstructor.setAccessible(true); 
  6.         SingleTonEnum singleTonEnum = declaredConstructor.newInstance(); 
  7.         SingleTonEnum instance = SingleTonEnum.INSTANCE; 
  8.         System.out.println("instance = " + instance); 
  9.         System.out.println("singleTonEnum = " + singleTonEnum); 
  10.     } 

结果

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

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

  1. //获取构造器的时候修改成这样子 
  2. Constructor declaredConstructor = singleTonEnumClass.getDeclaredConstructor(String.class,int.class); 

结果

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

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

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

结束语

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

本文转载自微信公众号「码海」,可以通过以下二维码关注。转载本文请联系码海公众号。

 

来源:码海内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯