程序员要透彻理解单例模式

程序IT圈

共 6961字,需浏览 14分钟

 ·

2020-12-16 21:12

前言

本系列文章带你温习常见的设计模式。主要内容有:

  • 该模式的介绍,包括:

    • 引子、意图(大白话解释)

    • 类图、时序图(理论规范)

  • 该模式的代码示例:熟悉该模式的代码长什么样子

  • 该模式的优缺点:模式不是万金油,不可以滥用模式

  • 该模式的实际使用案例:了解它在哪些重要的源码中被使用


创建型——单例模式

引子

《HEAD FIRST设计模式》中“单例模式”又称为“单件模式”

对于系统中的某些类来说,只有一个实例很重要。比如大家熟悉的Spring框架中,Controller和Service都默认是单例模式。

如果用生活中的例子举例,一个系统中可以存在多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。

如何保证一个类只有一个实例并且这个实例易于被访问呢?

答:定义一个全局变量可以确保对象随时都可以被访问,但不能防止我们实例化多个对象。一个更好的解决办法是让类自身负责保存它的唯一实例。这个类可以保证没有其他实例被创建,并且它可以提供一个访问该实例的方法。这就是单例模式的模式动机。

意图

确保一个类只有一个实例,并提供该实例的全局访问点。

单例模式的要点有三个:

  • 一是某个类只能有一个实例;

  • 二是它必须自行创建这个实例;

  • 三是它必须自行向整个系统提供这个实例。

使用一个私有构造函数、一个私有静态变量以及一个公有静态函数来实现。

私有构造函数保证了不能通过构造函数来创建对象实例,只能通过公有静态函数返回唯一的私有静态变量。

类图

如果看不懂UML类图,可以先粗略浏览下该图,想深入了解的话,可以继续谷歌,深入学习:

单例模式的类图:

时序图

时序图(Sequence Diagram)是显示对象之间交互的图,这些对象是按时间顺序排列的。时序图中显示的是参与交互的对象及其对象之间消息交互的顺序。

我们可以大致浏览下时序图,如果感兴趣的小伙伴可以去深究一下:

实现

单例模式有非常多的实现方式,这里我们从最差的实现方式逐渐过渡到优雅的实现方式(剑指offer的方式),包括:

  • 懒汉式-线程不安全

  • 饿汉式-线程安全

  • 懒汉式-线程安全

  • 懒汉式(延迟实例化)—— 线程安全/双重校验 (重要,牢记)

  • 静态内部类实现

  • 枚举实现 (重要,牢记)

每个方式也会详细解释下面试可能会问到的问题,方便小伙伴复习。

1. 懒汉式-线程不安全

以下实现中,私有静态变量 uniqueInstance 被延迟实例化,这样做的好处是,如果没有用到该类,那么就不会实例化 uniqueInstance,从而节约资源。

这个实现在多线程环境下是不安全的,如果多个线程能够同时进入 if (uniqueInstance == null) ,并且此时 uniqueInstance 为 null,那么会有多个线程执行 uniqueInstance = new Singleton(); 语句,这将导致实例化多次 uniqueInstance。

public class Singleton {

    private static Singleton uniqueInstance;

