1.1说说冯诺依曼结构

计算机基础结构分为5个部分,分别是中央处理器(CPU)、内存、输入设备、输出设备、总线。
image.png
(1)内存:我们的程序和数据都在存储在内存中,数据存储的单位是一个二进制位,即0或1。最小的存储单位是字节(byte),1字节等于8位。内存的地址是从0开始编写的,然后自增排列,最后一个地址为内存总字节数-1,这种结构就好像是我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。
(2)中央处理器(CPU):32位CPU一次可以计算4个字节;64位CPU一次可以计算8个字节;之所以CPU要这么设计,是为了能计算更大的数值,如果是8位的CPU,那么一次计算只能计算的最大整数是2^8-1,这样就无法一次完成10000*500的计算,于是为了能一次计算大数的运算,CPU需要支持多个byte一起计算,所以CPU位宽越大,可以计算的数组就越大,比如说32位CPU能计算的最大整数是2^32-1。
CPU内部还有一些组件,常见的有寄存器、控制单元、逻辑运算单元等,其中控制单元负责CPU工作,逻辑运算单元负责计算。寄存器的种类:
1.通用寄存器:用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。
2.程序计数器:用来存储CPU要执行下一条指令所在的内存地址。指令还在内存中,程序计数器只是存储了下一条指令的地址。
3.指令寄存器:用来存放程序计数器指向的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。
(3)总线:用于CPU和内存以及其他设备之间的通信
1.地址总线:用于指定CPU将要操作到的内存地址。
2.数据总线:传输内存的数据。
3.控制总线:用于发送和接收信号,比如中断、设备复位等信号,CPU收到信号后自然响应,这时也需要控制总线。
当CPU要读写内存数据的时候,一般需要两个总线:首先通过地址总线来指定内存的地址,再通过数据总线来传输数据。
(4)输入、输出设备:输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。期间,如果输入设备是键盘,按下按键时需要和CPU进行交互的,这时需要用到控制总线。

1.2说说程序执行的基本过程

image.png
程序实际上就是一条条的指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是CPU了。
(1)CPU读取程序计数器的值,这个值是指令的内存地址,然后CPU的【控制单元】操作【地址总线】指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过【数据总线】将指令数据传给CPU,CPU收到内存传来的数据后,将这个指令存入到【指令寄存器】。
(2)CPU分析【指令寄存器】中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给【逻辑运算单元】运算;如果是存储类型的指令,则交给【控制单元】执行。
(3)CPU执行完指令后,【程序计数器】的值自增,表示指向下一条指令。这个自增的大小,由CPU的位宽决定,如果是32位的CPU,指令是4个字节,需要4个内存地址存放,因此【程序计数器】的值会自增4。
image.png
四个阶段的具体含义:
(1)CPU通过程序计数器读取对应内存地址的指令,这个部分称为取指令(Fetch);
(2)CPU对指令进行解码,这个部分称为指令译码(Decode);
(3)CPU执行指令,这个部分称为执行指令(Execution);
(4)CPU将计算结果存回寄存器或者将寄存器的值存入内存,这个部分称为数据回写(Store)。

1.3说说存储器的层次结构?

