深入分析单件模式

本次主要介绍的内容有

  • 单件模式
  • 单线程下的单件模式实现
  • 多线程下实现单件模式出现的问题分析
  • JMM内存模型
  • 多线程下的单件模式实现的三种方式

这些内容,可以从最根本理解单例模式的代码,不信你就来看看吧。

单件模式:

确保一个类只有一个实例,并提供一个全局访问点。

单线程下的单件模式的实现

在单线程下,不存在线程安全的问题,所以完成一个单件模式非常容易。
Singleton

package com.bestqiang.singleton;/*** @author BestQiang*/
public class Singleton {private static Singleton uniqueInstance;// 这里是其他的有用实例化变量private Singleton() {}// 用getInstance方法实例化对象,并返回这个实例。public static Singleton getInstance() {if(uniqueInstance == null) {uniqueInstance = new Singleton();}// 不为空就直接返回,保证只有一个实例return uniqueInstance;}
}

Main线程中调用getInstance方法获取实例

package com.bestqiang.singleton;/*** @author BestQiang*/
public class Main {public static void main(String[] args) {for (int i = 0; i < 10; i++) {Singleton instance = Singleton.getInstance();System.out.println(instance);}}
}

打印结果如下,这里可以看出,打印出的地址都是相同的,说明获取的是同一个实例。

com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c
com.bestqiang.singleton.Singleton@4554617c

多线程下实现单件模式出现的问题

在这里我用线程池开启了10个线程,分别调用getInstance()方法获取对象,并打印响应的线程名和对象的地址:

package com.bestqiang.multithreading;import com.bestqiang.singleton.Singleton;import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;/*** @author BestQiang*/
public class Main {public static void main(String[] args) {ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 10, 5L, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(5));try {for (int i = 0; i < 10; i++) {threadPoolExecutor.execute(() -> {Singleton ins = Singleton.getInstance();System.out.println(Thread.currentThread().getName() + "\t 对象地址: " + ins);});}} catch (Exception e) {e.printStackTrace();} finally {threadPoolExecutor.shutdown();}}
}

运行结果:

pool-1-thread-8   对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-7  对象地址: com.bestqiang.singleton.Singleton@42970371
pool-1-thread-5  对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-3  对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-4  对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-10     对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-1  对象地址: com.bestqiang.singleton.Singleton@3f41a236
pool-1-thread-6  对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-9  对象地址: com.bestqiang.singleton.Singleton@4c680a74
pool-1-thread-2  对象地址: com.bestqiang.singleton.Singleton@4c680a74Process finished with exit code 0

上图中,地址出现了不同的现象,这不是单例模式吗?为什么获取的对象会出现不同?

内存不可见问题:

当判断 if(uniqueInstance == null) 时,不同线程的本地内存都有uniqueInstance 的副本,这个副本可以理解为从主内存获取,然后放到本地内存,如下图JMM内存模型所示,注意这个本地内存是虚拟的,其实并不存在。

当线程更改本地内存中的值的时候,会刷新到主内存。使用的时候,本地内存有副本,那就不必再从主内存加载值了。
比如现在线程A和线程B使用不同的CPU执行,
第一种情况:
现在线程A启动,发现本地内存没有uniqueInstance的副本,然后就从主内存获取,获取后,新建了Singleton对象,赋值给 uniqueInstance,然后刷新给主内存。线程B启动时发现自己本地内存没有uniqueInstance,然后从主内存获取,存在本地缓存中,此时这个变量已经被线程A赋值过了,不为空,就直接返回这个对象,这种情况下,是正常的。

第二种情况,线程A启动,发现本地内存没有uniqueInstance的副本,然后就从主内存获取,获取后,新建了Singleton对象,赋值给 uniqueInstance,然后刷新给主内存。线程B启动的时候,发现本地内存没有uniqueInstance的副本,然后就从主内存获取,存在本地缓存中(此时线程A修改的值还没有刷新给主内存),获取后,新建了Singleton对象,赋值给 uniqueInstance,然后刷新给主内存。这样一来,就出现了单件模式出现不同对象的情况,造成这种情况的是内存不可见问题导致的。

原子性问题:

如果内存不可见问题有人不了解,那么下面这个问题应该很多人都有所了解
当判断 if(uniqueInstance == null) 时,假设现在uniqueInstance 不存在内存可见性的问题,这个操作包含两步,第一步是从主内存获取,第二部是进行比较,那么A线程获取的时候是null,接下来一瞬间此时B线程对uniqueInstance 进行了修改,产生了一个实例,并刷新到了主内存,但是A线程并不知道,紧接着继续比较,这时候为null,A线程会都执行到方法内部,创建对象,出现了两个实例,对于这种问题,可以使用加锁的方式来解决。

多线程下的单件模式实现的三种方式

第一种:加锁解决线程安全问题

从上面导致线程不安全的问题中,我们了解到单件模式中导致线程不安全的有两个重要因素,可见性和原子性,那么如何解决?加锁是一种较好的方式:
代码如下:

