日报表格只有一份---单例模式

本文通过一个日报系统的案例,深入浅出地介绍了单例模式的概念及其多种实现方式,包括饿汉式、懒汉式、线程安全懒汉式、双重检查锁定DCL及静态内部类单例,并探讨了各自的优缺点。

前情提要

上集讲到, 小光建立了开分店的标准(工厂), 以后开分店都按照这套标准执行(从CompanyFactory的实现中生产开分店的必须东西), 开分店变得更加容易了.

小光也是马上将自己的这套”开分公司的工厂”投入使用了, 开出了花山软件新城分店.

随着分店越来越多, 小光也请了分别请了店长来”代理”小光之前的职责. 当然, 小光可不能完全放任不管啊, 他想着我至少得知道下每天各个店的基本情况吧.

所有示例源码已经上传到Github, 戳这里

日报制度

小光想到了当时做程序猿时, 敏捷开发每天站立会议的三个问题:

  1. 昨天完成了什么
  2. 今天要做什么
  3. 有什么困难, 阻力

心想, 我也可以根据这个弄个日报制度啊, 让各个店长按照这个格式汇报下当天的战绩:

表格:

public class Form {

    private ArrayList<String> mFormData = new ArrayList<>();

    public void write(String data) {
        mFormData.add(data);
    }

    @Override
    public String toString() {
        return "表格:" + this.hashCode() + ", 数据:" + mFormData;
    }
}

每天让各个店子在打烊之前在系统中拿当日的表格(如果还没有, 就创建一个)来填写数据, 然后提交.

出了问题

想法挺好, 但是刚刚用上, 就出了问题:

光谷店店长表妹登录系统, 发现2016-12-16这个文件夹中还没有表格文件, 于是本地创建了一个, 用来填写数据, 准备稍后提交. 然而此时, 花山店的店长小章也登录了系统, 也发现还没有表格文件, 也创建了一个…

让我们来看下操作:

表妹:

public class Cousins {

    public Form submitReport() {
        Form form = new Form();
        form.write("光谷店数据");
        return form;
    }
}

小章:

public class XiaoZhang {

    public Form submitReport() {
        Form form = new Form();
        form.write("花山店数据");
        return form;
    }
}

两人的使用流程:

public class Demo {

    public static void main(String[] args) {

        Cousins cousins = new Cousins();
        Form form = cousins.submitReport();
        System.out.println(form);

        XiaoZhang xiaoZhang = new XiaoZhang();
        Form form2 = xiaoZhang.submitReport();
        System.out.println(form2);
    }
}

来看下结果:

表格:1639705018, 数据:[光谷店数据]
表格:1627674070, 数据:[花山店数据]

最终这个文件夹中有了两个(不同的)表格, 小光看起来很是不方便…

花山店店长提交的文件覆盖了光谷店店长提交的. 还没有上线协同编辑功能, 大家都是把文件download下来, 修改, 然后提交覆盖系统的, 花山店店长也没有做错什么, 他拿到的文件中本来就没有光谷店长写入的数据.

表格应该只能有一份

表格只能有一份, 小光心想. 那么怎么保证呢, 很简单, 我提前给创建好, 大家通过统一的接口来取这个文件, 而不能自己创建. 这样就不会有问题了:

public class HungryForm extends Form {

    // 提前创建好
    private static HungryForm sInstance = new HungryForm();

    // 私有化的构造, 避免别人直接创建表格
    private HungryForm() {}

    // 店长们通过这个接口来取表格
    public static HungryForm getInstance() {
        return sInstance;
    }
}

店长们这样提交报告:

public class Cousins {

    public Form submitReport() {
        // 直接新建一个表格
        // Form form = new Form();

        // 从固定的接口取表格
        Form form = HungryForm.getInstance();
        form.write("光谷店数据");
        return form;
    }
}

public class XiaoZhang {

    public Form submitReport() {
        // 直接新建一个表格
        // Form form = new Form();

        // 从固定的接口取表格
        Form form = HungryForm.getInstance();
        form.write("花山店数据");
        return form;
    }
}

提交方式不变:

public class Demo {

