《面试季》高频面试题-单例模式的七种写法

80 阅读9分钟

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情


  • 💂 个人网站: IT学习日记
  • 🤟 版权: 本文由【IT学习日记】原创、需要转载请联系博主
  • 💬 如果文章对你有帮助、欢迎关注、点赞、收藏(一键三连)和订阅专栏哦

前言

  • 大家好,这里是IT学习日记,相信大家对今年IT的行情应该也有所了解了,从大厂到小厂,各种裁员消息。公司裁员我们无法决定,我们能做的就是不断提升自己,提前准备。

  • 本系列文章主要分享了之前博主真实面试中遇到的一些问题,希望能够帮助准备就业或者跳槽的朋友。

写在前面

   单例模式(Singleton Pattern)是 Java 23种设计模式中最简单的设计模式之一,但是也是面试中出现最频繁的设计模式之一,常见实现方法有:"饿汉式"、"懒汉式",但实际上,它总共有7种写法,要搞懂单例模式,首先要知道它有什么特点,它的特点如下:

   1. 一个类只能有一个实例对象

   2. 类的构造方法是private修饰

   3. 类需要提供获取唯一实例的方法

(一): 饿汉式

   特点: 可以理解成已经饿到极致,上来就"吃"(创建),也就是类加载的时候就创建实例。

   优点: 简单、线程安全

   缺点: 类加载的时候就创建,如果实例没有被使用过,就造成内存浪费

// final修饰,类不能被继承
public final class SingletonTest {
	// 设置为静态属性,类加载时进行创建对象
    private static final SingletonTest INSTANCE = new SingletonTest();
    // 构造方法私有
    private SingletonTest() {

    }
    // 提高方法返回唯一实例对象
    public synchronized static SingletonTest hungryTypeGetInstance() {
        return INSTANCE;
    }
 }

(二): 懒汉式

   特点: 很懒,只有需要使用的时候才会去创建实例

   优点: 只有使用到的时候才会去创建,节省内存

   缺点: 多线程环境下会存在多个实例的情况,不能保证对象的唯一性。

// final修饰,类不能被继承
public final class SingletonTest {
	// 唯一实例对象
    private static SingletonTest singleItem;
    // 构造方法私有
    private SingletonTest() {

    }
    public static SingletonTest lazyTypeGetInstance() {
          if(null == singleItem) {
               singleItem = new SingletonTest();
           }
        return singleItem;
    }
 }

(三): 懒汉式 + 同步锁

   特点: 很懒,只有需要使用的时候才会去创建实例

   优点: 只有使用到的时候才会去创建,节省内存

   缺点: 同步锁保证了对象的唯一性,但是效率会下降

// final修饰,类不能被继承
public final class SingletonTest {
	// 唯一实例对象
    private static SingletonTest singleItem;
    // 构造方法私有
    private SingletonTest() {

    }
    // 返回对象方法
    public static SingletonTest lazyTypeGetInstance() {
      synchronized (SingletonTest.class) {
          if(null == singleItem) {
              singleItem = new SingletonTest();
          }
      }
      return singleItem;
  }
 }

(四):双重锁检测(Double Check)方式

   特点: 双重判断,使用时才创建对象

   优点: 只有使用到的时候才会去创建,节省内存

   缺点: 多线程环境下会存在可能会出现异常,可以使用第五种创建方式解决此问题

   疑问一: 为什么要使用两层判断?

  • 答: 如果只使用单层判断,去除最内层的判断,此时如果两个A,B线程同时判断实例为空都进入准备抢锁,A代码先获取得到锁,创建完实例后释放,此时B线程获得锁,因为没有最内层的判断,
  • 则会再创建一个实例,这样就违反了单例模式只能存在一个实例的规范。

   疑问二: 为什么说这种方式多线程环境下会存在可能会出现异常?

答: 因为此处设计到对象创建的过程,但是对象创建并非原子性操作,它主要分为以下四步:
1、给对象分配内存空间,并将对象的实例变量设置为对应的默认值
2、如果实例变量在声明时被显式的初始化则将初始化值赋给实例变量
3、调用构造函数进行初始化
4、返回对象的引用(将变量指向对象的内存地址)

在类创建的时候,cpu为了优化程序,可能会进行指令重排序,此时,有可能将步骤3和步骤4的颠倒过来,此时就会出现以下场景:

  如果线程 1 获取到锁进入创建对象实例,这个时候发生了指令重排序。当线程执行第4 执行到3步的时候

  线程 2 刚好进入,由于此时对象已经不为 Null,所以线程 2 可以自由访问该对象。然后该对象还未初始化,所以线程 2 访问时将会发生异常。

// final修饰,类不能被继承
public final class SingletonTest {
	// 唯一实例对象
    private static SingletonTest singleItem;
    // 构造方法私有
    private SingletonTest() {

    }
    // 返回对象方法
    public static SingletonTest doubleCheckTypeGetInstance() {
    if (null == singleItem) {
        synchronized (SingletonTest.class) {
            if(null == singleItem) {
                singleItem = new SingletonTest();
            }
        }
    }
    return singleItem;
	}
}

(五):双重锁检测(Double Check Locking)方式

   特点: 双重判断,使用时才创建对象

   优点: 只有使用到的时候才会去创建,节省内存,锁的范围缩小,提高效率

   疑问一: 为什么单例对象属性使用volatile修饰,它能解决第四种创建方式的问题?

