「补课」进行时:设计模式(1)——人人都能应该懂的单例模式

1. 引言

最近在看秦小波老师的《设计模式之禅》这本书,里面有句话对我触动挺大的。

设计模式已经诞近 20 年了,其间出版了很多关于它的经典著作,相信大家都能如数家珍。尽管有这么多书,工作 5 年了还不知道什么是策略模式、状态模式、责任链模式的程序员大有人在。

很不幸,我就是这部分人当中的一个。回想起这几年的工作生涯,设计模式不能说没有接触过,但是绝对不多,能想到的随手写出来的几个设计模式也仅限于「单例模式」、「工厂模式」、「建造者模式」、「代理模式」、「装饰模式」。

好吧,我认知比较深的也就这几个模式,说出来都自己感觉脸红,还有很大一部分仅限于听过,说了以后大致知道是什么玩意,没有细细的研究过,正好趁着这个机会,写点文章,给自己补补课,所以这个系列的名字叫「补课」进行时。

至于为什么要选设计模式,因为设计模式这个东西,它是软件行业的经验总结,因此它具有更广泛的适应性,不管你使用什么编程语言,不管你遇到什么业务类型,都需要用到它。

因为它是一个指导思想,学习了它以后,我们可以站在一个更高的层次去赏析程序代码、软件设计、架构,完成一个 Coder 的蜕变。

2. 单例模式

在古代行军打仗的时候,每支军队都要有一个将军,战场上如何作战,完全需要听将军的指挥,将军怎么说,这个仗就怎么打,每个士兵都知道将军是谁,而不需要在将军前面加上张将军或者是李将军。

既然将军只能有一个,我们需要用程序去实现这个将军的话,也就是一个类只能产生一个将军的对象,不能产生多个,这就是单例模式的要义。

产生一个对象有多重方式,最常见的是直接 new 一个出来,当然,还可以有反射、复制等操作,我们如何来控制一个类只能产生一个对象呢?

最简单的做法是直接在构造函数上动手脚,使用 new 来新建对象的时候,会根据输入的参数调用相应的构造函数,我们如果直接把构造函数设置成 private ,这样就可以做到不允许外部类来访问创建对象,从而保证对象的唯一性。

public class General {
    // 初始化一个将军
    private static final General general = new General();

    // 构造函数私有化
    private General() {

    }

    public static General getInstance() {
        return general;
    }

    public void command() {
        System.out.println("将军下令,兄弟们跟我上啊!!!");
    }
}

现在我们有了一个将军类,接下来我们实现一个士兵类:

public class Soldier {
    public static void main(String[] args) {
        for (int soldiers = 0; soldiers < 5; soldiers++) {
            General general = General.getInstance();
            general.command();
        }
    }
}

有 5 个士兵收到了将军的命令,跟着将军一起冲锋陷阵,成就一世英名。

单例模式(Singleton Pattern)的定义异常简单:Ensure a class has only one instance, and provide a global point of accessto it.(确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。)

优点:

由于单例模式在内存中只有一个实例,减少了内存开支,特别是一个对象需要频繁地创建、销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显。

缺点:

单例模式一般没有接口,扩展很困难,若要扩展,除了修改代码基本上没有第二种途径可以实现。

注意事项:

在某些有一定并发的场景中,需要注意线程同步的问题,防止创建多个对象,造成未知错误异常。

因为单例模式有多种变形的写法,一定要注意这个问题,举一个会产生线程同步问题的例子:

public class Singleton {
    private static Singleton singleton = null;

    private Singleton() {
    }

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

这种方案在没有并发的情况下不会出现任何问题,但若是出现了并发,就会在内存中产生多个实例。

原因是线程 A 在执行到 singleton = new Singleton() 这句话的时候,但是还没有完成实例的初始化操作,线程 B 恰巧执行到了 singleton == null 的判断,这时,线程 B 判断条件为真,也去执行 singleton 初始化的这句代码,就会造成线程 A 获得了一个对象,线程 B 也获得了一个对象。

解决线程不安全的方式有很多种,比如加一个 synchronized 关键字。

public class Singleton1 {
    private static Singleton1 singleton1 = null;

    private Singleton1() {
    }

    public static synchronized Singleton1 getInstance() {
        if (singleton1 == null) {
            singleton1 = new Singleton1();
        }
        return singleton1;
    }
}

这种在代码块中使用 synchronized 关键字的方式名字叫做懒汉式单例,前面我们写的那个将军叫做饿汉式单例。

饿汉式和懒汉式的命名很有意思:

