底层知识-CPU
底层知识-CPU
CPU
首先明白cpu是用来做运算的 例如a+b=c; a、b就是输入数据(input),c就是输出数据(output),加法就是处理
关于CPU的基本组成:
- 寄存器(Registers) : CPU需要使用一个叫做**存储器(也就是各种寄存器)**的东西保存输入和输出数据。以下是几种常见的寄存器
- MAR: memory address register,保存将要被访问数据在内存中哪个地址处,保存的是地址值
- MDR: memory data register,保存从内存读取进来的数据或将要写入内存的数据,保存的是数据值
- AC: Accumulator,保存算术运算和逻辑运算的中间结果,保存的是数据值
- PC: Program Counter,保存下一个将要被执行指令的地址,保存的是地址值
- CIR: current instruction register,保存当前正在执行的指令
- 算术逻辑单元(ALU, Arithmetic Logic Unit) : CPU还要将一些常用的基本运算工具(如加法器)放进CPU,这部分负责运算。
- 控制器(CU, Control Unit) : 负责将存储器中的数据送到ALU中去做运算,并将运算后的结果存回到存储器中。
关于CPU的多核和多线程
- CPU的物理个数就是插在主板上的个数,每个CPU可以有多核心,每核心可能会有多线程。
- 多核CPU的每核(每核都是一个小芯片),在系统看来都是一个独立的CPU。
- 对于超线程CPU来说,每核CPU可以有多个线程(数量是两个,比如1核双线程,2核4线程,4核8线程),每个线程都是一个虚拟的逻辑CPU,而每个线程在系统看来也是独立的CPU。
- 多线程的CPU在能力上,比非多线程的CPU核心要更强,但每个线程不足以与独立的CPU核心能力相比较。因为多线程没有提供真正意义上的并行处理,每核CPU在某一时刻仍然只能运行一个进程,因为线程1和线程2是共享某核CPU资源的。可以简单的认为每核CPU在独立执行进程的能力上,有一个资源(上图中的ALU)是唯一的,线程1获取了该资源,线程2就没法获取。
- 多线程可能会出现一种现象:假如2核4线程CPU,有两个进程要被调度,那么只有两个线程会处于运行状态,如果这两个线程是在同一核上,则另一核完全空转,处于浪费状态。更期望的结果是每核上都有一个CPU分别调度这两个进程。
关于CPU上的高速缓存
缓存 | 耗时 |
---|---|
Registers | 1<ns |
L1 cache | 约 1ns |
L2 cache | 约 3ns |
L3 cache | 约 15ns |
main memory | 约 80ns |
缓存行大小 64byte
缓存行:
缓存行越大,局部性空间效率越高,但读取时间慢
缓存行越小,局部性空间效率越低,但读取时间快最高速的缓存是CPU的寄存器,它们和CPU的材料相同,最靠近CPU或最接近CPU,访问它们没有时延(<1ns)。但容量很小,小于1kb。
- 32bit:32*32比特=128字节
- 64bit:64*64比特=512字节
寄存器之下,是CPU的高速缓存。分为L1缓存、L2缓存、L3缓存,每层速度按数量级递减、容量也越来越大。
**每核心都有一个自己的L1缓存。L1缓存分两种:L1指令缓存(L1-icache)和L1数据缓存(L1-dcache)**。
L1指令缓存用来存放已解码指令,L1数据缓存用来放访问非常频繁的数据。
L2缓存用来存放近期使用过的内存数据。更严格地说,存放的是很可能将来会被CPU使用的数据。
多数多核CPU的各核都各自拥有一个L2缓存,但也有多核共享L2缓存的设计。无论如何,L1是各核私有的(但对某核内的多线程是共享的), L3 缓存一定是CPU共享的。
MESI(缓存一致性协议)
现在大多数CPU都是多核,并且每一个核都有自己的L1、L2缓存,那么不同的CPU或者不同核访问同一个变量的是如何进行同步的呢,这就是用到了MESI协议了。
首先我们可以认为缓存是被细分为很多个缓存行的,而不同的缓存之间传输的也是缓存行。
两个线程同时进行 i = i +1操作(i初始值是0),预期结果是2,但可能出现的结果是1
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存(主存)**。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存(主存)**。
解决缓存缓存一致性问题的方法
通过在总线加LOCK#锁的方式
在总线上加LOCK#锁的形式来解决缓存不一致的问题,会阻塞了其他CPU对其他部件访问, 效率低下
通过缓存一致性协议
如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态
在MESI协议中缓存行被标记为四种状态:
E(exclusive)、M(modified)、S(shared)、I(invalid)。下面我们介绍一下这四个状态分别代表什么意思。
M:代表该缓存行中的内容被修改了,并且该缓存行只被缓存在该CACHE中。这个状态的缓存行中的数据和内存中的不一样,在未来的某个时刻它会被写入到内存中(当其他CPU要读取该缓存行的内容时。或者其他CPU要修改该缓存对应的内存中的内容时(个人理解CPU要修改该内存时先要读取到缓存中再进行修改),这样的话和读取缓存中的内容其实是一个道理)。
E:E代表该缓存行对应内存中的内容只被该CPU缓存,其他CPU没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的内容和内存中的内容一致。该缓存可以在任何其他CPU读取该缓存对应内存中的内容时变成S状态。或者本地处理器写该缓存就会变成M状态。
S:该状态意味着数据不止存在本地CPU缓存中,还存在别的CPU的缓存中。这个状态的数据和内存中的数据是一致的。当有一个CPU修改该缓存行对应的内存的内容时会使该缓存行变成 I 状态。
I:代表该缓存行中的内容时无效的。
如何证明缓存行的存在
运行并对比下列代码 会发现永远是02更快,原因就是02的两个数处于不同的缓存行上。
JDK8,加入了
@Contended
注解, 需要加上:JVM -XX:-RestrictContended
不再需要使用变量来占位了
1 |
|
1 |
|
CPU 的乱序执行
乱序执行也就是同时执行, 将两个不相干的命令在两个核上同时计算提高效率。
下面代码如果没有乱序执行是不会出现(0,0)的结果的
为啥会出现0,0 结果呢, 因为两个线程中的两个赋值语句相互没有关联,当先执行了x=b,y=a 之后再执行a=1,b=1的时候就会出现0,0结果,概率很小但会出现。
1 |
|
单例模式-双重校验锁要不要加volatile - 要
1 |
|
新建java文件
javac NewObject.java
javap -c NewObject
可看jvm字节码,当然也可以使用idea插件jclasslib
1 |
|
简单来说创建对象分为3步骤:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
1 |
|
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1>3>2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
如何禁止指令重排的
cpu级别
内存屏障:两条指令,如果不想让它重排,在两条指令中间加一道屏障。即 屏障两侧的写指令不能重排
sfence:save| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成
lfence:load| 在lfence指令前的写操作当必须在lfence指令后的写操作前完成
mfence:mix| 在mfence指令前的写操作当必须在mfence指令后的写操作前完成
- 除了内存屏障,也可以使用原子指令,如x86上的”lock…” lock后面的指令不允许重排序
JVM级别
LoadLoad屏障:
对于这样的语句Load1;LoadLoad;Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
SroreStore屏障:
对于这样的语句Store1;SroreStore;Store2,在Store2及后续写入操作要读取的数据被访问前,保证Store1的写入操作对其他处理器可见
LoadStore屏障:
对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:
对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见
操作系统级别
字节码层面 ,编译完成在Class文件上加了ACC_VOLATILE
JVM层面读到ACC_VOLATILE时,会内存区的读写 都加屏障
StoreStoreBarrier
volatile 写操作
StoreLoadBarrier
LoadLoadBarrier
volatile 读操作
LoadStoreBarrier
3.操作系统OS实现
windows lock 指令实现 或者 MESI实现
常见关键术语
8个hanppens-before原则:
程序次序规则: 在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen—before(时间上)后执行的操作
(同一个线程中前面的所有写操作对后面的操作可见)管理锁定规则:一个unlock操作happen—before后面(时间上的先后顺序)对同一个锁的lock操作。
(如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))volatile变量规则:对一个volatile变量的写操作happen—before后面(时间上)对该变量的读操作。
(如果线程1写入了volatile变量v(临界资源),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程))线程启动规则:Thread.start()方法happen—before调用用start的线程前的每一个操作。
(假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见。)线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
(线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。)线程中断规则:对线程interrupt()的调用 happen—before 发生于被中断线程的代码检测到中断时事件的发生。
(线程t1写入的所有变量,调用Thread.interrupt(),被打断的线程t2,可以看到t1的全部操作)对象终结规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始。
(对象调用finalize()方法时,对象初始化完成的任意操作,同步到全部主存同步到全部cache。)传递性:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C。
(A h-b B , B h-b C 那么可以得到 A h-b C)
as-if-serial:
的含义: 不管硬件什么顺序,单线程执行的结果不变,看上去就像是顺序执行的一样。
合并写技术 Write Combining
寄存器和L1缓存之间还有一个buffer,空间特别小。另外还有一个WC(Write Combining)Buffer,一般是4个字节
由于ALU速度太快,所以在写入L1的同时,写入一个WC Buffer,满了之后,写满4个字节之后,才会一次性刷到缓存L2里。可以用程序证明。(不要使用超线程)
1 |
|
非同一访问内存 NUMA
UMA:同一内存访问。多个CPU通过一条总线,访问同一个内存。
现在很多服务器的架构是使用NUMA的,因为UMA不以拓展:随着CPU的数量增多,许多时间被浪费在CPU争抢内存资源上。
NUMA:非同一访问内存。每个CPU有自己专属的内存,CPU对于自己插槽上的内存访问是有优先级的。
ZGC 可以做到 NUMA aware,如果探测到计算机实现了NUMA的话,分配内存会优先分配该线程所在CPU的最近内存。