单例模式各版本的原理与实践

发表于:2017-1-26 10:47

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:曹丰斌    来源:51Testing软件测试网采编

  1.单例模式概述
  (1)引言
  单例模式是应用最广的模式之一,也是23种设计模式中最基本的一个。本文旨在总结通过Java实现单例模式的各个版本的优缺点及适用场景,详细分析如何实现线程安全的单例模式,并探讨单例模式的一些扩展。
  (2)单例模式的定义
  Ensure a class has only one instance,and provide a global point of access to it.(确保某一个类只有一个实例,并且自行实例化并向整个系统提供这个实例)
  通用类图为:
  Singleton类称为单例类,通过使用private的构造函数确保了在一个应用中只产生一个实例,并且是自行实例化的(在Singleton中自己使用new Singleton())。
  (3)使用场景
  在一个系统中,要求一个类有且仅有一个对象,如果出现多个对象就会出现“不良反应”,可以采用单例模式,具体的场景如下:
  要求生成唯一序列号的环境;
  在整个项目中需要一个共享访问点或共享数据,例如一个Web页面上的计数器,可以不用把每次刷新都记录数据库中,使用单例模式保持计数器的值,并确保是线程安全的;
  创建一个对象需要消耗的资源过多,如要访问IO和数据库等资源;
  需要定义大量的静态常量和静态方法(如工具类)的环境,可以采用单例模式(当然,也可以直接声明为static的方式)。
  (4)优缺点
  单例模式的优点
  由于单例模式在内存中只有一个实例,减少了内存开支,特别是一个对象需要频繁地创建销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显;
  由于单例模式只生成一个实例,所以减少了系统的性能开销,当一个对象的产生需要比较多的资源的时候,如读取配置,产生其他的依赖对象时,可以通过在应用启动的时候直接产生一个单例对象,然后用永久驻留内存的方式来解决;
  单例模式可以避免对资源的多重占用,例如对一个写文件动作,由于只有一个实例存在内存中,避免对同一个资源文件的同时写操作;
  单例模式可以在系统设置全局的访问点,优化和共享资源访问,例如可以设计一个单例类,负责所有数据表的映射处理
  单例模式的缺点
  单例模式一般没有接口,扩展困难,若要扩展,除了修改代码基本上没有第二种途径可以实现;
  单例模式与单一职责原则有冲突,一个类应该只实现一个逻辑,而不关心他是否是单例的,是不是要单例取决于环境,单例模式把“要单例”和业务逻辑融合在一个类中。
  2.最基本的实现方式
  代码实现为:
