注明:
参考书作者:方腾飞 魏鹏 程晓明
参考书目:《Java 并发编程的艺术》
Java内存模型基础
并发编程模型的两个关键问题
在并发编程中,需要处理的两个关键问题:
- 线程之间如何通信
- 线程之间如何同步
线程之间通信机制有两种:共享内存和消息传递
在共享内存的并发模型中,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通讯。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信
在共享内存并发模型中,同步显式进行,人为显式指定某个方法和某段代码需要在线程之间互斥执行
在消息传递的并发模型中,同步隐式进行,消息的发送总在消息的接收之前。
Java内存模型的抽象结构
首先明确的是哪些变量需要用到Java内存模型
共享变量(实例域,静态域和数组元素)会需要Java内存模型
局部变量,方法定义参数和异常处理器参数不需要Java内存模型
Java内存模型的抽象示意图:
从上图看,如果线程A和线程B之间要通信的话,必须要经历下面2个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量
下面举个例子来说明:
本地内存A和本地内存B由主内存中共享变量x的副本。假设初始时,这3个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值为1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
从整体来看,这两个步骤实际上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。Java内存模型(JMM)通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。重排序分为3种类型。
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终执行的指令序列,会分别经历下面3中重排序,如下图:
上图中1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
并发编程模型的分类
- 为什么要有写缓冲区,它的意义是什么?
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。
同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。 - 它的缺陷又是什么?
虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!
通过下面的例子可以说明:
假设处理器A和处理器B按程序顺序并行执行内存访问,最终可能得到x=y=0的结果。
这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序是:A1 - > A2,但内存操作实际发生的顺序却是A2 - > A1(A1 ->A2 -> A3,A1与A3中插入A2,实际A1并没有完全执行)。此时,处理器A的内存操作顺序被重排序了,处理器B的情况和A相同。
由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。
常见处理器允许的重排序类型列表:
注意:上面表格中“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。
从上表可以看出:常见的处理器都支持Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO和x86拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为他们都使用了写缓冲区)
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
内存屏障类型表:
StoreLoadBarriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。在内存屏障之前的内存读写,总是领先于内存屏障之后的内存读写操作。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常需要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
happens-before简介
从JDK5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
与程序员密切相关的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与JMM的关系如下图:
从上图可以看到,happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。happens-before定义好了接口规范,交由JMM来完成这些具体的实现。