JAVA关键字volatile引发的MSI协议

volatile

作用

可以使得在多处理器环境下保证了共享变量的可见性

如何保证可见性?

创建一个带有 由volatile修饰的成员变量 的类, 例如:

  public class VolatileDemo {
        public volatile static boolean v = false;
        public static void test() {
            v = true;
        }
    }

然后设置JVM参数如下:
【-server -Xcomp -XX:+UnlockDiagnosticVMOptions -
XX:+PrintAssembly -
XX:CompileCommand=compileonly,VolatileDemo.
在输出结果中查找lock指令会发现,在修改
带有 volatile 修饰的成员变量时,会多一个 lock 指令。lock
是一种控制指令,在多处理器环境下,lock 汇编指令可以
基于总线锁或者缓存锁的机制来达到可见性的一个效果。
总线锁 和缓存锁为CPU内部的实现,由此引发出对CPU,内存的交互方式,CPU的缓存方式,当对此理解之后 在对于volatile的修饰词能更深刻的理解.接下来我们在聊聊cpu的msi协议。

CPU


关于总线锁和缓存锁的意思看这个图就应该差不多理解了吧

CPU简易缓存模型

:
主内存指 : 服务器内存;
cpu0 cpu1 : 指多核CPU下的各个内核,当有多线程操作时 系统会分配各个内核运行各个线程;
因CPU速度比内存速度快很多,所以当线程对内存数据操作时会先把主内存数据通过总线读取到L3高速内存中,然后读取到线程内存进行操作.
L3为线程共享缓存;
L1L2为线程内部缓存;

MSI协议

是一个在多处理器系统中运行的基本的缓存一致性协议。与其他缓存一致性协议一样,协议名称中“M,S,I”三个字母表明高速缓存行可能的状态。
1. M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存的数据不一致
2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改
3. S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
4. I(Invalid) 表示缓存已经失效

举个栗子

主内存中有变量 v = true;
L3 缓存: v = true;
cpu0 :v = true;
cpu1 :v = true;
当L3中的数据有多个线程持有时,数据且相同 此时 数据状态都为:S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
cpu0开始变动:
此时cpu0操作是将V改为 false; cpu0的此时的状态为: 由 S->M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致 ,
同时通知有缓存v值的cpu告知失效, 其他cpu(cpu1)缓存由S->I(Invalid) 表示缓存已经失效

此时看似 msi协议已经优化了 可见性一致问题,但是 代码中为什么还是需要加 volatile 关键字呢
MSI 协议实现是通过消息传递来进行通信.
就是各个 CPU 缓存行的状态是通过消息传递来进行的。如 果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费。在 cpu 中引入了 Store Bufferes。

CPU0 只需要在写入共享数据时,直接把数据写入到 store bufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他所有 CPU 发送了 invalidate acknowledge 消息时,再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。
这种优化会出现
1异步操作等待: 数据什么时候提交是不确定的,因为需要等待其他 cpu
给回复才会进行数据同步。这里其实是一个异步操作
2 store bufferes 直接读取:引入store bufferes后,处理器会先尝试从store bufferes读取值,如果store bufferes中有数据,则直接读取

指令重排序

在这里插入图片描述
exeToCPU0和exeToCPU1分别在两个独立的CPU上执行。
假如 CPU0 的缓存行中缓存了 isFinish 这个共享变量,并且状态为(E)、而 Value 可能是(S)状态。那么这个时候,CPU0 在执行的时候,会先把 value=10 的指令写入到storebuffer中。并且通知给其他缓存了该value变量的 CPU。在等待其他 CPU 通知结果的时候,CPU0 会继续执行 isFinish=true 这个指令。而因为当前 CPU0 缓存了 isFinish 并且是 Exclusive 状态,所以可以直接修改 isFinish=true。
这个时候 CPU1 发起 read操作去读取 isFinish 的值可能为 true,但是 value 的值不等于 10。这种情况我们可以认为是 CPU 的乱序执行,也可以认为是一种重排序,而这种重排序会带来可见性的问题这下硬件工程师也抓狂了,我们也能理解,从硬件层面很难去知道软件层面上的这种前后依赖关系,所以没有办法通过某种手段自动去解决。所以硬件工程师就说: 既然怎么优化都不符合你的要求,要不你来写吧。
所以在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是 CPU flush store bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。

