Java 单例真的写对了么?

单例模式是最简单的设计模式,实现也非常“简单”。一直以为我写没有问题,直到被 Coverity 打脸。

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;
}

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

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 关键字原理详解请参考上面的 深入理解内存模型(四)

所以,上面的操作,只需要对 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 作为一个程序,本身知道的东西比我们多得多,而且还比我认真,它指出的问题必须认真对待和分析。



参考文章:

  1. https://en.wikipedia.org/wiki/Double-checked_locking
  2. http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
  3. http://www.oracle.com/technetwork/articles/javase/bloch-effective-08-qa-140880.html
  4. http://www.ibm.com/developerworks/java/library/j-dcl/index.html
  5. http://www.infoq.com/cn/articles/java-memory-model-1
时间: 2024-10-21 23:18:34

Java 单例真的写对了么?的相关文章

写一个安全的Java单例

单例模式可能是我们平常工作中最常用的一种设计模式了.单例模式解决的问题也很常见,即如何创建一个唯一的对象.但想安全的创建它其实并不容易,还需要一些思考和对JVM的了解. 1.首先,课本上告诉我,单例这么写 1 public class Singleton { 2 3 private static Singleton instance; 4 5 private Singleton() { 6 } 7 8 public static Singleton getInstance() { 9 if (i

Java 单例

最近在网上看到一篇关于 Java 单例的创建问题,虽然是一个 Java 程序员,但是到现在还没有真正的深入了解到 Java 的原理和机制.所以每每看到这样能够"真正"接触 Java 的机会内心总是充满了欣喜.记录下,以后备用. 懒汉模式 public class Singlton{ private static Singleton instance; private Singlton(){} public static Singlton getInstance(){ if(instac

java单例-积木系列

一步步知识点归纳吧,把以前似懂非懂,了解表面,知道点不知道面的知识归一下档. 懒汉式单例: 私有化构造函数,阻止外界实例话对象,调用getInstance静态方法,判断是否已经实例化. 为什么是懒汉,因为它是属于延迟加载这个实例的,也就是说不用到的时候,不实例化对象的. public class Singleton { private static Singleton instance; private Singleton (){} public static Singleton getInst

java单例类/

java单例类  一个类只能创建一个实例,那么这个类就是一个单例类 可以重写toString方法 输出想要输出的内容 可以重写equcal来比较想要比较的内容是否相等 对于final修饰的成员变量 一但有了初始值,就不能被重新赋值 static修饰的成员变量可以在静态代码块中 或申明该成员时指定初始值 实例成员可以在非静态代码块中,申明属性,或构造器中指定初始值 final修饰的变量必须要显示初始化 final修饰引用变量不能被重新赋值但是可以改变引用对象的内容分(只要地址值不变) final修

Java单例的实现

1.声明实例变量(静态) 2.私有化构造函数 3.创建获取实例的方法 public class Singleton{ //创建实例变量 private static Singleton singleton; //私有化构造函数 private Singleton(){ } //创建获取实例的方法 public static Singleton getInstance(){ if(singleton==null){ singleton=new Singleton(); } return singl

Java单例设计模式的实现

1. 单例设计模式的定义 单例设计模式确保类只有一个实例对象,类本身负责创建自己的对象并向整个系统提供这个实例.在访问这个对象的时候,访问者可以直接获取到这个唯一对象而不必由访问者进行实例化. 单例设计模式保证了全局对象的唯一性,在许多场景中都有应用.例如Windows中多个进程或线程同时操作一个文件的时候,所有的进程或线程对于同一个文件的处理必须通过唯一的实例来进行. 2. java单例设计模式的几种实现方式 单例设计的最大特点是类的构造函数是私有的,确保了只能由类本身创建对象,而访问者无法进

如何写出面试官欣赏的Java单例

单例模式是一种常用的软件设计模式.在它的核心结构中只包含一个被称为单例的特殊类.通过单例模式可以保证系统中一个类只有一个实例. 今天我们不谈单例模式的用途,只说一说如果在面试的时候面试官让你敲一段代码实现单例模式的情况下怎样写出让面试官眼前一亮的单例代码.因为笔者学的是Java,所以接下来的实例将用Java语言编写. 说到单例模式,第一个想到的是该类中有一个初始化为null的自身引用,且被private修饰符修饰,其它类不得直接访问.除此之外,单例模式的类还需要有private的构造方法,这一点

单例设计模式-(你确定自己写的懒汉单例真的是线程安全的吗)

1.单例设计模式的优缺点 优点: 1):只创建一个实例,就可以到处使用,加快创建实体的效率 缺点: 1):如果使用的频率比较低,实例会一直占据着内存空间,会造成资源浪费 2):可能会出现线程安全问题 2.单例设计模式的两种定法(饿汉.懒汉) 饿汉方法写法:(可能会造成资源浪费,类一被加载就创建了实例,但并不能确保这个实例什么时候会被用上) package com.zluo.pattern; /** * * 项目名称:single-instance <br> * 类名称:SingleInstan

从一个简单的Java单例示例谈谈并发

一个简单的单例示例 单例模式可能是大家经常接触和使用的一个设计模式,你可能会这么写 public class UnsafeLazyInitiallization { private static UnsafeLazyInitiallization instance; private UnsafeLazyInitiallization() { } public static UnsafeLazyInitiallization getInstance(){ if(instance==null){ /