package com.bestqiang.multithreading;import com.bestqiang.singleton.Singleton;/*** @author BestQiang*/
public class Singleton1 {private static Singleton1 uniqueInstance;// 其他有用的实例化的变量private Singleton1() {};public static synchronized Singleton1 getInstance() {if(uniqueInstance == null) {uniqueInstance = new Singleton1();}return uniqueInstance;}// 其他有用的方法
}

有同学在这里可能会疑惑,为什么加synchronized锁就解决了原子性和可见性的问题?
这里我科普一下:
synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它单做一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫监视器锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

synchronized的内存语义(重点):

进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接冲主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。

从它的内存语义中可得,它解决了变量的可见性的问题。它是Java提供的一种原子内置锁,解决了原子性的问题,二者都得到解决,所以,用它来实现同步方法,非常合适。

第二种:使用“急切”创建实例,而不用延迟实例化的做法

上面的加同步锁的方法,会大大降低程序的性能,只有第一次执行此方法时,才真正需要同步。换句话说,一旦设置好uniqueInstance变量,就不再需要同步这个方法了。之后每次调用这个方法,同步都是一种累赘。
如何改善呢?有一种方法简单有效,就是使用“急切”创建实例。话不多说,代码亮出来,就能明白了:

package com.bestqiang.multithreading;import com.bestqiang.singleton.Singleton;/*** @author BestQiang*/
public class Singleton2 {// 在静态初始化器中创建单件。这段代码保证了线程安全。private static Singleton2 uniqueInstance = new Singleton2();private Singleton2() {};public static Singleton2 getInstance() {return uniqueInstance;}
}

其中静态单件在类的生命周期的连接的阶段创建,JVM在类的初始化方法<clinit>中创建。然后在jdk1.8的环境下存在堆中,类的元信息存在方法区。
对于类的生命周期和JVM,可以从下面两篇文章做一下了解
"init"与"clinit"的区别
深入分析ClassLoader工作机制

因为uniqueInstance 创建过后就没有再改动,所以,不会出现线程安全的问题。

第三种:用“双重检查加锁”,在getInstance()中减少使用同步

利用双重检查加锁(double-checked locking),首先检查是否实例已经创建 了,如果尚未创建,"才"进行同步。这样一来,只有第一次会同步,这正是我们想要的。

代码如下:

package com.bestqiang.multithreading;import com.bestqiang.singleton.Singleton;/*** @author BestQiang*/
public class Singleton3 {private volatile static Singleton3 uniqueInstance;// 这里是其他的有用实例化变量private Singleton3() {}// 用getInstance方法实例化对象,并返回这个实例。public static Singleton3 getInstance() {// 检查视力,如果不存在,就进入同步区块,只有第一次,才彻底的执行这里的代码if(uniqueInstance == null) {// 进入区块后,再检查一次,如果仍是null,才创建实例synchronized (Singleton.class) {if (uniqueInstance == null) {uniqueInstance = new Singleton3();}}}// 不为空就直接返回,保证只有一个实例return uniqueInstance;}
}

上面的代码中,为了解决对象创建时的指令重排序问题,使用了volatile关键字。为了解决原子性的问题,使用了synchronized 加锁。

volatile关键字(重要)

关于Java中的volatile关键字,在这里做一下介绍:
上面介绍了使用锁的方式可以解决共享变量内存可见性的问题,但是使用锁太笨重因为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java还提供了一种弱形式的同步,也就是使用volatile关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会吧值缓存再寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。volatile的内存语义和synchronized有相似之处,具体来说就是,当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同步块( 先清空本地内存变量值,再从主内存获取最新值)。

第一个方法中提到,synchronized 可解决可见性和原子性的问题,为什么还要用双重锁呢,仔细看看,第一个 if(uniqueInstance == null) 判断存在原子性的问题,因为是先取,后比较,取过来之后可能又会更改,所以在里面嵌套一个 if(uniqueInstance == null),里面这个是加锁的,加上happens-before规则可以保证原子性和可见性,保证uniqueInstance直接从主存中获取,而且,在第一次创建后,因为里面有原子性内置锁,所以uniqueInstance不会再更改,因此外面的 if(uniqueInstance == null) 其实是安全的了,因为获取后,可以保证不再更改,不会因为原子性而造成线程不安全的问题。这样,就做到了只在第一次同步一次,避免了锁影响性能,而又可以懒加载对象。

上面的操作乍一看是没问题的,但是其实存在问题。

对象创建分为三步:

  1. 分配对象的内存空间。memory = allocate();

  2. 初始化对象。instance = memory;

  3. 设置instance指向内存空间。ctorInstance(memory);

这不是一个原子性操作,但即使不是原子性,这个操作也是没问题的,问题出在这个操作会进行重排序,可能第二部和第三步的顺序会发生变化,这时候第3步如果先执行,那么判断对象的值会依然为空,导致其他对象继续创建,导致单例模式的失败。

