最清晰易懂详解单例模式

69 阅读5分钟

1.什么是单例模式?

采取一定的办法保证在整个软件系统中,确保对于某个类只能存在一个实例。单例模式有如下三个特点:

  ①、单例类只能有一个实例

  ②、单例类必须自己创建自己的实例

  ③、单例类必须提供外界获取这个实例的方法

2.单例模式设计思想

①、外界不能创建这个类的实例,那么必须将构造器私有化。

image.png

  ②、单例类必须自己创建自己的实例,不能允许在类的外部修改内部创建的实例。

  比如将这个实例用 private 声明。为了外界能访问到这个实例,我们还必须提供 get 方法得到这个实例。因为外界不能 new 这个类,所以我们必须用 static 来修饰字段和方法。

image.png

  ③、是否支持延迟加载?

  有些情况下,创建某个实例耗时长,占用资源多,用的时候也少,我们会考虑在用到的时候才会去创建,这就是延迟加载。

  但有些情况,按照 fail-fast 的设计原则(有问题及早暴露),比如某个实例占用资源很多,如果延迟加载,会在程序运行一段时间后OOM,如果在程序启动的时候就创建这个实例,我们就可以立即去修复,不会导致程序运行之后的系统奔溃。

  所以,是否支持延迟加载需要结合实际情况考虑。

  ④、保证线程安全

  这个是一定要考虑的,如果你写的单例类存在线程安全问题,那就是伪单例了

3.单例类的实现方式

1.饿汉模式

image.png

image.png 这种模式在类加载的时候实例 singleton 就已经创建并初始化好了,所以是线程安全的。

  不过这种模式不支持延迟加载,有可能这个实例化过程很长,那么就会加大类装载的时间;有可能这个实例现阶段根本用不到,那么创建了这个实例,也会浪费内存。但是还是我们前面说的,是否支持延迟加载,需要结合实际情况考虑。

2.懒汉模式

我们知道编译就是将源代码翻译成机械码的过程,而Java虚拟机的目标代码不是本地机器码,而是虚拟机代码。编译原理里面有个过程是编译优化,就是指在不改变原来语义的情况下,通过调整语句的顺序,来让程序运行的更快,这个过程称为 reorder。

  JVM 只是一个标准,它并没有规定有关编译器优化的内容,也就是说,JVM可以自由的实现编译器优化。

  那么我们来再来考虑一下,创建一个变量需要哪些步骤?

    ①、申请一块内存,调用构造方法进行初始化

    ②、分配一个指针指向该内存

  而这两步谁先谁后呢?也就是存在这样一种情况:先开辟一块内存,然后分配一个指针指向该内存,最后调用构造方法进行初始化。 

那么针对单例模式的设计,就会存在这样一个问题:线程 A 开始创建 Singleton 的实例,此时线程 B已经调用了 getInstance的()方法,首先判断 instance 是否为 null。而我们上面说的那种模型, A 已经把 instance 指向了那块内存,只是还没来得及调用构造方法进行初始化,因此 B 检测到 instance 不为 null,于是直接把  instance 返回了。那么问题出现了:尽管 instance 不为 null,但是 A 并没有构造完成,就像一套房子已经给了你钥匙,但是里面还没有装修,你并不能住进去。

解决方案:使用 volatile 关键字修饰 instance

我们知道在当前的Java内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。

image.png

4.单例模式的应用

说了那么多,那么单例模式在实际项目中有啥用呢?

  还是根据其核心概念,某个数据在系统中只能存在一份,就可以设计为单例。

  1、windows 系统的回收站,我们能在任何盘符删除数据,但是最后都是到了回收站中

  2、网站的计数器,不采用单例模式,很难实现同步

  3、数据库连接池,可以节省打开或关闭数据库连接所引起的效率损耗,用单例模式来维护,可以大大降低这种损耗。当然对于海量数据系统,会存在多个数据库连接池,比如一个能够快速执行SQL的连接池,还有一个是慢SQL,如果都放在一个池里面,会导致慢SQL执行的时候,长时间占用数据库连接资源,导致其他SQL请求无法响应。

  4、系统的配置信息类,通常只存在一个。