并发编程系列(三)JMM内存模型及volatile底层实现原理详解

早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)等。【嗅探技术和MESI协议】的出现就是为了解决多核CPU时代,缓存不一致问题。
在这里插入图片描述

MESI缓存一致性协议

缓存系统操作的最小单位就是缓存行,而MESI是缓存行四种状态的首字母的缩写,任何多核系统中的缓存行都处于这四种状态之一。
①失效(Invalid)缓存行: 该CPU缓存中无该缓存行或缓存中的缓存行已经失效。
②共享(Shared)缓存行: 缓存行的内容是同主内存内容保持一致的一份拷贝,在这种状态下的缓存行只能被读取,不能被写入。
③独占(Exclusive)缓存行: 和S状态一样,也是和主内存内容保持一致的一份拷贝。区别在于一个CPU持有了一个E状态的缓存行,那其他的CPU就不能同时持有该内容的缓存行,所以叫’独占’。这意味着,如果其他CPU原本也持有同一缓存行,那么它会马上变成失效状态
④已修改(Modified)缓存行: 该缓存行已经被所属的CPU修改了。如果一个缓存行处于已修改状态那么它在其他CPU缓存中的拷贝马上会变成失效状态。此外,已修改缓存行如果被丢弃或标记为失效(从M状态变为I状态),那么先把它的内容回写到内存中-这和写模式下常规的处理方式一样。

嗅探技术

“嗅探” 背后的基本思想是,所有的内存传输都发生在一条共享的总线上。所有的CPU都能看到这条总线。
缓存本事是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁,同一指令周期内,只有一个CPU可以读写内存。
嗅探就是的思想是,CPU缓存不仅仅在做内存传输的时候才和总线打交道,而是不停地在嗅探总线上发生的数据交换,跟踪其他CPU缓存在做什么。所以当一个CPU缓存去读写内存时,其他CPU都会得到通知,它们以此来使自己的缓存保持同步。只要某一个CPU一写缓存,其他的CPU马上知道这块内存在它们自己的缓存中对应的缓存行已经失效。

状态 描述 监听任务
M修改 当前CPU缓存有效,数据被修改了,和内存中的数据不一致,数据只存在于当前CPU缓存中。 缓存行必须时刻监听所有试图读该缓存行存储于主存内的操作,这种操作必须在当前CPU缓存将该缓存行写回主存并将状态变成S(共享)状态之后执行。
E独享、互斥 当前CPU缓存有效,数据和内存中的数据一致,数据只存在于当前CPU缓存中。 缓存行也必须监听其他缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S共享 当前CPU缓存有效,数据和内存中的数据一致,数据存在于很多CPU缓存中。 缓存行也必须监听其他缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效。
I无效 当前CPU缓存无效。
内存模型

有一种内存模型叫TSO,英文全称为:Total Store Ordering,中文名称为完全存储顺序,这种模型是针对实际的CPU结构做的优化。为了加快CPU读取内存数据,在CPU和内存的中间加入了缓存,预先读取内存中的数据。在多CPU结构中,缓存分为公共缓存和私有缓存,公共缓存是所有CPU共享的缓存,私有缓存是每个CPU独有的,不论是公共缓存还是私有缓存对CPU来说都是黑盒的,CPU只管读取数据,不论读取的数据是怎么来的,同样CPU写数据,也不会关注缓存,所以写缓存要满足两个条件(MESI协议):1)写缓存的数据自动同步到内存中;2)所有CPU的私有缓存应该保持一致:一个CPU写缓存,其他CPU的私有缓存都要更新。拥有缓存的多CPU结构如下:
在这里插入图片描述
CPU操作分为两种:load(读)、store(写),加入缓存的目的是提前缓存内存的数据,提高load的效率,但是store的速度降低了,因为CPU将数据store到内存多了写缓存的步骤,并且需要同步所有CPU的私有缓存。如果在CPU+缓存结构中,不论load还是store内存中的数据,都要排队执行,store操作会严重阻塞后续的load操作,这样加缓存的意义完全就没有了,不仅没能提高load的效率,反而阻塞了load。为了解决load被阻塞的问题,在CPU中加入了新的组件store buffer(写队列),如下图:
在这里插入图片描述
每个CPU都有一个store buffer,当CPU需要执行store操作时,会将store操作先放入store buffer中,不会立即执行store操作,等待合适的时机再执行(store buffer满了等等情况会真正执行store操作),在store后面的load操作不用再等store真正执行完毕才能执行,只要store放入了store buffer中,load就可以执行了。store buffer造成的结果就是事实上
store-load操作被重排序了
:store操作后面的load先执行(store-load表示程序代码里先store后load,store-store,load-store,load-load依旧不能重排序)。但在数据依赖的情况下,都是不允许重排序的。保证了单线程始终能够正确执行,单线程的执行遵循as-if-serial,给人的感觉是代码一行一行按顺序执行的,但实际上不相互依赖的变量是可以重排序的,不会影响最终结果。

