《 Java并发编程实战 》这本书中,在关于Java内存模型-重排序章节,重新提到了关于Java中的可见性和重排序问题。 书中给出的例子是
int a,b,x,y=0
在ThreadA中的操作为
a = 1; x = b;
在另外一个线程ThreadB中的操作为
b = 1; y = a;
最后在两个线程分别start后,在 Main 中执行打印 x 和 y 的值,有一种情况是会出现打印 x,y 都为0的情况,并且可能的执行顺序为
线程A start——> x=b(0)——————————>a=1——end 线程B start——————> b=1——>y=a(0)——end
我们可以看到指令的执行顺序和代码中的顺序完全不一致。在JMM中定义了操作的偏序关系,并且Java规范提供了 Happens-Before 规则,其中一条规则是
通常我们看到这句话,会认为如果在代码上x在前,y在后,那么x在时间上或者指令执行顺序上一定先于y发生,但是如果了解指令重排序的话一定知道其实不然,但是一直很困惑这句话的意义。 特意从 Oracle 的官网上找到了相关的说明:
If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).
这句话应该是书中中文翻译的原文,但是在这句话下面又有另外一个说明。
It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation . If the reordering produces results consistent with a legal execution, it is not illegal. For example, the write of a default value to every field of an object constructed by a thread need not happen before the beginning of that thread, as long as no read ever observes that fact. More specifically, if two actions share a happens-before relationship, they do not necessarily have to appear to have happened in that order to any code with which they do not share a happens-before relationship . Writes in one thread that are in a data race with reads in another thread may, for example, appear to occur out of order to those reads. The happens-before relation defines when data races take place.
关于data race的解释如下
1.2 What is a Data Race? The Thread Analyzer detects data-races that occur during the execution of a multi-threaded process. A data race occurs when: two or more threads in a single process access the same memory location concurrently, and
at least one of the accesses is for writing, and
the threads are not using any exclusive locks to control their accesses to that memory.
When these three conditions hold, the order of accesses is non-deterministic, and the computation may give different results from run to run depending on that order. Some data-races may be benign (for example, when the memory access is used for a busy-wait), but many data-races are bugs in the program.
这段说明提示了我们编译器会对指令进行重排序,前提是不影响到程序的执行结果,一个例子是对一个对象字段的默认值的 write 操作不一定在 thead 开始的时候完成,只要 read 操作没有被影响。我们可以得出以下结论 1. 存在 Happens-Befere guarantee 的代码顺序和执行时间上的先后顺序没有直接关系
2. 在没有 Synchronization 的情况下,由于指令重排序的存在,从一个线程的角度观察另外一个线程的指令执行是无序的
3. 只有在存在 data race 的情况下, Happens-Before 规则才是有意义的
4. 如果操作B依赖于操作A的结果,那么编译器不能对A和B进行重排序,否则结果将是不可预测的
接下来看下有名的 DCL(Double-Checked Locking) 问题
class SomeClass { private Resource resource = null; public Resource getResource() { if (resource == null) resource = new Resource(); return resource; } }
这段代码的主要意图是为了延迟初始化(Lazy Initialization),但是在多线程环境下,这个是一个明显的 CheckAndSet 问题,因此必须在初始化 Resource 的地方进行同步,防止出现多次初始化。接着就有了下面的代码:
class SomeClass { private Resource resource = null; public Resource getResource() { if (resource == null) { synchronized { if (resource == null) resource = new Resource(); } } return resource; } }
不过这段代码依然存在问题,根据 data race 的描述,在 resource 变量的 read 和 write 操作上存在 data race ,同时由于指令重排序的存在,new Resource()和resource变量的write操作顺序是不定的,其他的线程有可能在resource == null的判断上读取到了非null的resource,但是 Resouce 对象可能是并未(构造器总)初始化完成的。我们通过一个相关的例子来说明这个问题:
singletons[i].reference = new Singleton();
下面是 Symantec JIT的编译结果
0206106A mov eax,0F97E78h 0206106F call 01F6B210 ; allocate space for[1] ; Singleton, return result in eax 02061074 mov dword ptr [ebp],eax ; EBP is &singletons[i].reference ; store the unconstructed object here. 02061077 mov ecx,dword ptr [eax] ; dereference the handle to ; get the raw pointer 02061079 mov dword ptr [ecx],100h ; Next 4 lines are 0206107F mov dword ptr [ecx+4],200h ; Singleton's inlined constructor[2] 02061086 mov dword ptr [ecx+8],400h 0206108D mov dword ptr [ecx+0Ch],0F84030h
从编译结果上看到赋值操作1(0206106F),竟然先于 constructor 执行(0206107F),这就解释了多线程场景中,可能读取到未实例化完全的 resource 变量,简单来说,就是引用赋值和对象构造器调用的执行顺序被重写。 那么 DCL 问题如何解决,一种是使用正确的 Synchronization ,Java中的 synchronize 不仅仅提供了独占访问,同时保证了在 enter synchronize 时从 main memory 读取最新的值到 work cache ,在 synchronize exit 时flush work cache 的值到 main memory ,但是由于每次访问 getResource() 的获取锁将会带来性能上的损失。
第二种方法是不使用延迟初始化,而使用
class MySingleton { public static Resource resource = new Resource(); }
来确保 resource 的成功实例化。 最后一种方案是使用 volatile 关键字修饰resource变量(>=java1.5)。 volatile 关键字提供了两个语义,可见性和禁止指令重排序,可见性应该很多人都了解,涉及到Java内存模型,在此不在细说,对于禁止指令重排序有两个层面的理解, 1.针对以下代码
code1 code2 volatile code code3{print code} code4
即使存在局部指令重排序, code1 和 code2 不能在 volatile code 后发生。 code3 或者 code4 不能先于 volatile code 发生,也就是说虚拟机指令重排序不能调整volatile前后代码相对 volatile code 的顺序。 再看一段代码
// Definition: Some variables // 变量定义 private int first = 1; private int second = 2; private int third = 3; private volatile boolean hasValue = false; // First Snippet: A sequence of write operations being executed by Thread 1 //片段 1:线程 1 顺序的写操作 first = 5; second = 6; third = 7; hasValue = true; // Second Snippet: A sequence of read operations being executed by Thread 2 //片段 2:线程 2 顺序的读操作 System.out.println("Flag is set to : " + hasValue); System.out.println("First: " + first); // will print 5 打印 5 System.out.println("Second: " + second); // will print 6 打印 6 System.out.println("Third: " + third); // will print 7 打印 7
另外一个 volatile 的 Happens-before 原则语义:所有在 volatile 写之前的操作 Happens-Before volatile 读之后的语句,即片段2的代码因为 volatile 变量读,从而后面的打印语句一定可以看到最新的 first , second , third 变量值,这个算是一个trick,我们在代码中可以利用这个trick实现部分代码的 Happens-before 语义保证。
2.对于 volatile 变量的赋值操作,不会因为指令重排序导致出现读取到未实例化完全的变量值,对应于
resource = new Resource();
在没有 volatile 修饰情况下读取到未实例化完全的 Resource 对象。
总结: Happens-Before 和指令重排序没有直接关系, JVM 在不违反 Happens-Before 和不影响程序执行结果的情况下可以对指令进行重排序。 Happens-Before 关系和时间上的先后执行顺序没有直接关系,语义表述上很简单,但是要深入理解内部机制并且从多个角度分析,可以引申出很多问题的分析。
参考资料: