Java并发2:Happens-Before规则


Java并发2:Happens-Before规则

由上篇总结可知:导致可见性的原因是缓存,导致有序性的原因是编译优化。

则解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但会出现性能问题。所以应该按需禁用缓存以及编译优化

Java 内存模型是个很复杂的规范,可以从不同视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法

具体来说,这些方法包括 volatilesynchronized final 三个关键字,以及六项 Happens-Before 规则

volatile:JDK1.5对该关键字做了增强,在原有禁止指令重排序&禁用缓存的功能上,通过读写屏障保证了传递性

Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

1. 程序的顺序性规则

在一个线程中,按照程序控制流顺序(而非代码顺序),前面的操作 Happens-Before 于后续的任意操作。

2. volatile 变量规则

指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

3. 传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

4. 管程中锁的规则

指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程是一种通用的同步原语,在 Java 中指的就是 synchronizedsynchronized 是 Java 里对管程的实现。

5. 线程 start() 规则

指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

主线程调用B.start()之前,启动B线程之前所有对共享变量的修改,B线程皆可见。

6. 线程 join() 规则

主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中join()方法返回),主线程能够看到子线程的操作,即对共享变量的操作。

7.线程中断规则:

对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

8.对象终结规则

一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

Note:代码执行时在时间上的先后顺序与Happends-Before原则基本没有因果关系。


补充-内存模型

Java内存模型规定所有变量存到主内存,可类比就是机器上的内存,当然仍处于虚拟机内存的一部分。

每条线程有自己的工作内存,可类比就是处理器缓存,里面会有对被该线程使用的主内存变量的副本。

线程先把要用的数据从主内存中读取到自己的工作内存中,再操作,操作完再写会主内存。

补充-volatile关键字

2个性质:

可见性:一个线程对volatile变量的修改,其他线程可以立即看见。

每次(指令级别)操作volatile变量,强制从主内存中读; 每次写volatile变量,强制向主内存中写。

禁止指令重排序:通过底层汇编代码lock add1 $0x0,(%esp)实现内存屏障,这样对volatile变量操作的指令的后面的指令就不会排到前面。

补充-final关键字

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以最大限度优化。

Java 编译器在 1.5 以前的版本的对该关键字有优化问题:类似利用双重检查方法创建单例,构造函数的错误重排导致线程可能看到 final 变量的值会变化。

在 1.5 以后 Java 内存模型对final类型变量的重排进行了约束。前提是保证提供正确的构造函数没有“引用逃逸”。

如何正确使用final保证没有引用逃逸?

How can final fields appear to change their values?

One of the best examples of how final fields’ values can be seen to change involves one particular implementation of the String class.

A String can be implemented as an object with three fields – a character array, an offset into that array, and a length. The rationale for implementing String this way, instead of having only the character array, is that it lets multiple String and StringBuffer objects share the same character array and avoid additional object allocation and copying. So, for example, the method String.substring() can be implemented by creating a new string which shares the same character array with the original String and merely differs in the length and offset fields. For a String, these fields are all final fields.

>String s1 = "/usr/tmp";
>String s2 = s1.substring(4); //该处底层代码调用 new String(array, offset, length)

The string s2 will have an offset of 4 and a length of 4. But, under the old model, it was possible for another thread to see the offset as having the default value of 0, and then later see the correct value of 4, it will appear as if the string “/usr” changes to “/tmp”.

The original Java Memory Model allowed this behavior; several JVMs have exhibited this behavior. The new Java Memory Model makes this illegal.

上面这段话解释了为什么可能出现线程访问final值时不同的情况。

举了一个很典型的例子:(需要重新组织下语言)

首先String类是由3字段实现的:一个字符数组(array),数组偏移量(offset),数组长度(length)。

为什么String类不直接只用一个字符数组实现就行了?反而要用这3个字段?

原因是让String类的StringBuffer类能复用同一个字符串底层的数组,这样就不用重复复制开辟内存空间了。

比如String s = "abc",底层是有abc3个字符的数组,那么实际上"a""b""c""ab","ac""bc"这些字符串都可以复用"abc"的数组,只要这几个字符串的offsetlength做出相应的变化就可以。

因此,一个String对象中的offsetlength字段也肯定是final的。

从上面第2行代码可以看到,字符串s2是个offset为4,length为4的字符串,它和字符串s1底层都指向的同一个字符数组。

在旧的内存模型中,其他线程访问s2时,可能会发现offset为0,之后offset才显示正确值4。

这样就会让访问s2的线程发现,s2可能出现s2="/usr"的情况(offset=0length=4),等到offset成为4的时候,s2才为"/tmp"

这种情况的产生,就是指令重排优化导致先返回了s2的指针,再去给offset初始化造成的。

新的Java内存模型已经禁止了这种重排行为。(JDK1.5之后)

How do final fields work under the new JMM?
The values for an object’s final fields are set in its constructor. Assuming the object is constructed “correctly”, once an object is constructed, the values assigned to the final fields in the constructor will be visible to all other threads without synchronization. In addition, the visible values for any other object or array referenced by those final fields will be at least as up-to-date as the final fields.

What does it mean for an object to be properly constructed? It simply means that no reference to the object being constructed is allowed to “escape” during construction. (See Safe Construction Techniques for examples.) In other words, do not place a reference to the object being constructed anywhere where another thread might be able to see it; do not assign it to a static field, do not register it as a listener with any other object, and so on. These tasks should be done after the constructor completes, not in the constructor.

