读书的原则
不求甚解,观其大略。
俗话说,“买书如山*倒,读书如抽丝”。计算机类的书籍都是砖头书,工作后如果从头到尾的仔细通读全书,不仅效率低下而且特别痛苦,还会慢慢地打击读书的积极性,往往半途而废。
工作后读书,最好是先快速的通读,大体读明白即可,了解轻重点。把对自己目前有用的东西先拿来用,用着用着,很多道理就明白了。然后再去读相关部分,往往会有醍醐灌顶的感觉。
书籍推荐
- 《编码:隐匿在计算机软硬件背后的语言》:基本上高中理科生就能理解
- 《深入理解计算机系统》(俗称CSAPP)
- 语言:c 或 java
- K&R的《C程序设计语言》、《C Primer Plus》
- 《java编程的艺术》
- 数据结构与算法:《Java数据结构与算法》《算法 第四版》《算法导论》
- 操作系统:《 Linux内核设计与实现》《30天自制操作系统》
- 网络:《TCP/IP详解》卷一
- 编译原理:《编译原理》(俗称龙书)
- 数据库:SQLite源码(C语言,含有很多数据结构的知识)、 Derby(JDK自带数据库)
硬件知识
CPU的制作过程
CPU的原理
计算机需要解决的最根本问题:如何代表数字
cpu的主要是由晶体管构成的:
晶体管是如何工作的
晶体管的工作原理
CPU的基本组成
主要器件:
- PC(Program Counter): 程序计数器 (记录下一条指令的地址)
- Registers :暂时存储CPU计算需要用到的数据
- ALU(Arithmetic Logic Unit) :运算单元
- CU:Control Unit 控制单元
- MMU :Memory Management Unit 内存管理单元
- cache
超线程
什么是超线程
超线程: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和内存之间引入了缓存,来缓解这种速度差异。
缓存金字塔:
多核CPU
缓存行(cache line)
在计算机中,由于程序局部性原理(即如果程序使用了某位的数据,它有很大的概率会在下次使用相邻位的数据),从任何存储单元(内存、缓存等)读取数据都是按块读取。而CPU存取缓存的最小单位是一行,即缓存行。不同品牌CPU缓存行大小可能不同,通常为64字节。
缓存行越大,局部性空间效率越高,但读取时间慢
缓存行越小,局部性空间效率越低,但读取时间快
缓存一致性协议
在上图中,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字节,且数组在内存中是连续的地址,所以它们会存在一个缓存行中。两个线程在不同的核上执行时,就会出现上图中的场景,会由缓存一致性协议来保证共享的缓存行的一致性。
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 这一个数据。
在JDK7中,很多都和 disruptor 框架一样,采用 long padding 实现缓存行对齐以提高效率。
在JDK8中,加入了@Contended注解来实现缓存行对齐(需要加上:JVM -XX:-RestrictContended)
@Contended
volatile long x;
CPU的乱序执行
cpu 为了提高执行的效率,不是完全按指令的顺序自上而下执行的,在进行如同读等待指令的同时会去执行其他指令。
指令重排
什么是指令重排
比如下图中,当cpu执行指令1时,如果指令2与指令1之间没有依赖关系,则指令2可以先于指令1执行。这就是指令重排。这和"华罗庚烧水泡茶"的故事如出一辙。
代码证明
指令重排产生的问题
1、双重检查锁(DLC)的单例为什么要加volatile?
因为在对象的创建过程中会存在一个中间态。
在上图中,对象的创建主要分为如下3步:
- 在堆上为T对象分配地址空间,此时m为默认值0
- 对象T进行初始化
- 将变量 t 指向T对象
这其中,步骤2和步骤3可能会发生指令重排。这就会打破DLC的双重检查。
比如,当 thread1 执行到第1步时,会检查对象t是否为null,由于还未进行初始化,所以此时检查通过。然后会执行初始化。
假设当初始化执行到一半时,发生了指令重排。当 thread2 进来时,由于此时的变量t指向了一个半初始化对象,此时就打破了双重检查锁的检查机制,thread2也检查通过,破坏了对象的单例性。
hanppens-before原则
不是所有的指令都可以进行重排序,JVM规定重排序必须遵守如下hanppens-before原则(其实都是很自然的原则),以保证内存可见性。也就是在这八种情况下是不能进行指令重排的。
禁止重排序——内存屏障
什么是内存屏障
cpu层面如何禁止重排序呢?
cpu中通过内存屏障来禁止指令重排。
如上图,在指令1和指令2之间加一层内存屏障,使屏障前后的操作无法重排序。
硬件上的实现
对Intel x86的CPU 来说,有两种实现内存屏障的方式
- 3种指令原语 Ifence、mfence、sfence
- lock 原子指令
指令原语:
- 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指令实现),而且它是在写和读操作的前后都加了屏障。
as-if-serial语义
as-if-serial:不管怎么重排序,单线程程序的执行结果不能被改变。
简单地说,就是CPU不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被重排序。
as-if-serial 语义使得单线程程序看上去是按程序的顺序来执行的。
总结
禁止CPU乱序执行的几种实现机制:
- CPU 层面:指令原语和lock指令锁总线
- JVM 层面:8个hanppends-before原则和4个内存屏障(LL、LS、SL、SS)
- as-if-serial语义: 不管硬件什么顺序,单线程执行的结果不变,看上去像是serial
NUMA(非统一内存访问)
UMA
在一开始,所有CPU对内存的访问都要通过内存控制器来完成。此时所有CPU访问内存都是一致的,即每个处理器核心共享相同的内存地址空间。但随着CPU核心数的增加,不易扩展、总线带宽限制、内存访问冲突等问题越来越明显。为了解决这些问题,在硬件层面引入了NUMA架构。
NUMA
在 NUMA 下,一个物理cpu(一般包含多个逻辑cpu或者说多个核心)以及一块内存构成了一个node。每个cpu可以访问自己node下的内存(速度快),也可以访问其他node的内存(速度相对慢)。
可以看出,这还是缓存的思想。
JVM的ZGC就具有NUMA aware 机制,即分配内存时会优先分配该线程所在CPU的最近内存。