查看原文
其他

漫话:如何给女朋友解释什么是单例模式?

漫话编程 漫话编程 2019-11-28

周末了,临近五一劳动节,女朋友还没有想好要去哪里玩,还在看着各种攻略。我则在旁边一边看书默默的心疼着我的钱包。突然女朋友开始发问:

什么是单例

单例模式,也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。

许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。

举个简单的例子,就像中国的一夫一妻制度,夫妻之间只能是一对一的,也就是说,一个男子同时只能有一个老婆。这种情况就叫做单例。在中国,是通过《婚姻法》来限制一夫一妻制的。

男女双方来到民政局登记
if 男方目前已经有老婆{
提醒二位无法结婚。并告知其当前老婆是谁。
}else{
检查女方婚姻状况,其他基本信息核实。
同意双方结为夫妻。
}

对于代码开发中,一个类同时只有一个实例对象的情况就叫做单例。那么,如何保证一个类只能有一个对象呢?

我们知道,在面向对象的思想中,通过类的构造函数可以创建对象,只要内存足够,可以创建任意个对象。

所以,要想限制某一个类只有一个单例对象,就需要在他的构造函数上下功夫。

实现对象单例模式的思路是:

1、一个类能返回对象一个引用(永远是同一个)和一个获得该实例的方法(必须是静态方法,通常使用getInstance这个名称);

2、当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用;

3、同时我们还将该类的构造函数定义为私有方法,这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例。

public class Singleton {  
    private static Singleton instance;  
    private Singleton (){}  

    public static Singleton getInstance() {  
    if (instance == null) {  
        instance = new Singleton();  
    }  
    return instance;  
    }  
}  

以上Java代码,就实现了一个简单的单例模式。我们通过将构造方法定义为私有,然后提供一个getInstance方法,该方法中来判断是否已经存在该类的实例,如果存在直接返回。如果不存在则创建一个再返回。

线程安全的单例

关于并发,可以参考《如何给女朋友解释什么是并行和并发》

在中国,想要拥有一个妻子,需要男女双方带着各自的户口本一起去民政局领证。民政局的工作人员会先在系统中查询双方的婚姻状况,然后再办理登记手续。之所以可以保证一夫一妻登记成功的前提是不会发生并发问题。

假设某男子可以做到在同一时间分别和两个不同的女子来登记,就有一种概率是当工作人员查询的时候他并没有结婚,然后就可能给他登记两次结婚。当然,这种情况在现实生活中是根本不可能发生的。

但是,在程序中,一旦有多线程场景,这种情况就很常见。就像上面的代码。

如果有两个线程同时执行到if(instance==null)这行代码,这是判断都会通过,然后各自会执行instance = new Singleton();并各自返回一个instance,这时候就产生了多个实例,就没有保证单例!

上面这种单例的实现方式我们通常称之为懒汉模式,所谓懒汉,指的是只有在需要对象的时候才会生成(getInstance方法被调用的时候才会生成)。这有点像现实生活中有一种"生米煮成熟饭"的情况,到了一定要结婚的时候才开始去领证。

上面的这种懒汉模式并不是线程安全的,所以并不建议在日常开发中使用。基于这种模式,我们可以实现一个线程安全的单例的,如下:

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

通过在getInstance方法上增加synchronized,通过锁来解决并发问题。这种实现方式就不会发生有多个对象被创建的问题了。

双重校验锁

上面这种线程安全的懒汉写法能够在多线程中很好的工作,但是,遗憾的是,这种做法效率很低,因为只有第一次初始化的时候才需要进行并发控制,大多数情况下是不需要同步的。

我们其实可以把上述代码做一些优化的,因为懒汉模式中使用synchronized定义一个同步方法,我们知道,synchronized还可以用来定义同步代码块,而同步代码块的粒度要比同步方法小一些,从而效率就会高一些。如以下代码:

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    if (singleton == null) {  
        synchronized (Singleton.class) {  
        if (singleton == null) {  
            singleton = new Singleton();  
        }  
        }  
    }  
    return singleton;  
    }  
}  

上面这种形式,只有在singleton == null的情况下再进行加锁创建对象,如果singleton!=null的话,就直接返回就行了,并没有进行并发控制。大大的提升了效率。

从上面的代码中可以看到,其实整个过程中进行了两次singleton == null的判断,所以这种方法被称之为"双重校验锁"。

还有值得注意的是,双重校验锁的实现方式中,静态成员变量singleton必须通过volatile来修饰,保证其初始化不被重排,否则可能被引用到一个未初始化完成的对象。

为什么双重校验锁需要使用volatile来修饰静态成员变量singleton?为什么线程安全的懒汉就不需要呢?关于这个问题,后续文章深入讲解。

饿汉模式

前面提到的懒汉模式,其实是一种lazy-loading思想的实践,这种实现有一个比较大的好处,就是只有真正用到的时候才创建,如果没被使用到,就一直不会被创建,这就避免了不必要的开销。

但是这种做法,其实也有一个小缺点,就是第一次使用的时候,需要进行初始化操作,可能会有比较高的耗时。如果是已知某一个对象一定会使用到的话,其实可以采用一种饿汉的实现方式。

