为什么用枚举类来实现单例模式越来越流行?

18,622 阅读9分钟

前言

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

单例模式的定义

单例模式就是在程序运行中只实例化一次,创建一个全局唯一对象,有点像 Java 的静态变量,但是单例模式要优于静态变量,静态变量在程序启动的时候JVM就会进行加载,如果不使用,会造成大量的资源浪费,单例模式能够实现懒加载,能够在使用实例的时候才去创建实例。开发工具类库中的很多工具类都应用了单例模式,比例线程池、缓存、日志对象等,它们都只需要创建一个对象,如果创建多份实例,可能会带来不可预知的问题,比如资源的浪费、结果处理不一致等问题。

单例的实现思路

  • 静态化实例对象
  • 私有化构造方法,禁止通过构造方法创建实例
  • 提供一个公共的静态方法,用来返回唯一实例

单例的好处

  • 只有一个对象,内存开支少、性能好

  • 避免对资源的多重占用

  • 在系统设置全局访问点,优化和共享资源访问

单例模式的实现

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

饿汉模式

饿汉模式采用一种简单粗暴的形式,在定义静态属性时,直接实例化了对象。代码如下:

//在类加载时就完成了初始化,所以类加载较慢,但获取对象的速度快
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;
    }
}

上面是懒汉模式的实现方式,但是上面这段代码在多线程的情况下是不安全的,因为它不能保证是单例模式,有可能会出现多份实例的情况,出现多份实例的情况是在创建实例对象时候造成的。所以我单独把实例化的代码提出,来分析一下为什么会出现多份实例的情况。

     1   if (instance == null)
     2       instance = new SingletonObject2();

假设有两个线程都进入到 1 这个位置,因为没有任何资源保护措施,所以两个线程可以同时判断的instance都为空,都将去执行 2 的实例化代码,所以就会出现多份实例的情况。

通过上面的分析我们已经知道出现多份实例的原因,如果我们在创建实例的时候进行资源保护,是不是可以解决多份实例的问题?确实如此,我们给getInstance()方法加上synchronized关键字,使得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;
    }
}

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

懒汉模式的优缺点

优点
  • 实现了懒加载,节约了内存空间
缺点
  • 在不加锁的情况下,线程不安全,可能出现多份实例
  • 在加锁的情况下,会是程序串行化,使系统有严重的性能问题

双重检查锁模式

再来讨论一下懒汉模式中加锁的问题,对于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在实例化对象的时候会进行优化和指令重排序操作。什么是指令重排?,看下面这个例子,简单了解一下指令从排序

    private SingletonObject4(){
     1   int x = 10;
     2   int y = 30;
     3  Object o = new Object();
                
    }

上面的构造函数SingletonObject4(),我们编写的顺序是1、2、3,JVM 会对它进行指令重排序,所以执行顺序可能是3、1、2,也可能是2、3、1,不管是那种执行顺序,JVM 最后都会保证所以实例都完成实例化。 如果构造函数中操作比较多时,为了提升效率,JVM 会在构造函数里面的属性未全部完成实例化时,就返回对象。双重检测锁出现空指针问题的原因就是出现在这里,当某个线程获取锁进行实例化时,其他线程就直接获取实例使用,由于JVM指令重排序的原因,其他线程获取的对象也许不是一个完整的对象,所以在使用实例的时候就会出现空指针异常问题。

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

    // 添加volatile关键字
    private static volatile SingletonObject5 instance;

    private SingletonObject5(){

    }

    public static SingletonObject5 getInstance(){

        if (instance == null)
            synchronized (SingletonObject5.class){
                if (instance == null){
                    instance = new SingletonObject5();
                }
            }

        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;
    }
}

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

枚举类实现单例模式

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

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();
    }
}

破坏单例模式的方法及解决办法

1、除枚举方式外, 其他方法都会通过反射的方式破坏单例,反射是通过调用构造方法生成新的对象,所以如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例, 则阻止生成新的实例,解决办法如下:

private SingletonObject1(){
    if (instance !=null){
        throw new RuntimeException("实例已经存在,请通过 getInstance()方法获取");
    }
}

2、如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例,所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(), 反序列化时直接返回相关单例对象。

  public Object readResolve() throws ObjectStreamException {
        return instance;
    }

如果你觉得文章不错,欢迎点赞转发

最后

打个小广告,欢迎扫码关注微信公众号:「平头哥的技术博文」,一起进步吧。

平头哥的技术博文