引言

上一节咱们讲到在并发场景中,因可见性、原子性、有序性导致的问题经常会违反咱们的直觉,从而成为并发编程的Bug之源。这三者在编程畛域属于共性问题,所有的编程语言都会遇到,Java在诞生之初就反对多线程,天然也有针对这三者的技术计划,而且在编程语言畛域处于领先地位。了解Java解决并发问题的解决方案,对于了解其余语言的解决方案有举一反三的成果。

那咱们就先来聊聊如何解决其中的可见性和有序性导致的问题,这也就引出来了明天的配角——Java内存模型

Java内存模型这个概念,退职场的很多面试中都会考核到,是一个热门的考点,也是一个人并发程度的具体体现。起因是当并发程序出问题时,须要一行一行地查看代码,这个时候,只有把握Java内存模型,能力慧眼如炬地发现问题。

什么是Java内存模型?

你曾经晓得,导致可见性的起因是缓存,导致有序性的起因是编译优化,那解决可见性、有序性最间接的方法就是禁用缓存和编译优化,然而这样问题尽管解决了,咱们程序的性能可就堪忧了。

正当的计划应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员晓得,那所谓“按需禁用”其实就是指依照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只须要提供给程序员按需禁用缓存和编译优化的办法即可。

Java内存模型是个很简单的标准,能够从不同的视角来解读,站在咱们这些程序员的视角,实质上能够了解为,Java内存模型标准了JVM如何提供按需禁用缓存和编译优化的办法。具体来说,这些办法包含 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规定,这也正是本期的重点内容。

应用volatile的困惑

volatile关键字并不是Java语言的特产,古老的C语言里也有,它最原始的意义就是禁用CPU缓存。

例如,咱们申明一个volatile变量 volatile int x = 0,它表白的是:通知编译器,对这个变量的读写,不能应用CPU缓存,必须从内存中读取或者写入。这个语义看上去相当明确,然而在理论应用的时候却会带来困惑。

例如上面的示例代码,假如线程A执行writer()办法,依照 volatile 语义,会把变量 “v=true” 写入内存;假如线程B执行reader()办法,同样依照 volatile 语义,线程B会从内存中读取变量v,如果线程B看到 “v == true” 时,那么线程B看到的变量x是多少呢?

