本书部分摘自《Java 并发编程的艺术》程序员
在并发编程中,有两个须要处理的关键问题:编程
通讯指线程之间以何种机制来交换信息,通讯机制有两种:缓存
同步是指程序中用于控制不一样线程间操做发生的相对顺序的机制。在共享内存并发模型中,同步是显式进行的,程序员必须显式指定某个方法或某段代码须要在线程之间互斥执行。在消息传递的并发模型里,因为消息的发送必须在消息的接收以前,所以同步是隐式的安全
前面提到线程的通讯与同步问题,Java 线程之间的通讯由 Java 内存模型(简称 JMM)控制,JMM 决定一个线程对共享变量的写入什么时候对另外一个线程可见多线程
线程之间的共享变量存储在主内存,每一个线程都一个私有的本地内存,本地内存中存储了该线程以 读/写 共享变量的副本并发
Java 内存模型的抽象示意如图app
若是线程 A 和线程 B 之间要通讯的话,必须通过下面两个步骤:ide
这两个步骤其实是线程 A 向线程 B 发送消息,并且这个通讯过程必须通过主内存。JMM 经过控制主内存与每一个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证性能
在执行程序时,为了提升性能,编译器和处理器经常会对指令作重排序。重排序可能会致使线程程序出现内存可见性问题,下面分别介绍三种类型的重排序以及它们对内存可见性的影响:优化
编译器优化的重排序
编译器在不改变单线程程序语义的前提下,能够从新安排语句的执行顺序
指令级并行的重排序
现代处理器采用了指令级并行技术来将多条指令重叠执行,若是不存在数据依赖性,处理器能够改变语句对应机器指令的执行顺序
内存系统的重排序
因为处理器使用缓存和 读/写 缓冲区,这使得加载和存储操做看上去多是在乱序执行
这里对第三种状况作个详细解释,现代处理器使用缓冲区临时保存向内存写入的数据,此举能够保证指令流水线持续进行,避免因为处理器停顿下来等待向内存写入数据而产生延迟。但每一个处理器上的写缓冲区,仅仅对它所在的处理器可见,这个特性可能会致使处理器对内存的 读/写 操做执行顺序不必定与内存实际发生的 读/写 操做顺序一致。为了说明状况,请看下表:
Processor A | Processor B | |
---|---|---|
代码 | a = 1; // A1 x = b; // A2 |
b = 2; // B1 y = a; // B2 |
处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终可能获得 x = y = 0 的结果,具体缘由如图
处理器 A 和 处理器 B 同时把共享变量写入本身的缓冲区(A一、B1),而后从内存中读取另外一个共享变量(A二、B2),最后才把缓存区中保存的脏数据刷新到内存中(A三、B3),这种状况下,程序最后就获得 x = y = 0 的结果
因此 Java 源代码到最终实际执行的指令序列,会分别经历如下三种重排序
因而可知,JMM 不能任由重排序发生,必须加以控制,不然会引起线程不安全问题。为了更好地解释 JMM 为保证内存可见性所采起的措施,首先介绍一些基础概念
若是两个操做访问同一个变量,且这两个操做有一个为写操做,此时这两个操做之间就存在数据依赖性,只要重排序这两个操做的执行顺序,程序的执行结果就会被改变。编译器和处理器在重排序时,会遵照数据依赖性,不会改变存在数据依赖关系的两个操做的执行顺序。数据依赖分为下列三种类型:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1; b = a; |
写一个变量以后,再读这个位置 |
写后写 | a = 1; a = 2; |
写一个变量以后,再写这个变量 |
读后写 | a = b; b = 1; |
读一个变量以后,再写这个变量 |
上面三种状况,只要重排序两个操做的执行顺序,程序的执行结果就会被改变
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操做,不一样处理器之间和不一样线程之间的数据依赖性不被编译器和处理器考虑
as-if-serial 语义的意思是:无论怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵照 as-if-serial 语义,为了遵照 as-if-serial 语义,编译器和处理器不会对存在数据依赖性关系的操做作重排序
as-if-serial 语义把单线程程序保护起来,给程序员建立了一个幻觉:单线程程序是按程序的顺序来执行的,程序员无需担忧重排序会干扰他们,也无需担忧内存可见性问题
happens-before 是 JMM 最核心的概念,对于 Java 程序员来讲,理解 happens-before 是理解 JMM 的关键。
从 JDK5 开始,Java 使用新的 JSR-133 内存模型,JSR-133 使用 happens-before 的概念来阐述操做之间的内存可见性。在 JMM 中,若是一个操做执行的结果须要对另外一个操做可见,那么这两个操做之间必须存在 happens-before 关系,这两个操做既能够是在一个线程以内,也能够是不一样线程之间
A happens-before B,就是 A 操做先于 B 操做执行。固然这种说法并不许确,两个操做之间具备 happens-before 关系,仅仅要求前一个操做(执行的结果)对后一个操做可见,且前一个操做按顺序排在第二个操做以前
在 JMM 中定义了 happens-before 的原则以下:
有关 happens-before 每个原则的实现,这里再也不具体阐述,只要知道有这么一回事就行了
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特征:
顺序一致性内存模型为程序员提供的视图以下:
在概念上,顺序一致性模型有一个单一的全局内存,这个内存经过一个左右摆动的开关能够链接到任意一个线程。同时,每个线程必须按程序的顺序来执行内存读/写操做。从上图咱们能够看出,在任意时间点最多只能有一个线程能够链接到内存。当多个线程并发执行时,图中的开关装置能把全部线程的全部内存 读/写 操做串行化
为了更好的理解,下面咱们经过两个示意图来对顺序一致性模型的特性作进一步的说明
假设有两个线程 A 和 B 并发执行,其中 A 线程有三个操做,它们在程序中的顺序是:A1 -> A2 -> A3,B 线程也有三个操做,它们在程序中的顺序是:B1 -> B2 -> B3
假设这两个线程使用监视器来正确同步:A 线程的三个操做执行后释放监视器,随后 B 线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将以下图所示:
如今再假设这两个线程没有作同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:
未同步程序在顺序一致性模型中虽然总体执行顺序是无序的,但全部线程都只能看到一个一致的总体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之因此能获得这个保证是由于顺序一致性内存模型中的每一个操做必须当即对任意线程可见
因为重排序的存在,JMM 不可能实现顺序一致性内存模型,同时也不可能彻底禁止重排序,由于这样会影响效率。一方面,程序员但愿内存模型易于理解、易于编程,但愿基于一个强内存模型来编写代码;另外一方面,编译器和处理器但愿内存模型对它们的束缚越少越好,这样它们就能够作尽量多的优化来提升性能,编译器和处理器但愿实现一个弱内存模型。这两个因素相互矛盾,因此关键在于找到一个平衡点
平衡的关键在于优化重排序规则,根据前面提到的 happens-before 原则、数据依赖性以及 as-if-serial 原则等规定了编译器和处理器什么状况容许重排序,什么状况不容许重排序。对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序,不然不做要求。因而程序员所看到的就是一个保证了内存可见性的可靠的内存模型
下图是 JMM 的设计示意图
从上图咱们也能够发现,JMM 会遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化均可以。例如,若是编译器通过细致地分析后,认定一个锁只会被单个线程访问,那么这个锁能够被消除。再如,若是编译器通过细致的分析后,认定一个 volatile 变量只会被单个线程访问,那么编译器能够把这个 volatile 变量看成一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提升程序的执行效率。而从程序员的角度来看,程序员其实并不关心重排序是否真的发生,程序员关心的是只程序执行时的语义不能被改变而已