写了那么久的博客,始于Python爬虫,目前专于Java学习,终于有了属于自己的小窝,欢迎各位访问我的个人网站,未来我们一起交流进步。
CPU、内存、I/O设备三者的处理速度差异很大,其中 CPU 处理速度最高,I/O设备速度最差。而一个系统中会同时用到这三者,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。
为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统都做出了贡献,主要体现为:
- CPU 增加了缓存,以均衡与内存的速度差异;
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
- 处理器优化指令执行次序,使得缓存能够得到更加合理地利用。
那么接下来让我们看看计算机究竟做了什么贡献,关于进程和线程留到 Java 并发中再做介绍。
硬件内存结构
我们当前使用的计算机硬件架构为冯·诺依曼体系结构,如下图所示:
而现代硬件内存结构则是针对上图中间那块内容进行细致划分,我们看一下现代硬件内存模型。
- CPU:它是计算机最重要的核心配件,叫中央处理器(Central Processing Unit),一个现代计算机通常由两个或者多个CPU。
- 寄存器:是 CPU 里的存储单元,大小有限,将寄存器内的数据执行算术及逻辑运算。CPU在寄存器上执行操作的速度远大于在主存上执行的速度。这是因为 CPU 访问寄存器的速度远大于主存。
- CPU缓存:CPU 缓存是位于 CPU 与内存之间的临时数据交换器,它的容量比内存小的多,但是交换速度却比内存要快得多。CPU缓存一般直接跟 CPU 芯片集成或位于主板总线互连的独立芯片上。为了解决 CPU 运算速度与内存读写速度不匹配的矛盾,就出现了CPU缓存。
- RAM:RAM 是存储器的一种。RAM 代表随机存取存储器,即我们所说的主内存,ROM 代表只读存储器,又称之为外存,常见的就是磁盘。我们都用过手机,RAM 相当于手机运行内存,以前的 SD 卡则是 ROM。
- 工作原理:首先需要将磁盘中的内容加载到主存,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。
为了区分寄存器、 CPU 缓存、RAM 和 ROM,我在网上找到这样一张图,供大家参考。
CPU 缓存一般又分为 L1、L2、L3 折三层,这里就不具体讲了。图中的内存指的就是 RAM,硬盘指的是 ROM。
它们四者的区别,还体现在存储材质、价格等方面,如下图所示:
需要注意的是,CPU 并不是直接和每一种存储器设备打交道,而是每一种存储器设备,只和它相邻的存储设备打交道。比如,CPU Cache 是从内存里加载而来的,或者需要写回内存,并不会直接写回数据到硬盘,也不会直接从硬盘加载数据到 CPU Cache 中,而是先加载到内存,再从内存加载到 Cache 中。
了解了计算机硬件内存结构后,我们来看一下指令乱序处理和高速缓存和主内存之间的数据交互。
指令乱序执行
我们常说,CPU 就是计算机的大脑。CPU 的全称是 Central Processing Unit,中文是中央处理器。
从硬件的角度来看,CPU 就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各种各样的处理逻辑。从软件工程师的角度来讲,CPU 就是一个执行各种计算机指令(Instruction Code)的逻辑机器。
一个计算机程序,不可能只有一条指令,而是由成千上万条指令组成的。为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化。整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。
乱序执行,极大地提高了 CPU 的运行效率。核心原因是,现代 CPU 的运行速度比访问主内存的速度要快很多。如果完全采用顺序执行的方式,很多时间都会浪费在前面指令等待获取内存数据的时间里。CPU 不得不加入 NOP 操作进行空转。而现代 CPU 的流水线级数也已经相对比较深了,到达了 14 级。这也意味着,同一个时钟周期内并行执行的指令数是很多的。
数据更新问题
如今的计算机通常由两个或者多个CPU。其中一些CPU还有多核。从这一点可以看出,在一个有两个或者多个CPU的现代计算机上同时运行多个线程是可能的。每个CPU在某一时刻运行一个线程是没有问题的。
我们现在用的 Intel CPU,通常都是多核的的。每一个 CPU 核里面,都有独立属于自己的 L1、L2 的 Cache,然后再有多个 CPU 核共用的 L3 的 Cache、主内存。因为 CPU Cache 的访问速度要比主内存快很多,而在 CPU Cache 里面,L1/L2 的 Cache 也要比 L3 的 Cache 快。
如上图所示,多核 CPU 里的每一个 CPU 核,都有独立的属于自己的 L1 Cache 和 L2 Cache。多个 CPU 之间,只是共用 L3 Cache 和主内存。
CPU 优先访问 CPU Cache,对于数据,计算机不光要读,还要去写入修改。那么就引发了两个问题。
第一个问题是,写入 Cache 的性能也比写入主内存要快,那我们写入的数据,到底应该写到 Cache 里还是主内存呢?如果我们直接写入到主内存里,Cache 里的数据是否会失效呢?
之前学习高并发之缓存策略时,了解到有三种缓存策略,分别是:
- Cache Aside(旁路缓存)策略。该策略进行写入时,先更新数据库中的记录,然后删除缓存中的记录。
- Read/Write Through(读穿 / 写穿)策略。Write Through 的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,则更新到数据库中。
- Write Back(写回)策略。这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。
而高速缓存与主内存之间的写策略,对应上述三种的后两者。
写直达(Write-Through)
在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。
写回(Write-Back)
这个策略里,我们不再是每次都把数据写入到主内存,而是只写到 CPU Cache 里,同时标记 CPU Cache 里的这个 Block 是脏(Dirty)的。只有当 CPU Cache 里面的数据要被“替换”的时候,我们才把数据写入到主内存里面去。(如果加载内存里面的数据到 Cache 的时候,发现 Cache Block 里面有脏标记,我们也要先把 Cache Block 里的数据写回到主内存,才能加载数据覆盖掉 Cache。)
第二个问题是,当我们在写入修改缓存后,多个线程,或者是多个 CPU 核的缓存一致性的问题。
缓存一致性
这里我们先探讨多个 CPU 核的缓存一致性问题。
我们拿一个有两个核心的 CPU,来看一下。
比方说,iPhone 降价了,我们要把 iPhone 最新的价格更新到内存里。为了性能问题,它采用了上一讲我们说的写回策略,先把数据写入到 L2 Cache 里面,然后把 Cache Block 标记成脏的。这个时候,数据其实并没有被同步到 L3 Cache 或者主内存里。1 号核心希望在这个 Cache Block 要被交换出去的时候,数据才写入到主内存里。
这个时候,2 号核心尝试从内存里面去读取 iPhone 的价格,结果读到的是一个错误的价格。这个问题,就是所谓的缓存一致性问题,1 号核心和 2 号核心的缓存,在这个时候是不一致的。
为了解决这个缓存不一致的问题,我们就需要有一种机制,来同步两个不同核心里面的缓存数据。该机制需要满足以下两点要求:
第一点叫写传播(Write Propagation)。写传播是说,在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。这一点比较容易理解,某个 CPU 核心更新完毕,同步到其他 CPU 核的 Cache 中。
第二点叫事务的串行化(Transaction Serialization),事务串行化是说,我们在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。如果某个 CPU 核心接收到来自多个其他 CPU 核的更新操作,那么执行顺序将按照时序串行执行。
关于第二点,我们通过一个案例进行学习。
一个有 4 个核心的 CPU,1 号核心先把 iPhone 的价格改成了 6000 块。差不多在同一个时间,2 号核心把 iPhone 的价格改成了 5000 块。这里两个修改,都会传播到 3 号核心和 4 号核心。
然而这里有个问题,3 号核心先收到了 2 号核心的写传播,再收到 1 号核心的写传播。所以 3 号核心看到的 iPhone 价格是先变成了 6000 块,再变成了 5000 块。而 4 号核心呢,是反过来的,先看到变成了 5000 块,再变成 6000 块。虽然写传播是做到了,但是各个 Cache 里面的数据,是不一致的。
实际上,我们需要更新操作,先更新变成 6000 块,再变成 5000 块。这样,我们才能称之为实现了事务的串行化。
在上述例子中,1号核心和2号核心几乎同时更新,核心的 Cache 中都有 iPhone 的值,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。
接下来,我们就看看实现了这两点要求的 MESI 协议。
总线嗅探机制和 MESI 协议
要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题。最常见的一种解决方案呢,叫作总线嗅探(Bus Snooping)。这个策略,本质上就是把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。(嗅探这个词有点熟悉哈,在学习 volatile 关键字时会遇到,该关键字背后的实现原理原来就是基于该方案,现在才算是深入底层学习了)
基于总线嗅探机制,其实还可以分成很多种不同的缓存一致性协议。不过其中最常用的,就是今天我们要讲的 MESI 协议。和很多现代的 CPU 技术一样,MESI 协议也是在 Pentium 时代,被引入到 Intel CPU 中的。
MESI 协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个“失效”版本的 Cache Block,然后把这个也标记成失效的就好了。
如下图所示:
相对于写失效协议,还有一种叫作写广播(Write Broadcast)的协议。在那个协议里,一个写入请求广播到所有的 CPU 核心,同时更新各个核心里的 Cache。
写广播在实现上自然很简单,但是写广播需要占用更多的总线带宽。写失效只需要告诉其他的 CPU 核心,哪一个内存地址的缓存失效了,但是写广播还需要把对应的数据传输给其他 CPU 核心。
MESI 协议的由来呢,来自于我们对 Cache Line 的四个不同的标记,分别是:
- M:代表已修改(Modified)
- E:代表独占(Exclusive)
- S:代表共享(Shared)
- I:代表已失效(Invalidated)
关于上述四种标记,我们详细讲述一下。首先是“已修改”和“已失效”,假设有两个 CPU,即1号 CPU和2号 CPU,拥有共享变量 name 的 Cache,当 1号 CPU 修改了 name 值,那么 1号 CPU 的 Cache Block 就是“脏”的,但是未同步到主内存中,即“已修改”状态。而 2号 CPU 中 Cache Block 里的数据就是旧值,即“已失效”状态。
无论是独占状态还是共享状态,缓存里面的数据都是“干净”的。这个“干净”,自然对应的是前面所说的“脏”的,也就是说,这个时候,Cache Block 里面的数据和主内存里面的数据是一致的。
“独占”状态指的是主内存的某个变量,仅被一个 CPU 所使用,加载到了它的 Cache 里。这个时候,如果要向独占的 Cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核。
那么就容易理解“共享”状态了,指的是同样的数据在多个 CPU 核心的 Cache 里都有。如果某个 CPU 核要更新数据,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是获取当前对应 Cache Block 数据的所有权。
“独占”状态如何转变为“共享”状态呢?当独占状态下的数据,收到一个来自于总线的读取对应缓存的请求,它就会变成共享状态。即有另外一个 CPU 要加载该数据到自己的 Cache里。
总结
本文主要讲述了一些计算机组成的知识,为了后续学习 Java 并发做一下铺垫,这里提前预热一下。比如说:
- 计算机硬件内存结构中,高速缓存与主内存之前的关系与 Java 内存模型中工作内存与主内存的联系很接近;
- 处理器为了保证 CPU 的高效率,采用了指令乱序技术。Java 虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化;
- 计算机是如何解决高速缓存与主内存之间的数据一致性问题,这为后续 Java 内存模型的工作提供了基础。
后续我们将正式进入 Java 高并发的学习,一起努力吧。