写点什么

JMM 的前世今生

用户头像
fkc_zyk
关注
发布于: 2020 年 05 月 30 日

最近在研究JMM的时候,看了一些书籍,找了一些资料,发现都只是定义式的讲解JMM是什么,JMM有哪些规则,很多知识点相对零散,没有把JMM的来龙去脉说清楚。鉴于此,本文采用矛盾发展观来进行阐述,力争做到通俗易懂。本文先把JMM的来龙去脉讲清楚(哲学上的“为什么”),然后再讲一下JMM的定义(哲学上的“是什么”),最后就是怎么使用JMM的规则(哲学上的“怎么做”)

一、为什么要有JMM

     (1)可见性问题:计算机的出现,是为了加快对任务的处理能力,计算的过程不仅仅需要CPU的参与,还需要和内存、硬盘、IO外设进行交互,至少CPU和内存的交互是少不了的。由于CPU和内存等其他设备之间,速度存在几个数量级的差距,所以引入了"缓存"(Cache),CPU和内存之间有缓存,内存和硬盘之间有缓存,其中CPU和内存之间有一级缓存、二级缓存、三级缓存(速度介于内存和CPU之间),这些缓存大多数安装在CPU芯片内。为了提高CPU的利用率(下文详细描述),cache和CPU之间又增加了StoreBuffer,进而让CPU利用率提高了不少。但是,由于StoreBuffer没有及时的写入到cache里面,造成cache短暂的可见性问题,进而导致内存的可见性问题。

    解决可见性问题:通过插入特定类型的内存屏障指令来强制读取StoreBuffer里面的数值或者强制StoreBuffer一定要写入cache。有的CPU架构没有StoreBuffer或者不支持从StoreBuffer里面读取最新值,所以不同的物理机架构支持不同的内存屏障指令类型。

    (2)重排序问题:为了更有效的压榨CPU(根据Amdahl定律),使得每个处理器内部的运算都达到最大限度的利用,处理器可能会对执行的任务(代码)进行重排序优化,使得排序后执行的结果和代码执行的结果保持一致。但是,对多线程的重排序,可能会改变程序的执行结果(比如:控制依赖的重排序)。

    解决重排序带问题:通过插入特定类型的内存屏障指令来禁止指令的重排序。但是,不同物理机架构支持的内存屏障指令类型不尽相同。常见的处理器允许的重排序类型如下图所示。



图1 处理器重排序规则



    由(1)(2)可以知道,在不同架构的计算机下,会有不同类型的内存屏障,也就有不同的内存模型(内存模型:对内存或者高速缓存进行读写的抽象过程)。程序员写代码需要考虑不同计算机架构的内存模型,大大的增加了开发成本,由此,JAVA虚拟机对不同的内存模型进行再次封装,该层次叫做JAVA MEMORY MODEL,也就是JMM。JMM提供给程序员统一的接口(happens-before规则),JMM内部实现了不同架构物理机的禁止重排序规则,对于程序员来说是透明的。因此,JMM的出现,是为了统一解决可见性问题和统一解决禁止重排序问题(统一方式插入内存屏障),并且还解决了编译器重排序的问题。



图2 JMM内存模型



二、引申思考

     从第一点可以看出,在多线程环境下,会出现可见性和重排序问题,可见性和重排序可以通过内存屏障来解决问题。那可见性和重排序是什么关系呢?谁是因,谁是果,内存屏障怎么解决可见性问题,内存屏障又怎么解决重排序问题?带着这些问题继续往下寻找答案。

【为什么会重排序】

     重排序发生的情况,分为以下几个层次,分别为:CPU对指令的重排序、CPU缓存的写入和读取导致的重排序、编译器对指令的重排序,下面分别进行探讨。

(1)CPU对指令的重排序

    【背景】现代CPU的并行技术大多是采用流水线技术。指令的一次重叠执行方式是一种简单的指令流水线。一般的流水线,一条指令的执行过程分解为取指令IF、译码ID、执行EX、写回WB这四个阶段。现在有代码示例如下所示。(为了清楚的了解到CPU对指令的重排序,使用汇编语言来进行讲解,且涉及CPU原理性的问题。)

