一、摘要
在读完《Java内存模型》这一篇文章之后,我们知道了Java内存模型(JMM)是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会怼代码指令重排序、处理器会怼代码乱序执行等带来的问题。目的是保证并发场景中的原子性、可见性和有序性(这个也是并发编程中,为了保证数据的安全,需要满足的三个特性)。JMM处理并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
我们也知道,由于CPU的三级缓存与内存通信的架构可能导致内存可见性问题;而且在执行程序时为了提高性能,编译器和处理器的优化:指令重排序,也可能导致多线程内存可见性的问题和有序性问题。
那如何解决内存可见性的问题?
这个便是happens-before规范存在的重要意义,而且happens-before也是JMM中最核心的概念,对于Java程序员来说,理解happens-before是理解JMM的关键。后面我们将分别从JMM的设计,happens-before的定义和规则三个方面详细进行解析。
二、happens-before的设计
JMM的设计者在设计JMM的时候需要考虑两个关键因素:
- 程序员对内存模型的使用。程序员希望内存模型易于理解、更能简化编程。程序员希望能基于一个强内存模型来编写代码。
- 编译期和处理器对内存模型的实现。编译期和处理器希望内存模型对它们的约束越少越好,这样它们就可以做尽可能多的优化来提高性能。编译期和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。下面我们举例来看下如何实现该目标:
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
上面计算圆面积的示例代码存在3个happens-before关系,如下:
- A happens-before B.
- B happens-before C.
- A happens-before C.
在3个happens-before关系中,2和3是必需的,但1是不必要的。因此,JMM把happens-before要求禁止的重排序分为了下面两类。
会改变程序执行结果的重排序。
不会改变程序执行结果的重排序。
JMM对这两种不同性质的重排序,采取了不同的策略,如下:
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。
JMM的设计示意图:
从图中我们可以得出以下两点结论:
- JMM向程序员提供的happens-before规则能满足程序员的需求。JMM的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的A happens-before B)。
- JMM对编译期和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当做一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
三、happens-before的定义
JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
上面的1是JMM堆程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证------A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
上面的2是JMM对编译器和处理器重排序的约束原则。正如前面所说,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
- as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
四、happens-before规则
《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C.
- start()规则:如果线程A执行操作ThreadB.start() (启动线程B),那么A线程的ThreadB.start() 操作 happens-before 于线程B中的任意操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens-before 于线程A 从ThreadB.join() 操作成功返回。
我们举例来解释以上规则:
五、总结
JMM提供的happens-before规则,极大的简化了我们对多线程的代码的编写;为我们规避了许多编译器和处理器进行的指令重排以及cpu三级缓存架构导致的内存可见性以及程序执行顺序等各种问题导致的程序在多线程场景的不正确性。
从内存抽象结构来说,可能出在数据“脏读”的现象,这就是数据可见性的问题,另外,重排序在多线程中不注意的话也容易存在一些问题,比如一个很经典的问题就是DCL(双重检验锁),这就是需要禁止重排序,另外,在多线程下原子操作例如i++不加以注意的也容易出现线程安全的问题。但总的来说,在多线程开发时需要从原子性,有序性,可见性三个方面进行考虑,多从happens-before的规则上面来考虑自己编写的代码是否内存可见等等。
参考引用:
《Java并发编程的艺术》
https://www.logicbig.com/tutorials/core-java-tutorial/java-multi-threading/happens-before.html