什么是as-if-serial
在这里插入图片描述
在这里插入图片描述

JMM(java memory model)

java程序可以部署在任何平台上,比如上面的x86和PowerPC等平台,由于x86和PowerPC等平台内存模型不一样,为了屏蔽平台差异性,JMM为java程序提供了统一的内存模型。java内存模型主要目标是定义程序中的变量,在虚拟机中存储到内存与从内存读取出来的规则细节,Java 内存模型规定所有变量都存储在主内存中每条线程还有自己的工作内存,工作内存保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在自己的工作内存中进行而不能直接读写主内存的变量,不同线程之间无法相互直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

Java 内存模型对主内存与工作内存之间的具体交互协议定义了八种操作,具体如下:
lock(锁定): 作用于主内存变量,把一个变量标识为一条线程独占状态。

unlock(解锁): 作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

read(读取): 作用于主内存变量,把一个变量从主内存传输到线程的工作内存中,以便随后的 load 动作使用。

load(载入): 作用于工作内存变量,把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。

use(使用): 作用于工作内存变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时执行此操作。

assign(赋值): 作用于工作内存变量,把一个从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个需要给变量进行赋值的字节码指令时执行此操作。

store(存储): 作用于工作内存变量,把工作内存中一个变量的值传递到主内存中,以便后续 write 操作。

write(写入): 作用于主内存变量,把 store 操作从工作内存中得到的值放入主内存变量中。

JMM内存模型内存同步规则:

1.不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
2.一个新的变量只能从主内存中产生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
3.一个变量在同一时刻只允许一个线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作变量才会被解锁。lock和unlock必须是成对出现。
4.如果一个变量执行lock操作,将会清空工作内存中此变量的值。在执行引擎使用这个变量之前需要重新执行load或assign操作初始变量的值。
5.如果一个变量事先没有被lock锁定,则不允许对其执行unlock操作;也不允许unlock一个被其他线程锁定的变量。
6.对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store或write操作)

如果要把一个变量从主内存复制到工作内存就必须按顺序执行 read 和 load 操作,从工作内存同步回主内存就必须顺序执行 store 和 write 操作,但是 JVM 只要求了操作的顺序而没有要求上述操作必须保证连续性,所以实质执行中 read 和 load 间及 store 和 write 间是可以插入其他指令的。

Java 内存模型还会对指令进行重排序操作,在执行程序时为了提高性能编译器和处理器经常会对指令进行重排序操作,重排序主要分下面几类:

编译器优化重排序: 编译器在不改变单线程程序语义的前提下可以重新安排语句的执行顺序。

指令级并行重排序: 现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性处理器可以改变语句对应机器指令的执行顺序。

内存系统重排序: 由于处理器使用缓存和读写缓冲区使得加载和存储操作看上去可能是在乱序执行。

