Java并发编程 | 内存模型大展身手

1,363 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第17天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

在说Java内存模型之前先说个容易混淆的东西,这里的Java内存模型是Java Memory Model,它是一套复杂的规范,用来解决并发编程中的一些问题。

而很多人也叫JVM运行时内存结构也叫作Java内存模型,这就大错特错了,也就是下面这张图:

image.png

这个是JVM运行Java程序时开辟的各种空间,包括线程共享的堆和方法区(元数据区或者永久代,不同版本不同处理),以及线程非共享的方法栈和程序计数器。这个是JVM内存结构,千万不要再说成是Java内存模型了。本章我们来真正理解什么是Java内存模型。

1650097935(1).jpg

正文

上一篇文章我们说了引起并发编程Bug的3大问题:可见性、原子性和有序性,而这3个问题是计算机发展这几十年来演变出的问题,而Java作为一门高级编程语言,是支持并发的,所以Java语言为了解决其中的可见性和有序性而导致的问题,引入了大名鼎鼎的Java内存模型

什么是Java内存模型

上一篇文章我们说了可见性问题的源头是CPU缓存,而有序性的源头是编译器优化,那我直接禁用CPU缓存和编译器优化不就行了,但是这样虽然问题解决了,人也没了,程序性能可就堪忧了。

合理的方案是按需禁用缓存和编译优化,那什么时候是按需呢 这个也就是编写代码的程序员最清楚了,所以能提供给程序员按需去禁用缓存和编译优化的方法就好了,而Java内存模型就是干这个事的。

Java内存模型定义了一套规范,能使JVM按需禁用CPU缓存和编译优化;而对于程序员来说,就是提供了一些方法可以让JVM按需禁用缓存和编译优化,这些方法包括了volatile、synchronized和final三个关键字,以及六项Happen-Before规则。

使用volatile的困惑

volatile在古老的C语言中就有,最原始的意义就是禁用CPU缓存,例如我们声明一个volatile变量volatile int x = 0,这句话的意思对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。

那如果只有这个功能的话也只能说明这个变量是线程间可见的,但是还不够完全解决问题,我们来看下面代码:

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

假如线程A执行writer()方法,会把变量v的值为true写入内存,线程B执行reader()方法,按照volatile语义,线程B会从内存中获取变量v,如果线程B看到的是"v == true",这里x值可能是42也可能是0,这里要分版本:低于1.5版本上,x值可能是42,也可能是0;如果1.5以上的版本,x只能是42。

这个问题很有意思 变量x可能由于CPU缓存导致在执行reader()方法时还没有被写入内存,所以值是0;也有可能是由于编译器优化,因为对于writer()方法来说,这2个赋值语句谁先谁后在单线程看来是不影响的,所以可能先执行的v=true,从而导致x的值在reader()中读取是0。

在Java 1.5版本对volatile关键字进行了语义增强,这里就涉及了Happens-Before规则,我们来看看。

Happens-Befroe规则

Happen-Before规则是Java内存模型制定的规则,用来处理线程间可见性问题,至于如何去处理,我们先不做讨论细节。

这个Happen-Before可以说是Java内存模型中最难懂的地方,理解起来非常绕;首先这个词的翻译就比较难,Happen-Before并不是说前面一个操作发生在后续操作之前,它要真正表达的意思是:前面一个操作的结果对于后续操作是可见的

就像有心灵感应的2个人,虽然远隔千里,一个人所想,另一个人能看得见,而Happens-Before规则就是要线程之间保持这种"心灵感应",所以比较正式的说法是:Happens-Berfore约束了编译器的优化行为,允许编译器优化,但是优化后必须遵守Happens-Before规则

说到这里或许就明白了,虽然volatile变量禁用了CPU缓存,但是没有禁止编译器优化啊,编译器依旧可以优化,但是像前面说的把"x = 42和v = true"给调换位置的优化就不会,而且x可见性也能得到保证,那这个强大的Happen-Before规则是什么样的呢。

和程序员相关的规则有6个,且都关于可见性的。

(1) 程序的顺序性规则

指的是在一个线程中按照程序顺序,前面的操作Happens-Berfore于后续的任意操作。比如前面的代码中,第6行代码"x = 42" Happens-Before于第7行代码"v = true",这比较符合单线程的思维:程序前面对某个变量的修改,一定是对后续操作可见的

注意哦,这个规则是单线程下的规则,比如上面代码如果没有volatile修饰的话,x和v的赋值之间是没有依赖的,所以这2个赋值操作可以重排,但是x的赋值结果却对v的赋值这条语句来说是可见的,虽然这个可见性没啥用(因为v的赋值不依赖x的值)。

(2) volatile变量规则

这条规则是指一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作,单独看这一条规则,这不就是禁用缓存的意思吗,别急,我们看第三条。

(3) 传递性规则

这条规则是指如果A Happens-Berfore B,且B Happens-Before C,那么A Happens-Before C,那我们将规则3的传递性应用到我们的例子中是:

image.png

可以看到:

  • "x = 42" Happens-Before 写变量"v = true",这是规则1;

  • 写变量"v = true" Happens-Before 读变量"v = true",这是规则2;

再根据传递性规则,我们得到结果"x = 42" Happens-Before读变量"v = true",这意味什么呢 那就是线程A设置的"x = 42"是对线程B可见的,也就是线程B能看到"x == 42",这就是1.5版本对volatile语义的增强,这个意义重大,Java并发工具包就是靠volatile语义来搞定可见性的。

而这里对这个可见性的实现是禁止这2段语句的重排,这个也是volatile的通俗功能说法会禁止指令重排序。

(4) 管程中锁的规则

这条规则是指一个锁的解锁Happens-Before于后续对这个锁的加锁

管程是一种通用的同步原语,在Java中是指synchronized,synchronized是Java里对管程的实现,管程中的锁在Java里隐式实现的,比如下面代码在进入同步代码块之前,会自动加锁,而在代码块执行完后会自动释放锁,加锁以及释放锁都是编译器帮我实现的:

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

可以这样理解:假设x初始值为10,线程A执行完代码块后,x值为12,自动释放锁,线程B进入代码块时,能够看见线程A对x的写操作,即线程B能够看到x==12,这也是符合我们对synchronized的用法。

(5) 线程start()规则

这个规则是关于线程启动,也就是主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作,比如下面代码:

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

这个也是符合我们常识的,也就是start()操作Happens-Before于线程B的任意操作。

(6) 线程join()规则

这个规则是关于线程等待,也就是主线程A等待子线程B完成即主线程A调用子线程B的join()方法,当子线程B完成后,主线程能够看到子线程的操作,这里所谓看见也就是指对共享变量的操作

比如下面代码:


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

这也就是线程B中任意操作Happens-Before于该join()操作的返回。

总结

首先我们得明白Java内存模型是一套复杂的规则,而这个规则就是用来解决上一篇文章所说的可见性和有序性问题。对于程序员来说,Java内存模型提供了volatile和synchronized关键字,然后在其JVM实现了6项Happens-Before规则,来解决可见性和有序性问题。

其中理解可见性至关重要,还有就是Happens-Before会约束编译器的部分优化,在目前我们可以直接认为比如volatile修饰的变量,会禁用缓存和编译器优化,但是其JVM具体实现,它却有很多方案。所以这个规则就像是底层规则一样,JVM要按照这个规则来实现。至于具体是如何实现的,我们后面文章细说。

下篇文章我们来看如何解决原子性问题。