1、CPU物理缓存结构

2.2、多线程并发编程安全性问题 - 图1
2.2、多线程并发编程安全性问题 - 图2

1.2.1、CPU Cache 模型

image.png
lQDPJxaA-doIG73NEgDNDYCwnovdSfpn27YC1GEwTYAsAA_3456_4608.jpg
2.2、多线程并发编程安全性问题 - 图5

1.2.2、CPU 通过 Cache与主内存进行交互大致流程图12-3

上图单个CPU 就是现实生活中真实CPU 里面的一个内核Core、现在Cpu都是多核的,所以现在并行、并发处理时每个核心上面分配一个线程处理存储在 主内存(RAM)中的共享数据时,每个线程Thread 都有自己的本地内存(对于CPU Cache),所以会存在缓存一致性的问题。
CPU Cache 的出现是为了解决 一类问题:CPU访问主内存效率低下的问题,但是也带来一些新的问题!!!

1.2.3、CPU 缓存一致性问题

image.png
image.png

由于硬件界出了两大问题违反了摩尔定律:

  1. 单核CPU计算性能出现了瓶颈,硬件工程师给出了一个CPU 多核心Core处理器解决方案。
  2. CPU与内存的发展访问速度的差异性越来越大,内存的访问速度迟缓,Intel、AMD 等各大供应商硬件工程师给出了新的解决方案 在CPU处理器中增加不同层次的缓存机制解决差异。
  3. 硬件的发展架构变更,同时也带来了新的其他问题,对软件工程师也增加了新的挑战。

比如:单核CPU中不会出现并发编程的三大问题(原子、有序)单核多线程下 可能会出现可见性问题-线程与线程之间可见性;
比如:多核CPU 增加三级缓存,多线程之间的可见性问题等;

  1. 由于多核、多级缓存的出现导致了缓存一致性问题,
    1. 假设只有多核没有多级缓存会不会存在缓存一致性问题?
      1. 会,多个核心针对不同的线程并行处理共享变量。
    2. 或者假设只有单核会不会存在缓存一致性问题?
      1. 会,单核多线程下也会存在,因为每个线程上下文切换。

回到Cache :引出 MESI协议

在了解MESI协议之前,先了解下并发编程(并行编程)的三大问题


2、并发编程的三大问题

因为并发针对单核CPU 、目前CPU 都是多核,所以也都是并行编程;又因为线程数量往往多于CPU核心数 ,所以每个核心core 上又是 并发编程;不过我们通常都是说并发编程!!!!

2.1、原子性问题

原子(Atomic)的字面意思是不可分割的(Indivisible)。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性。
许多资料都会提及原子操作的定义中的“不可分割”,但是很少有资料会对其含义做进一步的解释。而弄清楚“不可分割”的具体含义是理解原子性的关键所在。所谓“不可分割”,其中一个含义是指访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作的中间状态效果。
小贴士:原子操作多线程环境下的一个概念,它是针对访问共享变量的操作而言的。原子操作的“不可分割”包括以下两层含义。

  1. 访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行了部分的中间结果。
  2. 访问同一组共享变量的原子操作是不能够交错的。

2.1.1、理解原子操作这个概念还需要注意以下两点:

  1. 原子操作是针对访问线程共享的共享变量(堆区、方法区)的操作而言的。也就是说,仅涉及局部变量栈区、PC 等访问的操作无所谓是否是原子的,或者干脆把这一个类操作都看成原子操作,
  2. 原子操作是从该操作的执行线程以外的线程来描述的,也就是说它只有在多线程环境下有意义。换言之,单线程环境下一个操作无所谓是否具有原子性,或者我们干脆把一类操作都看成原子操作。

2.1.2、总结
总的来说,Java中有两种方式实现来保障原子性。一种是使用锁(lock)。锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能够被一个线程访问。这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰与冲突的可能,即消除了竟态。另一种是利用硬件处理器提供的专门CAS(Compare-and-swap)指令,CAS 实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是“软件”这一层次实现的,而CAS是直接在硬件(处理器和内存)这一层次实现的,它可以被看作 ” 硬件锁 “。

2.2、可见性问题

2.2.1、处理器缓存
处理器并不是直接与主内存(RAM)打交道而执行内存的读、写操作,而是通过寄存器(Register)、高速缓存(Cache)、写缓冲器(StoreBuffer,也称为Writer Buffer)和无效化队列(Invalidate Queue)等部件执行内存的读、写操作的。从这个角度看,这些部件相当于主内存的副本,因此本书为了叙述方便将这些统称为处理器对主内存的缓存,简称处理器缓存。

2.2.2、缓存同步 | 冲刷处理器缓存 | 刷新处理器缓存
虽然一个处理器的高速缓存中的内容不能被另外一个处理器直接读取,但是一个处理器可以通过缓存一致性协议(Cache Coherence Protocol)来读取其他处理器的高速缓存中的数据,并将读的数据更新到该处理器的高速缓存中。这种一个处理器从其自身处理器缓存以外的其他存储部件中读取数据并将其更新到自身的高速缓存中的过程。称为 缓存同步(指的是高速缓存与高速缓存或主内存之间共享变量数据同步,不是指写缓冲器中的)。相应地,我们称这些存储部件的内容是可同步的,这些存储部件包括处理器的高速缓存、主内存。
缓存同步使得一个处理器(上运行的线程)可以读取到另外一个处理器(上运行的线程)对共享变量所做的更新,即保障了可见性。因此为了保障可见性,我们必须使一个处理器对共享变量所做的更新最终被写入该处理器的高速缓冲区或者主内存中(而不是始终停留在其写缓冲器中),这个过程被称为 “冲刷处理器缓存”。
(简单理解:就是将CPU处理器中的写缓冲器中的数据 刷新到高速缓存或者主内存中,这个过程称为“冲刷处理器缓存”)
并且,一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器必须从其他处理器的高速缓存或者主存储器中对 相应的变量进行缓存同步。这个过程称为“刷新处理器缓存”。
(简单理解:两个处理器执行缓存同步保持数据一致,“冲刷处理器缓存”。)
因此,可见性的保障是通过使更新共享变量的处理器执行 ”冲刷处理器缓存”的动作,并使读取共享变量的处理器执行刷新处理器缓存的动作来实现的。
那么,在Java中平台中我们如何保证可见性呢?上面举得例子中需要对实例变量声明加一个volatile关键字即可,这里volatile关键字所起到的作用是提示JIT编译器被修改的变量可能被多个线程共享,以阻止JIT编译器做成可能导致程序运行不正常的优化。另一个作用就是:

  • 读取一个volatile关键字修饰的变量会是相应的处理器执行”刷新处理器缓存“ 的动作。
  • 写一个volatile关键字修饰的变量会使相应的处理器执行” 冲刷处理器缓存“ 的动作。

另一方面,可见性问题与计算机的存储系统有关。程序中的变量可能会被分配到寄存器中(Register)而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。———所以Java层面抽象出了JMM内存模型规定所有变量都存储在主内存RAM当中!重要理解!重要理解!重要理解上面这句话!!

2.3、有序性问题

2.3.1、重排序概念

2.3.2、指令重排序

2.3.4、存储子系统重排序

3、MESI协议 - 硬件层面

3.1、MESI协议是什么?

3.2、MESI协议解决了什么问题?

MESI协议是硬件处理层面的相关协议, 主要解决数据一致性问题,三大问题中 可见性问题。

3.3、MESI 实现的原理/如何解决缓存一致性问题?


回到Java:引出 JMM

由于并发程序要比串行程序复杂很多,其中一个重要原因是并发程序中数据访问的一致性和安全性将会受到严重挑战。如何保证一个线程可以看到正确的数据呢?这个问题看起来很白痴。对于串行程序来说,根本就是小菜一碟,如果你读取一个变量,这个变量的值是1,那么你读到的一定是1,就是这么简单的问题在并行计算中居然变得复杂起来,事实上,如果不加控制的任由线程胡乱改,读取的值可能不是你所期望的。
因此,我们需要在深入了解并行机制的前提下,再定义一种规则,保证多个线程间可以有效地、正确地协同工作。而JMM也就是为此而生的。
JMM的关键技术点都是围绕着多线程的原子性、可见性、和有序性来建立的。因此我们首要必须了解这些概念。

4、JMM - Java语言层面

4.1、JMM 是什么?

image.png
image.png

4.2、JMM 如何保证三大特性的?

上面详细的介绍了高并发编程需要保证的三个主要特性,这对并发程序正确的运行有着至关重要的作用。
在本节中,我们将结合Java的内存模型来看看在Java的世界中,是通过何种方式来保证原子性、可见性、和有序性这三大特性的。
image.png

  1. JVM 采用内存模型的机制来屏蔽各个平台和操作系统之间内存访问的差异,以实现让Java程序在各种平台下达到一致的内存访问效果。
  2. Java的内存模型JMM规定了所有的变量都是存在于主内存RAM当中的,而每个线程都有自己的工作内存或者本地内存(这点像CPU的Cache)。

与上面这句话一致,另一方面,可见性问题与计算机的存储系统有关。程序中的变量可能会被分配到寄存器中(Register)而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。———所以Java层面抽象出了JMM内存模型规定所有变量都存储在主内存RAM当中!!重要理解!重要理解!重要理解上面这句话!

4.2.1、JMM与原子性

image.png

4.2.2、JMM与可见性

image.png
总结:volatile 关键字具有保证可见性的语义

4.2.3、JMM与有序性

image.png

5、volatile 关键字的深入解析

5.1、volatile关键字的语义

被volatile修饰的实例变量或者类变量具备如下两层语义。

  1. 保证了不同线程之间对共享变量操作时的可见性,也就是说一个线程修改volatile修饰的变量后,另外一个线程会立即看到最新的值。
  2. 禁止对指令进行重排序操作。

    5.1.1、理解volatile保证可见性

    关于共享变量在多线程间的可见性,在VolatileFoo例子中已经体现得非常透彻了,Updater线程对init_value变量的每一次更改都会使得Reader线程能够看到;
    在Happens-before规则中,第三条变量规则:对一个变量的写操作要早于对这个变量之后的读操作。其步骤具体如下:
  1. Reader线程 从主内存中获取init_value的值为 0 ,并且将其缓存到本地工作内存中。
  2. Updater线程 将init_value的值在本地工作内存中修改为1,然后立即刷新至主内存中。
  3. Reader线程在本地工作内存中的init_value 失效(反映到硬件上就是CPU的L1或者L2的 Cache Line 失效)。
  4. 由于Reader线程工作内存中的init_value 失效,因此需要到主内存中重新读取init_value的值。

    5.1.2、理解volatile保证顺序性

    volatile 关键字对顺序性的保证就比较霸道一点,直接禁止JVM和处理器对volatile关键字修饰的指令重排序,但是对于volatile前后无依赖关系的指令则可以随便怎么排序

    5.1.3、理解volatile不保证原子性

5.2、volatile的原理和实现机制

经过前面内容的学习,我们知道了volatile关键字可以保证可见性以及有序性,那它到底是如何保证做到呢?

image.png

5.3、volatile的使用场景

5.3.1、开关控制利用可见性的特点

5.3.2、状态标记利用顺序性的特点

5.3.3、单例double-checked利用顺序性的特点

image.png
image.png

5.3.4、volatile 和 synchronized

image.png

image.png