CPU Cache通常会分为L1、L2、L3三层,其中L1 Cache通常分为【数据缓存】和【指令缓存】,L1是距离CPU最近的,因此它比L2、L3的读写速度都快、存储空间都小。我们大脑中短期记忆,就好比L1 Cache,而长期记忆就好比L2/L3 Cache。寄存器和CPU Cache都是在CPU内部,跟CPU挨着很近,因此它们的读写速度都相当得快,但是能存储的数据较少。
image.png
对于存储器,它的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。CPU里的寄存处和Cache,是整个计算机存储器中价格最贵的,虽然存储空间很小,但是读写速度是极快的,而相比比较便宜的内存和硬盘,速度肯定比不上CPU内部的存储器,但是能弥补存储空间的不足。
image.png
【L1高速缓存】:
L1高速缓存的访问速度几乎和寄存器一样快,通常只需要2-4个时钟周期,而大小在几十KB到几百KB不等。每个CPU核心都有一块属于自己的L1高速缓存,指令和数据在L1是分开存放的,所以L1高速缓存通常分成指令缓存和数据缓存。
【L2高速缓存】:
L2高速缓存同样每个CPU核心都有,但是L2高速缓存位置比L1告诉缓存距离CPU核心更远,它大小比L1高速缓存更大,CPU型号不同大小也就不同,通常大小在几百KB到几MB不等,访问速度则更慢,速度在10-20个时钟周期。
【L3高速缓存】:
L3高速缓存通常是多个CPU核心共用的,位置比L2高速缓存距离CPU核心更远,大小也会更大些,通常大小在几MB-几十MB不等,具体值根据CPU型号而定。访问速度相对也比较慢一些,访问速度在20-60个时钟周期。
【内存】:
内存相比于CPU Cache的存储容量更大,但是访问速度会更慢,内存速度大概在200-300个时钟周期之间。
image.png
【SSD/HDD硬盘】:
SSD就是固定硬盘,结构与内存相似,但是它相比内存的优点是断点后数据还是存在的,而内存、寄存器、高速缓存断电后数据都会丢失,内存的读写速度比SSD大概快10-1000倍。
传统的机械硬盘,是通过物理读写的方式来访问数据的,因此它的访问速度是非常慢的,它的速度比内存慢10W倍左右。

1.4CPU缓存一致性

数据不光只有读操作,还有写操作,如果数据写入Cache后,内存与Cache相对应的数据不同了,就需要把Cache中的数据同步到内存里。两种数据写回的方法:写直达、写回。
【写直达】:保持内存与Cache一致性的最简单的方式是,把数据同时写入内存和Cache中,这种方式是写直达 image.png
【写回】:写直达由于每次写操作都会把数据写回内存,导致影响性能,于是为了减少数据写回内存的频率,就出现了写回的方法。在写回机制中,当发生写操作时,新的数据仅仅被写入Cache Block里,只有当修改过的Cache Block被替换时才需要写内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。
image.png

1.5缓存一致性问题