  • 饿汉:类一旦加载,就把单例初始化完成,保证 getInstance 的时候,单例是已经存在的了。
  • 懒汉:懒汉比较懒,只有当调用getInstance的时候,才回去初始化这个单例。

饿汉式天生就是线程安全的,可以直接用于多线程而不会出现问题,懒汉式本身是非线程安全的,为了实现线程安全有几种写法,上面那种加方法锁的方式有点笨重,我们还可以使用同步代码块,减少锁的颗粒大小。

public class Singleton2 {
    private static volatile Singleton2 singleton2;

    private Singleton2() {
    }

    public static synchronized Singleton2 getInstance() {
        // 第一层检查,检查是否有引用指向对象,高并发情况下会有多个线程同时进入
        if(singleton2 == null) {
            // 第一层锁,保证只有一个线程进入
            synchronized (Singleton2.class) {
                // 第二层检查
                if (singleton2 == null) {
                    // volatile 关键字作用为禁止指令重排,保证返回 Singleton 对象一定在创建对象后
                    singleton2 = new Singleton2();
                }
            }
        }
        return singleton2;
    }
}

关于 volatile 关键字多说两句,如果对象没有 volatile 关键字,这里会涉及到一个指令重排序问题, singleton2 = new Singleton2() 这句话实际上会涉及到以下三件事儿:

  1. 申请一块内存空间。

  2. 在这块空间里实例化对象。

  3. singleton2 的引用指向这块空间地址。

对于以上步骤,指令重排序很有可能不是按上面 123 步骤依次执行的。比如,先执行 1 申请一块内存空间,然后执行 3 步骤, singleton2 的引用去指向刚刚申请的内存空间地址,那么,当它再去执行 2 步骤,判断 singleton2 时,由于 singleton2 已经指向了某一地址,它就不会再为 null 了,因此,也就不会实例化对象了。

而我们添加的关键字 volatile 就是为了解决这个问题,因为 volatile 可以禁止指令重排序。

不过还是建议大家使用饿汉式的单例模式,毕竟比较简单,出错的概率比较低。

2.1 单例模式扩展——上限的多例模式

还是刚才那个例子,如果一只军队中,偶然情况下出现了 3 个将军,士兵需要听从这 3 个将军的命令,我们用代码实现一下,这段代码稍微有点长:

public class General1 {
    // 定义最多能产生的将军数量
    private static int maxNumOfGeneral1 = 3;

    // 定义一个列表,存放所有将军的名字
    private static ArrayList<String> nameList = new ArrayList<> ();

    // 定义一个列表,容纳所有的将军实例
    private static ArrayList<General1> general1ArrayList = new ArrayList<> ();

    // 定义当前将军序号
    private static int countNumOfGeneral1 = 0;

    // 在静态代码块中产生所有的将军
    static {
        for (int i = 0; i < maxNumOfGeneral1; i++) {
            general1ArrayList.add(new General1(String.valueOf(i)));
        }
    }

    private General1() {
        // 目的是不产生将军
    }

    private General1(String name) {
        // 给将军加个名字,建立一个将军对象
        nameList.add(name);
    }

    public static General1 getInstance() {
        // 随机产生一个将军,只要能发号施令就成
        Random random = new Random();
        countNumOfGeneral1 = random.nextInt(maxNumOfGeneral1);
        return general1ArrayList.get(countNumOfGeneral1);
    }

    public void command() {
        System.out.println("将军说:我是 " + nameList.get(countNumOfGeneral1) + " 号将军");
    }
}

上面这段代码使用了两个 ArrayList 分别存储实例和实例变量。

如果考虑到线程安全的问题,可以使用 Vector 来代替,或者加锁等方式。

我们再创建一个士兵类,等将军发号施令:

public class Soldier1 {
    public static void main(String[] args) {
        for (int soldiers1 = 0; soldiers1 < 5; soldiers1++) {
            General1 general = General1.getInstance();
            general.command();
        }
    }
}

结果是这样的:

将军说:我是 0 号将军
将军说:我是 0 号将军
将军说:我是 1 号将军
将军说:我是 0 号将军
将军说:我是 2 号将军

这种需要产生固定数量对象的模式就叫做有上限的多例模式,它是单例模式的一种扩展,采用有上限的多例模式,我们可以在设计时决定在内存中有多少个实例,方便系统进行扩展,修正单例可能存在的性能问题,提供系统的响应速度。例如读取文件,我们可以在系统启动时完成初始化工作,在内存中启动固定数量的 reader 实例,然后在需要读取文件时就可以快速响应。