计算机整体构成
CPU、内存、IO设备之间通过总线进行数据传输。
CPU=控制器+运算器=数据寄存器(registers)+指令寄存器(pc)+运算器(alu)+缓存(cache)
指令寄存器从内存中取出指令,将对应地址中的数据存储在数据寄存器,交给alu运算,运算结果存储在内存。有的指令也可能自己携带数据。
CPU指令执行逻辑与MIPS指令
CPU由控制器和计算器(ALU)组成。运算器负责对数据的加工,即对数据进行算术和逻辑运算;控制器是整个系统的指挥中枢,对整个计算机系统进行有效的控制,包括指令控制、操作控制、时间控制和中断管理。数据单元是一堆数据寄存器。寄存器是具有存储功能的部件是寄存器,即CPU与内存的数据交换本质为寄存器与内存的数据交换。
cpu工作实际是从系统的RAM中提取指令,随后解码该指令的实际内容,最后再由CPU的相关部分执行该指令。具体来讲,是取指令、指令译码、执行指令、访存取数、结果写回的流程。
取指令阶段就是将内存中的指令读取到CPU中寄存器的过程,程序起存起用语存储下一条指令所在的地址;
在取指令完成后,立马进入指令译码阶段,在指令译码阶段,指令编码器按照预先的指令格式,对取回的指令进行拆分和解释,识别区分出不同的指令类别和各种获取操作数的方法;
执行指令阶段的任务是完成指令所规定的各种操作,具体实现指令的功能;
访问取数阶段的任务是:根据指令地址码,得到操作数在主存中的地址,并从主存中读取该操作数用于运算;
结果写回阶段作为最后一个阶段,把执行指令阶段的运行结果数据“写回”到某种存储形式:结果数据经常被写到CPU的内部寄存器中,以便被后续的指令快速地存取。
cpu可以解析的指令叫做MIPS指令是计算机CPU提取执行的最基本命令,由32位二进制数组成。MPIS指令集有三种格式:R型、I型、J型,区别是不同的位数存放不同内容。
R型指令用于寄存器之间的操作,如加减乘除。rs、rt分别表示源寄存器1和源寄存器2,rd表示目标寄存器,shamt字段用于移位操作,funct字段指定具体的操作类型。例如,add $d, $s, $t指令就是一种R型指令
I型计算类指令用于计算类、存取数操作、条件判断(有符号的16bit)。Immediate中存立即数,或地址。
I型计算类其操作码为6位,rs字段代表第一个操作数寄存器,rt字段代表第二个操作数寄存器,立即数字段表示立即数操作数。这类指令执行时,先将第一个操作数从寄存器中取出,再将立即数或者第二个操作数从寄存器中取出,经过计算后将结果存储到目标寄存器中。
I型指令用于存取数操作时,rs表示源寄存器,rt表示目标寄存器,immediate表示立即数。例如,
addi $t, $s, imm
指令就是一种I型计算类指令。I型指令用于条件判断时,rs表示源寄存器,rt表示目标寄存器,offset表示偏移量,根据rs中的值进行条件分支操作。例如,
beq $s, $t, offset
指令就是一种I型条件判断类指令。
J型指令是直接跳转指令,操作功能是op,后面有26位地址,如
j、jal
等。
因为机器只能读取二进制的指令,因此想让高级编程语言运行,或想让人的语言运行,必须要通过一些约定俗成的别名将高级编程语言转化为二进制,这个过程又叫汇编。
CPU的三级缓存模型与MESI协议
三级缓存是一个什么样的架构
多核CPU,每个CPU核心各自拥有L1、L2,所有核心共用L3。如果有多个CPU,L3也会有多个。
CPU读取数据先从L1读,读不到去L2找,还读不到去L3找
越靠近CPU核心的缓存读写越快,访问L1为2-4个时钟周期,L2为10-20个时钟周期,L3为20-60个时钟周期,内存有200-300个时钟周期
超线程:一核CPU中,可能有数据寄存器A和指令寄存器A存储线程A所需的内容,数据寄存器B和指令寄存器B存储线程B所需的内容,运算单元ALU在组A和组B间来回切换运算,叫超线程
缓存行 - 三级缓存数据读写单元
当cpu从三级缓存中找不到数据,就会去内存读取,然后写入三级缓存。读取内存数据的时候不是一位一位,而是一块一块。这个块叫做Cache Line,64位计算机中Cache Line大小为64字节。
可以通过以下指令在linux系统中查看cache line大小:
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
可以通过以下指令查看三级缓存大小:
cat /sys/devices/system/cpu/cpu0/cache/index0/size -- L1指令缓存32k
cat /sys/devices/system/cpu/cpu0/cache/index1/size -- L1数据缓存32k
cat /sys/devices/system/cpu/cpu0/cache/index2/size -- L2数据缓存1024k
cat /sys/devices/system/cpu/cpu0/cache/index3/size -- L3数据缓存30976k
cpu写入缓存时,因为一次写入一个Cache Line大小,因此当使用的数据只有一小部分,也会写入相邻存储空间整个Cache Line的数据读取出来,而当读取后续数据时,因为三级缓存中已经有了,就无需重复读取,这是重要的性能优化点,比如:
有一个int array[100],int占4字节,当载入array[0]时,cpu一次加载一个Cache Line大小即array[0]到array[15],下次读取array[1]就无需重复读取。
缓存行Cache Line的组成是头标志Tag+数据块Data Block
写回 - 三级缓存数据写入内存的原理
当cpu读取三级缓存数据运算后产生新的结果时,需要同步更新三级缓存和内存,如果这时采取 同时直接写入的方式,效率很低。目前使用的是Write Back写回机制。
写回机制:cpu执行写操作时,先判断三级缓存中的数据跟运算的数据是不是一个内存地址存放的数据。
如果是一个,则直接写入三级缓存,然后标记为dirty
如果不是一个,则判断当前三级缓存中那份数据是不是dirty
如果是,则将当前三级缓存中那份数据写入内存,再把cpu运算结果写入三级缓存,同时标记为dirty
如果不是,则直接把cpu运算结果写入三级缓存,同时标记为dirty
当cpu核心多时,每个核心用自己的L1和L2,如果两个核心共同修改同一个变量,或一个修改array[0],一个修改array[1],但因为Cache Line两个核心都会读到这两个数据,就会导致缓存一致性问题。
MESI原则 - intel架构下的缓存一致性原则
保证缓存一致性的方案是写点播+事务串行化
写点播:一个核心的写操作,对所有cpu所有核心可见
事务串行化:一个核心计算顺序,对所有cpu所有核心可见且顺序一致
总线嗅探机制就是cpu将所有写操作进行总线广播,同时监听总线上的所有活动。但这种效率是不高的且总线负载高。
MESI协议基于总线嗅探开发,通过状态机制实现了缓存的一致性且降低总线带宽压力。MESI是四个状态单词,用来表示Cache Line的状态:
Modified:已修改,即dirty状态,即三级缓存已更新,还没写回内存
Exclusive:独占,我保存的Cache Line是干净的,且只有我有保存。
如果有其他核心读取了该数据,状态由独占切换为共享。
Shared:共享,我保存的Cache Line是干净的,但别人也有。
共享状态下更新数据不能直接更新,要先向其他CPU广播一个请求将数据更新为已失效状态。
Invalidated:已失效,即我保存的Cache Line被别人修改了,已失效。
变成已失效状态后,别的核心怎么操作数据已跟我无关。
当我需要重新读取该数据但发现已是无效状态,将重新去内存中读取
指令重排
CPU执行指令时会基于性能优化考虑进行指令重排序。例如指令1需要去内存中取数据,CPU将避免等待,先执行指令2,前提是先执行指令2对结果无影响。例如:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
CPU在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
指令重排:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
在上面多线程的例子中,线程1看来语句1和2直接没有依赖关系,可能发生重排,但发生重排后,线程2可能会认为1已经初始化成功,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,然后取到错误的context值。
创建实例的指令重排
看一个创建对象的字节码案例:
public class MyTest {
public static void main(String[] args) {
Object o = new Object();
}
}
0 new #2 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init> : ()V>
7 astore_1
8 return
new语句代表引用object常量池#2的创建方法。申请内存空间,还没初始化
dup是一个操作数栈管理指令,负责复制栈顶(注意,这个栈指的是操作数栈)一个或者两个数值并将复制值或双份的复制值重新压人栈顶。简单理解就是给操作数栈栈顶的元素弄了一个备份。
invokespecial调用#1的初始化元素,将Object进行初始化,如果构造函数有内容在此时执行。
astore_1将o和new Object建立关联
构造方法不能起线程,因为线程并发,会跟构造方法一起执行,如果astore和invokespecial重排,可能在初始化之前就把默认值获取了,即this溢出
DCL出现指令重排的原理
class Test2{
private static volatile Test2 instance;
public Test2() {};
public static Test2 getInstance() {
if (instance == null) {
synchronized (Test2.class) {
if (instance == null) {
instance = new Test2();
}
}
}
return instance;
}
}
看一个DCL单例的例子,想要使用单例模式,用了double check lock的写法,因为加锁部分有一个new Test2()的操作,观察字节码astore和invokespecial是可能会进行指令重排的。如果有两个线程同时运行,线程1执行到new Test2,发生了指令重排,而线程2正好在执行第一个if语句(此语句没有锁),此时线程2会获取到一个未完成初始化的单例instance,导致后续执行时可能会出错。
如何解决指令重排
通过内存屏障可以解决指令重排。内存屏障有CPU级别的,也有编译器级别的。x86、x64指令集种使用lock指令加上空操作(不能是nop指令)实现,例如:
lock add1 $0, 0 (%esp)
lock指令的作用就类似于MESI,会先让持有数据的缓存失效,然后重新写入数据,等其他持有的线程去拉取数据。
JVM也实现了内存屏障,见:JVM内存屏障
评论区