【Java程序员应该掌握的底层知识】 01 硬件知识CPU

读书的原则

不求甚解,观其大略。

俗话说,“买书如山*倒,读书如抽丝”。计算机类的书籍都是砖头书,工作后如果从头到尾的仔细通读全书,不仅效率低下而且特别痛苦,还会慢慢地打击读书的积极性,往往半途而废。

工作后读书,最好是先快速的通读,大体读明白即可,了解轻重点。把对自己目前有用的东西先拿来用,用着用着,很多道理就明白了。然后再去读相关部分,往往会有醍醐灌顶的感觉。

书籍推荐

  • 《编码:隐匿在计算机软硬件背后的语言》:基本上高中理科生就能理解
  • 《深入理解计算机系统》(俗称CSAPP)
  • 语言:c 或 java
    • K&R的《C程序设计语言》、《C Primer Plus》
    • 《java编程的艺术》
  • 数据结构与算法:《Java数据结构与算法》《算法 第四版》《算法导论》
  • 操作系统:《 Linux内核设计与实现》《30天自制操作系统》
  • 网络:《TCP/IP详解》卷一
  • 编译原理:《编译原理》(俗称龙书)
  • 数据库:SQLite源码(C语言,含有很多数据结构的知识)、 Derby(JDK自带数据库)

硬件知识

CPU的制作过程

Intel cpu的制作过程
CPU是如何制作的(文字描述)

CPU的原理

计算机需要解决的最根本问题:如何代表数字

cpu的主要是由晶体管构成的:
晶体管是如何工作的
晶体管的工作原理

CPU的基本组成

在这里插入图片描述
主要器件:

  • PC(Program Counter): 程序计数器 (记录下一条指令的地址)
    可以理解为我们的指令在内存中相当于一个字节数组,程序计数器记录的就是当前指令的内存地址,便于每次根据当前操作指令的占用大小进行向后移动。
  • Registers :暂时存储CPU计算需要用到的数据
    cpu中有许多不同的寄存器。因为数据每次都从内存去取太慢了,所以把从内存取来的数据暂时存储在寄存器,便于CPU计算快速拿到需要用到的数据。现在说的64位计算机,它的每一个寄存器就是可以存储64位的数据。
    JVM中的栈帧中的本地局部变量表其实就相当于“寄存器”的存在,但它是通过内存实现的,而这个寄存器是硬件级别的,效率要高的多。
  • ALU(Arithmetic Logic Unit) :运算单元
    就是用来进行运算的地方,举个例子,如果要计算2+3:
    1、把2和3分别从内存取出来,把2 move到AX寄存器,把3 move到在BX寄存器,
    2、通过程序计数器读取当前的指令,是add。
    3、ALU发现是add指令,固定的从AX寄存器读取一个数2,从BX寄存器读取一个数3,然后进行一系列的电路运算,得出运算结果5,将5输出给另外一个寄存器DX
    4、寄存器DX通过输出指令将5输出到内存的某个位置,完成本次运算
  • CU:Control Unit 控制单元
    就是对一些比如中断信号的控制。
  • MMU :Memory
  • Management Unit 内存管理单元
    最早的内存管理单元都是由操作系统实现的,现在都是硬件+操作系统实现的。
  • cache 缓存
    从任何存储单元读取数据都是按块读取。
    利用程序局部性原理(当用到某个数字时程序也会马上用到相邻的那些数字),可以提高效率。
    充分发挥总线CPU针脚等一次性读取更多数据的能力
CPU如何读取存储器

CPU如何读取存储器
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

汇编语言(机器语言)的执行过程

汇编语言的本质:机器语言的助记符号,就是机器语言

​ 比如 move --> 10001000 在汇编语言中的move在执行的时候就会在对应的表中找到对应的记录并变成10001000 的value值

计算机通电–>CPU读取内存中的程序(电信号输入)–>时钟发生器不断震颤通电–>推动CPU内部一行一行的执行(执行多少步取决于指令需要的时钟周期)–>计算完成–>写回(电信号)–>写给显卡输出(sout,或者图形)

缓存(重点)

存储器层次结构
https://www.jianshu.com/p/5d7c8d7092af

缓存金字塔:
在这里插入图片描述
内存空间大,但是太远所以太慢(CPU速度和内存速度100:1),寄存器快是快,空间又太小,折合之下,就多了cache的概念,cache根据级别的不同,离cpu的距离也不同,越近的存储空间越小,性能越高,价格越贵。
在这里插入图片描述
由于CPU和内存之间的速度差异太大,所以在CPU和内存之间引入了缓存,来缓解这种速度差异。

