就在不久前,读者群因为一个提问引发了激烈的讨论!
代码如下:
从问题来看,大家讨论的问题的焦点在于 map 去 put 一个对象的时候,究竟会不会因为对象没有完全初始化完成而导致另外一个线程 get 的时候只是拿到了对象的引用,导致报错呢?
从提供的代码的写法来看,是一个最基本的DCL稍微改变了的写法,在探讨map的问题之前,我想先从DCL(双重检查校验)说起。
DCL的由来
在最初的时候,我们常规的单例写法就像这样:
很容易你就应该知道,这段代码不是线程安全的,所以有了加锁的单例方法实现。
但是synchronized又会导致多线程下性能开销过大,虽然现在优化了,但是早期synchronized的性能是堪忧的,所以就诞生了双重检查锁定DCL的写法。先判断一次null,然后再加锁,这样第一次检查不是null的话就不需要加锁了,就可以避免synchronized的性能开销过大的问题。
看样子问题是解决了,就很棒的样子,但是回到开头说的问题。
DCL的问题
从CPU的角度来看,instance = new Instance()可以分为分为几个步骤:
- 分配对象内存空间
- 执行构造方法,对象初始化
- instance指向分配的内存地址
实际上,由于指令重排的问题,2、3的步骤可能会发生重排序,那么问题就发生了。
instance先被指向内存地址,然后再执行初始化,如果此时另外一个线程来访问getInstance方法,就会拿到instance不是null,最后拿到的将是一个没有被完全初始化的对象!
实际上,这个问题已经是大部分人都知道的DCL的一个问题了。
因为根据Java内存模型语义来说,不管编译器和处理器怎么排序,单线程的执行结果都不能改变,只要数据没有依赖关系,就都可以重排序。
那对上面的例子来说重排序改变了单线程下程序结果吗?并没有,因为无论线程A内是先初始化对象还是先把instance指向分配好的内存地址,对于单线程A的结果来说是没有任何改变的。
也就是说,对于重排序的结果来说,只要保证线程B在访问对象的时候能拿到instance引用就可以,无论线程A内部初始化和执行内存地址两个步骤怎么重排序都不会影响到最终结果。
重排序的结果只是造成了线程B拿到的是一个没有完全初始化完成的对象而已,可能这时候构造方法没有执行,拿到的对象属性可能是错误的,也有可能如果拿着这个没有完全初始化完成的对象去操作,可能会导致空指针的问题。
所以,一般在使用DCL的时候会把变量声明为volatile
,因为volatile的语义会禁止指令重排,而本质上就是加上了内存屏障。
一切都是猜测
如果依据这个解释,来回答群内提出的问题貌似也可以解释的通。
因为 map 在 put 的时候可以不管 new 对象时候的指令重排,只要能拿到对象引用的内存地址就可以了,所以另外一个线程如果去 get 的话就可能拿到一个空值。
从as-if-serial的语义来看,确实不会改变单线程内的执行结果,但是还有一点说的是只要数据没有依赖关系,就都可以重排序。问题的关键点在于 put 这个到底有依赖关系吗?依赖关系怎么定义?
如果是最简单的比如 x=1,y=2
那么我们可以认为完全没有依赖关系,可以指令重排。如果是 x=1,y=x+1
那么由于单线程内y
依赖于x
,所以不能指令重排。
那么 map.put(key,new B(value))
呢?
一道证明题
Jcstress(Java Concurrency Stress)是一个帮助测试JVM和硬件并发正确性的工具库。
首先,先证明DCL的问题是否确实存在,是否真的在另外的线程中能看到未完全初始化的对象。代码如下:
通过测试代码,如果最终能输出0,1,2,3那么代表确实是能拿到未完全初始化完成的对象。
首先对代码 mvn clean install
打包,然后执行命令:
java -XX:-UseCompressedOops -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand="print com.jcst.UnsafePublication_jcstress*::call" -XX:CompileCommand="inline, com.jcst.UnsafePublication::publish" -XX:+PrintAssembly -jar target/jcstress.jar -f -1 -t UnsafePublication -v > log2.txt
执行完成之后,查看输出结果发现问题确实存在:
而且,通过生成的汇编指令,也可以看到发生了指令重排,引用被先赋值,对象还没有完全初始化完成。但是实际测试过程中,这个问题并不好复现,需要反复的测试才有可能拿到我们想要的结果。
有了基础的事实之后,再继续修改代码,如果加上 map 操作还能出现这个现象的话,那么证明实际上 map 的 put 操作也是同样存在可能性的。
如果同样我们能得到汇编的结果,put 的操作也被指令重排发生在初始化完成之前的话,那么就可以证明我们的猜测了。
结果和我们之前预料的不太一致,无论怎么修改代码顺序,测试脚本都是执行通过。这说明 put 不会把一个没有初始化完成的对象给保存进去。
总结
由于指令重排发生的场景非常多而且也非常底层,目前我们能看到的资料无非就告诉我们单线程结果不能改变,数据不能有依赖性,这样的话就能指令重排。
而我们的代码从 Java 通过 javac 编译成字节码,再经过 JIT 动态编译成机器码,从机器码再经过处理器,到缓存这些过程都可能发生指令重排。而编译器、处理器、缓存这些根据机器、硬件环境不同,又都可能造成不同的影响。
通过DCL的已知问题和最后根据jcstress得到汇编的结果来看,由于没有改变单线程最终结果,指令重排是确实发生了。但是从 map 的 put 的结果来看,最终结论是不会,put 操作不会把一个没有初始化完成的对象保存进去。
而我也尝试了不少其他的方式,比如打印、模拟 map 写了一个空方法,只是用到了引用对象,测试结果无一都是通过,所以大胆猜测使用了引用其实也是依赖的一种,这样就不会导致重排的发生。
最后,如果你有更好的证实的方法,或者有不同的意见可以证实结论的错误性,欢迎拍砖打脸指正。
参考:
https://stackoverflow.com/questions/35883354/is-there-any-instruction-reordering-done-by-the-hotspot-jit-compiler-that-can-be/64941168#64941168
https://stackoverflow.com/questions/42079959/explain-how-jit-reordering-works?answertab=votes#tab-top