工作中用到的设计模式(一):单例模式

735 阅读7分钟

近期在工作中为了完成需求开发,同时为了把代码写得稍许优雅一些,应用到了一些设计模式。借此契机就在这记录一下运用的设计模式,以及一些运用时需要注意的地方。Ps:这部分内容大概会以系列形式进行不定期更新,所有内容的设计模式可能包括但不限于GOF的23种设计模式。

单例模式

单例模式是GOF第一种设计模式。在Spring框架中所创建的Bean,默认的生命周期是singleton,也就是单例,由Spring容器进行统一的生命周期管理,而prototype,则由创建者进行对象生命周期的管理。单例模式虽然是23种设计模式中最简单的设计模式,但在实际应用中还是注意很多细节,不然还是会出一些问题。

饿汉 - 单例模式

饿汉单例模式,通过JVM的类加载机制所实现的单例模式,实现方式很简单,也能保证线程安全,但是由于是主动加载,所以会造成一定的空间资源浪费。

具体实现:

public class SingletonDemo{
    private static SingletonDemo instance = new SingletomDemo();
    
    private SingletonDemo(){}
    
    public static getInstance(){
        return instance;
    }
}

至此,饿汉单例模式的实现就已经完成了,至于为什么饿汉单例模式是线程安全的,需要有一定的JVM-ClassLoader加载方面的知识,这边由于主要是谈论设计模式的系列,所以就稍微简单解释一下,有需求的朋友可以去参考一下《深入理解Java虚拟机》。

JVM中所持有三个类加载器,分别是BootstrapClassLoaderExtClassLoaderApplicationClassLoader,当一个类需要被加载的时候,类加载器会去询问父加载器是否加载过,层层递进,若根加载器都未加载过该目标类,则通过本身进行类加载操作,而类加载操作一共有五个阶段,分别是 加载 -> 验证 -> 验证 -> 解析 -> 初始化。而初始化过程,会对类的类的静态变量进行初始化以及对静态方法块进行执行。而类加载过程只会进行一次,类加载完成后类信息会存放在方法区。而饿汉单例模式,借此实现单例,是线程安全的。

懒汉 - 单例模式

懒汉单例模式,即在类加载时不主动去创建单例对象,而是在需要使用时再去创建单例对象,但因此也会存在线程不安全的问题。我们先来看一个单线程应用的简单懒汉单例实现。

public class SingletonDemo{
    private static SingletonDemo instance;
    
    private SingletomDemo(){}
    
    public static getInstance(){
        if(instace == null){
            instance = new SingletonDemo();
        }
        return instance;
    }
}

以上代码在单线程应用下可能不会出问题,但是设想一下,若是多线程环境下,线程A,线程B同时会调用getInstance()方法,线程A判断instance对象为空,进入到if方法块后,还未执行instance赋值语句,此时线程切换,线程B进入获取到的instance对象还未赋值,则进入了if方法块执行赋值操作并完成返回,此时线程切换回到A线程,A线程继续执行赋值操作,并完成返回。则A、B线程获取到了两个不同的对象。为了应对这种问题,则可以使用双重检查锁(Double-checked-locking)。

public class SingletonDemo{
    private static volatile SingletonDemo instance;
    private SingletonDemo(){}
    
    public static getInstance(){
        if(instance == null){
            Synchronized (SingletonDemo.class){
                if(instance == null){
                    instance = new SingletonDemo();
                }
            }
        }
        return instance;
    }

此时,线程A、B同时调用getInstance()方法时,线程A、B都得到判断instance为空进入if块,A线程获得锁,B线程进入等待池,A线程完成instance对象的初始化操作,进行锁的释放,接着B线程获得锁,进行对象判空,此时获取到instance对象已经不为空,则跳出同步块,返回instance对象。这段代码是没有问题的,但可以注意到instance对象多了一个volatile修饰关键字,要是没有这个关键字,会出现怎样的问题呢?

让我们来设想一种比较极端的情况,线程A调用getInstance()方法,判断instance对象为空,进入同步块进行对象初始化操作,而instance对象的初始化操作在JVM内部会被拆分成三步 为对象分配空间 -> 初始化对象 -> 将instance指针指向初始化完成的对象的地址,若要是能按照这样的对象初始化步骤执行也是不会出现问题的,但若经过JVM和CPU的优化指令重排 初始化过程可能会变成 为对象分配空间 -> 将instance指针指向分配的对象的地址 -> 初始化对象,若在执行完第二条指令,第三条指令未执行时,线程B切入,进行对象判空,读取到instance对象已经指向了一块区域而不是null,则直接返回了未被初始化完成的对象,那么线程B根据该对象执行的后置操作,都是有问题的。

关于volatile关键字,字面意思是易变的,我们可以把其理解成一个轻量级的锁。 该关键字主要有两个特性:

  1. 保证修饰对象的内存可见性
    • Java内存模型中,对象都存放在主存中,而线程只操作各自工作内存(高速缓存),所以在操作对象时会将主存的值同步到自己的工作内存中,在操作完成后再将工作内存中的值写回主程中去,而在执行操作的过程中会遇到别的线程执行操作,可能最终的结果就不是我们想要的了。
    • 举个简单的例子:
        static int i = 0;
        /*
         * 线程A、B同时对i进行+1操作
         * 线程A加载i=0写入高速缓存 进行+1操作 但未写回主存
         * 线程B加载i=0写入告诉缓存 进行+1操作 写入主存 此时i=1
         * 线程A将值写回主存 由于线程A写入高速缓存的i值为0 所以写回主存的值也是1
         */
    
    • 而通过volatile修饰过的对象会被立即写回主存,当其它线程需要读取该变量时,会去内存中读取新值。普通变量则不能保证这一点。
  2. 禁止指令重排
    • 禁止指令重排就很好理解了,之前我们说过了CPU和JVM会对指令进行优化重排,且优化重排后的指令顺序是不一定的。而volatile关键字能保证其执行顺序始终是 为对象分配空间 -> 初始化对象 -> 将instance指针指向初始化完成的对象的地址,如此一来,则再不会出现获取中间态的问题。

枚举 - 单例模式

虽然看似双重检查锁配合volatile关键字确实完美的解决了单例对象的获取问题,但是却依然无法阻止通过反射去创建一个对象实例(获取单例类构造器 -> 设置构造器可访问 -> 构造实例),所以依然还是存在问题的。这时候就可以考虑用一种简洁又优雅的方式去实现一个单例。

public enum SingletonDemo{
    INSTANCE;
}

此时若通过反射去创建一个INSTANCE实例时则会收到一个没有相关方法的异常,因为JVM会组织反射去获取枚举类的私有构造方法。但此种实现方式和饿汉单例实现方式也存在同样的问题,单例对象会在类被加载时进行初始化,而不是通过懒加载的方式实现的。

总结

单例模式虽然说是最容易实现的方式,但是其中也有很多细节问题需要多加注意。

实现方式 线程安全 主动初始化 预防反射构建 预防序列化构建
饱汉单例 安全
懒汉单例
懒汉单例(双重检查锁)
懒汉单例(双重检查锁 + volatile) 安全
枚举单例 安全

ps:个人博客地址是shawjie.me,不定期会发布一些自己所经历的,所学习的,所了解的,欢迎来坐坐。