为什么要用volatile关键字呢?原因是volatile不仅仅可以保证程序的可见性,而且可以禁止指令重排序。至此,这个问题解决了。

注意: jdk 1.4 及更早的版本中,许多JVM对于volatile关键字的实现会导致双重检查加锁的失效。如果不能使用Java 1.4以上的版本,而必须使用旧版的Java,就请不要利用此技巧实现单件模式。

本次对单例模式的实现做了相对深入的分析,希望读完这篇文章的朋友都能有所收获,共同进步。

参考的书籍:
《并发编程之美》,
《HeadFirst设计模式》

HeaFirst设计模式-单件模式[单例模式](Singleton Pattern)相关推荐

  1. C++设计模式——单件模式(singleton pattern)

    一.原理讲解 由于单件模式也称为单例模式,分为懒汉式单例模式和饿汉式单例模式,两者主要区别是类对象的返回是在编译时创建?还是调用时才创建?其中,懒汉式单例模式是在程序调用时才创建,而饿汉式单例模式是在 ...

  2. 设计模式-单件模式(Singleton pattern)

    模式描述:确保一个类只有一个实例,并提供访问这个实例的全局点. Code using System; using System.Collections.Generic; using System.Li ...

  3. .NET设计模式(2):单件模式(Singleton Pattern)

    转载:http://terrylee.cnblogs.com/archive/2005/12/09/293509.html 单件模式(Singleton Pattern) --.NET设计模式系列之二 ...

  4. 单件模式(Singleton Pattern)

    单件模式(Singleton Pattern) 概述 Singleton模式要求一个类有且仅有一个实例,并且提供了一个全局的访问点.这就提出了一个问题:如何绕过常规的构造器,提供一种机制来保证一个类只 ...

  5. 设计模式之单件模式(Singleton Pattern)

    一.单件模式是什么? 单件模式也被称为单例模式,它的作用说白了就是为了确保"该类的实例只有一个" 单件模式经常被用来管理资源敏感的对象,比如:数据库连接对象.注册表对象.线程池对象 ...

  6. 设计模式:单件模式(Singleton Pattern)

    作者:TerryLee  创建于:2005-12-09 出处:http://terrylee.cnblogs.com/archive/2005/12/09/293509.html  收录于:2013- ...

  7. Net设计模式实例之单例模式( Singleton Pattern)

    一.单例模式简介(Brief Introduction) 单例模式(Singleton Pattern),保证一个类只有一个实例,并提供一个访问它的全局访问点.单例模式因为Singleton封装它的唯 ...

  8. 单件模式(Singleton Pattern)(转自TerryLee)

    概述  Singleton模式要求一个类有且仅有一个实例,并且提供了一个全局的访问点.这就提出了一个问题:如何绕过常规的构造器,提供一种机制来保证一个类只有一个实例?客户程序在调用某一个类时,它是不会 ...

  9. 【设计模式笔记】单例模式Singleton Pattern

    单例模式是比较简单的一个模式,项目中也经常用得到. 实现细节 将类的构造方法设置为私有的(private),通过个公有的(public)的方法来获取类的实例. 代码示例 public class Si ...

最新文章

  1. Spring Aop的应用
  2. ThinkPad -- Intel 无线网卡网络连接方法限制及无法用 Fn + F5 控制的问题
  3. VC6.0显示代码行号
  4. 使用root用户安装Hybris遇到的错误
  5. mysql查询优化explain命令详解
  6. Asp.net--DropDownList控件绑定数据库数据
  7. 女性开车5大安全驾车好习惯 为您支招
  8. 上海美特斯邦威成被执行人 执行标的超79万
  9. Eclipse 基于接口编程的时候,快速跳转到实现类的方法(图文)
  10. 计算机组成原理(2021最新版)面试知识点集锦
  11. 全志A64 Android6.0编译
  12. 手写操作系统2——编写MBR主引导程序
  13. NLP-文本摘要:“文本摘要”综述(Text Summarization)
  14. 南华大学计算机科学学院,南华大学计算机科学与技术学院介绍
  15. setdbprefs matlab,matlab数据导入与导出
  16. 图像处理与机器视觉网络资源
  17. JETSON AGX XAVIER GMSL2接口相机驱动
  18. 4-鸡肉为何如此受欢迎
  19. 大数据分析:消费金融公司利润
  20. JaveScript内置对象(JS知识点归纳八)

热门文章

  1. 校招 | 网易21届互联网校招补录来啦!
  2. 重置Windows打印机COM端口USB端口
  3. 如何使错误日志更加方便排查问题?
  4. 能用四川电信卡开通的虚拟服务器,双网通手机也能用电信卡了?VoLTE开放:发短信就能开通...
  5. Oracle数据库培训视频教程 oracle工程师培训视频教程
  6. 爬楼梯【浙江工商大学oj】
  7. Psychtoolbox刺激呈现方式
  8. iOS设备数据恢复工具:UltData mac中文版
  9. P1498 南蛮图腾---洛谷(分冶)
  10. SAP MM批次管理(2)批次主数据--大海