单例模式各版本的原理与实践

2022-10-27 14:48:12 浏览数 (4)

1.单例模式概述

(1)引言

单例模式是应用最广的模式之一,也是23种设计模式中最基本的一个。本文旨在总结通过Java实现单例模式的各个版本的优缺点及适用场景,详细分析如何实现线程安全的单例模式,并探讨单例模式的一些扩展。

(2)单例模式的定义

Ensure a class has only one instance,and provide a global point of access to it.(确保某一个类只有一个实例,并且自行实例化并向整个系统提供这个实例) 通用类图为:

Singleton类称为单例类,通过使用private的构造函数确保了在一个应用中只产生一个实例,并且是自行实例化的(在Singleton中自己使用new Singleton())。

(3)使用场景

在一个系统中,要求一个类有且仅有一个对象,如果出现多个对象就会出现“不良反应”,可以采用单例模式,具体的场景如下:

  • 要求生成唯一序列号的环境;
  • 在整个项目中需要一个共享访问点或共享数据,例如一个Web页面上的计数器,可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的;
  • 创建一个对象需要消耗的资源过多,如要访问IO和数据库等资源;
  • 需要定义大量的静态常量和静态方法(如工具类)的环境,可以采用单例模式(当然,也可以直接声明为static的方式)。
(4)优缺点

单例模式的优点

  • 由于单例模式在内存中只有一个实例,减少了内存开支,特别是一个对象需要频繁地创建销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显;
  • 由于单例模式只生成一个实例,所以减少了系统的性能开销,当一个对象的产生需要比较多的资源的时候,如读取配置,产生其他的依赖对象时,可以通过在应用启动的时候直接产生一个单例对象,然后用永久驻留内存的方式来解决;
  • 单例模式可以避免对资源的多重占用,例如对一个写文件动作,由于只有一个实例存在内存中,避免对同一个资源文件的同时写操作;
  • 单例模式可以在系统设置全局的访问点,优化和共享资源访问,例如可以设计一个单例类,负责所有数据表的映射处理

单例模式的缺点

  • 单例模式一般没有接口,扩展困难,若要扩展,除了修改代码基本上没有第二种途径可以实现;
  • 单例模式与单一职责原则有冲突,一个类应该只实现一个逻辑,而不关心他是否是单例的,是不是要单例取决于环境,单例模式把“要单例”和业务逻辑融合在一个类中。

2.最基本的实现方式

代码实现为:

代码语言:javascript复制
public class Singleton {
    private static Singleton singleton;
    // 限制产生多个对象
    private Singleton() {
    }

