浅谈缓存一致性原则和Java内存模型(JMM)

Java内存模型(JMM)是一个概念模型,底层是计算机的寄存器、缓存内存、主内存和CPU等。
多处理器环境下,共享数据的交互硬件设备之间的关系:
《浅谈缓存一致性原则和Java内存模型(JMM)》
JMM:
《浅谈缓存一致性原则和Java内存模型(JMM)》
从以上两张图中,谈一谈以下几个概念:

1.缓存一致性协议(MESI):

由于每个处理器都含有私有的高速缓存,在对缓存中数据进行更新后,其他处理器中所含有的该共享变量的缓存如果被处理器进行读操作,就会出现错误。有些计算机采用LOCK#信号对总线进行锁定,当一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞,那么该处理器就能独自共享内存。然而总线锁定的开销太大,在之后的计算机中一般都采用“缓存锁定”的方式实现。
MESI是代表了缓存数据的四种状态的首字母,分别是Modified、Exclusive、Shared、Invalid)
– M(Modified):被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
– E(Exclusive):独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
– S(Shared):共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
– I(Invalid):要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。
在缓存行中有这四种状态的基础上,通过“嗅探”技术完成以下功能:【嗅探技术能够嗅探其他处理器访问主内存和它们的内部缓存】
– 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
– 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
– 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
– 只有E和M可以进行写操作而且不需要额外操作,如果想对S状态的缓存字段进行写操作,那必须先发送一个RFO(Request-For-Ownership)广播,该广播可以让其他CPU的缓存中的相同数据的字段实效,即变成I状态。
通过以上机制可以使得处理器在每次读写操作都是原子的,并且每次读到的数据都是最新的。

2.Java并发编程中要保证的几个原则

1)原子性:

是指CPU在执行操作时,要么执行要么不执行,对于单个的读/写操作,在多线程环境下保证是原子操作,但复合操作比如i++,相当于是以下三个操作:

int temp = get();  // 读
temp += 1;         // ADD
set(temp);         // 写

Java主要提供了锁机制以及CAS操作实现原子性,对于单个读/写操作是通过LOCK#信号或“缓存锁定”实现的。
除此之外,long和double类型的变量读/写是非原子性的,每次都只读/写32位数据,所以一个单个的读/写操作就变成了两个读/写操作,有可能在只读/写了其中32位操作后CPU就被其他线程抢占到。

2)可见性:

由于每个线程都有一个私有的工作空间,并且保存一个主存中共享变量的副本,在线程对私有的工作空间中的数据进行写操作,别的线程并没有读到最新的值,就会出现问题。Java提供了volatile关键字保证了内存的可见性,底层通过LOCK#或“缓存锁定”实现。

instance = new Singleton(); // instance 是一个volatile变量

以上代码在进行反汇编后得到的汇编代码如下:

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

如果是一个volatile关键字修饰的变量,则会有第二行的汇编代码,这是一条含有lock前缀的代码。带有lock前缀的代码则会通过LOCK#或通过“缓存锁定”实现线程间的可见性。

3)有序性

编译器和处理器会通过多种方式比如重排序对代码进行优化,然而在重排序后可能会导致运行结果与预想的不同。
a.重排序的方式:
计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:
《浅谈缓存一致性原则和Java内存模型(JMM)》
– 编译器优化的重排序:
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。【as-if-serial原则保证,as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。】
– 指令级并行的重排序:
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
– 内存系统重排序:
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

b.内存屏障(Memory Barrier,又称内存栅栏):
内存屏障是一个CPU指令,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。它的作用有两个:
一是保证特定操作的执行顺序;
二是保证某些变量的内存可见性。
如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
JMM把内存屏障指令分为下列四类:
《浅谈缓存一致性原则和Java内存模型(JMM)》

3.happens-before原则:

使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
【如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求A操作(执行的结果)对B操作可见,且A操作按顺序排在B操作之前。】
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。【在A happens-before B中,如果A和B重排序后不会导致结果变化,那么这种重排序是被允许的】
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()操作成功返回。

本文参考资料:
书籍:
《Java并发编程的艺术》
博客:
https://blog.csdn.net/javazejian/article/details/72772461
https://blog.csdn.net/happy_horse/article/details/51657957
本文图片来自博客:
https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html

    原文作者:java内存模型
    原文地址: https://blog.csdn.net/sdr_zd/article/details/81323519
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