Android(Java) 中的单例类的实现

1/26/2018 posted in  Android comments

单例模式(Singleton) 是我们常用的一种设计模式,通常用来保证在全局情况下总是能够拿到同样的一个类实例来实现相关的工作。
单例模式很常见,因此也有戏称:

单例的写法茴香豆的的写法还多。

0x00 简单的单例类

单线程模式下的单例模式常见的写法比如:

  public class Singleton {
    private static Singleton INSTANCE; 
    private Singleton() {}
 
    public static Singleton getInstance() {
        if(null==INSTANCE) INSTANCE = new Singleton()
        return INSTANCE;
    }
  }

代码很简单,就是私有化构造方法,并且使用一个 public 方法来获取实例,但是仅限于单线程的时候。

0x01 多线程下的一种实现

在多线程的时候这样的单例类如果在多个线程同时调用的时候,很可能就会导致初始化问题(两个线程同时拿到了一个 null 的实例,同时进入了新的初始化),因此在多线程下,我们的代码中使用了这样的方式来实现单例。

public class Singleton {
    private static Singleton INSTANCE;
    private Singleton() { }
    public static Singleton getInstance() {
        if (null == INSTANCE) {
            synchronized (Singleton.class) {
                if (null==INSTANCE) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

0x02 对比分析

上面实现的这样的单例类,通过加了synchronized锁来保证同一时刻只有一个线程能够进入到这个临界区中,并且通过两次的 null check 来确保不会重复创建实例,这样做对吗?好不好呢?

在分析这段代码的问题之前,我们先看一下 Google 提倡的一种单例类的实现。

public class Singleton {
    private static Singleton INSTANCE;
    private Singleton() { }

     public static synchronized Singleton getInstance() {
        if (null == INSTANCE) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
}

对比一下代码我们会发现,这两段代码的主要区别在于,一个是对创建新的实例进行了加锁,而另一个则是在每一次获取实例的时候,都加锁,这样来保证不会有异常情况。第一种写法在已经创建完实例后就不会再进行 synchronized 操作,这样可以节约一部分操作时间(毕竟不是经常需要加锁来创建新的实例的),第二段代码保证了每次调用都不会出问题,但是会多一些额外的消耗。

说到异常,我们来看一下第一段代码为什么是有问题的,这个地方非常的有趣并且很不容易发现,我们是通过 Coverity 这个工具,看到了代码中这样的实现被它给标为 High level 的 bug,才想到这里可能会有这样的问题,一开始还很奇怪为什么加了两次 null check,还会丢 warning 出来,难不成还会创建两个实例不成?

0x03 揭秘

让我们再来看一遍这段代码:

    public static Singleton getInstance() {
        if (null == INSTANCE) {
            synchronized (Singleton.class) {
                if (null==INSTANCE) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }

首先,通过一次 null check, 判断是否需要创建新的实例,如果已经有这个实例了,那么OK,直接返回,如果没有,我们先申请一下锁,拿到之后,进行第二次 null check,这是因为可能在线程等待锁的过程中,已经有另一个线程拿到了锁,并且创建好了资源,如果发现已经不是 null 了,不做任何事,安静的释放掉锁,然后返回,如果发现自己仍然是空的呢,那么就开始进行新的创建实例操作。

到这里是不是还没有发现任何不太对劲的地方?放心,不可能出现两个实例。

那么问题出现在哪里呢?

我们来温习一下创建一个类的实例的过程:类加载(class loader),分配方法区,堆,常量池,构造函数,初始化函数...等等等等

让我们再来温习一个概念,原子操作

是不是终于意识到不对的地方了,恭喜!让我们一起来看看,为什么 coverity 会报出这段代码的 warning。

首先,这段代码的核心在于要返回一个实例(废话),不管是已经存在的,还是新创建的,已经存在的自然不用说了,问题就出现在这个新创建的实例上。创建实例的时候我们用了这样的一句话:

INSTANCE = new Singleton();

一切的烦恼皆因此而起。

在我们初始化实例的时候,在某一个瞬间,INSTANCE已经有了引用(构造完成了),但是初始化并没有完成!假象一下有两个线程 Thread AThread B,最开始的时候,单例还没有被初始化,于是当 Thread A 调用 Singleton.getInstance() 的时候,初始化开始了。

而刚好在这一时刻,Thread B 也来调用 Singleton.getInstance(),问题来了,在第一次 null check 的时候,INSTANCE 是 null 吗?

是?不是?

别急着回答。由于这个时候 Thread A 正在紧锣密鼓的筹办 Singleton 类的构造和初始化,究竟进行到了哪一步了我们也不知道,是正在构造呢,还是已经构造完了刚准备初始化,还是初始化到一半了?

而此时的 Thread B,在第一次 null check 的时候内心是不安的,因为如果这个时候 INSTANCE 还是个 null 的,那还好,大不了我等嘛,等你释放锁了,我拿到锁了进去看一下,不是 null 的我就直接回去就是了,可万一 INSTANCE 不是 null 的,这个时候就头大了。

不是 null 啊,初始化完成了吗?如果初始化过程中要做的事情非常的多,或者很耗时(比如注册一个服务,通过网络请求去拿一个config),这个时候就不好说了,虽然 Thread B 拿到了一个不是 null 的 INSTANCE,但是谁知道用起来会不会 Crash,这简直就是个定时炸弹啊!

当然了,这样的情形我们假设的时候是遇到了,但是现实里两个线程在几乎同时(大概几百毫秒内)同时去拿一个单例,可能性虽然有,但毕竟还是比较小的。 但是!一旦出现了这样的bug,debug 的时候怎么去发现??? 也很难有办法重现,于是这里就成了一个埋藏至深的地雷,说不定哪天不开心了就炸了。

0x04 解决方案

找到了问题的根源,要解决这样的 bug 就简单了。很好说,归根结底,这个问题出现是因为两个线程 A and B 在同一时刻对同一实例 INSTANCE 进行了读写操作。直接给你禁了呗。

说的好!怎么禁?简单啊,一个单词的事儿,volatile,搞定!

代码如下:

public class Singleton {
    private static volatile Singleton INSTANCE;

    private Singleton() { }

    public static Singleton getInstance() {
        if (null == INSTANCE) {
            synchronized (Singleton.class) {
                if (null == INSTANCE) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

跟最初的版本相比,只是多加了一个 volatile 关键字,但是就这样解决了这个看起来很麻烦的问题,是不是很开心!

关于 volatile 关键字有机会再展开。

0x05 能再简单点儿么?

能!

让我们回到最初的起点,问一下自己,为什么要写出这么麻烦的 code?

为了让单例类在多线程的情况下没有异常。

那让我们以这个为目标,再重新思考一下,有没有别的方法。

Talk is cheap, show me the code.

public class Singleton {
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton() { }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

通过一个内部的静态类来完成实例的单例,并且保证了在多线程的时候不会有异常,为什么?提示,在内部静态类加载的时候进行的初始化。

其实呢,如果要求更低一点,在单例类的初始化过程没有那么复杂或者耗时的操作的时候,可以直接用最简单的实现,即直接初始化。

public class Singleton {
    private INSTANCE = new Singleton();
    private Singleton() { }

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

0x06 总结

小小的总结一下关于这篇文章。今天下午在公司里跟同事一起讨论单例的实现的时候,刚好看到 coverity 给我们报了这样的bug,几个人坐在那里简单讨论了一下,也没商量出个对策来,又因为时间紧迫,还要完成今天的测试,因此就搁置了。回到家闲着也是闲着,边备份 Time Machine 边想这个问题,顺带着看看其他人的博客什么的,也便有了这篇博客。

里面出现的一些写法我相信都是经常出现的,包括有问题的,Google 推荐的,还有最后使用内部静态类的(其实这个方法是 Effective Java)中推荐过的,当然还有比如更新的使用一个 Enum 来实现的,单例的写法是真的要比字的写法还要多的。

总结经验教训来看的话,

  1. Coverity 是个很不错的工具
  2. 多思考(没事多想想为什么)
  3. 多看书(强力推荐 Effective Java)
  4. 多动手(写篇博客很有成就感的,真的)

That's all!
Thanks a lot~