关于java:Java并发编程实战三Java内存模型看Java如何解决可见性和有序性问题

17次阅读

共计 4749 个字符,预计需要花费 12 分钟才能阅读完成。

引言

上一节咱们讲到在并发场景中,因可见性、原子性、有序性导致的问题经常会违反咱们的直觉,从而成为并发编程的 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

正文完
 0