单例模式-双重检查锁定的警告

01. 单例模式

1. 介绍与比较

我们经常看到的单例模式,按加载时机可以分为:饿汉方式和懒汉方式;按实现的方式,有:synchronized修饰方法、双重检查加锁,内部类方式和枚举方式等等。另外还有一种通过Map容器来管理单例的方式。

2. 双重检查锁定的Bug

今天写了一个工具类,以单例的形式持有内部具体处理类的引用。

public class LogProcessorUtils {

    private static LogProcessorInterface logProcessor = null;

    private LogProcessorUtils() {
    }

    public static String processLog2Json(String s) {

        // 保证logProcessor单例
        if (logProcessor == null) {
            synchronized (LogProcessorUtils.class) {
                if (logProcessor == null) {
                    logProcessor = new LogProcessorImpl();
                }
            }
        }

        return logProcessor.process(s);
    }
}

但是,在FindBugs插件中爆出bug。以下是网页中的介绍:

  • DC: Possible double check of field (DC_DOUBLECHECK)

This method may contain an instance of double-checked locking. This idiom is not correct according to the semantics of the Java memory model. For more information, see the web page http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html.

大致意思是,以双重检查锁定实现的单例,在理论是正确的,但是实际上,因为Java内存模型的原因,可能造成错误,不推荐使用。

网上查阅了资料,参考《http://blog.csdn.net/chenchaofuck1/article/details/51702129》

解释如下:

public static Singleton getInstance(){
 	if (instance == null){
	    synchronized(Singleton.class) {  //1
	      if (instance == null)          //2
	        instance = new Singleton();  //3
		}
	}
 	return instance;
}

双重检查锁定背后的理论是:在 //2 处的第二次检查使(如清单 3 中那样)创建两个不同的 Singleton 对象成为不可能。假设有下列事件序列:
线程 1 进入 getInstance() 方法。

  1. 由于 instance 为 null,线程 1 在 //1 处进入 synchronized 块。
  2. 线程 1 被线程 2 预占。
  3. 线程 2 进入 getInstance() 方法。
  4. 由于 instance 仍旧为 null,线程 2 试图获取 //1 处的锁。然而,由于线程 1 持有该锁,线程 2 在 //1 处阻塞。
  5. 线程 2 被线程 1 预占。
  6. 线程 1 执行,由于在 //2 处实例仍旧为 null,线程 1 还创建一个 Singleton 对象并将其引用赋值给 instance。
  7. 线程 1 退出 synchronized 块并从 getInstance() 方法返回实例。
  8. 线程 1 被线程 2 预占。
  9. 线程 2 获取 //1 处的锁并检查 instance 是否为 null。
  10. 由于 instance 是非 null 的,并没有创建第二个 Singleton 对象,由线程 1 创建的对象被返回。

双重检查锁定背后的理论是完美的。不幸地是,现实完全不同。双重检查锁定的问题是:并不能保证它会在单处理器或多处理器计算机上顺利运行。
双重检查锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型。内存模型允许所谓的“无序写入”,这也是这些习语失败的一个主要原因。

无序写入

为解释该问题,需要重新考察上述清单 4 中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 instance 来引用此对象。这行代码的问题是:在 Singleton 构造函数体执行之前,变量 instance 可能成为非 null 的。

什么?这一说法可能让您始料未及,但事实确实如此。在解释这个现象如何发生前,请先暂时接受这一事实,我们先来考察一下双重检查锁定是如何被破坏的。假设清单 4 中代码执行以下事件序列:

  1. 线程 1 进入 getInstance() 方法。
  1. 由于 instance 为 null,线程 1 在 //1 处进入 synchronized 块。
  2. 线程 1 前进到 //3 处,但在构造函数执行之前,使实例成为非 null。
  3. 线程 1 被线程 2 预占。
  4. 线程 2 检查实例是否为 null。因为实例不为 null,线程 2 将 instance 引用返回给一个构造完整但部分初始化了的 Singleton对象。
  5. 线程 2 被线程 1 预占。
  6. 线程 1 通过运行 Singleton 对象的构造函数并将引用返回给它,来完成对该对象的初始化。
    此事件序列发生在线程 2 返回一个尚未执行构造函数的对象的时候。

为展示此事件的发生情况,假设为代码行 instance =new Singleton(); 执行了下列伪代码: instance =new Singleton();

mem = allocate();             //Allocate memory for Singleton object.
instance = mem;               //Note that instance is now non-null, but
                          //has not been initialized.
ctorSingleton(instance);      //Invoke constructor for Singleton passing
                          //instance.

这段伪代码不仅是可能的,而且是一些 JIT 编译器上真实发生的。执行的顺序是颠倒的,但鉴于当前的内存模型,这也是允许发生的。JIT 编译器的这一行为使双重检查锁定的问题只不过是一次学术实践而已。

为说明这一情况,假设有清单 5 中的代码。它包含一个剥离版的 getInstance() 方法。我已经删除了“双重检查性”以简化我们对生成的汇编代码(清单 6)的回顾。我们只关心 JIT 编译器如何编译 instance=new Singleton(); 代码。此外,我提供了一个简单的构造函数来明确说明汇编代码中该构造函数的运行情况。

3. 静态内部类实现

public class LogProcessorUtils {
    private LogProcessorUtils() {
    }

    /**
     * 保证logProcessor单例
     */
    private static class ProcessorSingletonHolder {
        private final static LogProcessorInterface logProcessor = new LogProcessorImpl();
    }

    public static String processLog2Json(String s) {
        return ProcessorSingletonHolder.logProcessor.process(s);
    }
}

之后,FindBugs警告消失。

posted @ 2017-06-14 22:44  神话小小哥  阅读(3397)  评论(1编辑  收藏  举报