Java并发编程的艺术读书笔记-JMM

因为可见性问题导致处理器不同核心看到的内存操作顺序不一致,从而看起来就像是指令被重排了一样

JMM在编译器重排阶段, 会禁止特定类型的编译器重排, 在处理器重排序阶段,通过在指令之间插入内存屏障来禁止特定类型的处理器重排, 从而确保了在不同的编译器和不同的处理器平台上,不会应为重排序规则不一致导致非预期的运行结果

现代处理器与内存进行数据交互,会先将数据写到自己的缓冲区中, 每个处理器都有自己的缓冲区, 这种特性就会导致缓冲区中的数据和内存的数据存在不一致, 例如核心A执行两条指令, 指令1是更新x的值, 指令2是读取y的值, 核心A更新了x并写入缓冲区,然后去主存读了y; 核心B执行两条指令, 指令1是更新y的值, 指令2是读取x的值, 核心B更新了y的值写入缓冲区, 然后去主存读了x的值, 因为两个核心的缓冲器都还未刷新,所以他们读到的都是旧值, 或者是只刷新了一个缓冲区; 这些都会导致在指令层面看起来就像是发生了指令重排一样, 其实核心是因为可见性问题,所以也会有人将这种现象称之为内存系统指令重排。

1.5开始, java开始使用jsr-133规范的内存模型 (jsr-133是java内存模型和线程规范,是JSR-176的一部分) jsr-133中引入了happens-before原则, 这是一种描述多个操作之间先后顺序关系的概念 , 如果操作A happens-before 于操作B, 那么操作A的结果一定对操作B可见, 操作A和B可以是同一线程内的,也可以是不同线程之间的。

注意 happens-before 是一种偏序关系, 它只要求前一个操作的结果对后一个操作可见, 但不能保证前一个操作一定在后一个操作之前执行, 比如A happens-before B, B先执行,B在执行过程中需要读取A写入的数据, 那么happens-before是可以保证B读取到正确的数据的,但是不能保证A肯定在B之前执行。

如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。 这里的操作顺序指的是程序的逻辑执行流程

as-if-serial语义的意思是,在单线程环境下, 不管怎么重排序, 执行结果始终是不变的; 可以理解为看起来像是顺序执行的, 但其实可能已经被重排过了。

当程序中的操作存在控制依赖关系, 会影响指令序列执行的并行度, 因此,编译器和处理器会使用猜测执行来消除控制依赖对并行度的影响, 比如if(flag) {a = a + b} ; 处理会提前执行a = a + b , 并将结果临时保存在重排序缓冲中(Reorder Buffer); 当flag为真时,就将计算结果写入到变量a中。

所以, 即使多个指令之间存在控制依赖关系, 编译器和处理器依旧会对它们进行重排序, 而在多线程环境下,这种重排是可能改变程序的预期运行结果的, 需要通过同步机制来约束, 比如锁 volatile 或者是 final 。 单线程下有as-if-serial规则, 所以不会改变运行结果。

顺序一致性内存模型是一个理想化的理论模型, 它的两大特性分别是 1.一个线程中的所有指令都必须按照顺序执行 ; 2.不管是否在同步环境下, 所有线程看见的指令执行顺序都是一致的, 每个操作都必须是原子执行并且立刻对所有线程可见

但是在JMM中, 是无法保证未同步的程序中,所有线程看见的执行顺序是一致的,而且也没法保证实时可见性, 因为这种特性会大大约束编译器和处理器的优化手段,JMM通过锁或者是一些同步原语来保证多线程环境下的执行结果; 并且在临界区内的指令,也是可能会被重排的, 这会很大提高程序的执行速度。

volatile的特性可以近似的看成通过锁操作对单个变量的读写操作做了同步, 对于单个变量的读写操作, volatile是可以确保原子性的, 注意,这里的原子性指的单个变量 ,比如在32位的设备中, 对于64位的long变量的写入, jmm会将其拆分为两个32位的写操作 ,也就是高低32位会被分开写入,这可能会导致字节撕裂现象,(jdk1.5之后, 对于这种64位的变量的读已经强制要求原子性,也就是读操作必须在单个总线事务中执行,已经很好的解决了字节撕裂的问题, 但是写操作依旧是可以拆分的) 而volatile可以确保这类操作的原子性, 但在多个变量之间或者是复合操作,volatile是无法保证原子性的, 比如a++,其实是三条指令, 分别是读取a,a+1 , 写回a ; volatile是无法保证这三个操作是原子的。

根据happens-before原则, 一个volatile变量的写操作的结果可以被后续的volatile读操作看到,编译器会在编译中对volatile变量的写操作之后加上写内存屏障和在读操作之前加上读内存屏障,(看具体的策略, 最保守的策略会在读写操作的前后都加上屏障, 编译器会根据实际情况来优化, 在确保正确性的前提下提升执行效率) 写屏障会强制把写操作的结果刷入主存, 读屏障则会强制将主存中数据更新到本地内存, 确保了变量的可见性。 另外读屏障也可以确保读指令不被重排序到写操作之前, 写屏障也可以确保写指令不被重排序到读操作之前

如果你不需要确保整个临界区代码的原子性, 完全可以使用volatile来代替锁,更加轻量化,性能更好(没有上下文切换开销)

锁释放与volatile写有相同的内存语义(将数据刷入主存), 锁获取与volatile读有相同的内存语义(从主存中读取数据更新本地内存)

JMM向程序员提供的happens-before规则来规范程序的内存可见性, 实际上JMM将happens-before要求禁止的重排序分为了两种,一种是会改变程序执行结果的重排序, 另一种是不会改变程序执行结果的重排序, 对于后一种重排序, JMM实际是允许的, 换句话说, happens-before的规则,和实际jmm执行的规则, 是有出入的

JMM遵循一个基本原则, 只要不改变程序的执行结果, 编译器和处理器怎么优化都行, 举个例子, 如果一个锁只会被单个线程访问,那么编译器会将这个锁消除掉, 称之为锁消除; 再比如, 如果一个volatile变量只会被单线程访问, 那么编译器就会把它当做一个普通变量来对待。