看到一篇博客上讲happens-before。
其中举了一个例子,就是使用DCL的单例模式的写法,可能会产生其他线程读到未能被完全初始化的对象的问题。
也就是下面的代码,如果线程1执行getInstance(),而线程2执行getInstance()和getSomeField()的话, someField
的值可能会是0。
据说这个例子出自某本书,不过我没看过。
public class LazySingleton {
private int someField;
private static LazySingleton instance;
private LazySingleton() {
this.someField = new Random().nextInt(200)+1; // (1)
}
public static LazySingleton getInstance() {
if (instance == null) { // (2)
synchronized(LazySingleton.class) { // (3)
if (instance == null) { // (4)
instance = new LazySingleton(); // (5)
}
}
}
return instance; // (6)
}
public int getSomeField() {
return this.someField; // (7)
}
}
复制代码
确实,当线程1执行到 (5) 的时候,线程2在 (2) 这儿的判断可能会为false
。 那线程2就会拿到不是null
的instance
。
那这个instance
为什么会没有被完全初始化呢?
博客中是这样说的,我用我的语言转述下
因为上述的线程1和线程2的执行路径中,并没有任何的同步块处理,也没有
volatile
变量,所以没有任何happens-before关系,所以无法推倒出(1)<<(7)
令Ta(1)代表线程a执行指令1,Tb(7)代表线程b执行指令7,≼表示happen-before。 我们的目的是想证明Ta(1)≼Tb(7)不成立。首先显然Ta(1)≼Ta(5),然后Tb(2)≼Tb(6)≼Tb(7),因为这都是一个线程内部传递的(规则1+规则8)。 那Ta(5)≼Tb(2)成立吗?可以看到Ta(5)后面有个unlock,那么就可以尝试匹配规则2,然而Tb(2)前面并没有lock,除此之外也没有其他规则可以匹配了,所以Ta(5)≼Tb(2)不成立。 最终结论就是,Ta(1)≼Tb(7)不成立。
上面一段引用是原博中的说明,没有问题。
下面一段引用是博客的一个评论。这个评论把happens-before变成了枯燥的数学问题。想用数学来证明happens-before关系不存在,这样不仅容易出错,而且让人的思维脱离了JMM原理本身,变成了使用公式的计算器,认识不到本质。
其实从本质上来说,这里有两个问题。
- 理论上(5)其实分为3个步骤,申请内存+拿到引用->执行(1)->对instance引用的赋值。这两个操作可能被指令重排。其中第二步和第三步可能会被指令重排。就是先对instance引用赋值,然后再执行(1)。
- 假如没有发生指令重排,那就没问题了吗?答案是否定的。即使线程1把上面提到的3个步骤都执行完毕,线程2也可能看不到1的值。因为线程1写的仅仅是工作内存,线程2读的之后未必是最新值。
博客中也提到了解决办法
就是把instance
按照如下声明
private volatile static LazySingleton instance;
复制代码
volatile
为啥能解决问题? 原来我以为,对修饰为volatile
的变量的写会立即同步回主内存,而对volatile
变量的读会从主内存拿最新值,现在想想并不准确。
instance
是个啥,是个reference类型。确切地说,就是个地址,32bit或者64bit。如果volatile
仅仅是把引用的可见性保证的话,对对象成员的修改并不能保证可见性。这也是很多对象成员也不得不用volatile
修饰的原因。
那volatile
究竟做了什么?
其实是编译器在对volatile
变量的写操作之后加入了同步主存的指令,这个指令不仅仅作用于volatile
修饰的变量本身,而且作用于这个语句的所有step涉及的变量。这样就间接地保证了,volatile
修饰的变量,能够保证对象的安全发布。
从语义的角度来说,volatile
变量的new
操作,是对整个对象的操作,并不是对成员的操作,所以volatile
有义务保证new语句的可见性。
还有什么解决办法?
to be done