Java程序员需要掌握的底层知识
汇编语言(机器语言)的执行过程
汇编语言的本质:机器语言的助记符 其实它就是机器语言
过程:计算机通电 ---> CPU读取内存中程序(电信号输入)--->时钟发生器不断震荡通断电--->推动CPU内部一步一步执行(执行多少步取决于指令需要的时钟周期)--->计算完成->写回(电信号)--->写给显卡输出(sout,或者图形)
CPU的基本组成
PC -> Program Counter 程序计数器 (记录当前指令地址)
Registers -> 暂时存储CPU计算需要用到的数据
ALU -> Arithmetic & Logic Unit 运算单元
CU -> Control Unit 控制单元
MMU -> Memory Management Unit 内存管理单元
Cache -> 缓存
存储器层次结构&局部性原理
参照一位大佬的分享:
https://www.jianshu.com/p/5d7c8d7092af
CPU读取不同位置数据的速度对比
从CPU到:
存储单元 | 时间 |
---|---|
Registers | < 1ns |
L1 cache | 约 1ns |
L2 cache | 约 3ns |
L3cache | 约 15ns |
main memory | 约 80ns |
超线程
一个ALU对应多个PC| Registers (如单核2线程),实现在单处理器上模拟双处理器的效能。
Cache缓存(CPU)
- 从任何存储单元读取数据都是按块读取。
- 利用程序局部性原理(当用到某个数字时程序也会马上用到相邻的那些数字),可以提高效率。
- 充分发挥总线CPU针脚等一次性读取更多数据的能力。
伪共享&缓存行对齐
- 如上图,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字节。
缓存行对齐
对于有些特别敏感的数字,会存在线程高竞争的访问,为了保证不发生伪共享,可以使用缓存行对齐的编程方式
- JDK7中,很多采用long padding提高效率:
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
long类型是8字节(64位),一个cache line 64字节,这样不管怎么读,变量cursor都会只在一个缓存行中。
- JDK8,加入了@Contended注解,需要加上:JVM -XX:-RestrictContended
@Contended
volatile long x;
intel MESI Cache 缓存一致性协议:
参考:
https://www.cnblogs.com/z00377750/p/9180644.html
CPU的乱序执行
CPU在进行读等待的同时执行指令,是CPU乱序执行的根源,目的是提高效率。
如图,如果指令1与指令2无依赖关系,在指令1读取内存数据的等待期内指令2会优先执行。
乱序执行可能会产生问题
DCL单例(Double Check Lock)为什么要加volatile,到底需不需要volatile?
参考:volatile关键字 禁止指令重排序-
如何禁止重排序?
-- CPU层面
内存屏障。对某部分内存做操作时前后添加的屏障,屏障前后的操作不可以乱序执行。intel CPU中提供 lfence mfence sfence 原语(汇编指令)作为屏障。 也可以使用总线锁来解决(非CPU层面)。
有序性保障 (硬件层面)
intel lock汇编指令
原子指令,如x86上的"lock ... "指令是一个Full Barrier,执行时会锁住内存子系统(锁总线)来确保执行顺序,甚至跨多个CPU。Software Locks 通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。
x86 intel CPU内存屏障
sfence:在sfence指令前的写操作必须在sfence指令后的写操作前完成。
lfence:在lfelnce指令前的读操作必须在lfence指令后的读操作前完成。m
mfence:在mfence指令前的读写操作必须在mfence指令后的读写操作前完成。
JSR内存屏障 (JVM层面,是一种规范)
HotSpot虚拟机在实现屏障时使用的是Lock指令
- LoadLoad屏障:屏障上下两条Load指令不可互换
对于这样的语句Load1; LoadLoad; Load2,
在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:屏障上下两条Store指令不可互换
对于这样的语句Store1; StoreStore; Store2,
在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:屏障上下Load Store指令不可互换
对于这样的语句Load1; StoreStore; Store2,
在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:屏障上下Store Load指令不可互换
对于这样的语句Store1; StoreStore; Load2,
在Load2及后续读取操作执行前,保证Store1的写入对所有处理器可见。
禁止乱序
CPU层面:Intel -> 原语(mfence lfence sfence) 或者锁总线。
JVM层级:8个hanppens-before原则 4个内存屏障 (LL LS SL SS)。
as-if-serial : 不管硬件什么顺序,单线程执行的结果不变,看上去像是顺序执行。
volatile 的实现细节
volatile 底层是使用内存屏障实现的,在写操作和读操作前后都加了屏障。(JVM要求这样实现,是一种规范,底层用Lock指令实现)
- JVM层面上:
StoreStoreBarrier
volatile 写操作(Store)
StoreLoadBarrier
LoadLoadBarrier
volatile 读操作(Load)
LoadStoreBarrier
合并写技术
Write Combining Buffer
一般是4个字节
由于ALU速度太快,所以在写入L1的同时,写入一个WC Buffer,满了之后,再直接更新到L2
NUMA
Non Uniform Memory Access
不同的CPU和不同的内存分成一组放在不同的地方再互相连接起来。
ZGC - NUMA aware
分配内存会优先分配该线程所在CPU的最近内存
操作系统(OS)基础
kernel(内核)
内核分类
微内核 - 弹性部署 5G IoT
宏内核 - PC phone
外核 - 科研 实验中 为应用定制操作系统 (多租户 request-based GC JVM)
用户态与内核态
cpu分不同的指令级别
linux内核跑在ring 0级, 用户程序跑在ring 3,对于系统的关键访问,需要经过kernel的同意,保证系统健壮性
内核执行的操作 --- > 200多个系统调用 sendfile read write pthread fork
JVM --- > 站在OS老大的角度,就是个普通程序,用户态
一个程序的执行过程,要么处于用户态,要么处于内核态。
进程 线程 纤程 中断
进程:Linux中也称为task,是系统分配资源的基本单位。
资源:独立的地址空间,内核数据结构(进程描述符PCB...),全局变量,数据段...
进程描述符:内核中维护进程时用的结构是PCB(Process Control Block),每一个进程都跟着一个PCB。Linux管理进程时,把相应的进程信息记录到PCB中,PCB大小不固定,每个进程的不一样线程:线程在Linux中的实现:
就是一个普通进程,只不过和其它进程共享资源(内存空间 全局数据等),其它系统如Windows都有各自所谓的LWP的实现 Light Weight Process。
高层面理解:一个进程中不同的执行路线。内核线程:内核启动之后经常需要做一些后台操作(如计时, 定期清理某些垃圾),这些由Kernel Thread来完成,只在内核空间运行。
进程(线程)创建和启动(Linux):系统函数 fork() 创建进程(底层调用的是clone() ),exec() 运行进程
从A中fork B的话,A称之为B的父进程
面试高频:进程和线程有什么区别?
答:进程就是一个程序运行起来的状态,线程是一个进程中的不同的执行路径。专业:进程是OS分配资源的基本单位,线程是执行调度的基本单位。分配资源最重要的是:独立的内存空间,线程调度执行(线程共享进程的内存空间,没有自己独立的内存空间)
纤程:
用户态的线程,线程中的线程,切换和调度不需要经过OS,JVM自己管理自己切换。
优势:
- 占有资源很少 OS : 线程 1M, Fiber 4K
- 切换比较简单
- 启动很多个10W+
目前2020 3 22支持内置纤程的语言:Kotlin Scala Go Python(lib)... Java? (open jdk : loom)。
Java对于纤程的支持还没有内置,需要利用Quaser库(不成熟)
ps:Go语言比Java好的地方主要就是Go内置纤程,更适合并发编程。
<dependencies>
<!-- https://mvnrepository.com/artifact/co.paralleluniverse/quasar-core -->
<dependency>
<groupId>co.paralleluniverse</groupId>
<artifactId>quasar-core</artifactId>
<version>0.8.0</version>
</dependency>
</dependencies>
纤程的应用场景
纤程 vs 线程池:很短的计算任务,不需要和内核打交道,并发量高!
僵尸进程&孤儿进程(Linux)
什么是僵尸进程
ps-ef | grep defult
父进程产生子进程后,会维护子进程的一个PCB结构,子进程退出,由父进程释放,如果父进程没有释放,那么子进程成为一个僵尸进程。
僵尸进程只占PCB,对系统影响不大。
什么是孤儿进程
子进程结束之前,父进程已经退出。孤儿进程会成为init进程的孩子,由init进程(1号进程)维护。
进程(任务)调度
进程调度基本概念
- 进程类型:
- IO密集型 大部分时间用于等待IO
- CPU密集型 大部分时间用于计算
- 进程优先级:
- 实时进程 > 普通进程(0 - 99)
- 普通进程 nice 值(-20 - 19)
- 时间分配:
- Linux采用按优先级的CPU时间比
- 其它系统多采用按优先级的时间片
- eg. 两个app同时运行
- 一个文本处理程序
- 一个影视后期处理程序
内核进程调度器决定:该哪个进程运行, 何时开始, 运行多长时间。
Linux内核中每个进程都有专属的调度方案并且可以自定义。
进程调度中的常见算法
非抢占式(cooperative multitasking)
除非进程主动让出CPU(yielding),否则将一直运行。抢占式(preemptive multitasking)
由进程调度器强制开始或暂停(抢占)某一进程的执行。 现在多用该种方式。
Linux内核的进程调度
- linux2.5内核 经典Unix O(1) 调度策略
每个进程所分配的时间片都一样(绝对公平),偏向服务器。但对UI交互不友好,需要显示时如果没有被分配到时间片会产生较长延迟。
- linux2.6.23内核 采用CFS完全公平调度算法Completely Fair Scheduler
按优先级分配时间片的比例,记录每个进程的执行时间,如果有一个进程执行时间不到他应该分配的比例,优先执行
Linux默认调度策略:
对于实时进程:使用 SCHED_FIFO(优先级高先执行) 和 SCHED_RR(轮巡)两种。
对于普通进程:使用CFS完全公平调度算法。
其中等级最高的是FIFO,这种进程除非自己让出CPU否则Linux会一直执行它,除非更高等级的 FIFO 和 RR 抢占它。
RR只是这种线程中是同级别FIFO的平均分配。
只有实时进程主动让出,或者执行完毕后,普通进程才有机会运行。
总结:
实时(急诊):优先级分高低 - FIFO (First In First Out),优先级一样 - RR(Round Robin)
普通:CFS
中断
硬件跟操作系统内核打交道的一种机制
软件向操作系统调用函数 软中断(80中断) == 系统调用
系统调用:int 0x80 或者 sysenter原语
通过ax寄存器填入调用号
参数通过bx cx dx si di传入内核
返回值通过ax返回
java读网络 – jvm read() – c库read() --- >
内核空间 ---> system_call() (系统调用处理程序)
---> sys_read()
内存管理
内存管理的发展历程
DOS时代 - 同一时间只能有一个进程在运行(也有一些特殊算法可以支持多进程)
windows9x - 多个进程装入内存 1:内存不够用 2:互相打扰
为了解决这两个问题,诞生了现在的内存管理系统:虚拟地址 分页装入 软硬件结合寻址。
1. 分页(解决内存不够用)
内存中分成固定大小的页框(4K),把程序(硬盘上)分成4K大小的块,用到哪一块,加载那一块,加载的过程中,如果内存已经满了,会把最不常用的一块放到swap分区, 把最新的一块加载进来,这个就是著名的LRU算法。所有涉及到缓存的基本都有 LRU 或 LFU 算法。
- LRU算法 LeetCode146题,头条,阿里面试题
- LRU(Least Recently Used) "最不常用"算法
- 哈希表(保证 查找操作O(1)) + 链表 (保证 排序操作和新增操作 O(1))) 但是单链表在改变指针时仍需遍历,复杂度高 O(N)。
-
哈希表+双向链表(保证 左边指针 指向右边块) 最终结构
2. 虚拟内存(解决相互打扰问题)
为什么使用虚拟内存?
- 隔离应用程序
- 每个程序都认为自己有连续可用的内存
- 突破物理内存限制
- 应用程序不需要考虑物理内存是否够用,是否能够分配等底层问题
- 安全
- 保护物理内存,不被恶意程序访问
- DOS Win31 ... 互相干掉
- 为了保证互不影响 - 让进程工作在虚拟空间,程序中用到的空间地址不再是直接的物理地址,而是虚拟的地址,这样,A进程永远不可能访问到B进程的空间
- 虚拟空间多大呢?寻址空间 - 64位系统 2 ^ 64,比物理空间大很多 ,单位是byte
- 站在虚拟的角度,进程是独享整个系统 + CPU
- 内存映射:偏移量 + 段的基地址 = 线性地址 (虚拟空间)
-
线性地址通过 OS + MMU(硬件 Memory Management Unit)
3. 缺页中断 | 缺页异常(不太重要)
需要用到页面内存中没有,产生缺页异常(中断),由内核处理并加载
ZGC
算法叫做:Colored Pointer
GC信息记录在指针上,不是记录在头部, immediate memory use
42位指针 寻址空间4T JDK13 -> 16T 目前为止最大16T 2^44
CPU如何区分一个立即数 和 一条指令
总线内部分为:数据总线 地址总线 控制总线
地址总线目前:48位
颜色指针本质上包含了地址映射的概念
上一篇: Java 程序员都需要懂的 反射!