所谓饿汉,就是事先准备好,需要的时候直接给你就行了。这就是日常中比较常见的"先买票后上车",走正常的手续。

如以下代码,饿汉模式:

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

或者以下代码,饿汉变种:

public class Singleton {  
    private Singleton instance = null;  
    static {  
    instance = new Singleton();  
    }  
    private Singleton (){}  
    public static Singleton getInstance() {  
    return this.instance;  
    }  
}  

以上两段代码其实没有本质的区别,都是通过static来实例化类对象。饿汉模式中的静态变量是随着类加载时被完成初始化的。饿汉变种中的静态代码块也会随着类的加载一块执行。

以上两个饿汉方法,其实都是通过定义静态的成员变量,以保证instance可以在类初始化的时候被实例化。

因为类的初始化是由ClassLoader完成的,这其实是利用了ClassLoader的线程安全机制。ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。也正是因为这样, 除非被重写,这个方法默认在整个装载过程中都是同步的(线程安全的)

除了以上两种饿汉方式,还有一种实现方式也是借助了calss的初始化来实现的,那就是通过静态内部类来实现的单例:

public class Singleton {  
    private static class SingletonHolder {  
    private static final Singleton INSTANCE = new Singleton();  
    }  
    private Singleton (){}  
    public static final Singleton getInstance() {  
    return SingletonHolder.INSTANCE;  
    }  
}  

前面提到的饿汉模式,只要Singleton类被装载了,那么instance就会被实例化。

而这种方式是Singleton类被装载了,instance不一定被初始化。因为SingletonHolder类没有被主动使用,只有显示通过调用getInstance方法时,才会显示装载SingletonHolder类,从而实例化instance。

使用静态内部类,借助了classloader来实现了线程安全,这与饿汉模式有着异曲同工之妙,但是他有兼顾了懒汉模式的lazy-loading功能,相比较之下,有很大优势。

单例的破坏

前文介绍过,我们实现的单例,把构造方法设置为私有方法来避免外部调用是很重要的一个前提。但是,私有的构造方法外部真的就完全不能调用了么?

其实不是的,我们是可以通过反射来调用类中的私有方法的,构造方法也不例外,所以,我们可以通过反射来破坏单例。

除了这种情况,还有一种比较容易被忽视的情况,那就是其实对象的序列化和反序列化也会破坏单例。

如使用ObjectInputStream进行反序列化时,在ObjectInputStream的readObject生成对象的过程中,其实会通过反射的方式调用无参构造方法新建一个对象。

所以,在对单例对象进行序列化以及反序列化的时候,一定要考虑到这种单例可能被破坏的情况。

可以通过在Singleton类中定义readResolve的方式,解决该问题:

/**
 * 使用双重校验锁方式实现单例
 */

public class Singleton implements Serializable{
    private volatile static Singleton singleton;
    private Singleton (){}
    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

    private Object readResolve() {
        return singleton;
    }

枚举实现单例

在StakcOverflow中,有一个关于What is an efficient way to implement a singleton pattern in Java?的讨论:

如上图,得票率最高的回答是:使用枚举。

回答者引用了Joshua Bloch大神在《Effective Java》中明确表达过的观点:

使用枚举实现单例的方法虽然还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。

如果你真的深入理解了单例的用法以及一些可能存在的坑的话,那么你也许也能得到相同的结论,那就是:使用枚举实现单例是一种很好的方法。

枚举实现单例:

public enum Singleton {  
    INSTANCE;  
    public void whateverMethod() {  
    }  
}  

以上,就实现了一个非常简单的单例,从代码行数上看,他比之前介绍过的任何一种都要精简,并且,他还是线程安全的。

这些,其实还不足以说服我们这种方式最优。但是还有个至关重要的原因,那就是:枚举可解决反序列化会破坏单例的问题

关于这个知识点,大家可以参考《为什么我墙裂建议大家使用枚举来实现单例》这篇文章,里面详细的阐述了关于枚举与单例的所有知识点。

不使用synchronized实现单例

前面讲过的所有方式,只要是线程安全的,其实都直接或者间接用到了synchronized,那么,如果不能使用synchronized的话,怎么实现单例呢?

使用Lock?这当然可以了,但是其实根本还是加锁,有没有不用锁的方式呢?

答案是有的,那就是CAS。CAS是一项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

在JDK1.5 中新增java.util.concurrent(J.U.C)就是建立在CAS之上的。相对于对于synchronized这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。

借助CAS(AtomicReference)实现单例模式:

public class Singleton {
    private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>(); 

    private Singleton() {}

    public static Singleton getInstance() {
        for (;;) {
            Singleton singleton = INSTANCE.get();
            if (null != singleton) {
                return singleton;
            }

            singleton = new Singleton();
            if (INSTANCE.compareAndSet(null, singleton)) {
                return singleton;
            }
        }
    }
}

用CAS的好处在于不需要使用传统的锁机制来保证线程安全,CAS是一种基于忙等待的算法,依赖底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并行度。

使用CAS实现单例只是个思路而已,只是拓展一下帮助读者熟练掌握CAS以及单例等知识、千万不要在代码中使用!!!这个代码其实有很大的优化空间。聪明的你,知道以上代码存在哪些隐患吗?

推荐阅读:


喜欢我可以给我设为星标哦


Modified on

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存