练内功,打怪升级,近一年第一次写点技术blog,今天主要介绍以下几个模块
- CPU的组成和特性
- 缓存
- 进程调度
- 内存管理
CPU
二话不说,先上一张图
** 写东西先学习轮廓 **
-
ALU: Arithmetic Logic Unit (逻辑运算单元),按字面意思所说,就是一个纯粹计算的单元,输入二进制码,输出二进制结果,我个人习惯把它称为计算机的大脑,一台计算机的算力就是它决定的
-
PC:Program Count (程序计数器),存放指令在内存中的地址,下一条指令地址并不是物理上+1,要看各个OS指令大小
-
Register:寄存器,暂时存储CPU计算需要用到的数据
-
CU:Control Unit(控制单元),控制CPU工作的流程
-
MMU:Memory Management Unit(内存管理单元),1.虚拟地址与物理地址的转换,2.CPU对内存的访问状态
以上可能有三个问题:
- CPU只接受二进制码,那么真正运算的运算符(物理上的加减乘除)咋标识呢,那就是另外有一个小知识点,计算机只识别机器语言——或与运算,机器语言之上就是汇编语言,再上层就是C/C++/java等,那么运算符其实就是汇编语言与机器语言有一个映射关系,例如000111代表add(只是举例,并不是这个二进制数),这样对CPU来说,完完全全就是接收二进制数了,汇编语言总的来说就是机器语言的助记符
- 地址:地址分为逻辑地址、虚拟地址(线性地址)和物理地址,一个应用程序在计算机上运行之后就是一个进程,OS老大会为每个进程分配一个逻辑地址,那么每条执行路径(也就是线程)在执行的时候,存/取数据需要定位到磁盘的某个位置上,这个磁盘的位置就是物理地址,前面说到MMU就是做虚拟地址与物理地址的转换,那虚拟地址是啥呢?它其实是逻辑地址+偏移量,偏移量就是每个线程变量在进程上的偏移,换句话说偏移量 + 段的基地址 = 线性地址 (虚拟空间),线性地址通过 OS + MMU(硬件 Memory Management Unit)转换成物理地址
- CPU对内存的访问状态:CPU对内存的访问级别有0/1/2/3,在Linux上,只用到ring0和ring3级别,kernel内核能访问ring0级别的内存,用户态则对应ring3级别,常说的用户态和内核态切换就是kernel完成,(启动计算机时有一个保护机制,一部分内存对用户态不可见,自行科普。。。。)
语言最终跟计算机打交道,了解一些计算机底层的工作原理,不仅有助于理解我们应用程序到底是如何执行的,更重要的是这个设计理念,很多上层的衍生物。例如JVM、中间件等都有参考这个原理和设计,小思考题,
1.CPU的结构能联想到Hotspot JVM的内存模型吗????
2.超线程的模型应该是什么样的????
缓存
二话不说,也是先上图
***科普一下(不重要,权当大概了解一下):L0——小于1ns,L1——1ns,L2——3ns,L3——15ns,L4——80ns ***
学计算机的都清楚,ALU的计算能力和内存的访问不是一个量级的,而缓存的出现就是对这种不平衡的一种优化,目的就是充分利用CPU,不让它因为回写内存慢而暂停工作.另一方面,缓存本身也是一种存储结构,对缓存的读取并不是说读多少就取多少,底层是按line读取的,简称缓存行,Linux目前是64字节(工业测试所得,无相关理论),当然缓存技术的加入也带来了一个很严重的问题,那就是一致性,著名的缓存一致性协议,见【并发编程】MESI--CPU缓存一致性协议。
以下验证一下缓存行的读取,不明显的话for循环加个量级
public class CacheLinePadding1 {
public static volatile long[] arr = new long[2];//2*8=16,大概率在同一个缓存行
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(()->{
for (long i = 0; i < 10000_0000L; i++) {
arr[0] = i;
}
});
Thread t2 = new Thread(()->{
for (long i = 0; i < 10000_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);
}
}
public class CacheLinePadding2 {
public static volatile long[] arr = new long[16];//16*8=128=64*2,大概率在不同的缓存行(可能一个是缓存行尾,可能是另一个头)
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(()->{
for (long i = 0; i < 10000_0000L; i++) {
arr[0] = i;
}
});
Thread t2 = new Thread(()->{
for (long i = 0; i < 10000_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);
}
}
缓存行大小的取舍:
缓存行越大,局部空间性效率更高(相对来说把相邻地址的数据读出来,下次不需要单独再去读),但读取速度越慢(数据比较大的原因)
缓存行越小,局部空间性效率更低,但读取速度越快
那么JDK1.8之前,为了保证变量命中在单独的缓存行,通常都是使用long padding的方式(强制在不同的cache line,仅限于Linux,扩展性差),JDK1.8之后,使用@Contended注解,同时JVM参数-XX:-RestrictContended
插两个小知识点:
1.乱序执行:如果没有依赖或者前后执行顺序不影响结果的两个操作,由于CPU是以时间片的方式执行,操作1的执行时间明显比操作2的执行时间长得多,那么CPU可能先执行操作2的代码的代码逻辑,也符合人类的思维,简单的事情先做,难的事情后做
2.合并写(Write Combining Buffer):前文提到L1和L2的两个缓存在CPU内,写回L2的速度比L1的慢,那么CPU可能把数据写回L1的同时,先写到一个Combining Buffer(一般4Byte),等这个buffer满了之后再一起写回到L2
3.NUMA:Non Uniform Memory Access,分配内存时优先分配该线程所在CPU最近的内存空间(ZGC使用机制)
以上两个CPU特性,仅仅是为了进一步提高执行效率,注意乱序执行在单CPU是没问题的,也就是as-if_serial,但是在多线程程下有安全性问题,为此JVM和CPU两个层面都给出了解决方案:
JVM:内存屏障(Load/Store)
CPU:计算机原语(iFence/sFence/mFence)和总线锁
进程、线程、纤程(协程)
由上图可知:
**应用程序:进程:线程:纤程=1:N:N:N **
关于这几个的区别,有这么一个标准话语:进程是OS老大资源分配的基本单位,线程是执行调度的基本单位,线程是线程中的线程,纯用户态线程(不跟OS打交道),线程切换效率高,资源分配内存极小,一台计算机可以启10W+的数量,