乐趣区

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

前言

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

单例模式的定义

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

单例的实现思路

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

单例的好处

  • 只有一个对象,内存开支少、性能好(当一个对象的产生需要比较多的资源,如读取配置、产生其他依赖对象时,可以通过应用启动时直接产生一个单例对象,让其永驻内存的方式解决)
  • 避免对资源的多重占用(一个写文件操作,只有一个实例存在内存中,避免对同一个资源文件同时写操作

  • 在系统设置全局访问点,优化和共享资源访问(如:设计一个单例类,负责所有数据表的映射处理)

单例模式的实现

单例模式的主流写法有饿汉模式、懒汉模式、双重检查锁模式、静态内部类单例模式、枚举类实现单例模式五种方式,其中懒汉模式、双重检查锁模式两种模式写法不当,会导致在多线程下不是单例或者单例出异常,后面将会给大家详细介绍。我们从最基本的饿汉模式开始我们的单例编写之路。

饿汉模式

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

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

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

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

饿汉模式的优缺点

优点
  • JVM 层面的线程安全,static 关键字保证了在引用这个变量时,关于这个变量的所以写入操作都完成
缺点
  • 不能实现懒加载,造成空间浪费,如果一个类比较大,我们在初始化的时就加载了这个类,但是我们长时间没有使用这个类,这就导致了内存空间的浪费。

懒汉模式

懒汉模式就像一个懒汉的时候,只有饿了才会想办法找东西来填饱肚子,从来不会先的准备好食物,以防饿了。懒汉模式实现了懒加载,解决了饿汉模式带来的空间浪费问题,实现了使用时才去初始化类,但是也引入了其他的问题,我们先来看看下面这个懒汉模式

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()方法大部分的操作都是读操作,读操作是线程安全的。

懒汉模式的优缺点

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

双重检查锁模式

在懒汉模式中我们知道了 getInstance() 方法大部分的操作都是读操作,读操作是线程安全的,对 getInstance() 方法加锁造成了很大的性能问题,由此产生了一种更加优雅的加锁方式,既能对 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;
    }
}

可以说双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,上面的实现看上去完美无缺,但是上面的实现代码,在多线程的情况下,可能会出现 空指针问题,下面我们一起来了解是为什么会出现空指针问题。

空指针问题是由于虚拟机的优化和指令重排序造成的,我们在实例化对象时,虚拟机会对里面的代码进行优化,也许你还不太理解,我们来看看下面一段代码

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

JVM 在实例化 SingletonObject4() 时不一定按照 1、2、3 的顺序执行,JVM 会对它进行优化,可能是 3、1、2,也可能是 2、3、1,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;}

最后

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

退出移动版