在这里插入图片描述
局部性原理,就是说程序读取了一块地方的数据,那它相邻的后续地址的数据也很大概率会被读取。

缓存行(cache line)

在计算机中,由于程序局部性原理(即如果程序使用了某位的数据,它有很大的概率会在下次使用相邻位的数据),从任何存储单元(内存、缓存等)读取数据都是按块读取。而CPU存取缓存的最小单位是一行,即缓存行。不同品牌CPU缓存行大小可能不同,通常为64字节。

在这里插入图片描述

  • 如上图,xy位于同一缓存行cache line,此时若读取x或y会把整个cache line 即xy同时读取。
  • 计算单元ALU找数据时按照就近原则查找L1,没有再找L2,这样依次找下去。回读数据时会把cache line这一块数据读取。
  • 这会导致核1读x时同时读了y,核2读y时也读了x。这时若核1修改了x,则核2的x也必须保持一致,这通过MESI缓存一致性协议(缓存锁)保证。缓存同步以cache line为单位进行。
  • 缓存失效
    如果一个核正在使用的数据所在的缓存行被其他核修改,那么这个cache line会失效,需要重新读取缓存。
  • False Sharing(伪共享)
    如果多个核的线程在操作同一个cache line中的不同数据,那么就会出现频繁的缓存失效,即使在代码层面看这两个线程操作的数据之间完全没有关系。
    这种不合理的资源竞争情况叫False Sharing(伪共享),会严重影响机器的并发执行效率。
  • 如果一个cache line装不下某个数据,需要通过锁总线的方法解决。核1访问内存时锁住总线,禁止其它访问,当核1访问完后其它才能访问。
  • 锁总线MESI缓存一致性协议(缓存锁)效率低。

缓存行大小
不同品牌CPU缓存行大小可能不同
缓存行越大,局部性空间效率越高,但读取时间慢
缓存行越小,局部性空间效率越低,但读取时间快
取一个折中值,目前:intel CPU 缓存行取的是64字节。

多核CPU时的缓存位置

在这里插入图片描述

缓存一致性协议

在上图中,x、y位于同一缓存行cache line,此时若读取x或y就会读取整个cache line,即xy同时被读取。如果左边的核对一级缓存(L1)中的数据x进行修改,那么右边核中的L1的数据x也必须保持一致。那么如何解决CPU中共享变量的一致性问题呢?

在这里插入图片描述

有两种常见的方式解决此类问题:

通常通过缓存一致性协议就可解决大多数情况下的共享变量一致性问题。但是在访问有些无法被缓存的数据或访问跨越多个缓存行的数据等情况时必须锁总线。即在某个时刻,一个核访问内存时会锁住总线,禁止其它核访问。相当于对内存加了synchronize,由并行变为了串行,效率显然比缓存锁(缓存一致性协议)低。

代码证明
CacheLinePadding01 中,先定义了一个大小为2的long型数组,并用volatile来修饰,以保证其可见性。然后由2个线程分别对0位和1位进行10亿次的写操作。由于数组arr的大小为16字节,且数组在内存中是连续的地址,所以它们会存在一个缓存行中。两个线程在不同的核上执行时,就会出现上图中的场景,会由缓存一致性协议来保证共享的缓存行的一致性。

// 测试缓存行 64字节 位于同一个缓存行
public class CacheLinePadding01 {
 	public static volatile long[] arr = new long[2];

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

        Thread t2 = new Thread(()->{
            for (long i = 0; i < 10_0000_0000L; i++) {
                arr[1] = i;
            }
        });

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

CacheLinePadding02 中,将数组arr的大小改为16,即128个字节,有两个缓存行大小。两个线程分别对0位和8位进行写操作,即线程t1写第一个缓存行,线程t2写第二个缓存行。这种情况下,CPU之间不存在缓存行竞争,不会触发缓存一致性协议。

// 位于不同缓存行
public class CacheLinePadding02 {

    public static volatile long[] arr = new long[16];

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

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < 10_0000_0000L; i++) {
                arr[8] = i;
            }
        });

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

结论:执行上面两个代码,下面的执行时间要比上面的快的多

缓存失效与伪共享

缓存失效: 如果一个核正在使用的数据所在的缓存行被其他核修改,这个cache line会失效,需要重新读取缓存。

伪共享: 如果多个核的线程在操作同一个cache line中的不同数据,那么就会出现频繁的缓存失效,即使在代码层面看这两个线程操作的数据之间完全没有关系。就会无意中影响彼此的性能,这就是伪共享。
比如上图中,左边的核修改x、右边的核修改y。假设左边的核先修改完,通过缓存一致性协议,右边的核所读取的缓存行将会被置位失效,它会重新从主存中拉取最新的缓存行数据。