    // 获得对象实例的方法
    public static Singleton getSingleton() {
        if(singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

相信大多数同学在入门Java的阶段都见过这段代码。该方式在低并发的情况下尚不会出现问题,若系统压力增大,并发量增加时则可能在内存中出现多个实例,破坏设计的初衷。本文的后续就是围绕这种实现分析改进,探讨实现线程安全的单例模式的最佳实践。   为什么这种实现是线程不安全的呢?如一个线程A执行到singleton = new Singleton();这里,但还没有获得对象(对象初始化是需要时间的),第二个线程B也在执行,执行到if(singleton == null)判断,那么线程B获得判断条件也是为真,于是继续运行下去,线程A获得了一个对象,线程B也获得了一个对象,在内存中就出现两个对象,造成单例模式的失效!!   所以根本原因在于可能存在多个线程并发的访问getSingleton()方法造成单例对象的多次创建,解决因多线程并发访问导致单例模式实效的最佳方法就是--不要使用多线程并发访问。(⊙o⊙)…


3.饿汉式

(1)实现原理

言归正传,上面说的问题其实就是对if(singleton == null)的判断失效造成singleton = new Singleton();可能会被多个线程并发的执行。饿汉式单例模式的实现的本质其实就是依赖类加载机制保证构造方法只会被执行一次。JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。 饿汉式单例的实现代码为:

代码语言:javascript复制
public class Singleton {
    private static Singleton singleton = new Singleton();

    private Singleton() {
    }

    // 获得对象实例的方法
    public static Singleton getSingleton() {
        return singleton;
    }
}
(2)优缺点及适用场景

可以看到饿汉式的实现非常简单,适合那些在初始化时就要用到单例的情况,如果单例对象初始化非常快,而且占用内存非常小的时候这种方式是比较合适的,可以直接在应用启动时加载并初始化。 不适用的场景:

  • 单例初始化的操作耗时比较长而应用对于启动速度又有要求;
  • 单例的占用内存比较大;
  • 单例只是在某个特定场景的情况下才会被使用,而一般情况下是不会使用的;

在上述的几种情况下使用饿汉式的单例模式是不合适的,这时候就需要用到懒汉式的方式去按需延迟加载单例


4.利用同步锁机制实现的懒汉式

实现代码为:

代码语言:javascript复制
public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }

    // 获得对象实例的方法
    public static Singleton getSingleton() {
        synchronized(Singleton.class) {
            if(singleton == null) {
                singleton = new Singleton();
            }
        }
        return singleton;
    }
}

这种是最常见的懒汉式单例实现,使用同步锁synchronized(Singleton.class)防止多线程同时进入造成instance被多次实例化。但是他的缺陷也是非常明显的,就是每次在调用getSingleton()获取单例的实例的时候,都需要进行同步。事实上我们只想保证一次初始化成功,其余的快速返回而已,如果在getInstance频繁使用的地方就要考虑重新优化了。


5.对同步锁机制实现懒汉式的改进--DCL

(1)原理与实现

由于synchronized(甚至是无竞争的synchronized)存在着巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(double-checked locking)。通过这种方式来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的代码实现:

代码语言:javascript复制
public class Singleton {
    private static Singleton instance = null;                 //1

    // 获得对象实例的方法
    public static Singleton getSingleton() {                  //2
        if(instance == null) {                                //3:第一次检查
            synchronized(Singleton.class) {                   //4:加锁
                if(instance == null)                          //5:第二次检查
                    instance = new Singleton();               //6:问题的根源产生
            }
        }
        return instance;
    }

    private Singleton() {
    }
}

如上面的代码所示,它的“优点”如下:

  • 在多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象;
  • 在对象创建好了以后,执行getSingleton()方法将不需要获取锁,直接返回已经创建好的对象

双重检查模式看上去好像很完美,但这是一个错误的优化,在线程执行到上面所示的代码3处读取到singleton对象不为null时,singleton引用的对象可能还没有完成初始化

(2)问题分析

可能产生错误的场景

  • 1.线程A进入getSingleton()方法;
  • 2.因为此时instance为null,所以线程A进入synchronized块;
  • 3.线程A执行 instance = new Singleton(); 把实例变量instance设置成了非空。(注意,是在调用构造方法之前)
  • 4.线程A退出,线程B进入。
  • 5.线程B检查instance是否为空,此时不为空(第三步的时候被线程A设置成了非空)。线程B返回instance的引用。(问题出现了,这时instance的引用并不是Singleton的实例,因为没有调用构造方法)
  • 6.线程B退出,线程A进入;
  • 7.线程A继续调用构造方法,完成instance的初始化,再返回。
(3)问题根源

多线程问题,很大程度是由于非原子性造成的,如果我们每一个可能产生竞争的地方都是原子性的,那多线程需要考虑的东西就要少很多了。在上述程序中也是一样,我们看第六行代码:

代码语言:javascript复制
instance = new Singleton();     //6:问题的根源产生

在JMM中,这行代码可以分解为3个过程:

代码语言:javascript复制
memory = allocate();            //#1为对象分配内存空间
init(memory);                   //#2初始化
instance = memory;              //#3设置instance,将其指向刚分配的内存空间。

上面的3行代码,如果是顺序执行,不会带来问题。但是,在某些JIT编译器上,#2和#3可能发生重排序。也就是说,重排序后,上面三个过程变成了:

代码语言:javascript复制
memory = allocate();            //#1为对象分配内存空间
instance = memory;              //#2
init(memory);                   //#3初始化

根据《The Java Language Specification, Java SE 7 Edition》一书中的内容:所有线程在执行java程序时必须要遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。换句话来说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面三行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。这个重排序在没有改变单线程程序的执行结果的前提下,可以提高程序的执行性能。   下面,再让我们看看多线程并发执行的时候的情况。请看下面的示意图:

这里2和3虽然重排序了,但java内存模型的intra-thread semantics将确保2一定会排在4前面执行。因此线程A的intra-thread semantics没有改变。但2和3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。

分析清楚问题发生的根源之后,可以想出两个办法来实现线程安全的延迟初始化:

  • 不允许2和3重排序;
  • 允许2和3重排序,但不允许其他线程“看到”这个重排序。

后文介绍的解决方案就分别对应于上面这两点。


6.Java1.5以后安全的DCL版本

(1)实现代码
代码语言:javascript复制
public class Singleton {
    private volatile static Singleton instance = null;

    // 获得对象实例的方法
    public static Singleton getSingleton() {
        if(instance == null) {
            synchronized(Singleton.class) {
                if(instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }

    private Singleton() {
    }
}
(2)原理分析

可以发现代码只做一点小的修改(把instance声明为volatile型),为什么volatile可以解决呢?回顾一下他的两层语义:

  • (1)可见性:指的是在一个线程中对该变量的修改会马上由工作内存(Work Memory)写回主内存(Main Memory)
  • (2)禁止指令重排序优化

当声明对象的引用为volatile后,“问题的根源”的三行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止,从而在根本上解决了问题。但是很不幸,禁止指令重排优化这条语义直到jdk1.5以后才能正确工作。此前的JDK中即使将变量声明为volatile也无法完全避免重排序所导致的问题。所以,在jdk1.5版本前,双重检查锁形式的单例模式是无法保证线程安全的。   写到这里,可能有的同学会有疑问了:


7.Java1.4以前安全的DCL版本

额(⊙o⊙)…虽然现在的日常开发已经普遍在使用Java1.7甚至1.8了。不过尝试着探讨下在Jav1.4以前实现安全的DCL还是一个还有意思的话题。我自己也没有找到太确定的答案,这方面的资料也非常的少。下面给出的实现代码不一定能保证正确,贴出来仅供参考,欢迎有兴趣的同学在评论区留言分享一下经验。

(2)实现代码及思路
代码语言:javascript复制
public class Singleton {
    private static Singleton instance = null;
    // 获得对象实例的方法
    public static Singleton getSingleton() {
        if(instance == null) {                                      //1.第一次检查
            synchronized(Singleton.class) {                         //2.第一个synchronized块
                Singleton temp = instance;                          //3.给临时变量temp赋值
                if(temp == null) {                                  //4.第二次检查
                    synchronized(Singleton.class) {                 //5.第二个synchronized块
                        temp = new Singleton();                     //6.解决问题的关键地方
                    }
                    instance = temp;                                //7.把temp的引用赋值给instance
                }
            }
        }
        return instance;
    }

    private Singleton() {
    }
}

上面给出的代码中,很关键的地方在于在synchronized块中引入了一个临时变量Singleton temp,通过对temp的判空及相应的初始化,保证在代码7处,执行intance = temp;时,instance不为null且完成了初始化


8.内部类方式

(1)实现

在第五部分的结尾我们提到了两个办法来实现线程安全的延迟初始化,内部类方式正是基于第二种方法--线程之间重排序透明性 实现代码为:

代码语言:javascript复制
public class Singleton {
    // 获得对象实例的方法
    public static Singleton getSingleton() {
        return SingletonHolder.instance;
    }

    /**
     * 静态内部类与外部类的实例没有绑定关系,而且只有被调用时才会
     * 加载,从而实现了延迟加载
     */
    private static class SingletonHolder {
        /**
         * 静态初始化器,由JVM来保证线程安全
         */
        private static Singleton instance = new Singleton();
    }

    private Singleton() {
    }
}
(2)原理分析

在这种方式中,使用了一个专门的内部类来初始化Singleton,JVM将推迟SingletonHolder的初始化操作,直到开始使用这个类时才初始化。并且在初始化的过程中JVM会去获取一个用于同步多个线程对同一个类进行初始化的锁,这样就不需要额外的同步。这种方式不仅能够保证线程安全,也能保证单例对象的唯一性,同时也延迟实例化,是一种非常推荐的方式


9.枚举方式

(1)实现方法

从Java1.5起,可以通过使用枚举机制来实现单例模式:

代码语言:javascript复制
public enum Singleton {
    // 定义枚举元素,他就是Singleton的一个实例
    INSTANCE;

    public void doSomething() {
        // do something
    }
}

调用方式

代码语言:javascript复制
Singleton singleton = Singleton.INSTANCE;
singleton.doSomething();

可以看到实现的代码非常的简洁,按照Joshua Bloch大神的原话来说: While this approach has yet to be widely adopted,a single-element enum type is the best way to implement a singleton.

(2)序列化与反序列化的问题

在上述的几种单例模式实现中,在一个情况下它们会出现重新创建对象的情况,那就是反序列化。   通过序列化可以将一个单例的实例对象写到磁盘,然后再读回来,从而有效地获得一个实例。即使构造函数是私有的,反序列化时依然可以通过特殊的途径去创建类的一个新的实例,相当于调用该类的构造函数。反序列化操作提供一个很特别的钩子函数,类中具有一个私有的、被实例化的方法readResolve(),这个方法可以让开发人员控制对象的反序列化。例如,上述几个实例中如果要杜绝单例对象在被反序列化时重新生成对象,那么必须加入如下方法:

代码语言:javascript复制
private Object readResolve() throws ObjectStreamException {
    return INSTANCE;
}

也就是在readResolve方法中将实例对象返回,而不是默认的重新生成一个新的对象。

(3)Java反射攻击

下面我们基于内部类实现的单例模式的方式,来演示一下通过JAVA的反射机制来“攻击”单例模式:

代码语言:javascript复制
public class TestMain {
    public static void main(String[] args) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
        Class<?> classType = Singleton.class;  
        Constructor<?> c = classType.getDeclaredConstructor(null);  
        c.setAccessible(true);  
        Singleton singleton1 = (Singleton) c.newInstance();  
        Singleton singleton2 = Singleton.getSingleton();  
        System.out.println(singleton1 == singleton2);  
    }
}

运行结果:false,可以看到,通过反射获取构造函数,然后调用setAccessible(true)就可以调用私有的构造函数,所有singleton1和singleton2是两个不同的对象。如果要抵御这种攻击,可以修改构造器,让它在被要求创建第二个实例的时候抛出异常。 修改原有代码为:

代码语言:javascript复制
public class Singleton {

    public static Singleton getSingleton() {
        return SingletonHolder.instance;
    }

    private static class SingletonHolder {
        private static Singleton instance = new Singleton();
    }

    private static boolean flag = false; 

    private Singleton() {
        synchronized(Singleton.class) {
            if(flag == false) {
                flag = !flag;
            } else {
                throw new RuntimeException("单例模式被破坏!");  
            }
        }
    }
}

再次运行上面的测试代码:得到的结果为:

代码语言:javascript复制
Exception in thread "main" java.lang.RuntimeException: 单例模式被破坏!
    at com.danli.Singleton.<init>(Singleton.java:29)
    at com.danli.Singleton.getSingleton(Singleton.java:12)
    at com.danli.TestMain.main(TestMain.java:23)

可以看到,成功的阻止了单例模式被破坏。

但是我们如果直接基于枚举方式实现的单例模式进行同样的代码测试,会直接得到结果:

代码语言:javascript复制
Exception in thread "main" java.lang.NoSuchMethodException: com.danli.Singleton.<init>()
    at java.lang.Class.getConstructor0(Class.java:2730)
    at java.lang.Class.getDeclaredConstructor(Class.java:2004)
    at com.danli.TestMain.main(TestMain.java:20)

可以看到,枚举方式实现的单例自己是可以避免反射攻击的

(4)枚举方式的优点

饿汉式、懒汉式、双重校验锁(DCL)还是静态内部类都存在的缺点:

  • 都需要额外的工作(Serializable、transient、readResolve())来实现序列化,否则每次反序列化一个序列化的对象实例时都会创建一个新的实例。
  • 可能会有人使用反射强行调用我们的私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。

枚举类很好的解决了这两个问题,使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。因此,《EffectiveJava》Item3中推荐尽可能地使用枚举来实现单例。   但是在Android中却不推荐这种用法,在Android官网Manage Your App's Memory中有这样一段话:

  • Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

意思就是枚举类这种写法虽然简单方便,但是内存占用上是静态变量的两倍以上,所以尽可能的避免这种写法。   不过网上有的建议是如果程序不是大量采用枚举,那么这种性能的体现是很小的,基本不会受到影响,不用特别在意。如果程序出现了性能问题,理论上这个地方就是一个性能优化点。


10.单例模式的扩展

(1)定义

上文的几种实现方式里,一个类都只产生一个对象。万一有天产品提的需求中,需要一个类只产能产生两三个对象呢?该怎么实现?

这种需要产生固定数量对象的模式就叫做多例模式,实际上就是单例模式的自然推广,作为对象的创建模式,多例模式有以下的特点:

  • 多例类可有多个实例;
  • 多例类必须自己创建,管理自己的实例,并向外界提供自己的实例。
(2)应用实例

喜欢打麻将的同学(捂脸)都知道,每一桌麻将牌局都需要两个骰子,因此骰子就应该是多例类,这里就以这个场景为例来说明多例模式的应用。 实现代码为:

代码语言:javascript复制
public class Die {

    private static Die die1 = new Die();
    private static Die die2 = new Die();

    private Die() {

    }

    public static Die getInstance(int whichOne) {
        if(whichOne == 1) {
            return die1;
        } else {
            return die2;
        }
    }

    public synchronized int dice() {
        Random rand = new Random(System.currentTimeMillis());
        int value = rand.nextInt(6);
        value  = 1;
        return value;
    }
}

在多例类Die中,使用了饿汉式方式创建了两个Die的实例,根据静态工厂方法的参数,工厂方法返还两个实例中的一个,Die对象调用die()方法代表掷骰子,这个方法会返还一个1--6之间的随机数,相当于骰子的点数。

(3)实践原则

一个多例类可以使用静态变量存储所有的实例,特别是实例数目不多的时候,可以使用一个个的静态变量存储一个个的实例。当数目较多的时候,就需要使用Map等集合存储这些实例。   使用这种模式可以让我们在设计时决定在内存中有多少个实例,方便系统进行扩展,修正单例可能存在的性能问题,提高系统的相应速度。例如读取文件,我们可以在系统启动时完成初始化工作,在内存中启动固定数量的reader实例,然后在需要读取文件时就可以快速响应。


11.小结

最后总结一下,不管哪种方案,时刻牢记单例模式的三大要点:

  • 线程安全
  • 延迟加载
  • 序列化与反序列化安全

本文详细的分析了懒汉式,饿汉式,双重检查锁定,静态内部类,枚举五种方式的具体实现原理和优缺点,并简要介绍了单例模式的扩展--多例模式。希望大家看完之后能对单例模式有进一步的了解,并在日常工作中结合具体需求选择适合的单例模式实现。

1 人点赞