单例模式

设计模式

1 单例模式定义

确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

2 单例模式使用场景

确保某个类有且只有一个对象的场景,避免产生多个对象消耗过多资源,或者某种类型的对象只应该有且只有一个。

3 实现单例模式的关键点

  1. 构造函数不对外开放---Private
  2. 通过一个静态方法或者枚举返回单利对象
  3. 确保单例类的对象有且只有一个,尤其是在多线程环境下。
  4. 确保单例类对象在反序列化时不会重新构建对象。

4 单例模式的实现方式

单例模式的实现方式有多种,根据需求场景,可分为2大类、6种实现方式。具体如下:


单例模式的实现方式.png

5 初始化单例类时 即 创建单例

5.1 饿汉式

这是 最简单的单例实现方式

原理
依赖 JVM类加载机制,保证单例只会被创建1次,即 线程安全

  1. JVM在类的初始化阶段(即 在Class被加载后、被线程使用前),会执行类的初始化
  2. 在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化

应用场景
除了初始化单例类时 即 创建单例外,继续延伸出来的是:单例对象 要求初始化速度快 & 占用内存小

public class HungrySingleton {
    private static  final HungrySingleton mSingleton= new HungrySingleton();
    //私有构造函数
    private HungrySingleton(){

    }
    //公有的静态函数,对外暴露获取单例对象的接口
    public static HungrySingleton getSingleton(){
        return mSingleton;
    }
}

5.2 枚举类型

原理
根据枚举类型的下述特点,满足单例模式所需的 创建单例、线程安全、实现简洁的需求

枚举类型特点.jpg

  • 注:这是 最简洁、易用 的单例实现方式,借用《Effective Java》的话:
  • 单元素的枚举类型已经成为实现 Singleton的最佳方法
public enum Singleton{

    //定义1个枚举的元素,即为单例类的1个实例
    INSTANCE;

    // 隐藏了1个空的、私有的 构造方法
    // private Singleton () {}

}

// 获取单例的方式:
Singleton singleton = Singleton.INSTANCE;

6 按需、延迟创建单例

6.1 懒汉式(基础实现)

原理

  • 饿汉式:单例创建时机不可控,即类加载时 自动创建 单例
  • 懒汉式:单例创建时机可控,即有需要时,才 手动创建 单例
class Singleton {
    // 1. 类加载时,先不自动创建单例
   //  即,将单例的引用先赋值为 Null
    private static  Singleton ourInstance  = null;

    // 2. 构造函数 设置为 私有权限
    // 原因:禁止他人创建实例 
    private Singleton() {
    }
    
    // 3. 需要时才手动调用 newInstance() 创建 单例   
    public static  Singleton newInstance() {
    // 先判断单例是否为空,以避免重复创建
    if( ourInstance == null){
        ourInstance = new Singleton();
        }
        return ourInstance;
    }
}
  • 缺点
    基础实现的懒汉式是线程不安全的,具体原因如下


    944365-ba2f81731ede7035.png

6.2 懒汉式--同步锁优化

  • 原理
    使用同步锁 synchronized锁住 创建单例的方法 ,防止多个线程同时调用,从而避免造成单例被多次创建
  1. 即,getInstance()方法块只能运行在1个线程中
  2. 若该段代码已在1个线程中运行,另外1个线程试图运行该块代码,则 会被阻塞而一直等待
  3. 而在这个线程安全的方法里我们实现了单例的创建,保证了多线程模式下 单例对象的唯一性

缺点
每次访问都要进行线程同步(即 调用synchronized锁),造成过多的同步开销(加锁 = 耗时、耗能)

具体实现

// 写法1
class Singleton {
    // 1. 类加载时,先不自动创建单例
    //  即,将单例的引用先赋值为 Null
    private static  Singleton ourInstance  = null;
    
    // 2. 构造函数 设置为 私有权限
    // 原因:禁止他人创建实例 
    private Singleton() {
    }
    
// 3. 加入同步锁
public static synchronized Singleton getInstance(){
        // 先判断单例是否为空,以避免重复创建
        if ( ourInstance == null )
            ourInstance = new Singleton();
        return ourInstance;
    }
}


// 写法2
// 该写法的作用与上述写法作用相同,只是写法有所区别
class Singleton{ 

    private static Singleton instance = null;

    private Singleton(){
}

    public static Singleton getInstance(){
        // 加入同步锁
        synchronized(Singleton.class) {
            if (instance == null)
                instance = new Singleton();
        }
        return instance;
    }
}

6.3 双重校验锁(懒汉式的改进)

原理
在同步锁的基础上,添加1层 if判断:若单例已创建,则不需再执行加锁操作就可获取实例,从而提高性能