JMM多核多级缓存架构图
在这里插入图片描述
针对于JMM多核多级缓存架构下的可见性问题和指令重排问题,java提供了volatile关键字来解决。

volatile关键字及底层实现原理

其实 Java JMM 内存模型是围绕并发编程中原子性、可见性、有序性三个特征来建立的,关于原子性、可见性、有序性的理解如下:

原子性: 就是说一个操作不能被打断,要么执行完要么不执行,类似事务操作,Java 基本类型数据的访问大都是原子操作,long 和 double 类型是 64 位,在 32 位 JVM 中会将 64 位数据的读写操作分成两次 32 位来处理,所以 long 和 double 在 32 位 JVM 中是非原子操作,也就是说在并发访问时是线程非安全的,要想保证原子性就得对访问该数据的地方进行同步操作,譬如 synchronized 等。

可见性: 就是说当一个线程对共享变量做了修改后其他线程可以立即感知到该共享变量的改变,从 Java 内存模型我们就能看出来多线程访问共享变量都要经过线程工作内存到主存的复制和主存到线程工作内存的复制操作,所以普通共享变量就无法保证可见性了;Java 提供了 volatile 修饰符来保证变量的可见性,每次使用 volatile 变量都会主动从主存中刷新,除此之外 synchronized、Lock、final 都可以保证变量的可见性。

有序性: 就是说 Java 内存模型中的指令重排不会影响单线程的执行顺序,但是会影响多线程并发执行的正确性,所以在并发中我们必须要想办法保证并发代码的有序性;在 Java 里可以通过 volatile 关键字保证一定的有序性,还可以通过 synchronized、Lock 来保证有序性,因为 synchronized、Lock 保证了每一时刻只有一个线程执行同步代码相当于单线程执行,所以自然不会有有序性的问题;除此之外 Java 内存模型通过 happens-before 原则如果能推导出来两个操作的执行顺序就能先天保证有序性,否则无法保证。

其实volatile实现可见性和禁止指令重排是通过happen-before原则和内存屏障来实现的,接下来我们来具体看一下。

happen-before原则

在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,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-before C。
5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
7 )线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

内存屏障 - 禁止指令重排
在这里插入图片描述

Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。
1、保证特定操作的执行顺序。
2、影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

总结:
1.为了满足CPU的快速发展,降低CPU和主存交互消耗的大量性能,所以在CPU中实现了快速缓存,每个CPU私有,但是带来的问题就是缓存不一致问题。

2.为了解决缓存不一致问题,通过总线加锁或是【MESI缓存一致性协议和嗅探技术】解决,因为总线加锁性能低下最终主要采用【MESI缓存一致性协议和嗅探技术】。

3.由于不同的机器类型的缓存模型是有差异的,因此java环境下进行统一,所以就出现了JMM内存模型。在JMM内存模型下通过使用volatile关键字解决可见性和指令重排问题。

4.在单线程环境下通过as-if-serial原则解决指令重排,但不涉及多线程下的通信,所以在多线程下volatile解决指令重排是通过“内存屏障”实现的,在需要禁止指令重排的位置插入Memory Barrier指令,告诉编译器不允许指令重排;通过happen-before原则解决可见性问题

5.mesi协议的引入会导致缓存的写入效率降低,所以在CPU中引入了store buffer等部件。store buffer将store操作缓存起来,不会立即写入缓存,导致多CPU内的值同步会有一定延迟,间接导致cpu的操作重排序,多cpu的共享变量的操作会发生混乱,所以JMM中可以使用volatile强制刷新store buffer,让多CPU中的值同步没有延迟,保证多CPU共享变量不会发生混乱。


参考文章
https://www.zhihu.com/question/296949412?sort=created
https://www.jianshu.com/p/8a58d8335270
https://www.cnblogs.com/cherish010/p/8569037.html

发布了12 篇原创文章 · 获赞 0 · 访问量 328

猜你喜欢

转载自blog.csdn.net/fd135/article/details/104382155