Java并发编程 | 内存模型大展身手
前言
在说Java内存模型之前先说个容易混淆的东西,这里的Java内存模型是Java Memory Model,它是一套复杂的规范,用来解决并发编程中的一些问题。
而很多人也叫JVM运行时内存结构也叫作Java内存模型,这就大错特错了,也就是下面这张图:
这个是JVM运行Java程序时开辟的各种空间,包括线程共享的堆和方法区(元数据区或者永久代,不同版本不同处理),以及线程非共享的方法栈和程序计数器。这个是JVM内存结构,千万不要再说成是Java内存模型了。本章我们来真正理解什么是Java内存模型。
正文
上一篇文章我们说了引起并发编程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的传递性应用到我们的例子中是:
可以看到:
-
"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要按照这个规则来实现。至于具体是如何实现的,我们后面文章细说。
下篇文章我们来看如何解决原子性问题。
转载自:https://juejin.cn/post/7087488770420768782