目录
- 1、为什么要有内存模型
- 1.1、为什么要有多级缓存?
- 1.2、缓存一致性问题
- 1.3、处理器优化和指令重排
- 2、并发编程的三大问题
- 2.1、原子性问题
- 2.2、有序性问题
- 2.3、可见性问题
- 2.4、三大特性
- 3、什么是内存模型?
- 3.1、概念
- 3.2、内存模型到底是怎么保证缓存一致性的呢?
- 3.3、缓存一致性协议 —— MESI 协议
- 4、什么是 Java 内存模型?
- 4.1、概念
- 4.2、实现
- 4.2.1、原子性
- 4.2.2、可见性
- 4.2.3、有序性
1、为什么要有内存模型
1.1、为什么要有多级缓存?
计算机在执行程序时,每条指令都是在 CPU 中执行的,而执行的时候,免不了和数据打交道。
在早期时,数据是存储在内存中。但是随着 CPU 技术的发展,CPU 的执行速度越来越快。而内存技术并没有太大的变化。所以,从内存中读取/写入数据的过程和 CPU 的执行速度比起来差距就会越来越大,这就导致 CPU 每次操作内存都要耗费很多等待时间
但是,不能因为内存的读写速度慢,就不发展 CPU 技术,不能让内存成为计算机处理的瓶颈。
所以,人们想到了一个办法:在 CPU 和内存之间增加高速缓存(特点是:速度快、存储空间小、价格昂贵)
程序执行过程就变成了:
当程序在运行过程中,会将运算需要的数据从内存复制一份到 CPU 的高速缓存中。那么,当CPU 进行计算时,就可以直接从它的高速缓存读取/写入数据;当运算结束之后,再将高速缓存中的数据刷新到内存当中
随着 CPU 能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。
按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为:一级缓存(L1),二级缓存(L2),部分高端 CPU 还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分【三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的】
有了多级缓存之后,程序的执行就变成了:
当 CPU 要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找
- 单核 CPU:只含有一套L1,L2,L3缓存
- 多核CPU:每个核都含有一套L1(甚至 L2)缓存,而共享L3(或者 L2)缓存
下图为一个单 CPU 双核的缓存结构:
1.2、缓存一致性问题
随着计算机能力不断提升,开始支持多线程。那么问题就来了。
分别来分析下单线程、多线程在单核 CPU、多核CPU中的影响:
- 单线程:CPU 核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题
- 单核CPU,多线程:进程中的多个线程会同时访问进程中的共享数据,CPU 将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突
- 多核CPU,多线程:每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的 cache 中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的 cache 之间的数据就有可能不同
在 CPU 和主存之间增加缓存,解决了 CPU 和主存速率不匹配的问题。但在多线程场景下就可能存在缓存一致性问题:在多核 CPU 中,每个核的的缓存中,关于同一个数据的缓存内容可能不一致
1.3、处理器优化和指令重排
除了上述情况,还有一种硬件问题也比较重要:为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理,这就是处理器优化
除了处理器会对代码进行优化处理,很多现代编程语言的编译器也会做类似的优化【为了提高性能】,比如像 Java 的即时编译器(JIT)会做指令重排序
从源码到最终执行的指令序列的示意图:
重排序可以分为三种类型:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题
2、并发编程的三大问题
在并发编程中有这三大问题:
- 原子性问题:处理器优化
- 可见性问题:缓存一致性问题
- 有序性问题:指令重排
2.1、原子性问题
原子性问题:多线程场景中操作如果不能保证原子性,会导致处理结果和预期不一致
线程是 CPU 调度的基本单位。CPU 有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。
因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃 CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作
在单线程中,一个读改写就算不是原子操作也没关系,因为只要这个线程再次被调度,这个操作总是可以执行完的。但是在多线程场景中可能就有问题了。因为多个线程可能会对同一个共享资源进行操作
如:i++ 操作 。一共有三个步骤:load
、add
、save
。共享变量就会被多个线程同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致
2.2、有序性问题
除了引入了时间片以外,由于处理器优化和指令重排等,CPU 还可能对输入代码进行乱序执行,比如:load => add => save 有可能被优化成 load => save => add 。这就是有序性问题
2.3、可见性问题
可见性问题就是上述的缓存一致性问题
2.4、三大特性
所以,在并发编程时,为了保证数据的安全,需要满足以下三个特性:
- 原子性:在一个操作中,CPU 不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行
- 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
- 有序性:序执行的顺序按照代码的先后顺序执行
3、什么是内存模型?
3.1、概念
上述问题是因硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢? —— 内存模型
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。 通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性【与处理器/缓存/并发/编译器有关】。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障
3.2、内存模型到底是怎么保证缓存一致性的呢?
为了解决前面提到的缓存数据不一致的问题,人们提出过很多方案,通常来说有以下 2 种方案:
- 通过在总线加 LOCK# 锁的方式:因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就是说阻塞了其他 CPU 对其他部件访问(如内存),从而使得只能有一个 CPU 能使用这个变量的内存。在总线上发出了 LCOK# 锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能从其内存读取变量,然后进行相应的操作
- 问题:在锁住总线期间,其他CPU无法访问内存,会导致效率低下
- 通过缓存一致性协议(Cache Coherence Protocol)【MESI 协议】:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取,保证了每个缓存中使用的共享变量的副本是一致的
3.3、缓存一致性协议 —— MESI 协议
在 MESI 协议中,每个缓存可能有有 4 个状态,它们分别是:
- M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本 Cache 中
- E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本 Cache 中
- S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多 Cache 中
- I(Invalid):这行数据无效
传统的 MESI 协议中有两个行为的执行成本比较大:
- 将某个 Cache Line 标记为 Invalid 状态
- 当某 Cache Line 当前状态为 Invalid 时写入新的数据
所以,CPU 通过 Store Buffer 和 Invalidate Queue 组件来降低这类操作的延时。如图:
当一个 CPU 进行写入时,首先会给其它 CPU 发送 Invalid 消息,然后把当前写入的数据写入到 Store Buffer 中。然后异步在某个时刻真正的写入到 Cache 中。当前 CPU 核如果要读 Cache 中的数据,需要先扫描 Store Buffer 之后再读取 Cache。但是,此时其它 CPU 核是看不到当前核的 Store Buffer 中的数据的,要等到 Store Buffer 中的数据被刷到了 Cache 之后才会触发失效操作
而当一个 CPU 核收到 Invalid 消息时,会把消息写入自身的 Invalidate Queue 中,随后异步将其设为 Invalid 状态,和 Store Buffer 不同的是,当前 CPU 核使用 Cache 时并不扫描 Invalidate Queue 部分,所以可能会有极短时间的脏读问题。
MESI 协议,可以保证缓存的一致性,但是无法保证实时性
4、什么是 Java 内存模型?
4.1、概念
同一套内存模型规范,不同语言在实现上可能会有些差别。
Java 内存模型就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范
Java内存模型,一般指的是 JDK 5 开始使用的新的内存模型,主要由 JSR-133: JavaTM Memory Model and Thread Specification 描述
JSR133中文版1.pdf
Java内存模型规定:所有的变量都存储在主内存中,每条线程还有自己的工作内存/本地内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行
如下图:
JMM 就作用于工作内存和主存之间数据同步过程。它规定了如何做数据同步以及什么时候做数据同步
如:两个线程都对一个共享变量进行操作,共享变量初始值为 1,每个线程都变量进行加 1,预期共享变量的值为 3。在 JMM 规范下会有一系列的操作:
为了更好的控制主内存和本地内存的交互,Java 内存模型定义了八种操作来实现:
- lock:锁定。作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock:解锁。作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read:读取。作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load:载入。作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use:使用。作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
- assign:赋值。作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
- store:存储。作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作
- write:写入。作用于主内存的变量,它把 store 操作从工作内存中一个变量的值传送到主内存的变量中
如下图:
总结:JMM 是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性
4.2、实现
在 Java 中,提供了一系列和并发处理相关的关键字,比如 volatile
、synchronized
、final
、concurren 包
等。其实这些就是 Java 内存模型封装了底层的实现后提供给程序员使用的一些关键字。
所以,Java 内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用
4.2.1、原子性
synchronized
关键字保证原子性【底层通过 monitorenter
和 monitorexit
字节码指令实现】。
在 Java 中,可以使用 synchronized
来保证方法和代码块内的操作是原子性的
4.2.2、可见性
volatile
关键字提供的功能:被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。 可以使用 volatile
来保证多线程操作时变量的可见性
- synchronized:获取锁时,会清除在工作内存中的所有共享变量的副本,并重新从主内存中读取。当一个线程修改了共享变量后,它必须释放锁,并把修改后的值刷新到主内存中,以便其他线程可以看到最新的值
- final:在初始化后就不会更改,所以只要在初始化过程中没有把 this 指针传递出去也能保证对其他线程的可见性
4.2.3、有序性
- volatile:禁止指令重排
- synchronized:保证同一时刻只允许一条线程操作