并发编程的艺术之读书笔记(二)
目录
前言:
第一部分我们学习了并发编程的挑战和java并发机制的底层实现原理如volitale,synchronized和原子操作的原理,这一部分我们一起来探究java的内存模型
java内存间的通信对程序员透明,同时内存可见性的问题又困扰着程序员,这一部分就来揭开java内存模型的神秘面纱
1.java内存模型的基础
1.1 并发编程的两个关键问题
并发编程中,需要搞清两个重要问题,就是线程之间如何通信以及线程之间如何同步。线程之间的通信是指线程之间以何种形式来交换信息,在命令式编程中,线程之间有两种通信机制,分别是共享内存和消息传递。在共享内存的并发模型里,线程之间共享程序的公共状态,通过读写内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间通过发送消息显式的进行通信。
同步是指程序中控制不同线程间操作发生的相对顺序的机制。在共享内存并发模型中,同步是显式进行的。在消息传递的并发模型中,同步是隐式进行的。
java并发采用的是共享内存并发模型,java线程间通信隐式进行,整个通信过程对程序员透明。
1.2 java内存模型的抽象结构
java中所有的实例域、静态域、数组元素(实例域、静态域、数组元素又叫做共享变量)都是存储在堆内存中,堆内存在线程之间共享。局部变量,方法中的参数,异常处理器参数不会在线程间共享,它们不受内存模型影响,不会有内存可见性问题。
java的线程间通信由java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程用以读/写共享变量的副本。本地变量是JMM中的一个抽象概念,实际并不真实存在。
java内存模型示意图如下
java内存模型规定了所有读写共享变量必须在本地内存中进行,本地内存从主内存中读取数据复制到本地内存中,在本地内存中修改后再刷新到主内存去,但是这么一来,就会发生内存可见性问题,因为本地内存都是线程私有的,当多个线程都持有一个共享变量时,一个线程在本地内存中修改了共享变量但还没有及时刷新到主内存,其他线程从自己的本地内存读取的就是旧的数据,这时候volitale关键字就起到了作用,volitale关键字声明的变量,JVM会通知线程强制把修改后的变量立即刷新到主内存中,这样其他线程获得的就是最新的数据。
1.3 从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做优化也就是重排序。重排序分三种类型。
1.编译器优化的重排序。编译器在不改变单线程程序语义的情况下,可以重新安排语句执行的顺序。
2.指令级并行的重排序。现代处理器采用指令级并行技术来将多条指令重叠执行,如果没有数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3.内存系统的重排序。因为处理器使用了缓存和读/写缓冲区,所以加载和存储操作看起来像是乱序执行。
java源代码到最终执行的指令序列,会经历3种重排序:编译器优化重排序->指令级并行重排序->内存系统重排序。这些重排可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序,对于处理器重排序,JMM的重排序规则会要求java编译器生成指令序列时,通过内存屏障指令来禁止特定类型的处理器重排序。
1.4 处理器的重排序
假设有两个处理器A和B,同时按顺序执行下面代码
处理器A | 处理器B | |
---|---|---|
代码 |
a=1;//A1 x=b;//A2 |
b=2;//B1 y=a;//B2 |
运行结果 |
初始状态下a=b=0; 最终结果可能为x=y=0 |
看到上面这张表,大家可能会想,为什么最终结果可能会有x=y=0呢,不是应该x=2,y=1吗,这就是因为在A和B并行执行内存操作的时候发生了指令重排序,下面来解释一下。
处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后再把写缓冲区里的数据刷新到主内存中去,因为直到刷新写缓冲区,写操作才算真正执行完成,但是在刷新操作之前,发生了指令重排序,已处理器A的操作为例,本来应该是A1->A2,结果因为重排序变成了A2->A1,处理器B也是同理,那么代码就会以x=b;a=1和y=a;b=2;的顺序执行,自然执行的结果就是x=y=0了。
此处的关键是,写缓冲区只对自己的处理器可见,它会导致处理器执行内存操作的顺序可能和内存实际的操作顺序不一致。由于现代的处理器都用写缓冲区,所以处理器都会允许对写-读操作重排序。
为了保证内存可见性,java编译器会在生成指令序列的适当位置加入内存屏障来禁止特性的处理器重排序,JMM管理下的内存屏障一共有4类,如下表所示(表中的Load代表读取,Store代表存储也就是写入)
屏障类型 | 指令序列 | 说明 |
---|---|---|
LoadLoadBarries | Load1;LoadLoad;Load2 | 确保Load1的数据读取先于Load2及所有后续装载指令的装载 |
StoreStoreBarries | Store1:StoreStore;Store2 | 确保Store1的数据在Store2及所有后续存储指令之前对其他处理器可见(刷新到内存) |
LoadStoreBarries | Load1:LoadStore;Store2 | 确保Load1在Store2及所有后续的存储指令刷新到内存之前读取 |
StoreLoadBarries | Store1;StoreLoad;Load2 | 确保Store1在Load2及所有后续的Load指令读取之前对其他处理器可见,StoreLoadBarries可以防止一个后续的load指令 不正确的使用了Store1的数据,会使该屏障之前的所有内存访问指令完成后,才执行该屏障后的内存访问指令 |
StoreLoad屏障是个全能型的屏障,它同时具有其他3个屏障的功能,所以大多数现代处理器都支持StoreLoad屏障,不过,执行这个屏障会很昂贵。
1.5 happens-before的概念
jdk1.5开始,出现了happens-before概念用来阐述操作之间的内存可见性。在JMM中,一个操作的执行结果如果要对另一个操作可见,这两个操作之间就一定要存在happens-before关系。这里说的两个操作可以是在一个线程内,也可以是在不同的线程中。
与程序员密切相关的happens-before规则如下
1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意后续操作。
2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3.volitale变量规则:对于一个volitale域的写,happens-before于任意后续对volitale域的读。
4.传递性:如果A happens-before于B,且B happens-before于C,那么A happens-before于C。
5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。
6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。
2. 重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
2.1 数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个是写操作,那么这两个操作之间就存在数据依赖性。数据依赖分为三种类型,如下表所示
名称 | 代码示例 | 说明 |
---|---|---|
写后读 |
a=1; b=a; |
写一个变量后,再读这个位置 |
写后写 |
a=1; a=2; |
写一个变量后,在写这个变量 |
读后写 |
a=b; b=1; |
读一个变量后,再写一个变量 |
上面的这几种情况,一旦重排序,那么结果就会发生变化,前面提到过,编译器和处理器会对操作做重排序,但是在重排序时,他们会遵守数据依赖性,意思就是数据之间如果有依赖关系,那么就不会进行两个操作的进行重排序。这里说的数据依赖仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
2.2 as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能改变,编译器,运行时和处理器都必须遵守as-if-serial语义。
编译器和处理器为了遵守as-if-serial,不会对存在数据依赖关系的操作做重排序,但是,如果操作之间不存在数据依赖关系,那么这些操作就可以被编译器和处理器重排序,下面来举例说明一下。
double pi=3.14;//A
double r=1.0;//B
double area=pi*r*r;//C
这三个操作的数据依赖关系如下图所示:
图中A和C存在依赖关系,B和C存在依赖关系,所以在最终的指令序列中,C不会被排到A和B的前面,但是A和B没有依赖关系,那么编译器和处理器可以对A和B进行指令重排序,这导致了这段代码可能产生两种执行顺序分别是A->B->C和B->A->C,但是这两种执行顺序最终得到的结果相同。
as-if-serial语义把单线程程序保护了起来,使得开发单线程程序的程序员不必考虑指令重排序和内存可见性的问题。
2.3 程序顺序规则
根据happens-before规则,上面的代码存在3个happens-before关系
- A happens-before B
- B happens-before C
- A happens-before C
这里的A happens-before B,但实际执行时B可以排在A之前执行。如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM只要求前一个操作对后一个操作可见,且前一个操作按顺序排在后一个和操作之前。但是在这里操作A的执行结果不需要对操作B可见,而且重排序后执行的结果和重排序前的结果一致。在这种情况下,JMM会认为这种重排序“不非法”,JMM允许这种情况下的指令重排序。
2.4 重排序对多线程的影响
我们来看看,重排序是否 会改变多线程程序的运行结果
package com.ww.test;
public class RecordExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1;//1
flag = true;//2
}
public void reader() {
if (flag) {//3
int i = a * a;//4
...
}
}
}
flag变量是一个标记,用来标志变量a是否已被写入。我们假设这里由两个线程A和B,A线程先执行writer方法,随后B线程执行reader方法。B线程在操作4的时候,能否看到A对共享变量a的写入呢,答案是不一定。因为操作1和操作2存在着指令重排序,2可能先于1执行,这时flag变量值为真,然后B线程将读取变量a,但是这时候变量a还没有被A线程写入,所以多线程程序的语义被破坏了。
那么3和4重排序的话会发生什么呢,在程序中,3和4的操作存在着控制依赖关系。当代码中存在控制依赖性的时候,会影响指令序列的并行度。为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。当猜测执行的时候,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲的硬件缓存中到,当操作3条件判断为真时,就把该计算结果写入变量i中。在这个例子中,重排序在这里破坏了多线程程序的语义。
总结
这一部分内容,我们学习了java内存模型的基础,了解了指令重排序,理解了happens-before规则,下一章我们将继续探索顺序一致性和volitale语义
推荐阅读
-
《C#并发编程经典实例》读书笔记-关于并发编程的几个误解
-
读书笔记之第二回深入浅出关键字---对抽象编程:接口和抽象类
-
python 之 并发编程(非阻塞IO模型、I/O多路复用、socketserver的使用)
-
Java并发编程的艺术-----Java并发编程基础(线程间通信)
-
《Java 编程思想》读书笔记之并发(一)
-
《Java并发编程的艺术》笔记
-
【响应式编程的思维艺术】 (4)从打飞机游戏理解并发与流的融合
-
<
>-阅读笔记和思维导图 -
JAVA并发编程(三):同步的辅助类之闭锁(CountDownLatch)与循环屏障(CyclicBarrier)
-
JavaScript DOM编程艺术(第二版)读书笔记(1)——第三章 DOM