要实现缓存一致性,就必须保证以下两点:
(1)某个CPU核心里的Cache数据更新时,必须要传播到其他核心的Cache,这个称为写传播;
(2)某个CPU核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串行化;
打个比方,现在我们有一个含有4个核心的CPU,这4个核心都操作共同的变量i。A号核心先把i变量值变为100,而此时同一时间,B号核心先把i值变为200,这里两个修改,都会传播到C和D号核心。
image.png
那么现在问题就来了,C号核心先收到了A号核心更新数据的事件,再收到B号核心更新数据的事件,因此C号核心看到变量i是先变成100,再变成200。而如果D号核心收到的事件是反过来的,则D号核心看到变量i先变成200,再变成100,虽然是做到了写传播,但是各个Cache里面的数据还是不一致的。
所以,我们要保证C号核心和D号核心都能看到相同顺序的数据变化,比如变量i都是先变成100,再变成200,这样的过程就是事务的串行化。所以要实现事务串行化,就必须要做到两点:(1)CPU核心对于Cache中数据的操作,需要同步给其他CPU核心;(2)要引入锁的概念,如果两个CPU核心里有相同数据的Cache,那么对于这个Cache数据的更新,只有拿到了锁,才能进行对应的数据更新。
【总线嗅探】
写传播的原则就是当某个CPU核心更新了Cache中的数据,要把该事件广播给其他核心。最常见的方式就是总线嗅探。打个比方:
A号CPU核心修改了L1Cache中i变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个CPU核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的L1Cache里面,如果B号CPU核心的L1Cache中有该数据,那么也需要把该数据更新到自己的L1 Cache。总线嗅探的方法很简单,CPU需要每时每刻监听总线上的一切活动,但是不管别的核心的Cache是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。另外,总线嗅探只是保证了某个CPU核心的Cache更新数据这个事件能被其他CPU核心知道,但是并不能保证事务串行化。
【MESI协议】
MESI协议基于总线嗅探的机制实现了事务串行化,也用状态机机制降低了总线带宽压力,协议做到了CPU缓存一致性。举个例子:
(1)当A号核心从内存读取变量i的时候,数据被缓存在A号核心自己的Cache里面,此时其他CPU核心的Cache没有缓存该数据,于是Cache Line状态为独占,此时其Cache中的数据与内存是一致的。
(2)然后B号核心也从内存读取变量i的值,此时会发送消息给其他CPU核心,由于A号CPU核心已经缓存了该数据,所以会把数据返回给B号核心。这个时候,A和B核心缓存了相同的数据,Cache Line的状态就会变成共享,并且其Cache中的数据与内存也是一致的;
(3)当A号核心要修改Cache中变量i的值,发现数据对应的Cache Line的状态是共享状态,则要向所有其他的CPU核心广播一个请求,要求先把其他核心的Cache中对应的Cache Line标记为无效状态,然后A号核心才更新Cache里面的数据,同时Cache Line为已修改状态,此时Cache中的数据就与内存不一致了。
(4)如果A号核心继续修改Cache中i变量的值,由于此时Cache Line是已修改状态,因此不需要给其他CPU核心发送消息,直接更新数据即可。
(5)如果A号核心的Cache里i变量对应的Cache Line要被替换,发现Cache Line状态是已修改状态,就会在替换前先把数据同步到内存。
所以,可以发现当Cache Line状态是已修改或者是独占状态时,修改更新其数据不需要发送广播给其他CPU核心,这在一定程度上减轻了总线带宽压力。

1.6说说缓存行与伪共享?

缓存行:Cache中的数据是按块读取的,当CPU访问某个数据时,会将该数据连同附近区域的数据(共64字节)一起读取进缓存中,那么这一块数据成为一个缓存行。缓存系统是以缓存行为单位存储的,目前主流的CPU Cache的Cache Line大小都是64字节。
伪共享:举个例子
假设有个双核心的CPU,这两个CPU核心并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为long的变量A和B,这两个数据的地址在物理内存上是连续的,且这两个数据是位于同一个Cache Line中,那么这两个数据会被同时读入到两个CPU核心各自Cache中。
image.png
(1)最开始变量A和B都还不在Cache里面,假设1号核心绑定了线程A,2号核心绑定了线程B,线程A只会读写变量A,线程B只会读写变量B。
(2)1号核心读取变量A,由于CPU从内存读取数据到Cache的单位是Cache Line,也正好变量A和变量B的数据归属于同一个Cache Line,所以A和B的数据都会被加载到Cache,并将此Cache Line标记为独占状态。
(3)接着2号核心开始从内存中读取变量B,同样的也是读取Cache Line大小的数据到Cache中,此Cache Line中的数据页包含了变量A和变量B,此时1号核心和2号核心得到Cache Line状态变为共享状态。
(4)1号核心需要修改变量A,发现此Cache Line的状态是共享状态,所以先需要通过总线发送消息给2号核心,通知2号核心把Cache中对应的Cache Line标记为已失效状态,然后1号核心对应的Cache Line状态变为已修改状态,并且修改变量A。
(5)之后,2号核心需要修改变量B,此时2号核心的Cache中对应的Cache Line是已失效状态,另外由于1号核心的Cache也有此相同的数据,且状态为已修改状态,所以要先把1号核心的Cache对应的Cache Line写回到内存,然后2号核心再从内存读取Cache Line大小的数据到Cache中,最后把变量B修改到2号核心的Cache中,并将状态标记为已修改状态。
可以发现如果1号和2号CPU核心这样持续交替的分别修改变量A和B,就会重复4和5这两个步骤,Cache没有起到缓存的效果,虽然变量A和B之间其实并没有任何的关系,但是因为同时归属于一个Cache Line,这个Cache Line中的任意数据被修改后,都会相互影响。
伪共享:多个线程同时读写同一个Cache Line的不同变量时,而导致CPU Cache失效的现象称为伪共享。