public class Singleton {
private static Singleton singleton;
// 限制产生多个对象
private Singleton() {
}
// 获得对象实例的方法
public static Singleton getSingleton() {
if(singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
  相信大多数同学在入门Java的阶段都见过这段代码。该方式在低并发的情况下尚不会出现问题,若系统压力增大,并发量增加时则可能在内存中出现多个实例,破坏设计的初衷。本文的后续就是围绕这种实现分析改进,探讨实现线程安全的单例模式的最佳实践。
  为什么这种实现是线程不安全的呢?如一个线程A执行到singleton = new Singleton();这里,但还没有获得对象(对象初始化是需要时间的),第二个线程B也在执行,执行到if(singleton == null)判断,那么线程B获得判断条件也是为真,于是继续运行下去,线程A获得了一个对象,线程B也获得了一个对象,在内存中就出现两个对象,造成单例模式的失效!!
  所以根本原因在于可能存在多个线程并发的访问getSingleton()方法造成单例对象的多次创建,解决因多线程并发访问导致单例模式实效的最佳方法就是--不要使用多线程并发访问。(⊙o⊙)…
  3.饿汉式
  (1)实现原理
  言归正传,上面说的问题其实就是对if(singleton == null)的判断失效造成singleton = new Singleton();可能会被多个线程并发的执行。饿汉式单例模式的实现的本质其实就是依赖类加载机制保证构造方法只会被执行一次。JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
  饿汉式单例的实现代码为:
public class Singleton {
private static Singleton singleton = new Singleton();
private Singleton() {
}
// 获得对象实例的方法
public static Singleton getSingleton() {
return singleton;
}
}
  (2)优缺点及适用场景
  可以看到饿汉式的实现非常简单,适合那些在初始化时就要用到单例的情况,如果单例对象初始化非常快,而且占用内存非常小的时候这种方式是比较合适的,可以直接在应用启动时加载并初始化。
  不适用的场景:
  单例初始化的操作耗时比较长而应用对于启动速度又有要求;
  单例的占用内存比较大;
  单例只是在某个特定场景的情况下才会被使用,而一般情况下是不会使用的;
  在上述的几种情况下使用饿汉式的单例模式是不合适的,这时候就需要用到懒汉式的方式去按需延迟加载单例。
  4.利用同步锁机制实现的懒汉式
  实现代码为:
public class Singleton {
private static Singleton singleton = null;
private Singleton() {
}
// 获得对象实例的方法
public static Singleton getSingleton() {
synchronized(Singleton.class) {
if(singleton == null) {
singleton = new Singleton();
}
}
return singleton;
}
}
  这种是最常见的懒汉式单例实现,使用同步锁synchronized(Singleton.class)防止多线程同时进入造成instance被多次实例化。但是他的缺陷也是非常明显的,就是每次在调用getSingleton()获取单例的实例的时候,都需要进行同步。事实上我们只想保证一次初始化成功,其余的快速返回而已,如果在getInstance频繁使用的地方就要考虑重新优化了。
  5.对同步锁机制实现懒汉式的改进--DCL
  (1)原理与实现
  由于synchronized(甚至是无竞争的synchronized)存在着巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(double-checked locking)。通过这种方式来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的代码实现:
public class Singleton {
private static Singleton instance = null;                 //1
// 获得对象实例的方法
public static Singleton getSingleton() {                  //2
if(instance == null) {                                //3:第一次检查
synchronized(Singleton.class) {                   //4:加锁
if(instance == null)                          //5:第二次检查
instance = new Singleton();               //6:问题的根源产生
}
}
return instance;
}
private Singleton() {
}
}
  如上面的代码所示,它的“优点”如下:
  在多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象;
  在对象创建好了以后,执行getSingleton()方法将不需要获取锁,直接返回已经创建好的对象
  双重检查模式看上去好像很完美,但这是一个错误的优化,在线程执行到上面所示的代码3处读取到singleton对象不为null时,singleton引用的对象可能还没有完成初始化。
  (2)问题分析
  可能产生错误的场景
  1.线程A进入getSingleton()方法;
  2.因为此时instance为null,所以线程A进入synchronized块;
  3.线程A执行 instance = new Singleton(); 把实例变量instance设置成了非空。(注意,是在调用构造方法之前)
  4.线程A退出,线程B进入。
  5.线程B检查instance是否为空,此时不为空(第三步的时候被线程A设置成了非空)。线程B返回instance的引用。(问题出现了,这时instance的引用并不是Singleton的实例,因为没有调用构造方法)
  6.线程B退出,线程A进入;
  7.线程A继续调用构造方法,完成instance的初始化,再返回。
  (3)问题根源
  多线程问题,很大程度是由于非原子性造成的,如果我们每一个可能产生竞争的地方都是原子性的,那多线程需要考虑的东西就要少很多了。在上述程序中也是一样,我们看第六行代码:
  instance = new Singleton();     //6:问题的根源产生
  在JMM中,这行代码可以分解为3个过程:
  memory = allocate();            //#1为对象分配内存空间
  init(memory);                   //#2初始化
  instance = memory;              //#3设置instance,将其指向刚分配的内存空间。
  上面的3行代码,如果是顺序执行,不会带来问题。但是,在某些JIT编译器上,#2和#3可能发生重排序。也就是说,重排序后,上面三个过程变成了:
  memory = allocate();            //#1为对象分配内存空间
  instance = memory;              //#2
  init(memory);                   //#3初始化
  根据《The Java Language Specification, Java SE 7 Edition》一书中的内容:所有线程在执行java程序时必须要遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。换句话来说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面三行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。这个重排序在没有改变单线程程序的执行结果的前提下,可以提高程序的执行性能。
  下面,再让我们看看多线程并发执行的时候的情况。请看下面的示意图:
  这里2和3虽然重排序了,但java内存模型的intra-thread semantics将确保2一定会排在4前面执行。因此线程A的intra-thread semantics没有改变。但2和3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。。
  分析清楚问题发生的根源之后,可以想出两个办法来实现线程安全的延迟初始化:
  不允许2和3重排序;
  允许2和3重排序,但不允许其他线程“看到”这个重排序。
  后文介绍的解决方案就分别对应于上面这两点。
21/212>
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号