底层知识-CPU

底层知识-CPU

CPU

首先明白cpu是用来做运算的 例如a+b=c; a、b就是输入数据(input),c就是输出数据(output),加法就是处理

关于CPU的基本组成:

  1. 寄存器(Registers) : CPU需要使用一个叫做**存储器(也就是各种寄存器)**的东西保存输入和输出数据。以下是几种常见的寄存器
    • MAR: memory address register,保存将要被访问数据在内存中哪个地址处,保存的是地址值
    • MDR: memory data register,保存从内存读取进来的数据或将要写入内存的数据,保存的是数据值
    • AC: Accumulator,保存算术运算和逻辑运算的中间结果,保存的是数据值
    • PC: Program Counter,保存下一个将要被执行指令的地址,保存的是地址值
    • CIR: current instruction register,保存当前正在执行的指令
  2. 算术逻辑单元(ALU, Arithmetic Logic Unit) : CPU还要将一些常用的基本运算工具(如加法器)放进CPU,这部分负责运算。
  3. 控制器(CU, Control Unit) : 负责将存储器中的数据送到ALU中去做运算,并将运算后的结果存回到存储器中。
冯诺依曼结构图,也就是现在计算机的结构图
冯诺依曼结构图,也就是现在计算机的结构图

关于CPU的多核和多线程

超线程CPU示意图
超线程CPU示意图
  1. CPU的物理个数就是插在主板上的个数,每个CPU可以有多核心,每核心可能会有多线程。
  2. 多核CPU的每核(每核都是一个小芯片),在系统看来都是一个独立的CPU
  3. 对于超线程CPU来说,每核CPU可以有多个线程(数量是两个,比如1核双线程,2核4线程,4核8线程),每个线程都是一个虚拟的逻辑CPU,而每个线程在系统看来也是独立的CPU
  4. 多线程的CPU在能力上,比非多线程的CPU核心要更强,但每个线程不足以与独立的CPU核心能力相比较。因为多线程没有提供真正意义上的并行处理,每核CPU在某一时刻仍然只能运行一个进程,因为线程1和线程2是共享某核CPU资源的。可以简单的认为每核CPU在独立执行进程的能力上,有一个资源(上图中的ALU)是唯一的,线程1获取了该资源,线程2就没法获取
  5. 多线程可能会出现一种现象:假如2核4线程CPU,有两个进程要被调度,那么只有两个线程会处于运行状态,如果这两个线程是在同一核上,则另一核完全空转,处于浪费状态。更期望的结果是每核上都有一个CPU分别调度这两个进程。

关于CPU上的高速缓存

缓存 耗时
Registers 1<ns
L1 cache 约 1ns
L2 cache 约 3ns
L3 cache 约 15ns
main memory 约 80ns
  1. 缓存行大小 64byte

    缓存行:
    缓存行越大,局部性空间效率越高,但读取时间慢
    缓存行越小,局部性空间效率越低,但读取时间快

  2. 最高速的缓存是CPU的寄存器,它们和CPU的材料相同,最靠近CPU或最接近CPU,访问它们没有时延(<1ns)。但容量很小,小于1kb。

    • 32bit:32*32比特=128字节
    • 64bit:64*64比特=512字节
  3. 寄存器之下,是CPU的高速缓存。分为L1缓存、L2缓存、L3缓存,每层速度按数量级递减、容量也越来越大。

  4. **每核心都有一个自己的L1缓存。L1缓存分两种:L1指令缓存(L1-icache)和L1数据缓存(L1-dcache)**。

    L1指令缓存用来存放已解码指令,L1数据缓存用来放访问非常频繁的数据。

    L2缓存用来存放近期使用过的内存数据。更严格地说,存放的是很可能将来会被CPU使用的数据。

  5. 多数多核CPU的各核都各自拥有一个L2缓存,但也有多核共享L2缓存的设计。无论如何,L1是各核私有的(但对某核内的多线程是共享的), L3 缓存一定是CPU共享的。

    多核cpu示意图
多核cpu示意图

MESI(缓存一致性协议)

MESI缓存层级示意图
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的值写入内存(主存)**。

解决缓存缓存一致性问题的方法

  1. 通过在总线加LOCK#锁的方式

    在总线上加LOCK#锁的形式来解决缓存不一致的问题,会阻塞了其他CPU对其他部件访问, 效率低下

  2. 通过缓存一致性协议

    如果发现操作的变量是共享变量,即在其他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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class CacheLineDemo01 {
private static class T {
public volatile long x = 0L;
}

public static T[] arr = new T[2];

static {
arr[0] = new T();
arr[1] = new T();
}

public static void main(String[] args) throws Exception {
Thread t1 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});

Thread t2 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});

final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start)/100_0000);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class CacheLineDemo02 {
private static class Padding {
public volatile long p1, p2, p3, p4, p5, p6, p7;
}

private static class T extends Padding {
public volatile long x = 0L;
}

public static T[] arr = new T[2];

static {
arr[0] = new T();
arr[1] = new T();
}

public static void main(String[] args) throws Exception {
Thread t1 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});

Thread t2 = new Thread(()->{
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});

final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start)/100_0000);
}
}

CPU 的乱序执行

乱序执行也就是同时执行, 将两个不相干的命令在两个核上同时计算提高效率。

下面代码如果没有乱序执行是不会出现(0,0)的结果的

