Java并发编程:Java如何解决可见性和有序性问题

什么是JMM

线程安全需要保证多线程并发执行程序的三种特性:

  1. 原子性
  2. 可见性
  3. 有序性

现代计算机体系大部是采用的对称多处理器的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,并且因为不同指令的处理时长各自不相同,为了提高处理器的处理性能,引入了流水线的方式,对指令进行重排序来实现处理速度的优化,这里称为处理器层面的乱序执行。

在Java中,不同的线程可能访问同一个共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能读取到错误的变量数据。因此Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。

多线程对某共享变量不可见的原因就是为了解决内存和CPU的速度差异引入的缓存,而有序性是为了CPU为了利用流水线处理指令发生的编译优化。

解决可见性、有序性的方式其实就是禁用缓存和禁用编译优化,但是一刀切往往是不可取的方式。所以我们需要的是按需禁用缓存和禁用编译优化。那么“按需”一词背后涉及到的策略就需要我们程序员来控制。我们需要JVM给我们提供一套“方法”。

Java内存模型做的事情就是:规范了JVM能够提供的禁用缓存和编译优化的方法。这些方法主要涉及到三个关键字和六个happens-before规则。三个关键字包含volatile、synchronized和final。

volatile关键字

volatile关键字的原始语义就是禁用cpu缓存,在其他语言里面也有类似的关键字。

volatile修饰的关键字,可以告诉编译器:对这个变量的读写,不能作用于CPU缓存,必须从主存当中读写。比如对于下面的代码,volatile关键字可以保证“如果writer方法先执行修改了v变量使其=true,那么reader方法后续读到的x变量一定是42而不是0”。

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

但是上面这句话保证的内容在JDK5之前不一定成立,而对于后面的JDK版本来说是一定成立的,原因就是对volatile语义加强之后,加入了一项Happens-Before规则。

Happens-Before规则

Happens-Before翻译过来是“前面操作的结果-对后续操作可见”。这个规则的设立正对的对象是编译器。也就是告诉编译器,你优化编译的结果必须要符合这些规则。

总共有如下几个规则:

规则1:同线程的顺序性

还是上面那段VolatileExample类的代码。看到writer方法(在同一个线程内)的 x = 42;v = true;两行代码。x=42这个修改,对于v=true这个后续操作来说,它的操作就是可见的。

这个规则比较易于理解:假设v=true这里有一个人正在观测x的变动,当v=true指令将要执行之前,他将“有能力”看到内存中x变量的地址上值的变动。

规则2:写操作的影响读操作可见

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

可以关联规则3去理解。

规则3:传递性

假设Happens-Before是一个操作符(类似+/-/×/÷)。那么:

如果:

- A Happens-Before B
- B Happens-Before C

则一定:

- A Happens-Before C

使用write和reader方法里面的几个操作分别套用进去这个式子。

A如果是x=42(写操作),B是读v=true,C是任意(读或者写)对x的操作。

对于wirter方法来说,就是A的操作对B可见,这个在“顺序性”规则里面已经说明了。那么对于reader方法执行的线程来说,一旦读取到volatile关键字修饰的v=true的时候,就说明,在writer线程里面操作的x=42,对于reader线程来说也是可见的,线程B可以读取到x==42。

volatile就是规定了编译器在这里的编译优化必须符合这个规则。这里也可以和规则2关联一下,因为x=42是写操作,写操作的影响必须对读操作v==true?可见,所以reader线程肯定会因为在wirter发生了对volatile变量v的修改,导致writer线程能够看到x=42这样一个操作的修改。

规则4:管程中锁的规则

这个规则规定了编译器:对一个锁资源的解锁产生的影响对后续对这个锁资源的加锁可见。

标题里的管程是什么?管程是一种通用的同步原语,在Java语言的实现就是synchronized关键字。

synchronized (this) { //此处自动加锁
 
} //此处自动解锁

对于锁资源的加锁和释放的位置如上所示。

说回来之前的固定:假设线程A在synchronized代码块里面执行完之后解锁了锁资源,在代码块里产生的影响会对后续得到锁的线程B可见。

这个其实没什么难理解的,不多解释了。

规则5:“start方法”规则

标题很隐晦,解释一下就是:main线程start()了一个Thread,那么对于这Thread来说,main方法里面产生的修改操作对start()方法执行时、执行后的子线程可见。

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

即使B线程执行逻辑(run方法)在代码的定义优先于var=77的赋值,但是由于此规则的存在,编译器会保证B线程在start之后,能够看到主线程对var的赋值。

规则6:“join方法”规则

main线程使用join等待子线程执行完毕,对于join来说,JVM要求:“子线程的任何操作产生的影响”,比如下面的代码:

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

对于上面Thread里面定义的lambda表达式里面的逻辑,对于在主线程内调用得join方法来说,都是在之前执行的,主线程在join调用的时候都能看见。

Happens-Before规则总结

对上述说的Happens-Before规则进行总结一下,A Happens-Before B的含义就是A产生的操作影响对B可见。套入到六个规则里面,挨个理解如下:

  1. 首先从单线程角度来说,Happens-Before保证volatile变量的读写之前的任何变量的写操作都会对volatile变量可见,也就是保证编译器不将前面的修改操作提前。
  2. 从多线程参与的角度来说
    1. 同一个volatile变量的读和写操作来说,写操作产生的变化对后续的读取操作可见。
    2. Happens-Before规则有传递性,也就是假如A、B操作符合Happens-Before当中的顺序性,并且B、C操作符合Happens-Before当中的写对读可见性,那么传递性会保证A操作的修改能对C操作可见。
  3. 从锁机制的角度来看:JMM还能规定synchronized关键字会保证前面线程释放锁之后,前面线程操作产生的变化对后面得到锁的线程可见。
  4. 按照Thread类的两个API来分类:
    1. 对于start启动的子线程来说,start执行之前的主线程的修改会对start启动的子线程可见。
    2. 对于join收集的子线程结果来说,join执行之前的子线程的修改会对join和join之后的操作可见。

final关键字

volatile是JVM为了按需禁用缓存和编译器优化对上层(程序员开发向)提供的一个关键字,JVM为了更好的编译器优化效果,还提供了另外一个关键字——final。

final关键字的存在是为了告诉编译器这个变量是不变的,尤其是在JDK5之后,对final类型的变量的重拍序机制进行了优化。程序员显式的告诉编译器这个变量是不变的,意味着编译器可以自由的对这个变量进行优化。

posted @ 2022-05-30 22:00  來福l4ifu  阅读(93)  评论(0编辑  收藏  举报