缓存行对齐

所以对于有些特别敏感的数字,会存在线程高竞争的访问,为了保证不发生伪共享,可以使用缓存行对齐的编程方式。

比如在 disruptor 中,就使用了缓存行对齐的方式来提高其性能。

public long p1,p2,p3,p4,p5,p6,p7; // cache line padding
private volatile long cursor = INITIAL_CURSOR_VALUE;
public long p8,p9,p10,p11,p12,p13,p14; // cache line padding

在变量 cursor 中,前后各堆了56个字节。这样无论怎样读取缓存行,都可以保证整个缓存行中只有 cursor 这一个数据。

为什么只有三级缓存? 工业测试结果这是最佳的方案

这里因此是成块读取,CPU的缓存可能会出现伪共享问题。
因此出现了缓存行对齐:对于有些特别敏感的数字,会存在线程高竞争的访问,为了保证不发生伪共享,可以使用缓存航对齐的编程方式:

在JDK7中,很多都和 disruptor 框架一样,采用 long padding 实现缓存行对齐以提高效率。
在JDK8中,加入了@Contended注解来实现缓存行对齐(需要加上:JVM -XX:-RestrictContended)

@Contended
volatile long x;
超线程

有时候听到说有的cpu支持运行多个线程,这样的cpu叫做超线程,那什么叫超线程?

首先要先了解下普通的cpu的执行过程。

线程切换

正常来说,一个cpu中只会有一组寄存器和一个程序计数器。 假如多线程同时运行的情况下,线程1先由cpu执行着,寄存器和程序计数器都是放的是关于线程1的内容。此时线程2时间片到了,进来了,它会先把CPU中的寄存器和程序计数器的关于线程1的内容放入缓存中,然后再才能开始跑线程2的东西,这个过程就叫线程切换,可想而知,这个切换的过程肯定是消耗cpu的性能的。

什么是超线程

超线程:cpu虚拟化技术。通常一个物理核会虚拟化成两个逻辑核(processor)。主要是为了在内存io比较多的场景下,不让cpu闲置,提高cpu利用率。这里多线程是同时执行的。

简单的说,超线程就是一个 ALU 对应两组寄存器和PC程序计数器,实现在单处理器上模拟多处理器的效能。比如四核八线程的CPU中每个ALU就对应两组寄存器和PC程序计数器。

在这里插入图片描述

超线程的优势

原本一个核只能执行一个线程 Thread1,Thread1相关的数据和指令地址分别存在寄存器和PC中。如果某个时刻,要执行线程Thread2,就会导致CPU进行上下文切换,即需要先将Thread1的指令地址和数据保存到缓存中,然后再加载Thread2的数据和指令地址到当前的寄存器和PC中,这样Thread2才会得到执行。由于CPU的时间片机制,每个线程都只能执行一段时间,在这种场景下,CPU会频繁的做上下文切换操作,而上下文切换操作对CPU来说是一种无用功,会降低CPU的利用率。

如果使用超线程,比如让一个ALU对应两组寄存器和PC。在执行两个线程时,ALU只需要选择不同的寄存器即可从一个线程切换到另一个线程,无需进行上下文切换。效率将会大大提高。

CPU的乱序执行

cpu 为了提高执行的效率,不是完全按指令的顺序自上而下执行的,在进行如同读等待指令的同时会去执行其他指令。
在这里插入图片描述
https://preshing.com/20120515/memory-reordering-caught-in-the-act/

指令重排
什么是指令重排

比如下图中,当cpu执行指令1时,如果指令2与指令1之间没有依赖关系,则指令2可以先于指令1执行。这就是指令重排。这和"华罗庚烧水泡茶"的故事如出一辙。
在这里插入图片描述

代码证明

用代码证明指令重排

public class T04_Disorder {
    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);
            }
        }
    }


    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}

在这里插入图片描述
图解
在这里插入图片描述

指令重排产生的问题

1、双重检查锁(DLC)的单例为什么要加volatile?
因为在对象的创建过程中会存在一个中间态。
参考:volatile关键字 禁止指令重排序
在这里插入图片描述
在上图中,对象的创建主要分为如下3步:

  1. 在堆上为T对象分配地址空间,此时m为默认值0
  2. 对象T进行初始化
  3. 将变量 t 指向T对象

这其中,步骤2和步骤3可能会发生指令重排。这就会打破DLC的双重检查。