    public static void main(String[] args) {

        Cousins cousins = new Cousins();
        Form form = cousins.submitReport();
        System.out.println(form);

        XiaoZhang xiaoZhang = new XiaoZhang();
        Form form2 = xiaoZhang.submitReport();
        System.out.println(form2);
    }
}

来看下现在的结果:

表格:1639705018, 数据:[光谷店数据]
表格:1639705018, 数据:[光谷店数据, 花山店数据]

可以看到两人用的是同一份表格(hashCode一样的), 生成的数据也没有问题了.

故事之后

看到这, 同学们应该都看出来了, 小光这就是使用了大名鼎鼎的单例模式.
照例, 看下类图, 这个应该是最简单的类图了:

单例模式
保证一个类(HungryForm)仅有一个实例(sInstance), 并提供一个访问该实例的全局访问点(getInstance).
这就意味着单例通常有如下两个特点:
1. 构造函数是私有的(避免别的地方创建它)
2. 有一个static的方法来对外提供一个该单例的实例.

扩展阅读一

同学们可能注意到了, 我们在这个单例模式中使用了Hungry这个词, 没错, 我们这里实现单例的方式使用的就是饿汉式.

1, 饿汉式单例

饿汉式单例
顾名思义, 就是很饿, 不管三七二十一先创建了一个实例放着, 而不管最终用不用.

然而, 这个单例可能最终并不需要, 如果提前就创建好, 就会浪费内存空间了.
例如, 我们这个故事中, 年底假期中, 所有店子都歇业十天, 这十天就没有任何店长会去访问这个表格, 然而小光还是都每天都创建了, 这就造成了空间浪费(假设这个表格数据(对象实例)很大…)

2, 懒汉式单例

那么怎么办呢?
我们可以使用懒汉式单例:

public class LazyForm extends Form {

    private static LazyForm sInstance;

    // 私有化的构造, 避免别人直接创建表格
    private LazyForm() {}

    // 店长们通过这个接口来取表格
    public static LazyForm getInstance() {

        // 在有店长访问该文件时才创建, 通过判断当前文件是否存在(sInstance == null)来避免重复创建
        if (sInstance == null) {
            sInstance = new LazyForm();
        }
        return sInstance;
    }
}

懒汉式单例
“懒”, 也就是现在懒得创建, 等有用户要用的时候才创建.

3, 线程安全的懒汉式单例

但是这样创建也会有问题啊, 因为他是通过sInstance == null判断当前是否已经存在表格文件的, 假设有两个店长同时调用getInstance来取文件, 同时走到sInstance == null判断这一步, 就会出问题了 — 有可能创建了两个文件(实例), 就达不到单例的目的了.

所以说这种懒汉式是线程不安全的, 在多线程环境下, 并不能做到单例.

那么, 该如何做, 既能懒加载, 又线程安全呢?
我们都知道Java中多线程环境往往会用到synchronized关键字, 通过他来做线程并发性控制.

synchronized方法控制对类成员变量的访问, 每个类实例对应一把锁, synchronized修饰的方法必须获得调用该方法的类实例的锁方能执行, 否则所属线程阻塞. 方法一旦执行, 就独占该锁. 直到从该方法返回时才将锁释放. 此后被阻塞的线程方能获得该锁, 重新进入可执行状态.

让我们来看下线程安全的懒汉式单例:

public class SynchronizedLazyForm extends Form {

    private static SynchronizedLazyForm sInstance;

    // 私有化的构造, 避免别人直接创建表格
    private SynchronizedLazyForm() {}

    // 店长们通过这个接口来取表格
    // 注意, 这是一个synchronized方法
    // 参考https://docs.oracle.com/javase/tutorial/essential/concurrency/syncmeth.html
    public static synchronized SynchronizedLazyForm getInstance() {

        // 在有店长访问该文件时才创建, 通过判断当前文件是否存在(sInstance == null)来避免重复创建
        if (sInstance == null) {
            sInstance = new SynchronizedLazyForm();
        }
        return sInstance;
    }
}