class Singleton {
    private static  Singleton ourInstance  = null;

    private Singleton() {
    }
    
    public static  Singleton newInstance() {
     // 加入双重校验锁
    // 校验锁1:第1个if
    if( ourInstance == null){  // ①
     synchronized (Singleton.class){ // ②
      // 校验锁2:第2个 if
      if( ourInstance == null){
          ourInstance = new Singleton();
          }
      }
  }
        return ourInstance;
   }
}

两次判空

  • 校验锁1:第1个if
    作用:若单例已创建,则直接返回已创建的单例,无需再执行加锁操作。即直接跳到执行 return ourInstance

  • 校验锁2:第2个if
    作用:防止多次创建单例问题
    原理

  1. 线程A调用newInstance(),当运行到②位置时,此时线程B也调用了newInstance()
  2. 因线程A并没有执行instance = new Singleton();,此时instance仍为空,因此线程B能突破第1层if 判断,运行到①位置等待synchronized中的A线程执行完毕
  3. 当线程A释放同步锁时,单例已创建,即instance已非空
  4. 此时线程B 从①开始执行到位置②。此时第2层if判断 = 为空(单例已创建),因此也不会创建多余的实例

DCL失效问题

  • 根据《The Java Language Specification,Java SE 7 Edition》(后文简称为Java语言规范),所有线程在执行Java程序时必须要遵守intra-thread semanticsintra-thread semantics保证重排序不会改变单线程内的程序执行结果。
  • 换句话说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序

ourInstance = new Singleton();创建了一个对象。这一行代码可以分解为如下的3行伪代码。

memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory);  // 2:初始化对象
ourInstance = memory;    // 3:设置ourInstance 指向刚分配的内存地址

2和3之间重排序之后的执行时序如下

memory = allocate();  // 1:分配对象的内存空间
instance = memory;    // 3:设置instance指向刚分配的内存地址                                       
                        // 注意,此时对象还没有被初始化!
ctorInstance(memory);  // 2:初始化对象

2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能

线程执行时序图.png

只要保证2排在4的前面,即使2和3之间重排序了,也不会违反intra-thread semantics。

多线程执行时序图.png

多线程执行时序表

错误结果
线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。

实现线程安全的延迟初始化解决办法的解决办法
a. 不允许2和3重排序。
b. 允许2和3重排序,但不允许其他线程“看到”这个重排序。

a. 基于volatile的解决方案--即不允许2和3重排序

  • 只需要做一点小的修改(把instance声明为volatile型),就可以实现线程安全的延迟初始化。
  • 当声明对象的引用为volatile后,2和3之间的重排序,在多线程环境中将会被禁止。
private volatile static Instance instance;

volatile(这个是半成品 后面补充)
Volatile的重排序规则:

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

如何实现volatile的内存语义
JMM采取保守策略
下面是基于保守策略的JMM内存屏障插入策略

  1. 在每个volatile写操作的前面插入一个StoreStore屏障。
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障。
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障。
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。
    上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。

b. 基于类初始化的解决方案--即允许2和3重排序,但不允许其他线程“看到”这个重排序
使用静态内部类的方式

6.4 静态内部类

public class StaticInnerSingleton {
    private StaticInnerSingleton(){}
    public static StaticInnerSingleton getInstance(){
        return InstanceHolder .mStaticInnerSingleton;
    }
    //静态内部类
    private static class InstanceHolder {
        private static StaticInnerSingleton mStaticInnerSingleton=new StaticInnerSingleton();
        
    }
}

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个**锁可以同步多个线程对同一个类的初始化。

两个线程并发执行getInstance()方法.png

由于Java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用getInstance()方法来初始化InstanceHolder类)。因此,在Java中初始化一个类或者接口时,需要做细致的同步处理。 Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。
类加载机制

推荐使用
当第一次加载Singleton类时并不会初始化sInstance,只有在第一次调用Singleton的getInstance方法时才会导致sInstance被初始化。因此,第一次调用getInstance方法会导致虚拟机加载SingletonHolder类,这种方式不仅能够确保线程安全,也能够保证单例对象的唯一性,同时也延迟了单例的实例化

6.5.使用容器实现单例模式

public class SingletonManager {
    private static Map<String, Object> objectMap = new HashMap<>();

    private SingletonManager() {
    }

    public static void registerService(String key, Object instance) {
        if (!objectMap.containsKey(key)) {
            objectMap.put(key, instance);
        }
    }

    public static Object getService(String key) {
        return objectMap.get(key);
    }
}

在程序的初始,将多种单例类型注入到一个统一的管理类中,在使用时根据key获取对象对应类型的对象。
这种方式使得我们可以管理多种类型的单例,并且在使用可以通过统一的接口进行获取操作,降低了用户使用成本,也对用户隐藏了具体实现,降低了耦合度