final字段的值在构造器中设置。假设对象被正确构造,一旦对象构造完成,那么其他线程不用同步就可以看得到final字段的值。另外,被这些final字段引用的对象或者数组的可见值肯定至少是跟final字段一样新。

那么怎样才能说一个构造器是被合理适当地构造了?

简单说就是在对象构造过程中,对该对象的引用都不允许“引用逃逸”(escape)。(详见 Safe Construction Techniques for examples

也就是说不要将正在构造的对象的引用放在另一个线程可能能够看到它的任何地方;不要将其分配给静态字段,不要在将这个引用注册为listener等等。这种操作应该放在构造器完成之后,而不是构造器里面。

以下是正确的使用方式:

>class FinalFieldExample {
>final int x;
>int y;
>static FinalFieldExample f;
>public FinalFieldExample() {
>x = 3;
>y = 4;
>}

>static void writer() {
>f = new FinalFieldExample();
>}

>static void reader() {
>if (f != null) {
int i = f.x;
int j = f.y;
>}
>}
>}

The class above is an example of how final fields should be used. A thread executing reader is guaranteed to see the value 3 for f.x, because it is final. It is not guaranteed to see the value 4 for y, because it is not final.

从代码中可以看到,构造器仅仅是对属性值进行了初始化,构造器中并没有任何引用该对象的代码

这样一个线程在执行reader()方法时,一定能够看得到f.x=3

因为它是final的,这样尽管可能在构造f对象时,先返回了对象指针(参考上篇文章双重检查方法创建单例),但Java内存模型一定保证了返回指针前把final字段的值给初始化了,但不一定看得到f.y=4

If FinalFieldExample‘s constructor looked like this:

>public FinalFieldExample() { // bad!
>x = 3;
>y = 4;
>// bad construction - allowing this to escape
>global.obj = this;
>}

then threads that read the reference to this from global.obj are not guaranteed to see 3 for x.

这种就是不好的例子,因为其他线程调用global.obj时,它可能还没初始化,就造成了空指针异常。

The ability to see the correctly constructed value for the field is nice, but if the field itself is a reference, then you also want your code to see the up to date values for the object (or array) to which it points. If your field is a final field, this is also guaranteed. So, you can have a final pointer to an array and not have to worry about other threads seeing the correct values for the array reference, but incorrect values for the contents of the array. Again, by “correct” here, we mean “up to date as of the end of the object’s constructor”, not “the latest value available”.

Now, having said all of this, if, after a thread constructs an immutable object (that is, an object that only contains final fields), you want to ensure that it is seen correctly by all of the other thread, you still typically need to use synchronization. There is no other way to ensure, for example, that the reference to the immutable object will be seen by the second thread. The guarantees the program gets from final fields should be carefully tempered with a deep and careful understanding of how concurrency is managed in your code.

There is no defined behavior if you want to use JNI to change final fields.

——JSR 133 (Java Memory Model) FAQ Jeremy Manson and Brian Goetz, February 2004

上面这段话总结起来就是,如果你构造函数里面有个final指针指向的是数组或者对象,如果这个数组和对象在被构造的时候,其他线程也可能在操作这个数组或对象,那么这个数组和对象就就是非线程安全的。你还是要在访问的地方加锁才可以保证其他线程看到的值都是最新的。

补充-逃逸分析

JVM优化技术,通过编译时分析对象动态作用域,判断当一个对象在方法内定义后,是否可能逃逸,分成几个不同的逃逸程度。

不逃逸:变量仅在方法内使用,别的方法和线程无法访问。

方法逃逸:通过传参,被外部方法引用,但在同一线程内。

线程逃逸:通过赋值,变量会被其他线程访问。

优化方法:

  • 栈上分配

    如果对象无方法逃逸,对象可以之间放在方法的栈帧中,执行完即可销毁。HotSpot还没做此项优化。

  • 标量替换

    如果对象无方法逃逸,对象可以化整为零,只需要在方法栈中存成员变量进行计算即可。

  • 同步消除

    如果对象无线程逃逸,即无线程安全问题,就可以自动优化调加锁释放锁的操作。

思考

有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,有哪些办法可以让其他线程能够看到abc==3

1.声明共享变量abc,并使用volatile关键字修饰abc
2.声明共享变量abc,在synchronized关键字对abc的赋值代码块加锁,由于Happen-before管程锁的规则,可以使得后续的线程可以看到abc的值。
3.A线程启动后,使用A.JOIN()方法来完成运行,后续线程再启动,则一定可以看到abc==3

别人的总结

  1. 为什么定义Java内存模型?现代计算机体系大部是采用的对称多处理器的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,这里称为处理器的乱序执行。在Java中,不同的线程可能访问同一个共享或共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能出现无法想象的问题,这里称为编译器的重排序。除了处理器的乱序执行、编译器的重排序,还有内存系统的重排序。因此Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。

  2. 三个基本原则:原子性、可见性、有序性。

  3. Java内存模型涉及的几个关键词:锁、volatile字段、final修饰符与对象的安全发布。其中:第一是锁,锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。第二是volatile字段,volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。第三是final修饰符,final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。

  4. Happens-Before的7个规则:
    (1).程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
    (2).管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面”是指时间上的先后顺序。
    (3).volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的”后面”同样是指时间上的先后顺序。
    (4).线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
    (5).线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
    (6).线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
    (7).对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

  5. Happens-Before的1个特性:传递性。

  6. Java内存模型底层怎么实现的?主要是通过内存屏障(memory barrier)禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于volatile,编译器将在volatile字段的读写操作前后各插入一些内存屏障。

    ——Healtheon


文章作者: Wendell
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Wendell !
评论
  目录