ADD AX,101
ADD AX,32
MOV BX,DX (3// DX原值为1,BX赋值之前是0

     假设在理想条件下,每个阶段需要一个时钟周期,上述汇编代码对应的四个阶段如图3所示。



图3 CPU流水线



     横坐标为时钟周期,总共需要7个时钟周期。从指令(2)的执行依赖指令(1)可以看出,会等待一个时钟周期的时间。指令(3)是因为同一时间同一个CPU原件只能处理一个指令流,所以也出现了一个时钟周期的等待。由于指令(3)不依赖于任何指令,所以可以进行CPU指令重排序,重排序的结果如下所示。



图4 CPU指令重排序后的流水线



     由图4可知,CPU指令进行了重排序,执行三条指令只需要6个时钟周期,比原来更快。此时,假如另外一个线程,有这样一个伪代码如下:

While(BX==1)
print(AX)
return


     其中,AX和BX分别指汇编的寄存器(伪代码),当MOV BX,DX执行完,下一时刻,线程2执行上述伪代码(ADD AX,3 没有执行),则AX输出的值为10,并不是13,这是由于重排序导致可见性的问题。如果在“MOV BX,DX”语句前面加上内存屏障,则不会进行重排序。

     总结】如果有读写屏障的话,CPU就不会导致重排序,也就不会产生可见性问题。 读屏障,或者写屏障,表示后面的读或者写操作不能排序到屏障前面,限制了CPU指令的重排序。

(2)CPU内存的重排序

     该层次的重排序,准确的说是写入缓存造成的重排序,下面以x86为例子,参考书籍《Is Parallel Programming Hard, And, If So,What Can You Do About It?》。



图5 现代计算机的缓存架构

【背景】假设有两个CPU的计算机,分别是CPU0和CPU1,CPU的缓存分别为cache0和cache1, 计算机的缓存架构如图5所示。

问题:当每次从CPU0写数据到cache0的之前,本地cache0都会发送修改指令给其他CPU的cache,比如cache1(由缓存一致性决定的,比如MESI协议),由于其他cpu1的cache1有可能很忙,没办法马上恢复ack给本地CPU0的cache0,本地CPU0可能一直在等待。



图6 有StoreBuffer的cache结构

解决方案:需要CPU0和cache0之间加上了store Buffer。每当CPU0把写入的数据放在了StoreBuffer里面,CPU0继续做其他的事情,由StoreBuffer来把数据写入到cache0里面(如果cache有命中的数据,则不经过StoreBuffer,直接写入cache),架构如图6所示。

// CPU0 执行以下代码,a在CPU1上,初始值为0
a=1;
b=a+1;
assert(b==2);

问题:假设在StoreBuffer的数据,一直没有写入到cache0里面,然后CPU0先执行其他指令,则相当于把后面的步骤提前执行了。如下代码所示, CPU读取到a=0(CPU1 上的值 ),然后给a赋值为1,然后放入storeBuffer,此时CPU继续执行b=a+1操作,此时a不在cache里面,则继续读取CPU1上的a=0(CPU1上的值),则结果b=1,造成assert(b==2)不成立。可见性问题



图7 有StoreBuffer Forwarding的cache架构

解决方案:每次读取的时候,都从storeBuffer里面读取最新值,可以看见最新的a为1的值,则assert(b==2)成立,改进的架构如图7所示。

// CPU 0 执行 foo(), 拥有 b 的 Cache Line,初始值是 0
void foo()
{
a = 1;
b = 1;
}
// CPU 1 执行 bar(),拥有 a 的 Cache Line,初始值是 0
void bar()
{
while (b == 0) continue;
assert(a == 1);
}

问题:本地cache只能看见内存或者其他CPU的cache,不能看见其他CPU的storeBuffer,如上面代码所示。(1)CPU0上初始状态cache0有b=0,执行结束之后,storeBuffer有a=1的数据,cache0直接赋值b=1,由于缓存一致性原理,cache0通知cache1,cache1的b=1;(2)CPU1上跳出while循环(b找到CPU1的chache1为b=1),于是找到本地cache1 中a=0(错误的,由于没有响应到CPU0的最新值a=1),所以导致assert(a == 1)失败,相当于把assert(a= = 1)提前执行,造成foo函数执行的结果对bar不可见。(注意:本地cache只能看见内存或者其他CPU的cache,不能看见其他CPU的storeBuffer

// CPU 0 执行 foo(), 拥有 b 的 Cache Line,初始值是 0
void foo()
{
a = 1;
smp_wmb();
b = 1;
}
// CPU 1 执行 bar(),拥有 a 的 Cache Line,初始值是 0
void bar()
{
while (b == 0) continue;
assert(a == 1);
}

      解决方案:如上面代码所示,在代码里面插入smp_wmb(),smp_wmb()的作用是强制把smp_wmb()(写屏障)语句后面所有的赋值语句有放入storeBuffer,不管有没有在cache上(没有smp_wmb():cache上有缓存,直接赋值,不经过StoreBuffer),当CPU1看见b!=0的时候,说明CPu0的storeBuffer已经把a和b写入cache0,使得CPU1的chche1同步更新。



图8 有Invalidated Queue的缓存架构

     架构调整:如果StoreBuffer(有限大小)写满了(StoreBuffer没有及时收到其他cache的Invalidated ack),CPU还是会出现等待的状态,所以就会先向其他CPU发送Invalidate指令到其他CPU的Invalidate Queue队列里面,其他队列的CPU会消费Invalidate Queue里面的值。如果自己的缓存设置了Invalidated状态,等需要用到数据了再请求其他CPU的cache或者内存,模型如图8所示。



// CPU 0 执行 foo(), a 处于 Shared,b 处于 Exclusive
void foo()
{
a = 1;
smp_wmb();
b = 1;
}
// CPU 1 执行 bar(),a 处于 Shared 状态
void bar()
{
while (b == 0) continue;
assert(a == 1);
}

问题:如上面代码例子所示,(1)CPU0执行完之后,假设storeBuffer被执行完,则a=1,b=1(cache0里面),并发送invalidated信号给cache1的a和b,说明缓存无效。(2)当CPU1执行的时候,发现b在cache1里是无效的,则读取CPU0的cache0的值(b为1),则跳出循环,此时CPU1的Invalidate Queue没有被执行完成(还剩a是invalidate的元素),则还是使用CPU1的cache1的a的值(此时在CPU1中,a=0),则不符合程序期望。相当于assert(a==1)重排序到了最前面。造成foo函数修改a的值对bar函数不可见

// CPU 0 执行 foo(), 拥有 b 的 Cache Line
void foo()
{
a = 1;
smp_wmb();
b = 1;
}
// CPU 1 执行 bar(),拥有 a 的 Cache Line
void bar()
{
while (b == 0) continue;
smp_rmb();
assert(a == 1);
}

解决方案:如上面代码所示,增加smp_rmb()方法(读屏障),强制使得本地CPU的InvalidateQueue被清理完成,处理cache的数据,使得CPU1的cache1的InvalidatedQueue全部被处理完,也就是cache1的a和b的值都变成invalidated状态。当需要cache1的a和b的值的时候,读取CPU0的cache0,即可以读取到最新值。

     总结】如果没有加读写屏障的话,由于StoreBuffer和cache之间、InvalidatedQueue和cache之间会出现短暂的延迟,造成短暂的可见性问题,从而产生CPU内存重排序(伪排序)视觉效果。读写屏障会强制处理StoreBuffer和InvalidatedQueue,以刷新本地cache的值。

(3)编译器重排序



图9 volatile的重排序规则

对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。这种重排序应该很好理解,目的和CPU指令重排序类似,都是为了缩短执行时间,但是在多线程的条件下,就会产生可见性问题。比如:JMM针对编译器制定的关于volatile重排序如图9所示。

【总结】(1)重排序和可见性的关系:CPU对指令的重排序会导致可见性问题;编译器对指令的重排序会导致可见性问题;CPU缓存的写入和读取延迟,导致可见性问题,产生重排序的视觉(伪排序)。(2)通过加入写屏障,强制把StoreBuffer的数据刷新到缓存,CPU可以获取本地StoreBuffer的最新值;通过加入读屏障,强制把InvalidatedQueue处理完成,标记无效值,以读取到最新的值。

三 、JMM是什么

     JMM(java Memory Model)屏蔽掉各种操作系统和硬件的内存访问差异,使得java程序在各种操作系统和硬件环境下,达到一致的内存访问效果。JMM是一种在多样环境下对内存统一访问的模型。

四、怎样用JMM

JSR-133使用happens- before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。与程序员密切相关的happens-before规则如下。

1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

4)传递性:如果A happens-before B,且B happens-before C,那么A happens-beforeC。

5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操 作happens-before于线程B中的任意操作。

6)join()规则:如果线程A执行操作ThreadB. join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB. join()操作成功返回。

