巧谈23种设计模式:单例模式

994 阅读9分钟

前言

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。只需要一个类就能够实现单例模式,但是,你不能小看单例模式,虽然从设计上来说它比较简单,但是在实现当中你会遇到非常多的坑,所以,系好安全带,上车了。

定义

单例模式是一种常用的软件设计模式,这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。这种模式就是在程序中只实例化一次,确保全局只有一个唯一对象,并且提供一个全局访问点。

听起来有点像 Java 的静态变量,但是单例模式要优于静态变量,静态变量在程序启动的时候JVM就会进行加载,如果不使用,会造成大量的资源浪费,单例模式能够实现懒加载,能够在使用实例的时候才去创建实例。

为什么要用单例模式?

在系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、回收站等驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。

举个例子,在我们的 windows 桌面上,我们打开了一个回收站,当我们试图再次打开一个新的回收站时,Windows 系统并不会为你弹出一个新的回收站窗口。也就是说在整个系统运行的过程中,系统只维护一个回收站的实例。这就是一个典型的单例模式运用。

继续说说回收站,我们在实际使用中并不存在需要同时打开两个回收站窗口的必要性。假如我每次创建回收站时都需要消耗大量的资源,而每个回收站之间资源是共享的,那么在没有必要多次重复创建该实例的情况下,创建了多个实例,这样做就会给系统造成不必要的负担,造成资源浪费。

单例模式实现

单例模式的写法有饿汉、懒汉、双重检查锁、静态内部类、枚举类实现单例模式五种方式。其中懒汉模式、双重检查锁模式,如果写法不当,在多线程情况下会存在不是单例或者单例出异常等问题。具体的原因在后面的对应处会进行说明。

饿汉式

**线程安全,调用效率高。但是,不能延时加载。**示例:

//在类加载时就完成了初始化,所以类加载较慢,但获取对象的速度快
public class SingletonObject1 {
    // 利用静态变量来存储唯一实例
    private static final SingletonObject1 instance = new SingletonObject1();

    // 私有化构造函数
    private SingletonObject1(){
        // 里面可能有很多操作
    }

    // 提供公开获取实例接口
    public static SingletonObject1 getInstance(){
        return instance;
    }
}

优点:由于使用了static关键字,保证了在引用这个变量时,关于这个变量的所以写入操作都完成,所以保证了 JVM 层面的线程安全。

缺点:不能实现懒加载,造成空间浪费,如果一个类比较大,我们在初始化时就加载了这个类,但是我们长时间没有使用这个类,这就导致了内存空间的浪费。

懒汉式

懒汉模式是一种偷懒的模式,在程序初始化时不会创建实例,只有在使用实例的时候才会创建实例,所以懒汉模式解决了饿汉模式带来的空间浪费问题,同时也引入了其他的问题。请看示例:

public class SingletonObject2 {

    // 定义静态变量时,未初始化实例
    private static SingletonObject2 instance;

    // 私有化构造函数
    private SingletonObject2() {}

    public static SingletonObject2 getInstance(){

        // 使用时,先判断实例是否为空,如果实例为空,则实例化对象
        if (instance == null) {
            instance = new SingletonObject2();
        }
        return instance;
    }
}

通过上面这段代码在多线程的情况下是不安全的,因为它不能保证单例模式,有可能会出现多份实例的情况,所以我单独把代码拉出来,为什么为出现多份实例的情况。

if (instance == null) {
    instance = new SingletonObject2();
}

假设有两个线程都进入到 if 这个位置,因为没有任何资源保护措施,所以两个线程可以同时判断instance都为空,都将去new一个新实例,所以就会出现多份实例的情况。所以可以给getInstance()方法加上synchronized关键字,使得方法成为受保护的资源就能够解决多份实例的问题。示例如下:

public class SingletonObject3 {

    private static SingletonObject3 instance;

    private SingletonObject3() {}

    public synchronized static SingletonObject3 getInstance(){
        
        // 添加class类锁,影响了性能,加锁之后将代码进行了串行化, 我们的代码块绝大部分是读操作,在读操作的情况下,代码线程是安全的
        if (instance == null) {
            instance = new SingletonObject3();
        }
        return instance;
    }
}

经过修改后,我们解决了多份实例的问题,但是因为加入了synchronize关键字,对代码加了锁,就引入了新的问题,加锁之后会使得程序变成串行化,只有抢到锁的线程才能去执行这段代码块,这会使得系统的性能大大下降。

优点

  • 实现了懒加载,节约了内存空间

缺点

  • 在不加锁的情况下,线程不安全,可能出现多份实例

  • 在加锁的情况下,会是程序串行化,使系统有严重的性能问题

