一段 Java 代码从编译到最终执行会经历 3 种重排序:编译器重排序、指令重排序、内存重排序,其中后两者可归类为处理器重排序。
编译器重排序: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
也就是说不影响单线程的执行结果下,可以对语句的执行顺序进行调整。
指令重排序: 如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存重排序: 由于缓存的存在,使得处理器执行的加载和存储操作看上去可能是在乱序执行。
比如对一个变量执行了写操作写到了自己的缓存中,又经历了若干个指令后刷新到内存。对外表现上是在这若干个指令执行完后才进行了写操作。
其中数据依赖的含义为:如果两个操作访问同一个变量,其中一个为写操作或者两个都为写操作,那么这两个操作就存在数据依赖性。
重排序不会改变单线程程序的执行结果,所有的重排序都必须遵守 as-if-serial 语义
as-if-serial 语义是指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
可以从以下两方面去理解:
对于存在数据依赖关系的操作,编译器和处理器不会进行重排序,否则会改变执行结果。
对于不存在数据依赖关系的操作则不做要求,就是说可以进行重排序。
as-if-serial 语义保证了单线程情况下,重排序后的执行结果与原始顺序执行结果一致。但是它不能保证多线程的执行顺序。
多线程情况下,对不存在数据依赖的操作重排序,也会影响执行结果。
比如如下代码:
1 | class ReorderExample { |
这里 1 和 2 不存在数据依赖,可以进行重排序,3 和 4 也不存在数据依赖,同样可以进行重排序(通过猜测提前计算出 a * a 的值并缓存起来)。
假设 writer() 和 reader() 是在两个线程中执行,1 和 2 进行了重排序,则 reader() 可能会读到 writer() 还未写入的变量 a(也就是 1 执行前的值)。
重排序不会改变单线程的执行结果,但是对于多线程的情况则会导致执行结果不可预知
要解决重排序引起的并发问题,很显然需要对重排序加以限制,使得其遵守一定的规范。重排序之所以导致多线程执行存在问题,主要是因为存在数据竞争。
数据竞争是指未同步的情况下,多个线程对同一个变量的读写竞争,它的定义如下:
如果存在数据竞争,程序的执行结果是没有保证的。
相反,如果程序是正确同步的,就不存在数据竞争,程序的执行将具有顺序一致性,执行结果与在顺序一致性内存模型中的执行结果相同
所谓顺序一致性内存模型,其实是一个理论参考模型,它对指令的执行加了比较多的限制,从而提供了一个比较强的内存可见性保证。
从抽象层面来看,顺序一致性模型使用了一个全局性的内存。
这个全局内存可以通过一个开关连接到任意一个线程,确保了线程之间的有序性。
每一个线程必须按照程序的顺序来执行内存读/写操作,从而保证了线程内部的有序性。
顺序一致性模型使得所有内存读/写操作串行化。它有以下两个特点:
一个线程中的所有操作必须按照程序的顺序来执行。
不管程序是否正确同步,所有线程都只能看到同一个操作执行顺序。每个操作都必须原子执行且立刻对所有线程可见
假设有两个线程 A 和 B 并发执行。其中 A 线程的顺序是:A1→A2→A3,B线程的顺序是:B1→B2→B3
在顺序一致性内存模型中,每个线程的执行顺序都是有序的,整体的执行顺序也是有序的。
如下是一种可能的执行顺序:其中 A 线程先进入临界区,按顺序执行完所有指令并退出临界区,然后 B 线程进入临界区按顺序执行所有指令,执行完后退出临界区。两个线程看到的是同样的执行顺序
在顺序一致性模型中,整体执行顺序是无序的,但所有线程都只能看到同一个整体执行顺序。
如下所示是一种可能的执行顺序:每个线程内部的指令都是按照顺序执行的。从整体上看是无序的,两个线程的指令执行存在交叉。但是对于线程 A 和 B,它们看到的是同样的整体执行顺序。
顺序一致性模型对读写操作的限制比较严,编译器和处理器难以针对执行效率进行优化。
JMM 一方面要提供足够强的内存可见性保证,使得易于编写正确的并发程序。另一方面又要尽可能放松对编译器和处理器的限制,以便利用更多的执行效率优化手段。
针对上面的情况,看下 JMM 是如何处理的。
在 JMM 中,和顺序一致性内存模型不同的是,每个线程内部(也就是临界区内部)是允许重排序,比如 A1、A2、A3 在线程 A 的同步块内,允许重排序。但是不允许临界区内的指令和临界区外的指令重排序。另外由于监视器锁的互斥特点,每个线程是看不到其他线程内部的执行顺序的。
在 JMM 中,整体执行顺序是无序的,每个线程看到的顺序也可能不一致。注意有以下几个差异:
JMM 通过禁止特定的重排序,实现了在正确同步的情况下,具有与顺序一致性内存模型一致的执行效果
JMM 定义了 volatile 的内存语义,当一个变量声明为 volatile 时,它的读写操作将具有特殊的含义。
volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存
volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量
基于 volatile 写-读的内存语义,可以实现线程间的通信:一个线程往 volatile 变量写数据,另一个变量从 volatile 变量读数据,实际上就实现了消息的发送和接收
通过对编译器和处理器的重排序进行限制,从而实现了 volatile 的内存语义。
JMM 针对编译器制定了禁止重排序的规则,如下表所示:
行表示第二个操作 | |||
---|---|---|---|
列表示第一个操作 | 普通读/写 | volatile 读 | volatile 写 |
普通读/写 | 否 | ||
volatile 读 | 否 | 否 | 否 |
volatile 写 | 否 | 否 |
从表中可以看出,该规则禁止如下的重排序:
一句话说明就是禁止3种重排序:volatile 写之前、volatile 读之后、volatile 写-读
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad屏障 | Load1; LoadLoad; Load2 | 确保 Load1 的数据装载先于 Load2 及所有后续装载指令 |
StoreStore屏障 | Store1; StoreStore; Store2 | 确保 Store1 的数据刷新到内存先于 Store2 及所有后续存储指令 |
LoadStore屏障 | Load1; LoadStore; Store2 | 确保 Load1 的数据装载先于 Store2 及所有后续存储指令刷新到内存 |
StoreLoad屏障 | Store1; StoreLoad; Load2 | 确保 Store1 数据刷新到内存先于 Load2 及所有后续装载指令。该屏障会使它前面的所有内存访问(包括 Load 和 Store)指令执行完,然后再执行后面的内存访问指令 |
其中 StoreLoad 屏障同时具有其他 3 个屏障的效果。
JMM 的内存屏障插入策略如下:
需要的屏障 | 行表示第二个操作 | |||
---|---|---|---|---|
列表示第一个操作 | 普通读 | 普通写 | Volatile 读 / MonitorEnter | Volatile 写 / MonitorExit |
普通读 | LoadStore | |||
普通写 | StoreStore | |||
Volatile 读 / MonitorEnter | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile 写 / MonitorExit | StoreLoad | StoreStore |
X86 处理器仅会对写-读操作做重排序,因此 JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写-读的内存语义
对于 volatile 写-读重排序的处理,实际上有两种方案:
JMM 选择的是方案 1,这是一个保守策略,因为 volatile 写之后不一定有 volatile 读。
但是,这也有一个好处,当 volatile 读较多的时候效率会相对比较高,因为不用每次读之前都执行内存屏障
根据 volatile 内存语义的实现可以发现,对于 volatile 写 - volatile 读
操作,这两个操作本身不会重排序,并且还会禁止前面的操作和 volatile 写
重排序,同时会禁止后面的操作和 volatile 读
重排序。
这也就是说 volatile 写 - volatile 读
操作起到了禁止前后重排序的效果,这是从实现角度的理解。
JMM 通过 happens-before 规则对程序员提供了一种更容易理解的方式,其中一个规则是:
volatile 变量规则:对一个 volatile 变量的写,happens-before 于任意后续对这个 volatile 变量的读。
将 ReorderExample
代码中的 flag
改为 volatile
1 | class ReorderExample { |
根据 happens-before 规则
根据程序顺序规则(一个线程中的每个操作,happens-before于该线程中的任意后续操作)可以得到:
1 happens-before 2
3 happens-before 4
根据 volatile 规则可以得到:
2 happens-before 3
根据 happens-before 的传递性规则,结合前面的结果:
1 happens-before 4
两个线程操作同一个 volatile 变量,一个线程执行读操作,另一个线程执行写操作
写线程在所有操作的最后执行 volatile 写操作
读线程在所有操作中,将 volatile 读放在第一个位置
通过这种方式可以实现类似 解锁-加锁
的效果,也就是说 volatile 的 写-读
与锁的 释放-获取
有相同的内存效果
程晓明,方腾飞,魏鹏. Java并发编程的艺术
JSR-133 定义了新的 JMM 的规范,增强了 volatile 语义和 final 语义等。The JSR-133 Cookbook for Compiler Writers 是一份非正式的指南,本文对其进行了翻译,以方便查阅。可能存在翻译不准确甚至错误的情况,仅供参考,请对比原文查看。
单例模式确保一个类只有一个实例,并且为该实例提供全局的访问方式。其实现方式有多种,每种实现方式均有自己的特点。
在看轻量级锁加锁源码时,顺便看了下 jdk 里的 CAS 操作实现,本文记录下相关代码。