1、从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令序列做重排序。其中重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
从 Java 源代码到最终实际执行的指令序列,会分别经过下面三种从排序
上图,1 属于编译器重排序,2、3 属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(这里是按需禁用编译器重排序,并使不是对所有的都要禁止的)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel 称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM 术语语言级别的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
2、指令执行顺序问题
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一个内存地址的多次写,减少对内存总线的占用。每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响,处理器堆内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。
如上图,处理器 A 中和处理器 B 分别有两段代码要执行,这里的初始状态为:a=b=0,如何运行使得结果结果是:x=y=0?具体如图:
假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最总得到 x=y=0 的结果。步骤如下
- 处理器 A 和处理器 B 并行执行 A1 和 B1 操作,即同时把共享变量写入自己的写缓冲区
- 处理器 A 和处理器 B 并行执行 A2 和 B2 操作,即同时分别从内存中读取 b 和 a 赋值给 x 和 y
- 处理器 A 和处理器 B 并行执行 A3 和 B3 操作,即同时把自己写缓冲区中保存的脏数据刷新到内存中
当以上述时序执行时,程序就可以得到 x=y=0 的结果了。从内存操作实际发生的顺序来看,知道处理器 A 执行 A3 来刷新自己的写缓冲区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为 A1->A2,但内存操作实际发生的顺序是 A2-A1。此时,处理器 A 的内存操作顺序被重排序了。处理器 B 的情况和处理器 A 的一样。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。
常见处理器允许的重排序类型的列表
Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 | |
---|---|---|---|---|---|
SPARC-TSO | N | N | N | Y | N |
X86 | N | N | N | Y | N |
IA64 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
单元格中的 N 表示处理器不允许两个操作重排序,Y 表示允许
常见的处理器都允许 Store-Load 重排序,都不允许对存在数据依赖的操作重排序。SPARC-TSO 和 X86 拥有相对较强的处理器内存模型,它们仅允许对写-读操作重排序(因为它们都使用了写缓冲区)。
3、内存屏障
3.1、硬件处理器的实现
内存屏障在不同的处理器上,会有不同的实现,下面以 64位X86 为例
- sfence(store):在 sfence 指令前的写操作,必须在 sfence 指令后的写操作前完成
- lfence(load):在 lfence 指令前的读操作,必须在 lfence 指令后的读操作前完成
- mfence(load/store):在 mfence 指令前的读写操作,必须在 mfence 指令后的读操作前完成
原子指令,如 x86 上的 lock … 指令是一个 Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个处理器。Software Locks 同时使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。
3.2、JVM 的实现规范 JSR-133
为了保证线程可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。内存屏障主要分为两种:读屏障(Load Barrier)和写屏障(Store Barrier)。
- 对于 Load Barrier 来说,在指令前插入 Load Barrier,可以让缓存中的数据失效,强制重新从主内存中加载数据
- 对于 Store Barrier 来说,在指令后插入 Load Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见
3.3、内存屏障的两个作用:
- 阻止屏障两侧的指令重排序
- 写的时候,强制把缓冲区/高速缓存中的数据写回到主内存中,并让缓存中的数据失效,读的时候直接从主内存中读。保证可见性。
3.4、内存屏障指令类型
JMM 把内存屏障指令分为四种,即:LoadLoad、StoreStore、LoadStore、StoreLoad。实际上是上述两种的组合,完成一系列的屏障和数据同步功能。如表所示:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barrier | Load1; LoadLoad; Load2 | 确保 Load1 所要读取的数据,能够在被 Load2 以及所有后续的 load 指令访问前读取完毕 |
StoreStore Barrier | Store1; StoreStore; Store2 | 确保 Store1 的写入操作,能够在 Store2 以及所有后续 Store 指令操作执行之前,对其它处理器可见(向主内存刷新数据) |
LoadStore Barrier | Load1; LoadStore; Store2; | 确保 Load1 所要读取的数据,能够在 Store2 以及所有后续 Store 指令被刷新之前,读取完毕 |
StoreLoad Barrier | Store1; StoreLoad; Load2 | 确保 Store1 的写入操作,能够在被 Load2 以及所有后续的 Load 指令读取之前,对其他处理器可见。 |
StoreLoad Barrier 是一个全能型的屏障,他同时具有三个屏障下效果。现代多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销很大,因为当前处理器通常要把写缓冲区中的数据全部刷新到主内存中(Buffer Fully Flush)。
4、数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。依赖分类如下:
分类 | 示例 | 说明 |
---|---|---|
写后读 | a=1;b=a; | 写一个变量之后,再读这个变量 |
写后写 | a=1;a=2; | 写一个变量之后,再写这个变量 |
读后写 | a=b;b=1; | 读一个变量之后,再写这个变量 |
只要对表中的任意一种情况,重排序两个操作的执行顺序,程序的执行结果就会被改变。编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序
这里所说的数据依赖性针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。