线程安全的单例类

我们在之前讲到单例模式的时候,只是关注了如何创建一个单例对象。但其实当时没有关心是否线程安全。简单的单例类会有什么问题呢?先看一下代码。

private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null)
            instance = new Singleton();

        return instance;
    }

这是我们所熟悉的单例类。

加锁

如果只有一个线程顺序地执行当然是没有问题的。但如果有多个线程并发地执行getInstance,可能就会带来很大的问题了。例如,当第一个线程,进入到getInstance中,这时instance变量仍然是null,那么if条件就成立了,这个线程就会去执行创建Singleton实例的那条语句。在第一个线程的赋值语句之前,第二个线程也进入了getInstance方法,这时instance仍然是null,那么第二个线程也会去执行Singleton的构造方法,新建一个对象。

这就产生了问题。那为了让这个方法变成线程安全的,我们可以为这个方法加上锁:

public static synchronized Singleton getInstance() {
        if (instance == null)
            instance = new Singleton();

        return instance;
    }

通过使用synchronized关键字,就可以把这个方法变成线程安全的了。

但是我们知道其实,这个方法只是在instance为null的时候,会有并发的问题,如果instance已经创建,不为null以后,再调用getInstance()方法就不会再有线程安全的问题了,所以,每次都为这样一个只读的方法加一个锁,性能上划不来。

静态内部类

在《Effective Java》这本书里,作者推荐了一种叫做静态内部类的办法:

class Singleton {
    public static Singleton instance;

    private static class SingletonWrapper {
        static Singleton instance = new Singleton();
    }

    private Singleton() {

    }

    public static Singleton getInstance() {
        return SingletonWrapper.instance;
    }
}

这个代码看上去也很简单,逻辑比较清楚。它是怎么解决了单例类的问题的呢?

首先,我们注意到,在第一次调用getInstance方法之前, SingletonWrapper类是没有被加载的,因为它是一个静态内部类
。当有线程第一次调用getInstance的时候,SingletonWrapper就会被class loader加载进JVM,在加载的同时,执行instance的初始化。所以,这种写法,仍然是一种懒汉式的单例类。

为什么这样写就是线程安全的呢?大家要记住,类的加载的过程是单线程执行的。它的并发安全是由JVM保证的。所以,这样写的好处是在instance初始化的过程中,由JVM的类加载机制保证了线程安全,而在初始化完成以后,不管后面多少次调用getInstance方法都不会再遇到锁的问题了。

枚举

在Java语言引入了enum关键字以后,我们其实可以使用枚举来实现单例类:

public class Main {
    public static void main(String[] args) {
        Singleton.INSTANCE.sayHello();
    }
}

enum Singleton {
    INSTANCE;

    public void sayHello() {
        System.out.println("hello");
    }
}

这个Singleton到底是个什么呢?我们通过javac,将上述代码编译成class文件。然后就会看到,在目标目录下,出现了一个名为Singleton.class的文件,我们把这个文件javap反编译一下(为了节约篇幅,我只贴比较重要的地方):

final class Singleton extends java.lang.Enum {
  public static final Singleton INSTANCE;
  ......
  static {};
    Code:
       0: new           #4                  // class Singleton
       3: dup
       4: ldc           #10                 // String INSTANCE
       6: iconst_0
       7: invokespecial #11                 // Method "":(Ljava/lang/String;I)V
      10: putstatic     #12                 // Field INSTANCE:LSingleton;
      13: iconst_1
      14: anewarray     #4                  // class Singleton
      17: dup
      18: iconst_0
      19: getstatic     #12                 // Field INSTANCE:LSingleton;
      22: aastore
      23: putstatic     #1                  // Field $VALUES:[LSingleton;
      26: return
}

可以看到,enum Singleton只不过就是class Singleton的语法糖而已。在JVM看来,枚举类型不过就是java.lang.Enum类的子类。

这个类的static code里说明了在加载Singleton类的时候,就要把instance初始化完成。这仍然利用了类加载器是线程安全的这一特性。

不过,从某种意义上说,这种做法有点类似于饿汉式的单例类了。但不管怎么说,enum这个关键字会使得单例类的实现简洁很多,这是目前比较推荐的写法。

错误的双重检查

关于synchronized,有一种用法,流毒无穷,这就是double check。最早使用double check,是因为synchronized的性能不够好,为了避免不必要的加锁,可以在加锁之前先进行一次判断。具体的写法是这样:

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

        return instance;
    }

看上去很好。但是由于Java的指令重排问题,这种写法其实是有可能出问题的。关键是这一行:

instance = new Singleton();

这一行包含三个动作,一是申请一块堆内存用于存储Singleton对象,二是执行构造函数,三是将这个对象的地址赋给instance变量。

而这三个动作的先后顺序并不是确定的。例如第一个线程先执行了申请内存,将内存地址赋给instance变量,但还未执行构造函数。这时第二个线程进来,然后我们就发现第二个线程将不会进行初始化的动作,直接就拿到了instance对象,但这个时候instance对象是未初始的状态。这就带来了错误。

现在,我还在实际的产品代码中看到以前有人写的这种代码,如果出了问题就会十分难查。而且网上一些技术博客也还保留这种写法。这是错的,请大家一定注意。

上一节课:

下一节课:

目录:课程目录

稿源:进击的Java新人 (源链) | 关于 | 阅读提示

本站遵循[CC BY-NC-SA 4.0]。如您有版权、意见投诉等问题,请通过eMail联系我们处理。
酷辣虫 » 综合技术 » 线程安全的单例类

喜欢 (0)or分享给?

专业 x 专注 x 聚合 x 分享 CC BY-NC-SA 4.0

使用声明 | 英豪名录