为啥会出现0,0 结果呢, 因为两个线程中的两个赋值语句相互没有关联,当先执行了x=b,y=a 之后再执行a=1,b=1的时候就会出现0,0结果,概率很小但会出现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class CPUDisOrderDemo {
private static int x = 0, y = 0;
private static int a = 0, b =0;

public static void main(String[] args) throws InterruptedException {
int i = 0;
for(;;) {
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(new Runnable() {
public void run() {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
//shortWait(100000);
a = 1;
x = b;
}
});

Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();other.start();
one.join();other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
//System.out.println(result);
}
}
}
}
// 执行结果: 第597661次 (0,0)

单例模式-双重校验锁要不要加volatile - 要

1
2
3
4
5
public class NewObject {
public static void main(String[] args) {
Object o = new Object();
}
}

新建java文件

javac NewObject.java

javap -c NewObject

可看jvm字节码,当然也可以使用idea插件jclasslib

1
2
3
4
5
0: new           #2   (申请一块内存)                    // class java/lang/Object
3: dup (复制顶部操作数堆栈值)
4: invokespecial #1 (调用特殊方法,这里调用的是构造方法) // Method java/lang/Object."<init>":()V
7: astore_1 (将引用存储到局部变量中,就是建立o的引用关系)
8: return

简单来说创建对象分为3步骤:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//创建单例的代码
public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1>3>2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

如何禁止指令重排的

cpu级别

  1. 内存屏障:两条指令,如果不想让它重排,在两条指令中间加一道屏障。即 屏障两侧的写指令不能重排

    sfence:save| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成
    lfence:load| 在lfence指令前的写操作当必须在lfence指令后的写操作前完成
    mfence:mix| 在mfence指令前的写操作当必须在mfence指令后的写操作前完成

  1. 除了内存屏障,也可以使用原子指令,如x86上的”lock…” lock后面的指令不允许重排序
内存屏障示意图
内存屏障示意图

JVM级别

  1. LoadLoad屏障:

    对于这样的语句Load1;LoadLoad;Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕

  2. SroreStore屏障:

    对于这样的语句Store1;SroreStore;Store2,在Store2及后续写入操作要读取的数据被访问前,保证Store1的写入操作对其他处理器可见

  3. LoadStore屏障:

    对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

  4. StoreLoad屏障:

    对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见

操作系统级别

  1. 字节码层面 ,编译完成在Class文件上加了ACC_VOLATILE

  2. JVM层面读到ACC_VOLATILE时,会内存区的读写 都加屏障

    StoreStoreBarrier
    volatile 写操作
    StoreLoadBarrier
    LoadLoadBarrier
    volatile 读操作
    LoadStoreBarrier

3.操作系统OS实现

windows lock 指令实现 或者 MESI实现

常见关键术语

8个hanppens-before原则:

  1. 程序次序规则: 在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen—before(时间上)后执行的操作
    同一个线程中前面的所有写操作对后面的操作可见

  2. 管理锁定规则:一个unlock操作happen—before后面(时间上的先后顺序)对同一个锁的lock操作。
    如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)

  3. volatile变量规则:对一个volatile变量的写操作happen—before后面(时间上)对该变量的读操作。
    如果线程1写入了volatile变量v(临界资源),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)

  4. 线程启动规则:Thread.start()方法happen—before调用用start的线程前的每一个操作。
    假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见。

  5. 线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
    (线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。)

  6. 线程中断规则:对线程interrupt()的调用 happen—before 发生于被中断线程的代码检测到中断时事件的发生。
    (线程t1写入的所有变量,调用Thread.interrupt(),被打断的线程t2,可以看到t1的全部操作)

  7. 对象终结规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始。
    (对象调用finalize()方法时,对象初始化完成的任意操作,同步到全部主存同步到全部cache。)

  8. 传递性:如果操作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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public final class WriteCombining {

private static final int ITERATIONS = Integer.MAX_VALUE;
private static final int ITEMS = 1 << 24;
private static final int MASK = ITEMS - 1;

private static final byte[] arrayA = new byte[ITEMS];
private static final byte[] arrayB = new byte[ITEMS];
private static final byte[] arrayC = new byte[ITEMS];
private static final byte[] arrayD = new byte[ITEMS];
private static final byte[] arrayE = new byte[ITEMS];
private static final byte[] arrayF = new byte[ITEMS];

public static void main(final String[] args) {

for (int i = 1; i <= 3; i++) {
System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne());
System.out.println(i + " SplitLoop duration (ns) = " + runCaseTwo());
}
}

public static long runCaseOne() {
long start = System.nanoTime();
int i = ITERATIONS;

while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return System.nanoTime() - start;
}

public static long runCaseTwo() { // 一次正好写满一个四字节的 Buffer,比上面的循环效率更高
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
}
i = ITERATIONS;
while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return System.nanoTime() - start;
}
}

非同一访问内存 NUMA

UMA:同一内存访问。多个CPU通过一条总线,访问同一个内存。
现在很多服务器的架构是使用NUMA的,因为UMA不以拓展:随着CPU的数量增多,许多时间被浪费在CPU争抢内存资源上。
UMA内存示意图

UMA内存示意图

NUMA:非同一访问内存。每个CPU有自己专属的内存,CPU对于自己插槽上的内存访问是有优先级的。
NUMA内存示意图

NUMA内存示意图

ZGC 可以做到 NUMA aware,如果探测到计算机实现了NUMA的话,分配内存会优先分配该线程所在CPU的最近内存。