答: 可以,volatile是一种内存屏障,被volatile修饰的变量在编译成字节码文件时会多个lock指令,该指令在执行过程中会生成相应的内存屏障,以此来解决可见性跟重排序的问题,它的作用如下:

1、禁止指令重排序
2、保证可见性(每次读到的值都是内存中最新的值)

被volatile修饰的变量,就不会出现创建对象时步骤3(调用构造函数进行初始化)和步骤四(返回对象的引用)的颠倒,从而解决了返回的对象可能没有进行初始化,在使用时出错的问题。

   小优化提示(面试回答到这点,绝对的一个加分项): 可以使用局部变量来进行双重锁的优化,由于 volatile变量创建对象时需要禁止指令重排序,这就需要一些额外的操作,可能会影响到一些性能,此处可以参考下spring框架中的双重锁代码,具体如下图: 可以定义一个局部变量来存储创建后的对象,然后再将唯一变量实例指向这个局部变量,这样就可以减少volatile进行指令重排序带来的影响。

spring双重锁源码

// final修饰,类不能被继承
public final class SingletonTest {
	// 唯一实例对象(使用volatile修饰属性,防止指令重排序)
    private static volatile SingletonTest singleItem;
    // 构造方法私有
    private SingletonTest() {

    }
    // 返回对象方法
    public static SingletonTest doubleCheckTypeGetInstance() {
    if (null == singleItem) {
        synchronized (SingletonTest.class) {
            if(null == singleItem) {
                singleItem = new SingletonTest();
            }
        }
    }
    return singleItem;
	}
}

(六):内部内部类类方式,《Java并发编程实战》中推荐使用这种方式来代替DCL(双重锁检测)的方式。

   特点: 加载外部类的时候不会进行创建

   优点: 第一次调用返回实例对象的时候才会创建节省内存,不需要加锁,提高效率

// final修饰,类不能被继承
public final class SingletonTest {
    // 构造方法私有
    private SingletonTest() {

    }
    // 静态内部类
	public static class SingletonHolder{
        private static final SingletonTest instance = new SingletonTest();
    }
    // 获取单例实例对象
    public static SingletonTest innerClassTypeGetInstance() {
        return SingletonHolder.instance;
    }
}

(七):枚举方式

   特点: 默认枚举就是单例的,线程安全

   优点: 简单,不会存在反序列的问题

	// 获取时直接通过: 枚举类名.属性名称即可如:SingleEnum.SINGLETON
    public enum SingleEnum{
        SINGLETON;
    }

(八):延伸知识(面试回答,也是一个加分项)

  相信大家在看到第七种枚举方式创建的时候,在优点中我有提到不会存在反序列问题,大家可能会存在疑问,下面我们直接通过代码来验证是不是真的存在问题!!

  一: 单例模式(此处以“饿汉式“”为例)

// 类实现序列化
public final class SingleSerialized implements Serializable {
    private static final SingleSerialized instance = new SingleSerialized();

    private SingleSerialized() {
    }
	// 返回实例对象
    public static SingleSerialized getInstance() {
        return instance;
    }
}

  二: 进行序列化前后对象比较

public class ItemTest {
    public static void main(String[] args) {
        try {
            // 获取得到通过"饿汉式"返回的单例对象
            SingleSerialized instance = SingleSerialized.getInstance();
            System.out.println("反序列化之前单例对象的地址:"+instance);
            // 通过序列化,反序列,看是否对象还是同一个
            FileOutputStream out = new FileOutputStream(new File("demo.db"));
            ObjectOutputStream outputStream = new ObjectOutputStream(out);

            InputStream in = new FileInputStream(new File("demo.db"));
            ObjectInputStream inputStream = new ObjectInputStream(in);

            outputStream.writeObject(instance);

            // 反序列的对象
            SingleSerialized serialized = (SingleSerialized) inputStream.readObject();
            System.out.println("反序列化之后得到单例对象的地址:"+serialized);

            System.out.println("判断反序列后得到的对象是否跟序列化之前得到的单例对象是否是同一个:");
            System.out.println(instance == serialized);
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

  三: 比较结果

比较结果

  四: 疑问解答

  通过上面的案例可以知道,如果使用前六种方式实现单例模式,实际上还是不能完全保证单例模式中的:"一个类只能存在一个实例对象"的要求,因为当进行反序列的时候,会新创对象(具体的需要看到ObjectInputStream的readObject源码,此篇幅就不进行具体的深入讲解,如果想了解,后面会具体开一个专门的文字进行讲解),但是,序列化提供了readResolve方法,这个方法可以让开发人员控制对象的反序列化,所以要保证前六种实现单例模式的方法都保障一个类只能存在一个实例的话,就需要在单例类中添加以下方法:

	// 返回的值是单例类中的唯一实例
    private Object readResolve() throws ObjectStreamException{
        return instance;
    }

  五: 添加readResolve方法后的执行结果
执行结果


小结

   不积跬步,无以至千里;不积小流,无以成江海。今天播种努力的种子,总会有一天发芽!

   欢迎大家关注,如果觉得文章对你有帮助,不要忘记一键三连哦,你的支持是我创作更加优质文章的动力,希望大家都能够早日拿到心仪的Offer,有任何面试问题可以私信我,欢迎大家投稿面试题目哦!


  该篇文章已经被收录在个人开源专栏:《IT知识小屋》中。专栏以小白视角切入,讲解通俗易懂,内容包含IT各方向知识(JAVA基础、进阶、面试真题、算法、面试采坑经验、996公司等),是IT知识学习+面试首选IT小屋。