1.7说说怎么解决伪共享的问题的?

对于多个线程共享的热点数据,应该避免这些数据刚好在同一个缓存行中,否则就会出现伪共享的问题。结构体里的两个成员变量a和b在物理内存地址上是连续的,于是它们可能就位于同一个Cache Line中。
image.png
避免Cache伪共享实际上是用空间换时间的思想,浪费一部分的Cache空间,从而换来性能上的提升。
image.png
有一个Java并发框架Disrupt使用字节填充的方式,来避免伪共享的问题。Disruptor中有一个RingBuffer类会经常被多个线程使用,代码如下:
image.png
RingBufferPad类里有7个long类型的变量,他们对性能的提升起到了至关重要的作用。缓存行的大小是64个字节,long类型的变量是8个字节,所以cpu一下子会加载7个long类型的无用变量和1个long类型的实际数据。
image.png
并且RingBufferFields里面定义的这些变量都是final修饰的,意味着第一次加载之后不会再修改。又由于前后各填充了7个不会被读写的long类型变量,所以无论怎么加载Cache Line,这整个Cache Line里都没有会发生更新操作的数据,于是只要数据被频繁地读取访问,就自然没有数据被换出Cache的可能,也因此不会产生伪共享的问题。

1.8说说什么是中断、什么是软中断?什么是硬中断?

举个例子,我在刷力扣的时候肚子饿了点了个外卖,点完了以后就一直在刷力扣,等外卖员给我打电话。当我收到电话后,我就需要放下手中的力扣题,然后下楼的接外卖。这里的接收到外卖员的电话对应的就是计算机中的中断。中断就是一种异步的事件处理机制,可以提高系统的并发处理能力,操作系统收到了中断请求后,会打断其他进程的进行,来尽快处理中断处理程序,尽可能快得执行完,这样可以减少对正常进程运行调度的影响。
中断处理程序在响应中断时,可能还会【临时关闭中断】。意思是,在当前中断处理程序没有执行完之前,系统中其他的中断请求都无法被响应,中断有可能会丢失,所以中断处理程序要短且快。打个比方,我在刷力扣题,点了两份外卖,分别是一份小龙虾和两瓶啤酒,这两份外卖是不同的外卖员来配送的,如果我先收到了小龙虾外卖员的电话,放下手里的力扣题,这时候他打电话和我说了一堆,那在打电话期间如果啤酒的外卖员也打电话过来,就有可能造成中断丢失。所以说,就必须要尽快处理中断程序,避免中断丢失。所以就提出了软中断的概念。要解决这个问题就是,小龙虾外卖员打电话过来的时候,让他下楼等着,有啥事到那再说,不能一直占用着电话的资源;
【软中断】:
Linux系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分为了两个阶段,分别是:
(1)上半部分(硬中断)用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或时间敏感的事情。
(2)下半部分(软中断)用来延迟处理上半部分尚未完成的工作,一般以内核线程的方式运行。
【常见例子】:
当网卡接收网络包后,会通过中断信号通知内核有新的数据到了,内核会调用对应的中断处理程序来响应这个事件,这个事件的处理分为两部分:上部分要做到快速处理,所以只要把网卡的数据读到内存中,然后更新一下硬件寄存器的状态,比如把状态更新为表示数据已经读到内存中的状态值;接着,内核会触发一个软中断,把一些处理比较耗时且复杂的工作,交给软中断处理程序来做,从内存中找到网络数据,再按照网络协议栈,对网络数据进行解析、处理,最后把数据给应用程序。