懒汉形式的单例模式多线程问题研究

栏目:线程与并发 作者:admin 日期:2017-04-22 评论:0 点击: 2,502 次

作为一个单例,我们首先要确保的就是实例的“唯一性”,有很多因素会导致“唯一性”失效,它们包括:多线程、序列化、反射、克隆等,更特殊一点的情况还有:分布式系统、多个类加载器等等。其中,多线程问题最为突出。为了提高应用的工作效率,现如今我们的工程中基本上都会用到多线程;目前使用单线程能轻松完成的任务,日复一日,随着业务逻辑的复杂化、用户数量的递增,也有可能要被升级为多线程处理。所以任何在多线程下不能保证单个实例的单例模式,我都认为应该立即被弃用。

在只考虑一个类加载器的情况下,“饿汉方式”实现的单例(在系统运行起来装载类的时候就进行初始化实例的操作,由JVM虚拟机来保证一个类的初始化方法在多线程环境中被正确加锁和同步,所以)是线程安全的,而“懒汉”方式则需要注意了,先来看一种最简单的“懒汉方式”的单例:

这种写法只能在单线程下使用。如果是多线程,可能发生一个线程通过并进入了 if (singleton == null) 判断语句块,但还未来得及创建新的实例时,另一个线程也通过了这个判断语句,两个线程最终都进行了创建,导致多个实例的产生。所以在多线程环境下必须摒弃此方式。

除了多并发的情况,实现单例模式时另一个重要的考量因素是效率。前述的“懒汉方式”的多线程问题可以通过加上 synchronized 修饰符解决,但考虑到性能,一定不要简单粗暴地将其添加在如下位置:

上述方式通过为 getInstence() 方法增加 synchronized 关键字,迫使每个线程在进入这个方法前,要先等候别的线程离开该方法,即不会有两个线程可以同时进入此方法执行 new Singleton(),从而保证了单例的有效。但它的致命缺陷是效率太低了,每个线程每次执行 getInstance() 方法获取类的实例时,都会进行同步。而事实上实例创建完成后,同步就变为不必要的开销了,这样做在高并发下必然会拖垮性能。所以此方法虽然可行但也不推荐。那我们将同步方法改为同步代码块是不是就能减少同步对性能的影响了呢:

但是这种同步却并不能做到线程安全,同最初的懒汉模式一个道理,它可能产生多个实例,所以亦不可行。我们必须再增加一个单例不为空的判断来确保线程安全,也就是所谓的“双重检查锁定”(Double Check Lock(DCL))方式:

此方法的“Double-Check”体现在进行了两次 if (singleton == null) 的检查,这样既同步代码块保证了线程安全,同时实例化的代码也只会执行一次,实例化后同步操作不会再被执行,从而效率提升很多(详细比较见附录 1)。

双重检查锁定(DCL)方式也是延迟加载的,它唯一的问题是,由于Java 编译器允许处理器乱序执行,在JDK版本小于1.5时会有DCL失效的问题(原因解释详见附录 2)。当然,现在大家使用的JDK普遍都已超过1.4,只要在定义单例时加上1.5及以上版本具体化了的volatile关键字,即可保证执行的顺序,从而使单例起效。所以 DCL 方式是推荐的一种方式。

附录
重新贴一遍“双重检查锁定(DCL)”方式实现单例模式的代码,在下面两个分析中都会涉及:

  1. 粗略比较一下高并发的情况下,同步方法方式同 DCL 方式效率上的差别。在服务器允许的情况下,假设有一百个线程,则耗时结果如下:

    在第一次运行的时候,同步方法方式耗费的时间为:100 * (同步判断时间 + if 判断时间)。以后也保持这样的消耗不变。
    而DCL方式中虽然有两个if判断,但100个线程是可以同时进行第一个if判断的(因为此时还没有同步),理论上100个线程第一个if判断消耗的总时间只需一次判断的时间,第二个if判断,在第一次执行时,如果是最坏的情况会有100次,加上100个同步判断时间,DCL方法第一次执行会比同步方法方式多一个判断时间,即 100 * (同步判断时间 + if 判断时间) + 1 * if 判断时间。但重要的是,这种DCL方式只在第一次实例化的时候进行加锁,之后就不会再通过第一个if判断,也就不用加锁,不再有同步判断和第二次if判断的时间损耗,100个线程也只会有一个if 判断时间,效率相比 100 * (同步判断时间 + if判断时间) 大大提高。

  2. 双重检查锁定(DCL)单例在 JDK 1.5 之前版本失效原因解释
    在高并发环境,JDK 1.4 及更早版本下,双重检查锁定偶尔会失败。其根本原因是,Java 中 new 一个对象并不是一个原子操作,编译时 singleton = new Singleton(); 语句会被转成多条汇编指令,它们大致做了3件事情:
    1) 给 Singleton 类的实例分配内存空间;
    2) 调用私有的构造函数 Singleton(),初始化成员变量;
    3)singleton 对象指向分配的内存(执行完此操作 singleton 就不是 null 了)
    由于Java编译器允许处理器乱序执行,以及 JDK1.5之前的旧的Java内存模型(Java Memory Model)中
    Cache、寄存器到主内存回写顺序的规定,上面步骤 2) 和 3) 的执行顺序是无法确定的,可能是 1)→2)→3) 也可能是 1) →3)→2) 。如果是后一种情况,在线程 A 执行完步骤 3) 但还没完成 2) 之前,被切换到线程 B 上,此时线程 B 对 singleton 第1次判空结果为 false,直接取走了 singleton使用,但是构造函数却还没有完成所有的初始化工作,就会出错,也就是 DCL 失效问题。
    在 JDK 1.5的版本中具体化了 volatile 关键字,将其加在对象前就可以保证每次都是从主内存中读取对象,从而修复了 DCL 失效问题。当然,volatile 或多或少还是会影响到一些性能,但比起得到错误的结果,牺牲这点性能还是值得的。