Java 单例真的写对了么?

单例模式是最简单的设计模式,实现也很是“简单”。一直觉得我写没有问题,直到被 Coverity 打脸。 java

1. 暴露问题

前段时间,有段代码被 Coverity 警告了,简化一下代码以下,为了方便后面分析,我在这里标上了一些序号: 编程

private static SettingsDbHelper sInst = null;  
public static SettingsDbHelper getInstance(Context context) {  
    if (sInst == null) {                              // 1
        synchronized (SettingsDbHelper.class) {       // 2
            SettingsDbHelper inst = sInst;            // 3
            if (inst == null) {                       // 4
                inst = new SettingsDbHelper(context); // 5
                sInst = inst;                         // 6
            }
        }
    }
    return sInst;                                     // 7
}

你们知道,这但是高大上的 Double Checked locking 模式,保证多线程安全,并且高性能的单例实现,比下面的单例实现,“逼格”不知道高到哪里去了: 设计模式

private static SettingsDbHelper sInst = null;  
public static synchronized SettingsDbHelper getInstance(Context context) {  
    if (sInst == null) {
        sInst = new SettingsDbHelper(context);
    }
    return sInst;
}

你一个机器人竟敢警告我代码写的不对,我一度怀疑它不认识这种写法(后面将证实我是多么幼稚,啪。。。)。而后,它认真的给我分析这段代码为何有问题,以下图所示: 安全

coverity-report

2. 缘由分析

Coverity 是静态代码分析工具,它会模拟其实际运行状况。例如这里,假设有两个线程进入到这段代码,其中红色的部分是运行的步骤解析,开头的标号表示其运行顺序。关于 Coverity 的详细文档能够参考这里,这里简单解析一下其运行状况以下: 多线程

  1. 线程 1 运行到 1 处,第一次进入,这里确定是为true的;
  2. 线程 1 运行到 2 处,得到锁SettingsDbHelper.class;
  3. 线程 1 运行到 3 和 4 处,赋值inst = sInst,这时 sInst 仍是 null,因此继续往下运行,建立一个新的实例;
  4. 线程 1 运行到 6 处,修改 sInst 的值。这一步很是关键,这里的解析是,由于这些修改可能由于和其余赋值操做运行被从新排序(Re-order),这就可能致使先修改了 sInst 的值,而new SettingsDbHelper(context)这个构造函数并无执行完。而在这个时候,程序切换到线程 2;
  5. 线程 2 运行到 1 处,由于第 4 步的时候,线程 1 已经给 sInst 赋值了,因此sInst == null的判断为false,线程 2 就直接返回 sInst 了,可是这个时候 sInst 并无被初始化完成,直接使用它可能会致使程序崩溃。

上面解析得好像很清楚,可是关键在第 4 步,为何会出现 Re-Order?赋值了,但没有初始化又是怎么回事?这是因为 Java 的内存模型决定的。问题主要出如今这 5 和 6 两行,这里的构造函数可能会被编译成内联的(inline),在 Java 虚拟机中运行的时候编译成执行指令之后,能够用以下的伪代码来表示: 函数

inst = allocat(); // 分配内存  
sInst = inst;  
constructor(inst); // 真正执行构造函数

说到内存模型,这里就不当心触及了 Java 中比较复杂的内容——多线程编程和 Java 内存模型。在这里,咱们能够简单的理解就是,构造函数可能会被分为两块:先分配内存并赋值,再初始化。关于 Java 内存模型(JMM)的详解,能够参考这个系列文章 《深刻理解Java内存模型》,一共有 7 篇()。 工具

3. 解决方案

上面的问题的解决方法是,在 Java 5 以后,引入扩展关键字volatile的功能,它能保证: 性能

对volatile变量的写操做,不容许和它以前的读写操做打乱顺序;对volatile变量的读操做,不容许和它以后的读写乱序。 优化

关于 volatile 关键字原理详解请参考上面的 深刻理解内存模型(四)spa

因此,上面的操做,只须要对 sInst 变量添加volatile关键字修饰便可。可是,咱们知道,对 volatile 变量的读写操做是一个比较重的操做,因此上面的代码还能够优化一下,以下:

private static volatile SettingsDbHelper sInst = null;  // <<< 这里添加了 volatile  
public static SettingsDbHelper getInstance(Context context) {  
    SettingsDbHelper inst = sInst;  // <<< 在这里建立临时变量
    if (inst == null) {
        synchronized (SettingsDbHelper.class) {
            inst = sInst;
            if (inst == null) {
                inst = new SettingsDbHelper(context);
                sInst = inst;
            }
        }
    }
    return inst;  // <<< 注意这里返回的是临时变量
}

经过这样修改之后,在运行过程当中,除了第一次之外,其余的调用只要访问 volatile 变量 sInst 一次,这样能提升 25% 的性能(Wikipedia)。

有读者提到,这里为何须要再定义一个临时变量inst?经过前面的对 volatile 关键字做用解释可知,访问 volatile 变量,须要保证一些执行顺序,因此的开销比较大。这里定义一个临时变量,在sInst不为空的时候(这是绝大部分的状况),只要在开始访问一次 volatile 变量,返回的是临时变量。若是没有此临时变量,则须要访问两次,而下降了效率。

最后,关于单例模式,还有一个更有趣的实现,它可以延迟初始化(lazy initialization),而且多线程安全,还能保证高性能,以下:

class Foo {  
    private static class HelperHolder {
       public static final Helper helper = new Helper();
    }

    public static Helper getHelper() {
        return HelperHolder.helper;
    }
}

延迟初始化,这里是利用了 Java 的语言特性,内部类只有在使用的时候,才回去加载,从而初始化内部静态变量。关于线程安全,这是 Java 运行环境自动给你保证的,在加载的时候,会自动隐形的同步。在访问对象的时候,不须要同步 Java 虚拟机又会自动给你取消同步,因此效率很是高。

另外,关于 final 关键字的原理,请参考 深刻理解Java内存模型(六)

补充一下,有同窗提醒有一种更加 Hack 的实现方式--单个成员的枚举,据称是最佳的单例实现方法,以下:

public enum Foo {  
    INSTANCE;
}

详情能够参考 这里

4. 总结

在 Java 中,涉及到多线程编程,问题就会复杂不少,有些 Bug 甚至会超出你的想象。经过上面的介绍,开始对本身的代码运行状况都不那么自信了。其实大可没必要这样担忧,这种仅仅发生在多线程编程中,遇到有临界值访问的时候,直接使用 synchronized 关键字可以解决绝大部分的问题。

对于 Coverity,开始抱着敬畏知心,它是由一流的计算机科学家建立的。Coverity 做为一个程序,自己知道的东西比咱们多得多,并且还比我认真,它指出的问题必须认真对待和分析。

相关文章
相关标签/搜索