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

超线程


超线程.png

一个ALU对应多个PC| Registers (如单核2线程),实现在单处理器上模拟双处理器的效能。

Cache缓存(CPU)


  • 从任何存储单元读取数据都是按块读取。
  • 利用程序局部性原理(当用到某个数字时程序也会马上用到相邻的那些数字),可以提高效率。
  • 充分发挥总线CPU针脚等一次性读取更多数据的能力。

伪共享&缓存行对齐

image.png
  • 如上图,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 缓存一致性协议:

缓存一致性协议.png

参考:
https://www.cnblogs.com/z00377750/p/9180644.html

CPU的乱序执行


CPU在进行读等待的同时执行指令,是CPU乱序执行的根源,目的是提高效率。
如图,如果指令1与指令2无依赖关系,在指令1读取内存数据的等待期内指令2会优先执行。


指令乱序执行.png

乱序执行可能会产生问题

  • DCL单例(Double Check Lock)为什么要加volatile,到底需不需要volatile?
    参考:volatile关键字 禁止指令重排序

  • 如何禁止重排序?
    -- CPU层面
    内存屏障。对某部分内存做操作时前后添加的屏障,屏障前后的操作不可以乱序执行。intel CPU中提供 lfence mfence sfence 原语(汇编指令)作为屏障。 也可以使用总线锁来解决(非CPU层面)。

    内存屏障 .png

有序性保障 (硬件层面)


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(内核)

kernel.png
内核分类

微内核 - 弹性部署 5G IoT

宏内核 - PC phone

外核 - 科研 实验中 为应用定制操作系统 (多租户 request-based GC JVM)

用户态与内核态

cpu分不同的指令级别

linux内核跑在ring 0级, 用户程序跑在ring 3,对于系统的关键访问,需要经过kernel的同意,保证系统健壮性

内核执行的操作 --- > 200多个系统调用 sendfile read write pthread fork

JVM --- > 站在OS老大的角度,就是个普通程序,用户态

一个程序的执行过程,要么处于用户态,要么处于内核态。

进程 线程 纤程 中断

程序 进程 线程 纤程.png
  • 进程: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自己管理自己切换。

优势:

  1. 占有资源很少 OS : 线程 1M, Fiber 4K
  2. 切换比较简单
  3. 启动很多个10W+

目前2020 3 22支持内置纤程的语言:Kotlin Scala Go Python(lib)... Java? (open jdk : loom)。
Java对于纤程的支持还没有内置,需要利用Quaser库(不成熟)
ps:Go语言比Java好的地方主要就是Go内置纤程,更适合并发编程。

    
        
        
            co.paralleluniverse
            quasar-core
            0.8.0
        
    
纤程的应用场景

纤程 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)。
  • 哈希表+双向链表(保证 左边指针 指向右边块) 最终结构


    LRU.png

2. 虚拟内存(解决相互打扰问题)

为什么使用虚拟内存?

  1. 隔离应用程序
    • 每个程序都认为自己有连续可用的内存
    • 突破物理内存限制
    • 应用程序不需要考虑物理内存是否够用,是否能够分配等底层问题
  2. 安全
    • 保护物理内存,不被恶意程序访问
  • DOS Win31 ... 互相干掉
  • 为了保证互不影响 - 让进程工作在虚拟空间,程序中用到的空间地址不再是直接的物理地址,而是虚拟的地址,这样,A进程永远不可能访问到B进程的空间
  • 虚拟空间多大呢?寻址空间 - 64位系统 2 ^ 64,比物理空间大很多 ,单位是byte
  • 站在虚拟的角度,进程是独享整个系统 + CPU
  • 内存映射:偏移量 + 段的基地址 = 线性地址 (虚拟空间)
  • 线性地址通过 OS + MMU(硬件 Memory Management Unit)


    内存地址映射.png

3. 缺页中断 | 缺页异常(不太重要)

需要用到页面内存中没有,产生缺页异常(中断),由内核处理并加载

ZGC


算法叫做:Colored Pointer

GC信息记录在指针上,不是记录在头部, immediate memory use

42位指针 寻址空间4T JDK13 -> 16T 目前为止最大16T 2^44

CPU如何区分一个立即数 和 一条指令

总线内部分为:数据总线 地址总线 控制总线

地址总线目前:48位

颜色指针本质上包含了地址映射的概念

你可能感兴趣的:(Java程序员需要掌握的底层知识)