五、常见问题

(1)x86架构下,为什么只有StoreBuffer屏障有用?

下面举个例子来补充说明不同的CPU模型使用不同的内存屏障,比如x86的架构,强制CPU必须先写入StoreBuffer,再写入cache(这就不需要StoreStore屏障来保证),没有InvalidateQueue(这就不需要LoadLoad和LoadStore屏障来保证),所以x86只有StoreLoad屏障有起到禁止重排序的作用。



(2)x86架构下,为什么StoreLoad屏障是使用Lock前缀?

在C语言层面,内存屏障分为StoreStore屏障、StoreLoad屏障、LoadLoad屏障和LoadStore屏障。这四种屏障在Openjdk里面,根据不同的CPU架构翻译成不同的语句。比如“hotspot/os_cpu/linux_x86/orderAccess_linux_x86.hpp”路径下的源码如下所示,使用Lock前缀的性能比直接使用StoreLoad屏障更有效率,也可以实现StoreLoad的语义。其他三种屏障都译成编译器屏障。

#ifndef OS_CPU_LINUX_X86_ORDERACCESS_LINUX_X86_HPP
#define OS_CPU_LINUX_X86_ORDERACCESS_LINUX_X86_HPP
// Included in orderAccess.hpp header file.
// Compiler version last used for testing: gcc 4.8.2
// Please update this information when this file changes
// Implementation of class OrderAccess.
// A compiler barrier, forcing the C++ compiler to invalidate all memory assumptions
static inline void compiler_barrier() {
__asm__ volatile ("" : : : "memory");
}
inline void OrderAccess::loadload() { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore() { compiler_barrier(); }
inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::acquire() { compiler_barrier(); }
inline void OrderAccess::release() { compiler_barrier(); }
inline void OrderAccess::fence() {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
compiler_barrier();
}
inline void OrderAccess::cross_modify_fence() {
int idx = 0;
#ifdef AMD64
__asm__ volatile ("cpuid " : "+a" (idx) : : "ebx", "ecx", "edx", "memory");
#else
// On some x86 systems EBX is a reserved register that cannot be
// clobbered, so we must protect it around the CPUID.
__asm__ volatile ("xchg %%esi, %%ebx; cpuid; xchg %%esi, %%ebx " : "+a" (idx) : : "esi", "ecx", "edx", "memory");
#endif
}
#endif // OS_CPU_LINUX_X86_ORDERACCESS_LINUX_X86_HPP

(3)在x86处理器(Linux)中,哪些汇编指令有内存屏障的语义:

1)对 I/O 端口进行操作的所有指令。

