前言
《Java并发编程的艺术》的前三章内容有点乱,我在尽自己最大的能力捋一下前三章的核心知识点分享出来,这里还是和之前的想法一样,如果是没有基础的小白,千万不要买这本书,推荐可以去看下《Java并发编程之美》。
一、Java内存模型的基础
之前我提出过一个这么的概念来简化大家对线程的理解:
无论我们在编码层做多么复杂的业务处理,对于计算机而言就只有三个工作要做:
- 在内存中读取数据
- CPU运算数据
- 将运算结果写回内存
一个线程任务在计算机的底层中实际上完成的任务只有上面这三种,我们可以通过一个简单计算机模型来描述上面的关系:
这里我们的线程负责去主内存中获取数据然后交给CPU处理,在CPU处理结束后又将数据送还给主内存。那么在这个过程中,JMM(JAVA Memory Model 译:JAVA内存模型)是如何设计线程的内存模型来实现数据搬运处理的能力呢?这里我们可以看下面的这个JMM基础内存模型:
这里如果线程A想要完成 i=1 的这个赋值操作,就需要经历下面的这个过程:
- 线程A在主内存中读取 int i,将int i复制一份保存到线程A的私有内存空间
- 线程A将一个新的值0赋予i ,然后将更新后的i写回到内存中
虽然上面的流程看起来很简单,但是如果在并发的情况下是会存在问题的:
- 线程A 在主内存中读取 int i,将int i复制一份保存到线程A的私有内存空间
- 线程B 想要执行 i = i+1 的指令,此时线程A还没有还没有将更新后的值写回主内存
- 线程B 读到 int i = 0 并将读到的共享变量拷贝写入自己的私有内存空间
- 线程A 完成 i=1 的赋值并将结果写回到主内存
- 线程B 此时拿到自己私有内存变量 i=0 来计算 i=i+1 并得到结果1
- 线程B 将得到的结果写回主内存
我们期望的运算结果应该是i=2 ,但是由于线程B没有及时能够读到线程A更新的变量值,所以出现了我们读到i=1的结果,这无疑是线程不安全的,因此Java的设计者为了解决这个问题,也提出了一些解决方案。
二、 顺序一致性模型
1. 什么是顺序一致性模型
当线程之间没有很好的处理同步关系的时候,就会存在数据竞争,Java内存模型对于数据竞争的定义如下:
在线程A中写一个变量
在线程B中读同一个变量
而在写和读之间没有通过同步来排序
和明显在发生数据竞争后,代码并不能很好的得到我们预期的结果,所以我们需要一个手段来保证不会出现数据竞争。JMM此时提出,如果线程之间存在正确的同步关系,那么该程序的执行具有顺序一致性(Sequentially Consistent),既该程序的期望运行结果应与该程序在顺序一致性内存模型下的运行结果相同。但是这里要注意一点,顺序一致性模型只是计算机科学家们过于理想化提出的一个参考模型,他虽然为程序员提供了极强的内存可见性保证,但是还是由于存在下面特点而并没有被Java完全实现:
- 一个线程中所有的操作必须按照程序顺序来执行
- 所有线程都只能看到单一的操作执行顺序,在顺序一致性模型下所有操作都必须具有原子性且执行后立刻对所有线程可见。
上述两点特性无疑是和Java其他地方的设计特点有冲突的,就比如Java允许CPU对指令进行重排序,虽然这提高了程序的运行效率但是显然这样就违背了顺序一致性模型的要求。所以针对Java设计上无法满足顺序一致性模型的地方,设计师提出了新的规范来解决
2.as-if-serial
我们可以直接翻一下as-if-serial:就如同串行一样。和明显,as-if-serial要求JVM无论对代码进行怎样的重排序,其运行结果都应该与其在线程串行的结果相同。简单理解就是不管怎么重排序,单线程的运行结果都不允许发生改变。这是个很棒的提议,Java也严格的遵守了自己为自己设定的规矩,在单线程下无论代码发生了怎样的重排序,我们都可以始终保持对数据的可见性,也正是这样让我们似乎产生了一些幻觉,我们是不是无需关心重排序的干扰?那么我们看一下之前在将volatile时候用过的一个Demo:
public class VolatileOrderliness {
private static long a = 0;
private static long b = 0;
private static long c = 0;
private static long d = 0;
private static long e = 0;
private static long f = 0;
private static long g = 0;
private static long h = 0;
public static long count = 0;
public static void main(String[] args) {
// 由于cpu指令重排发生存在概率 所以使用死循环调用 然后再出现的时候通过break跳出循环
for (;;) {
a = 0;
b = 0;
c = 0;
d = 0;
e = 0;
f = 0;
g = 0;
h = 0;
count++;
Thread t1 = new Thread(() -> {
a = 1;
c = 101;
d = 102;
g = b;
});
Thread t2 = new Thread(() -> {
b = 1;
e = 201;
f = 202;
h = a;
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
String result = "count = " + count + " g = " + g + ", h=" + h;
if (g == 0 && h == 0) {
// 当g 和h 都出现0的时候 一定是发生了指令重排序
System.err.println(result);
break;
} else {
System.out.println(result);
}
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
}
}
运行结果:
这里很明显的我们通过两个线程对于共享变量做了操作后,JVM并没有在并行状态下为我们保障数据的可见性,针对这点JDK1.5之后java采用了新的概念:happens-before
3.happens-before
我们来通过《Java并发编程的艺术》 3.7.1章节中的举例来理解下:
double pi = 3.14 ; // 代码A 定义pi的值为3.14
double r = 1.0 ; //代码B定义圆的半径为1.0
double area = pi*r * r ;//代码C 计算圆的面积
如果按照happens-before原则,我们可以这么理解:
- A happens-before B
- B happens-before C
为了实现上面的效果,JMM设计了如下的运行模型:
这里我们看到内存模型在对处理器束缚非常小的情况下又满足了程序员对于内存的强可见性,本质上happens-before是对as-if-serial语义上的补充。
根据《JSR-133: Java Memory Model and Thread Specification》中详细的定义了happens-before的规则:
- 程序顺序规则: 一个线程中的每个操作happens-before 与该线程中的后续任意操作
- 监视器锁规则:对于一个锁的解锁happens-before 随后对这个锁的加锁
- volatile变量规则:对于一个volatile域的写操作happens-before 与任意后续对于这个volatile域的读操作
- 传递性: A happens-before B ,B happens-before C ,那么 A happens-before C
这里需要注意的是,千万不要认为happens-before 指的是A happens-before B就代表A一定在B之前运行,而是指程序A的运行结果对程序B可见。
三、后记
到这里Java内存模型相关的知识点就基本已经总结完了,这章节的内容偏理论,有一定的理解难度,如果觉得不清楚的地方或者觉得我说的有问题的地方欢迎评论区留言,我会在第一时间回复。
祝好!