    private Singleton({
    }

    public static Singleton getUniqueInstance({
        if (uniqueInstance == null) {
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }
}

2. 饿汉式-线程安全

如此一来,只会实例化一次,作为静态变量

private static Singleton uniqueInstance = new Singleton();

3. 懒汉式(延迟实例化)—— 线程安全

只需要对 getUniqueInstance() 方法加锁,那么在一个时间点只能有一个线程能够进入该方法,从而避免了实例化多次 uniqueInstance。

但是当一个线程进入该方法之后,其它试图进入该方法的线程都必须等待,即使 uniqueInstance 已经被实例化了。这会让线程阻塞时间过长,因此该方法有性能问题,不推荐使用。

public static synchronized Singleton getUniqueInstance() {
    if (uniqueInstance == null) {
        uniqueInstance = new Singleton();
    }
    return uniqueInstance;
}

4. 懒汉式(延迟实例化)—— 线程安全/双重校验

一.私有化构造函数

二.声明静态单例对象

三.构造单例对象之前要加锁(lock一个静态的object对象)或者方法上加synchronized。

四.需要两次检测单例实例是否已经被构造,分别在锁之前和锁之后

使用lock(obj)

public class Singleton {  

    private Singleton({}                     //关键点0:构造函数是私有的
    private volatile static Singleton single;    //关键点1:声明单例对象是静态的
    private static object obj= new object();

    public static Singleton GetInstance()      //通过静态方法来构造对象
    
{                        
         if (single == null)                   //关键点2:判断单例对象是否已经被构造
         {                             
            lock(obj)                          //关键点3:加线程锁
            {
               if(single == null)              //关键点4:二次判断单例是否已经被构造
               {
                  single = new Singleton();  
                }
             }
         }    
        return single;  
    }  
}

使用synchronized (Singleton.class)

public class Singleton {

    private Singleton() {}
    private volatile static Singleton uniqueInstance;

    public static Singleton getUniqueInstance() {
        if (uniqueInstance == null) {
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}
面试时可能的提问

0.为何要检测两次?

答:如果两个线程同时执行 if 语句,那么两个线程就会同时进入 if 语句块内。虽然在if语句块内有加锁操作,但是两个线程都会执行 uniqueInstance = new Singleton(); 这条语句,只是先后的问题,也就是说会进行两次实例化,从而产生了两个实例。因此必须使用双重校验锁,也就是需要使用两个 if 语句。

1.构造函数能否公有化?

答:不行,单例类的构造函数必须私有化,单例类不能被实例化,单例实例只能静态调用。

2.lock住的对象为什么要是object对象,可以是int吗?

答:不行,锁住的必须是个引用类型。如果锁值类型,每个不同的线程在声明的时候值类型变量的地址都不一样,那么上个线程锁住的东西下个线程进来会认为根本没锁。

3.uniqueInstance 采用 volatile 关键字修饰

uniqueInstance = new Singleton(); 这段代码其实是分为三步执行。

分配内存空间

初始化对象

将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,有可能执行顺序变为了 1-->3-->2

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton(){}
    public static Singleton getInstance(){
        if(uniqueInstance == null){
        // B线程检测到uniqueInstance不为空
            synchronized(Singleton.class){
                if(uniqueInstance == null){
                    uniqueInstance = new Singleton();
                    // A线程被指令重排了,刚好先赋值了;但还没执行完构造函数。
                }
            }
        }
        return uniqueInstance;// 后面B线程执行时将引发:对象尚未初始化错误。
    }
}

所以B线程检测到不为null后,直接出去调用该单例,而A还没有运行完构造函数,导致该单例还没创建完毕,B调用会报错!所以必须用volatile防止JVM重排指令

5. 静态内部类实现

当 Singleton 类加载时,静态内部类 SingletonHolder 没有被加载进内存。只有当调用 getUniqueInstance() 方法从而触发 SingletonHolder.INSTANCE 时 SingletonHolder 才会被加载,此时初始化 INSTANCE 实例。

这种方式不仅具有延迟初始化的好处,而且由虚拟机提供了对线程安全的支持。

public class Singleton {

    private Singleton() {}

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

    public static Singleton getUniqueInstance() {
        return SingletonHolder.INSTANCE;
    }
}

6. 枚举实现

这是单例模式的最佳实践,它实现简单,并且在面对复杂的序列化或者反射攻击的时候,能够防止实例化多次。

public enum Singleton {

    INSTANCE;

    private String objName;


    public String getObjName() {
        return objName;
    }


    public void setObjName(String objName) {
        this.objName = objName;
    }


    public static void main(String[] args) {

        // 单例测试
        Singleton firstSingleton = Singleton.INSTANCE;
        firstSingleton.setObjName("firstName");
        System.out.println(firstSingleton.getObjName());
        Singleton secondSingleton = Singleton.INSTANCE;
        secondSingleton.setObjName("secondName");
        System.out.println(firstSingleton.getObjName());
        System.out.println(secondSingleton.getObjName());

        // 反射获取实例测试
        try {
            Singleton[] enumConstants = Singleton.class.getEnumConstants();
            for (Singleton enumConstant : enumConstants) {
                System.out.println(enumConstant.getObjName());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
为什么枚举是单例模式的最好方式?

考虑以下单例模式的实现,该 Singleton 在每次序列化的时候都会创建一个新的实例,为了保证只创建一个实例,必须声明所有字段都是 transient,并且提供一个 readResolve() 方法。

public class Singleton implements Serializable {

    private static Singleton uniqueInstance;

    private Singleton() {
    }

    public static synchronized Singleton getUniqueInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }
}

如果不使用枚举来实现单例模式,会出现反射攻击,因为通过反射的setAccessible() 方法可以将私有构造函数的访问级别设置为 public,然后调用构造函数从而实例化对象。

枚举实现是由 JVM 保证只会实例化一次,因此不会出现上述的反射攻击。

从上面的讨论可以看出,解决序列化和反射攻击很麻烦,而枚举实现不会出现这两种问题,所以说枚举实现单例模式是最佳实践。

使用场景举例

  • Logger类,全局唯一,保证你能在每个类里调用为一个Logger输出日志

  • Spring:Spring里很多类都是单例的,也是你理解单例最合适的地方,比如Controller和Service类,默认都是单例的。

  • 数据库连接池对象:你从代码的任何地方都需要拿到连接池里的资源。

参考

  • http://blog.jobbole.com/109449/

  • https://github.com/CyC2018/CS-Notes/blob/master/notes/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%20%20-%20%E5%8D%95%E4%BE%8B.md

  • 《HEAD FIRST 设计模式》

  • 《剑指offer》




最近面试BAT,整理一份面试资料Java面试BAT通关手册,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。

获取方式:点“在看”,关注公众号并回复 Java 领取,更多内容陆续奉上。

明天见(。・ω・。)ノ♡
浏览 4
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报