CPU 层面的内存屏障

什么是内存屏障?从前面的内容基本能有一个初步的猜想,内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
X86 的 memory barrier 指令包括 lfence(读屏障) sfence(写屏障) mfence(全屏障)Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的Full Memory Barrier(全屏障) 确保屏障前的内存读写操作
的结果提交到内存之后,再执行屏障后的读写操作有了内存屏障以后,对于上面这个例子,我们可以这么来
改,从而避免出现可见性问题

总的来说,内存屏障的作用可以通过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性但是这个屏障怎么来加呢?回到最开始我们讲 volatile 关键字的代码,这个关键字会生成一个 Lock 的汇编指令,这个指令其实就相当于实现了一种内存屏障

什么是 JMM

这个时候问题又来了,内存屏障、重排序这些东西好像是和平台以及硬件架构有关系的。作为 Java 语言的特性,一次编写多处运行。我们不应该考虑平台相关的问题,并且这些所谓的内存屏障也不应该让程序员来关心。

JMM 全称是 Java Memory Model. 什么是 JMM 呢?
通过前面的分析发现,导致可见性问题的根本原因是缓存以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。JMM 属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了 CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。需要注意的是,JMM 并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在 JMM 中,也会存在缓存一致性问题和指令重排序问题。只是 JMM 把底层的问题抽象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成
Java 内存模型底层实现可以简单的认为:通过内存屏障(memory barrier)禁止重排序,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。

简单来说,JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。这些方法大家都很熟悉:volatile、synchronized、final;以及JMM 如何解决顺序一致性问题

重排序问题

为了提高程序的执行性能,编译器和处理器都会对指令做重排序,其中处理器的重排序在前面已经分析过了。所谓的重排序其实就是指执行的指令顺序。编译器的重排序指的是程序编写的指令在编译之后,指令可能会产生重排序来优化程序的执行性能。从源代码到最终执行的指令,可能会经过三种重排序。
在这里插入图片描述
2 和 3 属于处理器重排序。这些重排序可能会导致可见性问题。
编译器的重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序当然并不是所有的程序都会出现重排序问题编译器的重排序和 CPU 的重排序的原则一样,会遵守数据依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,比如下面的代码,
a=1; b=a;
a=1;a=2;
a=b;b=1;
这三种情况在单线程里面如果改变代码的执行顺序,都会导致结果不一致,所以重排序不会对这类的指令做优化。这种规则也成为 as-if-serial。不管怎么重排序,对于单个线程来说执行结果不能改变。比如
int a=2; //1
int b=3; //2
int rs=a*b; //3
1 和 3、2 和 3 存在数据依赖,所以在最终执行的指令中,3 不能重排序到 1 和 2 之前,否则程序会报错。由于 1 和 2不存在数据依赖,所以可以重新排列 1 和 2 的顺序

JMM 层面的内存屏障
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序,在 JMM 中把内存屏障分为四类
在这里插入图片描述

HappenBefore

它的意思表示的是前一个操作的结果对于后续操作是可见的,所以它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程

  1. 一个线程中的每个操作,happens-before 于该线程中的任意后续操作; 可以简单认为是 as-if-serial.单个线程中的代码顺序不管怎么变,对于结果来说是不变的顺序规则表示 1 happenns-before 2; 3 happensbefore4;
  2. volatile 变量规则,对于 volatile 修饰的变量的写的操作,一定 happen-before 后续对于 volatile 变量的读操作;根据 volatile 规则,2 happens before 3;
  3. 传递性规则,如果 1 happens-before 2; 3happensbefore 4; 那么传递性规则表示: 1 happens-before 4;
  4. start 规则,如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作
  5. join 规则,如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程A 从 ThreadB.join()操作成功返回。
  6. 监视器锁的规则,对一个锁的解锁,happens-before 于随后对这个锁的加锁 假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。
    文中大部分文字描述来源于:骚MIC
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值