2)有Lock前缀的所有指令。

3)写控制寄存器、系统寄存器或者调试寄存器的所有指令(例如:cli和sti,用于修改eflags寄存器的IF标志的状态)。

4)在Pentium 4微处理器中引入的汇编语言指令lfence、sfence和mfence,他们分别有效地实现读内存屏障、写内存屏障和读写内粗屏障。

5)少数专门的汇编语言指令,种植中断处理程序或者异常处理程序的iret指令就是其中的一个。



(4)在x86架构下,写入数据之后加一个写 Barrier 去刷缓存到主存,读数据之

前加入 Barrier 去强制从主存读?

上述说法错误。由上文描述可知,写入数据之后加一个写 Barrier 去把StoreBuffer强制刷新到cache,读数据之前加入 Barrier 去强制把InvalidatedQueue消费掉。



【参考资料】

(1)指令流水线

(2)《Is Parallel Programming Hard, And, If So,What Can You Do About It?》

(3)《JAVA并发编程的艺术》

(4)《深入理解Java虚拟机》

(5)《Understanding the Linux Kernel》

(6)《Intel® 64 and IA-32 Architectures Software Developer Manuals

(7)《计算机系统结构》



发布于: 2020 年 05 月 30 日阅读数: 914
用户头像

fkc_zyk

关注

还未添加个人签名 2019.03.30 加入

还未添加个人简介

评论 (1 条评论)

发布
用户头像
感谢分享原创,首页推荐这篇。
2020 年 05 月 30 日 18:30
回复
没有更多了
JMM的前世今生