Java中的Happens-Before,重排序和DCL问题

本文是我之前发表在其他网站的博客,近期统一迁移到个人博客并重新编辑后发布

最近在阅读《Java并发编程实战》,在关于Java内存模型-重排序章节,重新提到了一直以来未很有困惑性的问题。
书中给出的例子是

int a,b,x,y=0

在ThreadA中的操作为

a = 1;
x = b;

在另外一个线程ThreadB中的操作为

b = 1;
y = a;

最后在两个线程分别start后,在Main中执行打印xy的值,有一种情况是会出现打印x,y 都为0的情况,并且可能的执行顺序为

线程A start——> x=b(0)——————————>a=1——end
线程B start——————> b=1——>y=a(0)——end

我们可以看到指令的执行顺序和代码中的顺序完全不一致。在JMM中定义了操作的偏序关系,并且Java规范提供了Happens-Before规则,其中一条规则是

程序顺序规则:(一个线程中)如果一个操作A在操作B之前,那么在线程A中操作将在B操作之前执行。

通常我们看到这句话,会认为如果在代码上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)问题

1
2
3
4
5
6
7
8
class SomeClass {
private Resource resource = null;
public Resource getResource() {
if (resource == null)
resource = new Resource();
return resource;
}
}

这段代码的主要意图是为了延迟初始化(Lazy Initialization),但是在多线程环境下,这个是一个明显的CheckAndSet问题,因此必须在初始化Resource的地方进行同步,防止出现多次初始化。接着就有了下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
  class SomeClass {
private Resource resource = null;
public Resource getResource() {
if (resource == null) {
synchronized {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
}

不过这段代码依然存在问题,根据data race的描述,在resource变量的readwrite操作上存在data race,同时由于指令重排序的存在,new Resource()和resource变量的write操作顺序是不定的,其他的线程有可能在resource == null的判断上读取到了非null的resource,但是Resouce对象可能是并未(构造器总)初始化完成的。我们通过一个相关的例子来说明这个问题:

singletons[i].reference = new Singleton();

下面是 Symantec JIT的编译结果

1
2
3
4
5
6
7
8
9
10
11
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()的获取锁将会带来性能上的损失。

第二种方法是不使用延迟初始化,而使用

1
2
3
class MySingleton {
public static Resource resource = new Resource();
}

来确保resource的成功实例化。
最后一种方案是使用volatile关键字修饰resource变量(>=java1.5)。volatile关键字提供了两个语义,可见性和禁止指令重排序,可见性应该很多人都了解,涉及到Java内存模型,在此不在细说,对于禁止指令重排序有两个层面的理解,
1.针对以下代码

code1
code2
volatile code
code3{print code}
code4

即使存在局部指令重排序,code1code2不能在volatile code后发生。code3或者code4不能先于volatile code发生,也就是说虚拟机指令重排序不能调整volatile前后代码相对volatile code的顺序。
再看一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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

另外一个volatileHappens-before原则语义:所有在volatile写之前的操作Happens-Before volatile读之后的语句,即片段2的代码因为volatile变量读,从而后面的打印语句一定可以看到最新的first,second,third变量值,这个算是一个trick,我们在代码中可以利用这个trick实现部分代码的Happens-before语义保证。

2.对于volatile变量的赋值操作,不会因为指令重排序导致出现读取到未实例化完全的变量值,对应于

1
resource = new Resource();

在没有volatile修饰情况下读取到未实例化完全的Resource对象。

总结:Happens-Before和指令重排序没有直接关系,JVM在不违反Happens-Before和不影响程序执行结果的情况下可以对指令进行重排序。Happens-Before关系和时间上的先后执行顺序没有直接关系,语义表述上很简单,但是要深入理解内部机制并且从多个角度分析,可以引申出很多问题的分析。

参考资料: