java 内存模型(JMM) 之我之见

缘起

JMM是java memory model(java内存模型)的简称. 本文讲解的JMM是基于 JSR-133 规范引入的JMM.

分析

讲解JMM必须要从当代可共享内存多核处理器架构讲起(之所以是多核处理器架构,是因为时钟频率的提升已经很难经济的提高,可以提升的只有硬件的并行性). 因为在现在的硬件架构中,线程才是当前处理器时序调度的最小单元(参见【1】). 而每个处理器都有自己的缓存,并且周期性的和主内存(即你看到的内存条)协调一致. 处理器架构提供不同级别的缓存一致性(cache coherence), 有些级别的一致性允许在几乎任何时间,不同处理器在相同的存储位置看到不同的值——这都是为牺牲一致性换性能的举措.

想要保证每个处理器在任意时间内获知其他处理器正在进行的工作——代价极其高昂,而且大多数信息无用(所以处理器才往往牺牲一致性换取性能的提升). 但是有一些信息是有用甚至是关键的.

1
ps: 这里必须要意识到,在多线程环境中,如果要维护绝对的顺序性将不得不产生巨大的性能开销. 因为在大部分时间里,线程都在做自己的事情,额外的线程之间的通信只会降低效率——而不会带来任何性能上的好处,因此,我们在多线程环境中,只有当多个线程必要共享数据的时候才进行线程之间的通信. 线程之间所谓的通信就是通过进程的堆内存实现的(也就是必须要通过某种同步),而这个就不得不通过同步来实现(因为线程大家都在各自的工作内存中啊). 进程之间的通信靠的是管道、共享内存、TCP/IP协议 等等,这个和线程通信不一样.

所以硬件架构的存储模型必须要告诉应用程序可以从它的存储系统中获取何种保证——因此这些架构必须要对外暴露一些所谓内存屏障(fence)的指令. 这些屏障用以在需要共享数据的时候得到存储的某种一致性保障.

但是平台太多了呀, java为了帮助开发者屏蔽这些跨架构的存储模型之间的不同,java提出了自己的存储模型——JMM, JMM会通过在适当的位置上插入fence来取得某种一致性.

1
ps: 其实到这里,有些程序员会困惑——至少在单线程程序中. 因为在他们心中,操作顺序是唯一的,并且这个唯一序就是顺序性,这与执行这根线程的处理器无关,而且变量的每次读操作都能得到执行序列上这个变量最新的写入值——不论这个值是哪个处理器写入的. 其实这种模型就是经典的顺序化计算模型——冯·诺依曼模型. 但是没有哪个现代的,尤其是多处理器架构会精确实现这一点,因为前面也说了——实现这一点代价甚巨,而且大多情况下是无用信息. 现代多处理器的存储模型仅仅是冯·诺依曼模型的一种近似而已。

而且幸运的是,我们并不需要在java程序中指明fence的位置,通过正确的使用同步(例如volatile关键字)就可以做到这一点.

JMM其实就是一堆的happens-before规则. 但是要了解这些规则之前,我们首先要了解JMM中所谓的动作(action),因为JMM其实就是在动作集合上定义的 偏序.

1
ps: 偏序其实广泛存在我们的日常生活中, 例如 我喜欢莫扎特胜过肖邦,我喜欢肉蟹煲胜过杀猪菜,但是显然你要问我莫扎特和肉蟹煲中喜欢哪个,我肯定骂你有病.

action就是变量的读、写、监视器加锁和释放锁、线程的启动和join

happens-before规则(简称hb)有下面几条(具体详见 JSR-133的文档, 这里仅仅列出常用的).

hb(A,B)的含义是,执行动作B的线程能看到执行动作A的结果(或者说动作A的结果对执行B的线程可见),而不论执行动作A和动作B的线程是不是同一根.

也就是JMM 通过这些hb规则限定了JVM必须要提供一种”什么时候一根线程写入的变量会对其他线程可见”这种保证. 对于任何不满足hb规则的2个操作,JMM是不做任何保证的.

  1. 程序次序规则: 同一根线程内,如果动作A按程序员书写代码的顺序在B的前面,那么hb(A,B)
  2. 监视器锁法则(synchronized): 对一个锁的释放这个动作A和对同一个锁的获取这个动作B,有hb(A,B)
  3. volatile法则: 对volitale域的写入动作A,对同一个volatile域的读取动作B,则hb(A,B), 前提是A按照程序员在IDE中书写代码的顺序排在B的前面.
  4. 传递性:如果hb(A,B),且 hb(B,C),那么 hb(A,C)

hb规则还有诸如线程启动、线程终结、线程中断等规则,就不一一列举了.

1
2
3
4
									FBI-Warning

两个操作之间具有happens-before关系,并不意味着前一个操作在真正运行的时候必须要在后一个操作之后执行!
happens-before仅仅要求前一个操作的结果对后一个操作可见,且前一个操作按程序员在IDE中书写代码的顺序排在第二个操作之前

怎么理解上面的 FBI-Warning?

就拿hb法则中的第一条——程序次序规则来说,来看下面最简单的一段代码

1
2
3
4
public void methodA() {
int a = 1;
bool flag = false;
}

一根线程执行上面的代码的时候,因为第二行和第三行不存在数据之间的依赖关系,也就是不管你是先执行第二行还是先执行第三行都不会影响此单线程程序的执行结果——最终结果不就是a变成了1,flag变成了false嘛~ 则就可能methodA被编译器编译之后的字节码指令重排序. 重排序对于当代共享内存多核处理器硬件架构是有好处的(重排序会导致线程之间同步的问题,看起来是一个失败的设计,但是它意味着jvm可以充分利用当代硬件的多核处理器的性能,例如在没有同步的情况下,jmm允许编译器重排序,在寄存器中缓存数值,还允许CPU重排序或者并行执行指令,并且在处理器特有的缓存中缓存数值.). 但是不管你实际上是先执行第二行还是先执行第三行,第二行的执行结果对线程执行第三行的时候必然是可见的.

正因为hb规则,所以对于单线程环境下,在不影响运行结果的情况下重排序对于程序员透明——除了提高程序运行效率之外没有副作用(就单线程而言没有副作用,因为不会影响单线程程序运行的结果). 就好像程序本来就是严格顺序执行的一样. 这种现象有个术语——within thread as-if-serial semantics (简称as-if-serial 准则)

这就引入了一个概念——重排序. 重排序其实分成以下几类

  1. 编译器重排序:在不改变代码语义的情况下,优化性能而改变了代码执行顺序. 即字节码的顺序其实未必和程序员书写的代码的顺序一致
  2. 指令并行的重排序:处理器采用并行技术使多条指令重叠执行,在不存在数据依赖的情况下,改变机器指令的执行顺序 .
  3. 内存系统的重排序:使用缓存和读写缓冲区时,加载和存储可能是乱序执行.

是不是觉得根本没了解过诶~ 的确,JMM也是这么想的——它其实也觉得没必要让程序员了解这些——我管你指令怎么重排——你JMM 屏蔽了N多跨平台的fence机制之后就只要告诉我程序员哪些规则下能保证内存可见性就行了. 我还管你指令怎么重排的? 我程序员吃饱了撑的没事做哦!!! 所以JMM才出品了上面的hb规则, 这套规则才是程序员需要了解的.

也就是其实JMM呈现给我们程序员的蓝图是下面这种样子的

也就是hb通过进行指令重排实现(不论是处理器维度的还是编译器维度的). 但是程序员只需要关心hb规则即可.

参考

【1】https://yfsyfs.github.io/2019/06/20/java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B%E5%AE%9E%E8%B7%B5-1-2-%E7%BA%BF%E7%A8%8B%E7%9A%84%E4%BC%98%E7%82%B9/