文档章节

Java 单例真的写对了么?

zjg23
 zjg23
发布于 2016/05/08 08:52
字数 1728
阅读 16
收藏 1

单例模式是最简单的设计模式,实现也非常“简单”。一直以为我写没有问题,直到被 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;
}

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

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

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

本文转载自:http://www.race604.com/java-double-checked-singleton/?utm_source=tuicool&utm_medium=referral

共有 人打赏支持
zjg23
粉丝 19
博文 124
码字总数 41550
作品 0
济南
程序员
私信 提问
为什么我墙裂建议大家使用枚举来实现单例。

关于单例模式,我的博客中有很多文章介绍过。作为23种设计模式中最为常用的设计模式,单例模式并没有想象的那么简单。因为在设计单例的时候要考虑很多问题,比如线程安全问题、序列化对单例的...

06/10
0
0
从 Java 到 Scala(二):object

本文由 Rhyme 发表在 ScalaCool 团队博客。 是一种让静态回归常态、打破模式、天然的语言特性。 其实在写这篇文章之前,我思绪万千,迟迟不能落笔,总想着自己会不会遗漏了某个知识点,或者有...

ScalaCool
08/30
0
0
为什么java中用枚举实现单例模式会更好

枚举单例是java中使用枚举提供一个实例对象来实现单例模式的一种新方法,虽然单例模式在java中早已存在,但枚举单例实际上从java5引入枚举作为它的关键特性之后相对来说还是一个新的概念,这...

zhoujy
2013/06/01
0
0
Java语言中单例模式的四种写法

作为设计模式理论中的Helloworld,相信学习java语言的人,都应该听说过单例模式。单例模式作为对象的一种创建模式,它的作用是确保某一个类在整个系统中只有一个实例,而且自行实例化并向整个...

相见欢
2013/02/03
0
20
为什么我墙裂建议大家使用枚举来实现单例

我们知道,单例模式,一般有七种写法,那么这七种写法中,最好的是哪一种呢?为什么呢?本文就来抽丝剥茧一下。 哪种写单例的方式最好 在StakcOverflow中,有一个关于What is an efficient ...

冷_6986
06/13
0
0

没有更多内容

加载失败,请刷新页面

加载更多

Ugly Number(leetcode263)

Write a program to check whether a given number is an ugly number. Ugly numbers are positive numbers whose prime factors only include 2, 3, 5. Example 1: Input: 6Output: true......

woshixin
22分钟前
1
0
深度模型从研者 眼里的 似然估计 & Hessain 海森矩阵 & Fisher Information (费雪信息)

深度模型的训练的基本依据是最小化模型拟合数据的误差。旨在不仅知其然(如何构建和训练一个深度模型),还应知其所以然(为什么这样训练,可以做哪些优化)。我们就会发现,有很多研究者,在...

刘小米_思聪
26分钟前
0
0
阿里巴巴智能监控新场景的探索

摘要: 智能监控是智能运维的子领域,详细分析。 作者简介 王肇刚 阿里巴巴全球运行指挥中心高级技术专家 智能监控是智能运维的子领域,我们说的监控,探讨的更多是在监控策略,因为可能从数...

阿里云官方博客
29分钟前
1
0
【双十二】12 月 12 日 12 点 12 分限量抢福利,云计算不剁手!

活动详情 1.首充豪礼 双十二当日,新用户首充 200 元送 200 元代金券!仅限前 88 名!先到先得!拼手速!(新用户指 12 月 12 日前已完成注册但从未充值过的用户)
 2.超值返券 双十二当日,...

七牛云
54分钟前
3
0
Calculate Linux 发布 18 LXQt是基于Gentoo的发行版

导读 Calculate Linux 是一个基于 Gentoo 并与 Gentoo 完全兼容的滚动更新发行版家族,专为企业环境下的快速部署而优化。其提供多个不同的分支版本,分别适用于桌面、服务器等应用场景。该项...

问题终结者
57分钟前
2
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部