比如,当 thread1 执行到第1步时,会检查对象t是否为null,由于还未进行初始化,所以此时检查通过。然后会执行初始化。
在这里插入图片描述
假设当初始化执行到一半时,发生了指令重排。当 thread2 进来时,由于此时的变量t指向了一个半初始化对象,此时就打破了双重检查锁的检查机制,thread2也检查通过,破坏了对象的单例性。

在这里插入图片描述

禁止重排序——内存屏障
什么是内存屏障

cpu层面如何禁止重排序呢?
cpu中通过内存屏障来禁止指令重排。
在这里插入图片描述
如上图,在指令1和指令2之间加一层内存屏障,使屏障前后的操作无法重排序。

硬件上的实现

对Intel x86的CPU 来说,有两种实现内存屏障的方式

  • 3种指令原语 Ifence、mfence、sfence
  • lock 原子指令

inter 指令原语:

  • sfence:在 sfence 指令前的写操作必须在 sfence 指令后的写操作前完成。
  • lfence:在 lfelnce 指令前的读操作必须在 lfence 指令后的读操作前完成。
  • mfence:在 mfence 指令前的读写操作必须在 mfence 指令后的读写操作前完成。

lock指令:
当然一切原子性的操作都可以通过总线锁的方式来解决。所以大多数CPU都提供了一个lock指令,它通过总线锁的方式来禁止重排序。

lock 指令是一个 Full Barrier,执行时会锁住内存子系统(锁总线)来确保执行顺序,甚至跨多个CPU。Software Locks 通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。

同缓存一致性协议和总线锁机制一样,3种指令原语的性能要比lock指令高。但是由于前者在其他cpu上可能没有,所以 HotSpot虚拟机在实现屏障时使用的就是Lock指令。

JVM层面的内存屏障规范

在这里插入图片描述
volatile 关键字在JVM层面就是通过内存屏障来禁止指令重排的(底层用Lock指令实现),而且它是在写和读操作的前后都加了屏障。
在这里插入图片描述

hanppens-before原则()jvm 规定重排序必须遵守的规则)

不是所有的指令都可以进行重排序,JVM规定重排序必须遵守如下hanppens-before原则(其实都是很自然的原则),以保证内存可见性。也就是在这八种情况下是不能进行指令重排的。
在这里插入图片描述

as-if-serial语义(像是顺序执行的)

as-if-serial:不管怎么重排序,单线程程序的执行结果不能被改变。

简单地说,就是CPU不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被重排序。

as-if-serial 语义使得单线程程序看上去是按程序的顺序来执行的。

总结

禁止CPU乱序执行的几种实现机制:

  • CPU 层面:Intel -> 指令原语(mfence lfence sfence)和lock指令锁总线
  • JVM 层面:8个hanppends-before原则和4个内存屏障(LL、LS、SL、SS)
  • as-if-serial语义: 不管硬件什么顺序,单线程执行的结果不变,看上去像是serial
合并写技术

cpu在L1之间还有个Load Buffer ,Store Buffer,比L1更快,更小。 在向L1写之前会先写入到这里,不过这个不是所有CPU都有。

并且在L1之间还有个Write Combining Buffer,一般是4个字节
由于ALU速度太快,所以在写入L1的同时,写入一个WC Buffer,满了之后,再直接更新到L2。

在这里插入图片描述

package com.mashibing.juc.c_029_WriteCombining;

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() {
        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(非统一内存访问)

Non Uniform Memory Access
不同的CPU和不同的内存分成一组放在不同的地方再互相连接起来。

在这里插入图片描述

在一开始,所有CPU对内存的访问都要通过内存控制器来完成。此时所有CPU访问内存都是一致的,即每个处理器核心共享相同的内存地址空间。

但随着CPU核心数的增加,不易扩展、总线带宽限制、内存访问冲突等问题越来越明显。为了解决这些问题,在硬件层面引入了NUMA架构

NUMA

主板上分为不同的插槽,一个插槽放一组的CPU,这个CPU插槽组附近都有离自己组很近的内存,访问起来比起其他位置的CPU组来说要更快,具有优先级,平时各自访问自己的内存就行了,如果需要别的组的内存需要时再去别人槽附近的内存访问就行了,不过速度略慢一些,因此现在的程序其实做了优化,做到了NUMA aware ,分配内存会优先分配该线程所在CPU的最近内存。
在这里插入图片描述
可以看出,这还是缓存的思想。
JVM的ZGC就具有NUMA aware 机制,即分配内存时会优先分配该线程所在CPU的最近内存

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值