您的位置:首页 > 其它

通过 final 关键字来实现 双重检查(DCL) 时,为什么 局部变量 是必须的?

2015-11-04 09:25 260 查看
双重检查锁定(DCL)的wiki 中,对于 DCL提供了一种通过 final 关键字来实现的方式,源码如下:

public class FinalWrapper<T> {
public final T value;
public FinalWrapper(T value) {
this.value = value;
}
}

public class Foo {
private FinalWrapper<Helper> helperWrapper;

public Helper getHelper() {
FinalWrapper<Helper> wrapper = helperWrapper;

if (wrapper == null) {
synchronized(this) {
if (helperWrapper == null) {
helperWrapper = new FinalWrapper<Helper>(new Helper());
}
wrapper = helperWrapper;
}
}
return wrapper.value;
}
}


同时,文章中有如下说明:

The local variable wrapper is required for correctness.

请问一下,为什么这个 局部变量 wrapper 是必须的?

我个人认为此变量是可有可无的,还请有识者指点一下。

本人回答如下:

第一次看到双重锁定检查还可以用final来实现,我试着回答一下这个问题,说一下自己的理解。

首先说一下基于volatile实现的双重锁定检查:

public class SafeDoubleCheckedLocking {
private volatile static Instance instance;

public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance();//instance为volatile,现在没问题了
}
}
return instance;
}
}


如果instance不声明为volatile类型的,上面代码中的双重锁定检查在多线程环境下是会有问题的。

主要问题在于多线程环境下,在一个线程中instance有可能在没有完全初始化之前(只初始化了一部分),就被其他线程使用了。 比如,instance刚被分配了内存空间,还没完成new Instance()的全部操作,其他线程在第一次对instance进行是否为空的判断的时候,结果是false(由于instance指向的是个内存地址,所以分配了内存空间之后,instance这个变量已经不为空),这个时候这个线程就会直接返回,然而instance变量指向的内存空间还没完成new Instance()的全部操作。 这样一来,一个没有被完全初始化的instance就会被使用。

上面我说的这种状况看起来不可能发生,毕竟从代码来看,这根本不可能啊。

初始化instance变量的伪代码如下所示:

memory = allocate(); //1:分配对象的内存空间

ctorInstance(memory); //2:初始化对象

instance = memory; //3:设置instance指向刚分配的内存地址

之所以会发生上面我说的这种状况,是因为在一些编译器上存在指令排序,初始化过程可能被重排成这样:

memory = allocate(); //1:分配对象的内存空间

instance = memory; //3:设置instance指向刚分配的内存地址

//注意,此时对象还没有被初始化!

ctorInstance(memory); //2:初始化对象

而volatile存在的意义就在于禁止这种重排!要想详细了解一下指令重排,可以参考一下这里

再来说一下final域的重排规则

写final的重排规则:

JMM禁止编译器把final域的写重排序到构造函数之外。

编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

也就是说:写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了。

读final的重排规则:

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

也就是说:读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

如果final域是引用类型,那么增加如下约束:

在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

(个人觉得基本意思也就是确保在构造函数外把这个被构造对象的引用赋值给一个引用变量之前,final域已经完全初始化并且赋值给了当前构造对象的成员域,至于初始化和赋值这两个操作则不确保先后顺序。)

具体解释可以参考这里

再回到这个问题本身:

public class Foo {
private FinalWrapper<Helper> helperWrapper;

public Helper getHelper() {
FinalWrapper<Helper> wrapper = helperWrapper;

if (wrapper == null) {
synchronized(this) {
if (helperWrapper == null) {
helperWrapper = new FinalWrapper<Helper>(new Helper());
}
wrapper = helperWrapper;//此处增加了对helperWrapper的引用,写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了。
//所以此处增加一次对helperWrapper的引用,可以确保FinalWrapper中的value已经成功初始化。
//如果此处没有赋值给本地变量,final域的写操作保证不能生效,那么getHelper()方法并不能保证,被return的value已经被正确初始化过。
}
}
return wrapper.value;
//读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。所以此处的读取操作确保读value之前,会读取wrapper这个引用变量指向的对象,
//而写操作又可以确保在对象可见之前,final域已经正确初始化了。所以此处肯定会读取到正确的value。
//如果读final域不做这个保证,那么对value的读取操作有可能会被重排到对wrapper赋值之前,有可能读取到不正确的value值。
}
}


主要问题在于这些关键字对指令重排的保证,个人的一个理解是这样的,欢迎讨论。

本篇文章内容源自于对Segmentfault一个问题的回答
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: