单例模式

单例模式

一、概述

1. 定义

​ 一个类只有一个实例,且该类能够自行创建这个实例。

2. 特点

  • 单例类只有一个实例对象
  • 单例对象必须由单例类自行创建
  • 单例类对外提供一个访问该单例的全局访问点

3. 优点

  • 单例模式可以保证内存里只有一个实例,减少了内存的开销
  • 可以避免对资源的多重占用
  • 单例模式设置全局访问点,可以优化和共享资源的访问

4. 缺点

  • 单例模式一般没有接口,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则
  • 在并发测试中,单例模式不利于代码调试。在调试过程中,如果单例中的代码没有执行完毕,也不能模拟生成一个新的对象
  • 单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则

5. 主要应用场景

  • 需要频繁创建的类,使用单例可以降低系统的内存压力,减少GC
  • 某类只需要生成一个对象的时候
  • 某些类创建实例时占用资源比较多,或实例化耗时较长,且经常使用
  • 某类需要频繁实例化,而创建的对象又频繁被销毁
  • 频繁访问数据库或文件的对象
  • 控制硬件级别的操作,或从系统上来讲应当是单一逻辑的操作
  • 当对象需要被共享的时候

二、懒汉式

1. 线程不安全

public class Singleton {
    private static Singleton instance;

    // 构造方法私有化,避免在外部被实例化
    private Singleton() {}

    // 全局访问点
    public static Singleton getInstance() {
        // 判断是否已经实例化
        if (instance == null) {
            // 若没有,则实例化
            instance = new Singleton();
        }
        // 若有,则返回实例化对象
        return instance;
    }
}

该模式特点是类加载时没有生成单例,只有第一次调用 getInstance() 方法时才会生成。

缺点是在多线程情况下,会创建多个实例,因此是线程不安全的。

2. 线程安全

...
	// 全局访问点
    public static synchronized Singleton getInstance() {
        // 判断是否已经实例化
        if (instance == null) {
            // 若没有,则实例化
            instance = new Singleton();
        }
        // 若有,则返回实例化对象
        return instance;
    }
...

虽然通过加锁做到了线程安全,但是并不高效。因为无论何时都只能有一个线程调用getInstance()方法。

但是同步操作只需要在第一次调用时才被需要,其余线程应当不再执行锁内的命令。

3. 双重检验锁

双重检验锁模式(double checked locking pattern)是一种使用同步代码块加锁的机制。也被成为双重检查锁,因为会有两次instance == null检查,一次在同步块外,一次在同步块内。

...
	public static Singleton getInstance() {
        // 第一次检查,主要用于判断是否还要执行同步锁内容
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次检查,可能会有多个线程一起进入同步块内,避免创建多个实例
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
...

虽然上述代码看起来很完美,但是在极端情况下仍然会存在问题。主要在instance = new Singleton()这行代码中,这并非一个原子性操作,实际上在 JVM 中执行了以下操作:

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将 instance 对象指向分配的内存空间(执行完毕之后 instance 就为非 null 了)

但是 JVM 即时编译器中存在指令重排优化。也就是说第2步与第3步顺序不能保证。

如果线程一执行顺序为1-3-2,那么在3执行完毕之后,2执行之前,此时对象已经为非 null。这时线程二开始执行,由于对象还未被初始化却已经有了值,所以线程二会直接返回 instance 之后进行使用,程序就会报错。

因此需要使用volatile关键字避免指令重排。

public class Singleton {
    // volatile 可以保证线程间的可见性,也可以避免指令重排
    private volatile static Singleton instance;

    private Singleton() {}
    ...

当然,经过以上优化后(包括下面的几种方式)依然有可能被反射原理破坏单例模式,因此仍然是不够安全的。

三、饿汉式

public class Singleton {
    // 类加载时就被初始化
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

单例实例同时被staticfinal修饰,并且在第一次加载类到内存中时就会初始化,因此本身是线程安全的。

但是缺点也很明显,这并非懒加载模式(lazy initialization),单例会在加载类后一开始就被初始化,即使没有调用getInstance()方法。

同时,饿汉式创建单例在一些场景中也无法使用,比如,实例的创建是依赖配置文件或者参数的,在getInstance()之前必须调用某个方法给其传递参数,此时就无法使用这种单例写法了。

四、静态内部类

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

    private Singleton() {}

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

该方式同样使用 JVM 本身机制保证了线程安全。由于SingletonHolder是私有的,因此除了getInstance()方法之外没有办法访问它,因此是属于懒汉式的。同时读取实例的时候不会进行同步,没有性能缺陷。是一种比较推荐的方式。

五、枚举

public enum EnumSingleton {
    INSTANCE;
}

枚举方式下实现单例模式很简单,也是其最大的优点。通过使用EnumSingleton.INSTANCE就可以访问实例。同时,枚举默认就是线程安全的,而且还能防止反序列化导致重新创建新的对象,只是使用这种方式的人比较少。

posted @ 2021-08-16 22:27  冷火凉烟  阅读(78)  评论(0)    收藏  举报