6.6、反序列化获得单例

在反序列化的情况下他们会出现重新创建对象。
构造函数是私有的,反序列化依然可以通过特殊的途径去创建类的一个新的实例,相当于调用该类的构造函数。反序列化提供了一个特别的钩子函数,类中具有一个私有的readResolve()函数,这个函数可以让程序员控制对象的反序列化。如果要杜绝单例对象在被反序列化时重新生成对象,那么必须加入readResolve函数

public class SerializableSingleton implements Serializable {
    private static final long serialVersionUID=0L;
    private static final SerializableSingleton INSTANCE =new SerializableSingleton();
    private SerializableSingleton(){}
    public static SerializableSingleton getSerializableSingleton(){
        return INSTANCE;
    }
    private Object readResolve() throws ObjectStreamException{
        return INSTANCE;
    }
}

readResolve方法中将单例对象返回,而不是重新生成一个新的对象。而对于枚举则不存在这个问题

如何实现一个单例?

  • 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
  • 考虑对象创建时的线程安全问题;
  • 考虑是否支持延迟加载;
  • 考虑 getInstance() 性能是否高(是否加锁)。

7 单例存在哪些问题

7.1 单例对 OOP 特性的支持不友好

  • OOP 的四大特性是封装、抽象、继承、多态。
  • 单例这种设计模式对于其中的抽象、继承、多态都支持得不好。
    举例 IdGenerator 例子

public class Order {
  public void create(...) {
    //...
    long id = IdGenerator.getInstance().getId();
    //...
  }
}

public class User {
  public void create(...) {
    // ...
    long id = IdGenerator.getInstance().getId();
    //...
  }
}

IdGenerator 的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性。

如果未来某一天,我们希望针对不同的业务采用不同的 ID 生成算法。比如,订单 ID 和用户 ID 采用不同的 ID 生成器来生成。为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码的改动就会比较大。


public class Order {
  public void create(...) {
    //...
    long id = IdGenerator.getInstance().getId();
    // 需要将上面一行代码,替换为下面一行代码
    long id = OrderIdGenerator.getIntance().getId();
    //...
  }
}

public class User {
  public void create(...) {
    // ...
    long id = IdGenerator.getInstance().getId();
    // 需要将上面一行代码,替换为下面一行代码
    long id = UserIdGenerator.getIntance().getId();
  }
}

除此之外,单例对继承、多态特性的支持也不友好。
单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。

一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。

7.2 单例会隐藏类之间的依赖关系

代码的可读性非常重要。

通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。

但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。

如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

7.3 单例对代码的扩展性不友好

单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。

你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?

单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。

7.4. 单例对代码的可测试性不友好

单例模式的使用会影响到代码的可测试性。

单例类这种硬编码式的使用方式,导致无法实现 mock 替换。

如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。

7.5 单例不支持有参数的构造函数

单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。

  • 第一种解决思路是:创建完实例之后,再调用 init() 函数传递参数。
    要先调用 init() 方法,然后才能调用 getInstance() 方法,否则代码会抛出异常。

  • 第二种解决思路是:将参数放到 getIntance() 方法中。
    这个貌似只有首次配置有用

第三种解决思路是:将参数放到另外一个全局变量中。
通过静态常量来定义,也可以从配置文件中加载得到

8 有何替代解决方案?

如果不用单例,我怎么才能保证这个类的对象全局唯一呢?

为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。

为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。

静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。

单例除了我们之前讲到的使用方法之外,还有另外一种使用方法。


// 1. 老的使用方式
public demofunction() {
  //...
  long id = IdGenerator.getInstance().getId();
  //...
}

// 2. 新的使用方式:依赖注入
public demofunction(IdGenerator idGenerator) {
  long id = idGenerator.getId();
}
// 外部调用demofunction()的时候,传入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);

基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。

不过,对于单例存在的其他问题,比如对 OOP 特性、扩展性、可测性不友好等问题,还是无法解决。

类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式IOC 容器(比如 Spring IOC 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。

9 如何理解单例模式中的唯一性?

那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的。

单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。

10 如何实现集群环境下的单例?

我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。

在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。

参考

《Android源码设计模式解析与实战》
《Java并发编程的艺术》
单例模式(Singleton)- 最易懂的设计模式解析
41 | 单例模式(上):为什么说支持懒加载的双重检测不比饿汉式更优?
42 | 单例模式(中):我为什么不推荐使用单例模式?又有何替代方案?
43 | 单例模式(下):如何设计实现一个集群环境下的分布式单例模式?

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。