双重检查锁式

通过上面懒汉模式加锁的问题,对于getInstance()方法来说,绝大部分的操作都是读操作,读操作是线程安全的,所以我们没必让每个线程必须持有锁才能调用该方法,我们需要调整加锁的问题。由此也产生了一种新的实现模式:双重检查锁模式。请看示例:

public class SingletonObject4 {

    private static SingletonObject4 instance;

    private SingletonObject4() {}

    public static SingletonObject4 getInstance(){

        // 第一次判断,如果这里为空,不进入抢锁阶段,直接返回实例
        if (instance == null) {
            synchronized (SingletonObject4.class){
                // 抢到锁之后再次判断是否为空
                if (instance == null){
                    instance = new SingletonObject4();
                }
            }
        }
        return instance;
    }
}

双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,上面的双重检测锁模式看上去完美无缺,其实是存在问题的,在多线程的情况下,可能会出现空指针问题,出现问题的原因是 JVM 在实例化对象的时候会进行优化和指令重排操作。什么是指令重排?

指令重排:

一般而言初始化操作并不是一个原子操作,而是分为三步:

1.在堆中开辟对象所需空间,分配地址
2.根据类加载的初始化顺序进行初始化
3.将内存地址返回给栈中的引用变量

由于 Java 内存模型允许“无序写入”,有些编译器因为性能原因,可能会把上述步骤中的 2 和 3 进行重排序,顺序就成了

1.在堆中开辟对象所需空间,分配地址
2.将内存地址返回给栈中的引用变量(此时变量已不在为null,但是变量却并没有初始化完成)
3.根据类加载的初始化顺序进行初始化

要解决双重检查锁模式带来空指针异常的问题,只需要使用volatile关键字,volatile关键字严格遵循happens-before原则,即在读操作前,写操作必须全部完成。添加volatile关键字之后的单例模式代码:

public class SingletonObject4 {

    private static volatile SingletonObject4 instance;

    private SingletonObject4() {}

    public static SingletonObject4 getInstance(){
        if (instance == null) {
            synchronized (SingletonObject4.class){
                if (instance == null){
                    instance = new SingletonObject4();
                }
            }
        }
        return instance;
    }
}

添加volatile关键字之后会禁用指令重排,能够保证在多线程的情况下线程安全也不会有性能问题。

静态内部类单例式

静态内部类单例模式也称单例持有者模式,实例由内部类创建,由于 JVM 在加载外部类的过程中, 是不会加载静态内部类的, 只有内部类的属性/方法被调用时才会被加载, 并初始化其静态属性。静态属性由static修饰,保证只被实例化一次,并且严格保证实例化顺序。静态内部类单例模式代码如下:

public class SingletonObject6 {

    private SingletonObject6() {}
    // 单例持有者
    private static class InstanceHolder {
        private  final static SingletonObject6 instance = new SingletonObject6();
    }
    
    public static SingletonObject6 getInstance() {
        // 调用内部类属性
        return InstanceHolder.instance;
    }
}

静态内部类单例模式是一种优秀的单例模式,是开源项目中比较常用的一种单例模式。在没有加任何锁的情况下,保证了多线程下的安全,并且没有任何性能影响和空间的浪费。

枚举类实现单例式

枚举类型是线程安全的,并且只会装载一次,设计者充分的利用了枚举的这个特性来实现单例模式,枚举的写法非常简单,而且枚举类型是所用单例实现中唯一一种不会被破坏的单例实现模式。

public class SingletonObject7 {


    private SingletonObject7() {}

    // 枚举类型是线程安全的,并且只会装载一次
    private enum Singleton{

        INSTANCE;

        private final SingletonObject7 instance;

        Singleton(){
            instance = new SingletonObject7();
        }

        private SingletonObject7 getInstance(){
            return instance;
        }
    }

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

单例模式的优缺点

优点

  • 在内存中只有一个对象,节省内存空间。

  • 避免频繁的创建销毁对象,可以提高性能。

  • 避免对共享资源的多重占用,简化访问。

  • 为整个系统提供一个全局访问点。

缺点

  • 不适用于变化频繁的对象。

  • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出。

  • 如果实例化的对象长时间不被利用,系统会认为该对象是垃圾而被回收,这可能会导致对象状态的丢失。

总结

单例模式的实现方法还有很多。但是,这五种是比较经典的实现,也是我们应该掌握的几种实现方式。

从这几种实现中,我们可以总结出,要想实现效率高的线程安全的单例,我们必须注意以下两点:

  • 尽量减少同步块的作用域。

  • 尽量使用细粒度的锁。