单例模式五种写法

时间:2024-04-25 07:29:38

单例模式五种写法

单例模式有五种写法:饿汉、懒汉、双重检验锁、静态内部类、枚举.

单例模式属于设计模式中的创建型模式

一、单例模式应用场景

  • windows的task manager(任务管理器)就是很典型的单例模式;

  • windows的recycle bin(回收站)也是典型的单例应用,在整个系统运行过程中,回收站一直维护仅有的一个实例;

  • 项目中,读取配置文件的类,一般也只有一个对象,没有必要每次使用配置文件数据,每次new一个对象去读取;

  • 应用程序的日志应用,一般都可用单例模式实现,这一般是由于共享的日志文件一直处于打开状态,因为只能有一个实例去操作,否则内容不好追加;

  • 数据库连接池的设计一般也是采用单例模式,因为数据库连接是一种数据库资源;

  • 在spring中,每个bean默认就是单例的,这样做的优点是spring容器可以管理;

  • 在servlet编程中,每个servlet也是单例;

  • 在spring mvc框架,控制器对象也是单例;

单例模式算是设计模式中最容易理解,也是最容易手写代码的模式了。但是其中的坑却不少,所以也常作为面试题来考,本文主要对几种单例写法的整理,并分析其优缺点。

二、合格的单例模式要求

合格的单例模式的实现,至少要保证以下三点:

1. 实现单例功能;
2. 延迟加载;
3. 并发时不出错。 

三、五种单例模式的实现

我们将创建一个 SingleObject 类。SingleObject 类有它的私有构造函数和本身的一个静态实例。

SingleObject 类提供了一个静态方法,供外界获取它的静态实例。SingletonPatternDemo 类使用 SingleObject 类来获取 SingleObject 对象。

image-20240408234756088

1. 饿汉式

这种方式非常简单,因为单例的实例被声明成 static 和 final 变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的

public class Singleton {

    private static final Singleton instance = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }
}
  • 饿汉模式的缺点:可能在还不需要此实例的时候就已经把实例创建出来了,没起到 lazy loading 效果,空间换时间:浪费内存。
  • 饿汉模式的优点:实现简单,线程安全,调用效率高。

2. 懒汉式

public class Singleton {
    
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}
  • 懒汉模式的优点:弥补了饿汉模式的缺点,起到了 lazy loading 的效果,时间换空间:节约内存。
  • 懒汉模式的缺点:多线程并发时有线程安全问题,有可能创建多个实例,也就是说在多线程下不能正常工作。

3. 双重检验锁

双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。

称其为双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) { //第一次检查,提高性能,避免所有的线程都去加锁和释放锁
            synchronized (Singleton.class) {
                if (instance == null) { //第二次检查,确保只有一个线程创建实例
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了

这段代码看起来很完美,很可惜,它是有问题的。**主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情: **

(1) 给 instance 分配内存

(2) 调用 Singleton 的构造函数来初始化成员变量

(3) 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)

但是在 JVM 的即时编译器(JIT)中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。 如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

解决办法: 我们只需要将 instance 变量声明成 volatile 修饰就可以了,其作用是防止指令重排序

public class Singleton {
    private volatile static Singleton instance; //声明成 volatile
    private Singleton (){}
 
    public static Singleton getInstance() {
        if (instance == null) {                         
            synchronized (Singleton.class) {
                if (instance == null) {       
                    instance = new Singleton();
                }
            }
        }
        return instance;
    } 
}

使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作

  • 双校验懒汉模式的优点:弥补了懒汉模式的缺点,防止了并发问题。
  • 双校验懒汉模式的缺点:因为涉及到锁,因此性能有损耗;代码变得更复杂。

4. 静态内部类-登记式

静态内部类(static nested class)

使用静态内部类的方式,也是《Effective Java》上所推荐的。

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

懒汉式的变种: 静态内部类 空间换时间

首先静态内部类不会随着外部类的加载而加载 ,只有静态内部类的静态成员被调用时才会进行加载也就是初始化 ,这个就是调用静态内部类的静态成员,然后在初始化的过程中new一个对象INSTANCE

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

  • 静态内部类单例的优点:线程安全,支持延迟加载,获取singleton对象时不需要加锁,使用方便。
  • 静态内部类单例的缺点:会增加类的数量,在第一次加载时可能会略微增加启动时间。

5. 枚举

用枚举写单例非常简单,这也是它最大的优点。下面这段代码就是声明枚举实例的通常做法:

public enum EasySingleton{
    INSTANCE;
}

我们可以通过EasySingleton.INSTANCE来访问实例,这比调用getInstance()方法简单多了。创建枚举默认就是线程安全的,所以不需要担心double checked locking,而且还能防止反序列化导致重新创建新的对象。

枚举单例优点:写法简单,实现效率高,因为枚举本身就是单例,由JVM从根本上提供保障,避免通过反射和反序列化的漏洞

枚举单例缺点:没有延迟加载,枚举实例在编译时就已经创建,无法在运行时延迟加载

四、总结

一般来说,单例模式有五种写法:饿汉、懒汉、双重检验锁、静态内部类、枚举

一般情况下,不建议使用第 2 种懒汉方式,建议使用第 1 种饿汉方式。只有在要明确实现 lazy loading 效果时,才会使用第 4 种静态内部类登记方式。如果涉及到反序列化创建对象时,可以尝试使用第 5 种枚举方式。如果有其他特殊的需求,可以考虑使用第 3 种双检锁方式。

  • 单例类的特点:
(1)单例类确保自己只有一个实例

(2)单例类必须自己创建自己的实例

(3)单例类必须为其他对象提供唯一的实例。
  • 单例类的优点:
(1) 控制资源的使用,通过线程同步来控制资源的并发访问。

(2) 控制实例的产生数量,达到节约资源的目的。

(3) 作为通信的媒介,数据共享。他可以在不建立直接关联的条件下,让多个不相关的两个线程或者多个进程之间实现通信。
  • 单例模式的主要缺点如下:
(1) 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。

(2) 单例类的职责过重,在一定程度上违背了“单一职责原则”。因为单例类既充当了工厂角色,
提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。