直觉上看,应该是42,那理论应该是多少呢?这个要看Java的版本,如果在低于1.5版本上运行,x可能是42,也有可能是0;如果在1.5以上的版本上运行,x就是等于42。

    class VolatileExample {          int x = 0;          volatile boolean v = false;          public void writer() {            x = 42;            v = true;          }          public void reader() {            if (v == true) {              // 这里x会是多少呢?            }          }    }

剖析一下,为什么1.5以前的版本会呈现x = 0的状况呢?我置信你肯定想到了,变量x可能被CPU缓存而导致可见性问题。这个问题在1.5版本曾经被圆满解决了。Java内存模型在1.5版本对volatile语义进行了加强。怎么加强的呢?答案是一项 Happens-Before 规定。

Happens-Before 规定

如何了解 Happens-Before 呢?如果顾名思义(很多网文也都爱按字面意思翻译成“后行产生”),那就背道而驰了,Happens-Before 并不是说后面一个操作产生在后续操作的后面,它真正要表白的是:后面一个操作的后果对后续操作是可见的。就像有心灵感应的两个人,尽管远隔千里,一个人心之所想,另一个人都看失去。Happens-Before 规定就是要保障线程之间的这种“心灵感应”。所以比拟正式的说法是:Happens-Before 束缚了编译器的优化行为,虽容许编译器优化,然而要求编译器优化后肯定恪守 Happens-Before 规定。

Happens-Before 规定应该是Java内存模型外面最艰涩的内容了,和程序员相干的规定一共有如下六项,都是对于可见性的。

恰好后面示例代码波及到这六项规定中的前三项,为便于你了解,我也会剖析下面的示例代码,来看看规定1、2和3到底该如何了解。至于其余三项,我也会联合其余例子作以阐明。

1. 程序的程序性规定
这条规定是指在一个线程中,依照程序程序,后面的操作 Happens-Before 于后续的任意操作。这还是比拟容易了解的,比方方才那段示例代码,依照程序的程序,代码 “x = 42;” Happens-Before 于代码 “v = true;”,这就是规定1的内容,也比拟合乎单线程外面的思维:程序后面对某个变量的批改肯定是对后续操作可见的。
2. volatile变量规定
这条规定是指对一个volatile变量的写操作, Happens-Before 于后续对这个volatile变量的读操作。

这个就有点费解了,对一个volatile变量的写操作绝对于后续对这个volatile变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和1.5版本以前的语义没有变动啊?如果单看这个规定,确实是这样,然而如果咱们关联一下规定3,就有点不一样的感觉了。
3. 传递性
这条规定是指如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。

咱们将规定3的传递性利用到咱们的例子中,会产生什么呢?能够看上面这幅图:

从图中,咱们能够看到:

  1. “x=42” Happens-Before 写变量 “v=true” ,这是规定1的内容;
  2. 写变量“v=true” Happens-Before 读变量 “v=true”,这是规定2的内容 。

再依据这个传递性规定,咱们失去后果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?

如果线程B读到了“v=true”,那么线程A设置的“x=42”对线程B是可见的。也就是说,线程B能看到 “x == 42” ,有没有一种豁然开朗的感觉?这就是1.5版本对volatile语义的加强,这个加强意义重大,1.5版本的并发工具包(java.util.concurrent)就是靠volatile语义来搞定可见性的,这个在前面的内容中会具体介绍。
4. 管程中锁的规定
这条规定是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

要了解这个规定,就首先要理解“管程指的是什么”。管程是一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java里对管程的实现。

管程中的锁在Java里是隐式实现的,例如上面的代码,在进入同步块之前,会主动加锁,而在代码块执行完会主动开释锁,加锁以及开释锁都是编译器帮咱们实现的。

    synchronized (this) { //此处主动加锁      // x是共享变量,初始值=10      if (this.x < 12) {        this.x = 12;       }      } //此处主动解锁

所以联合规定4——管程中锁的规定,能够这样了解:假如x的初始值是10,线程A执行完代码块后x的值会变成12(执行完主动开释锁),线程B进入代码块时,可能看到线程A对x的写操作,也就是线程B可能看到x==12。这个也是合乎咱们直觉的,应该不难理解。
5. 线程 start() 规定
这条是对于线程启动的。它是指主线程A启动子线程B后,子线程B可能看到主线程在启动子线程B前的操作。

换句话说就是,如果线程A调用线程B的 start() 办法(即在线程A中启动线程B),那么该start()操作 Happens-Before 于线程B中的任意操作。具体可参考上面示例代码。

    Thread B = new Thread(()->{      // 主线程调用B.start()之前      // 所有对共享变量的批改,此处皆可见      // 此例中,var==77    });    // 此处对共享变量var批改    var = 77;    // 主线程启动子线程    B.start();

6. 线程 join() 规定
这条是对于线程期待的。它是指主线程A期待子线程B实现(主线程A通过调用子线程B的join()办法实现),当子线程B实现后(主线程A中join()办法返回),主线程可能看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

换句话说就是,如果在线程A中,调用线程B的 join() 并胜利返回,那么线程B中的任意操作Happens-Before 于该 join() 操作的返回。具体可参考上面示例代码。

    Thread B = new Thread(()->{      // 此处对共享变量var批改      var = 66;    });    // 例如此处对共享变量批改,    // 则这个批改后果对线程B可见    // 主线程启动子线程    B.start();    B.join()    // 子线程所有对共享变量的批改    // 在主线程调用B.join()之后皆可见    // 此例中,var==66

被咱们漠视的final

后面咱们讲volatile为的是禁用缓存以及编译优化,咱们再从另外一个方面来看,有没有方法通知编译器优化得更好一点呢?这个能够有,就是final关键字。

final润饰变量时,初衷是通知编译器:这个变量生而不变,能够可劲儿优化。Java编译器在1.5以前的版本确实优化得很致力,以至于都优化错了。

问题相似于上一期提到的利用双重查看办法创立单例,构造函数的谬误重排导致线程可能看到final变量的值会变动。具体的案例能够参考这个文档。

当然了,在1.5当前Java内存模型对final类型变量的重排进行了束缚。当初只有咱们提供正确构造函数没有“逸出”,就不会出问题了。

“逸出”有点形象,咱们还是举个例子吧,在上面例子中,在构造函数外面将this赋值给了全局变量global.obj,这就是“逸出”,线程通过global.obj读取x是有可能读到0的。因而咱们肯定要防止“逸出”。

    final int x;    // 谬误的构造函数    public FinalFieldExample() {       x = 3;      y = 4;      // 此处就是讲this逸出,      global.obj = this;    }

小结

Java的内存模型是并发编程畛域的一次重要翻新,之后C++、C#、Golang等高级语言都开始反对内存模型。Java内存模型外面,最艰涩的局部就是Happens-Before规定了,Happens-Before规定最后是在一篇叫做Time, Clocks, and the Ordering of Events in a Distributed System的论文中提出来的,在这篇论文中,Happens-Before的语义是一种因果关系。在事实世界里,如果A事件是导致B事件的起因,那么A事件肯定是先于(Happens-Before)B事件产生的,这个就是Happens-Before语义的事实了解。

在Java语言外面,Happens-Before的语义实质上是一种可见性,A Happens-Before B 意味着A事件对B事件来说是可见的,无论A事件和B事件是否产生在同一个线程里。例如A事件产生在线程1上,B事件产生在线程2上,Happens-Before规定保障线程2上也能看到A事件的产生。

Java内存模型次要分为两局部,一部分面向你我这种编写并发程序的利用开发人员,另一部分是面向JVM的实现人员的,咱们能够重点关注前者,也就是和编写并发程序相干的局部,这部分内容的外围就是Happens-Before规定。置信通过本章的介绍,你应该对这部分内容曾经有了深刻的意识。

参考文献

JSR 133 (Java Memory Model) FAQ
Java内存模型FAQ