线程安全的懒汉式单例
利用synchronized关键字来修饰对外提供该类唯一实例的接口(getInstance)来确保在一个线程调用该接口时能阻塞(block)另一个线程的调用, 从而达到多线程安全, 避免重复创建单例.

然而, synchronized有很大的性能开销. 而且在这里我们是修饰了getInstance方法, 意味着, 如果getInstance被很多线程频繁调用时, 每次都会做同步检查, 会导致程序性能下降.

实际上我们要的是单例, 当单例已经存在的时候, 我们是不需要用同步方法来控制的. 一如我们第一种单例的实现—饿汉模式单例, 我们一开始就创建好了单例, 就无需担心线程同步问题.

但是饿汉模式是提前创建, 那么我们怎么能做到延迟创建, 且线程安全, 且性能有所提升呢?

4, 双重检查锁定DCL(Double-Checked Locking)单例

如上所言, 我们想要的是单例, 故而单例已经存在的情况下我们无需做同步检查, 如下实现:

public class DCLForm extends Form {

    // 注意, 这里我们引入了volatile关键字
    private volatile static DCLForm sInstance;

    // 私有化的构造, 避免别人直接创建表格
    private DCLForm() {}

    // 店长们通过这个接口来取表格
    public static DCLForm getInstance() {

        // 第一次检查
        if (sInstance == null) {
            // 第一次调用getInstance时, sInstance为空, 进入此分支
            // 使用synchronized block来确保多线程的安全
            synchronized (DCLForm.class) {
                // 第二次检查
                if (sInstance == null) {
                    sInstance = new DCLForm();
                }
            }
        }
        return sInstance;
    }
}
  1. 舍弃了同步方法
  2. 在getInstance时, 先检查单例是否已经存在, 如果存在了, 我们无需同步操作了, 任何线程过来直接取单例就行, 大大提升了性能.
  3. 若单例不存在(第一次调用时), 使用synchronized同步代码块, 来确保进入的只有一个线程, 在此再做一次单例存在与否的检查, 进而创建出单例.

这样就保证了:

  1. 在单例还没有创建时, 多个线程同时调用getInsance时, 保证只有一个线程能够执行sInstance = new DCLForm()创建单例.
  2. 在单例已经存在时, getInsance没有加锁, 直接访问, 访问创建好的单例, 从而达到性能提升.

注意
这里我们对sInstance使用的volatile关键字
具体原因和原理, 请参考这篇文章, 讲的很详细.

然而, 使用volatile关键字的双重检查方案需要JDK5及以上(因为从JDK5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义).

那么我们还有什么更通用的方式能保证多线程单例创建, 以及懒加载方式呢?

5, 静态内部类单例
public class StaticInnerClassForm extends Form {

    // 私有化的构造, 避免别人直接创建表格
    private StaticInnerClassForm() {}

    // 店长们通过这个接口来取表格
    public static StaticInnerClassForm getInstance() {
       return FormHolder.INSTANCE;
    }

    // 在静态内部类中实例化该单例
    private static class FormHolder {
       private static final StaticInnerClassForm INSTANCE = new StaticInnerClassForm();
    }
}

这种方式, 通过JVM的类加载方式(虚拟机会保证一个类的初始化在多线程环境中被正确的加锁、同步), 来保证了多线程并发访问的正确性.

另外, 由于静态内部类的加载特性 — 在使用时才加载, 这种方式也达成了懒加载的目的.

显然, 这种方式是一种比较完美的单例模式. 当然, 它也有其弊端, 依赖特定编程语言, 适用于JAVA平台.


还有很多单例的实现模式, 例如利用JDK 5起的Enum 枚举单例模式, 使用容器类管理的单例模式等, 在此就不一一说了, 网上都比较泛滥了…

从使用上, 如果是单线程环境的, 个人推荐使用第二种懒汉式单例, 简单便捷. 如果考虑多线程同步的话, 推荐使用第五种静态内部类单例, 确保同步且懒加载完美结合.


好了, 小光创建并改善了一套完整的日报系统. 这样, 他每天就可以看到各个分店的战况了, 也能根据各个店的问题, 来及时协调资源解决, 保证各个分店的良好运转了.

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值