【Java程序员应该掌握的底层知识】 02 操作系统

书籍推荐《Linux内核的设计与实现 第三版》

OS的主要作用

  • 管理硬件
  • 管理应用

以下所有内容均以 Linux 为例

内核

内核是OS的核心,它管理着系统的各种资源。

内核的主要作用

【Java程序员应该掌握的底层知识】 02 操作系统_第1张图片

内核的分类

宏内核

宏内核:kernel + 一些高级的虚拟接口(控制硬件)

简单的说,宏内核相当于一个是一个中央集权控制中心,把内存管理,文件管理等功能全部管理。PC上用的比较多,比如常见的windows、Linux。
【Java程序员应该掌握的底层知识】 02 操作系统_第2张图片

微内核

微内核:提供操作系统核心功能的内核的精简版本,它设计成在很小的内存空间内增加移植性,提供模块化设计,以使用户安装不同的接口。

比如DOS、华为的鸿蒙。如嵌入式系统一样,可针对不同需求组装进来不同的模块。

外核

存在理论实验中。为应用定制操作系统。
类似淘宝的多租户JVM,比如可以专门为浏览器定制一个OS。

用户态与内核态

一般的操作系统对执行权限进行分级(Linux分为0~3),分别为用用户态(ring 3)和内核态(ring 0)。大多数时间各类程序都是执行在用户态下。

内核态相当于一个介于硬件与应用之间的层,内核有ring 0的权限,可以执行任何cpu指令,也可以引用任何内存地址,包括外围设备, 例如硬盘, 网卡,权限等级最高。

用户态则权利有限,例如在内存分配中,有一部分内存是仅为内核态使用的,用户态code则不允许访问那些内存地址,每个进程只允许访问自己申请到的内存。而且不允许访问外围设备。另外在执行cpu指令的时候也可以被高优先级抢占。

为了保障OS的安全,用户态相较于内核态有较低的执行权限,很多操作是不被操作系统允许的。对于系统的关键访问,需要经过kernel的同意,以保证系统健壮性。

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

进程、线程、纤程

进程

进程是OS分配资源的基本单位。Linux中也称为task。

资源:独立的地址空间。里面存放着PCB、全局变量、数据段等信息。

PCB(进程描述符):PCB是维护进程信息的数据结构。每一个进程都跟着一个PCB。PCB的大小不固定,因为每个进程的信息不一样。

linux通过系统函数 fork() 来创建进程,通过exec() 来运行进程。从进程A中fork进程B时,A被称之为B的父进程

线程

线程是执行调度的基本单位。

在linux中,线程就是一个普通的进程,它和其它进程共享资源(内存空间、全局数据等)。

和GC一样,线程也有后台线程,在OS中叫内核线程。它会在内核启动后做一些后台操作(如计时, 定期清理某些垃圾)。内核线程只在内核空间运行。

在java中,调用thread对象的start方法时,会调用native的start0方法,该方法将JVM中的一个线程对应上OS中的一个线程。这是一个重量级的线程,需要先从用户态切换到内核态,向内核申请资源,然后由内核态再切换到用户态。由于这种操作太重了,所以引入了fiber。

纤程(Fiber)

纤程是线程中的线程。它是用户态的线程,切换和调度时不需要经过OS。

优势:

  • 占有资源很少。通常开启一个线程,需要分配1M内存, 而Fiber只需4K
  • 切换比较简单,不和OS打交道,并发量很高
  • 同机器上可启动的fiber数量远大于线程数量

基于以上优点,Fiber比较适合具有很多很短的计算任务的场景。

支持Fiber的语言有Go、Scala、Kotlin等。Java目前(14)不支持,需要利用Quaser库(不成熟)。Go语言最大的优势就是Go内置 Fiber,更适合并发编程。

僵尸进程与孤儿进程

什么是僵尸进程

父进程产生子进程后,会维护子进程的一个PCB结构,子进程退出,由父进程释放,如果父进程没有释放,那么子进程成为一个僵尸进程。

僵尸进程只占PCB,对系统影响不大。可通过 ps-ef | grep defult 来查看

什么是孤儿进程

子进程结束之前,父进程已经退出。孤儿进程会成为init进程的孩子,由init进程(1号进程)维护。

进程调度

进程调度基本概念

  • 进程类型:
    • IO密集型 大部分时间用于等待IO
    • CPU密集型 大部分时间用于计算
  • 进程优先级:
    • 实时进程 > 普通进程(0 - 99)
    • 普通进程 nice 值(-20 - 19)
  • 时间分配:
    • Linux采用按优先级的CPU时间比
    • 其它系统多采用按优先级的时间片

进程的调度由内核进程调度器负责。它决定该哪个进程运行,何时开始,运行多长时间。

Linux内核中每个进程都有专属的调度方案并且可以自定义。

进程调度的常见方式

  • 独占式:除非进程主动让出CPU(yielding),否则将一直运行。
  • 抢占式:由进程调度器强制开始或暂停(抢占)某一进程的执行。 现在多用该种方式。

Linux内核的进程调度(了解)

  • 经典Unix O(1) 调度策略:每个进程所分配的时间片都一样(绝对公平),偏向服务器。这种方式对UI交互不友好,需要显示时如果没有被分配到时间片会产生较长延迟。
  • CFS完全公平调度算法:按优先级分配时间片的比例,记录每个进程的执行时间,如果有一个进程执行时间不到他应该分配的比例,优先执行。在linux2.6.23后的内核中使用。

Linux默认调度策略 :

  • 对于实时进程:使用 SCHED_FIFO(first in first out) 和 SCHED_RR(round robbin)两种。
  • 对于普通进程:使用CFS完全公平调度算法。

实时进程犹如急诊病人,当有多个急诊病人时,按FIFO的方式排队,如果有相同优先级的急诊病人,则在此基础上按RR方式执行。当急诊病人(实时进程)处理完毕或主动放弃治疗(主动让出)后,普通病人(普通进程)才会按CFS算法得到诊断的机会。

中断

中断是硬件跟操作系统内核打交道的一种机制。

在office软件中按下键盘上的一个键会发生什么?

首先,在键盘上按下一个键时,会触发一个电信号,这个信号会通过总线发送到中断控制器来处理,中断控制器检测键盘按下这个中断是否激活,如果是则将该信号发送给CPU,CPU接受到该信号后就会立即停止自己正在做的事,然后通知kernel,kernel再根据中断向量表查询出该中断的类型和要调用的中断处理函数(里面已经写好的一堆处理程序,如处理键盘,处理打印机等等),随后kernel调用相应的函数进行处理,最后再由office(应用程序)处理。

【Java程序员应该掌握的底层知识】 02 操作系统_第3张图片

中断的分类

中断又分硬中断和软中断:

  • 硬中断:由外设硬件产生的,会直接中断CPU。主要是用来通知操作系统系统外设状态的变化。
  • 软中断:由当前正在运行的进程所产生的,不会直接中断CPU。通常是软件在做系统调用时触发。

硬中断处理程序要确保它能快速地完成任务,这样程序执行时才不会等待较长时间,称为上半部。
软中断处理硬中断未完成的工作,是一种推后执行的机制,属于下半部。

硬中断中,有一张中断向量表来记录每一种中断信号的中断处理函数的关系。比如:

  • 1-键盘-键盘处理程序
  • 2-鼠标-鼠标处理程序
  • 0x80-软件-处理程序

0x80H是所有软中断的信号,这个号通常对应的一堆的中断处理函数。比如 read(),write() 等等。
当一个应用程序想要读取网卡上的数据时,必须先经过内核来进行系统调用,此时它就会发出0x80信号来通知kernel。向ax寄存器中填入调用号(比如read函数是1号,write函数是2号,exit函数是-1号等),参数通过寄存器bx、cx、dx、si、di传入内核,返回值通过ax返回。

从汇编角度理解软中断

;hello.asm
;write(int fd, const void *buffer, size_t nbytes)
;fd 文件描述符 file descriptor , 比如fd=0表示标准输入,fd=1表示标准输出,fd=2表示标准错误输出
​;buffer 文件内容
;nbytes 输出长度

section data
    msg db "Hello", 0xA
    len equ $ - msg
​
section .text
global _start
_start:
​
    mov edx, len
    mov ecx, msg
    mov ebx, 1 ;文件描述符1 std_out
    mov eax, 4 ;write函数系统调用号 4
    int 0x80
​
    mov ebx, 0
    mov eax, 1 ;exit函数系统调用号
    int 0x80

系统调用函数write接受3个参数,文件描述符、文件内容,输出长度,它们分别存放在ebx、ecx、edx中,随后向eax寄存器中填入调用号4,表示调用系统函数write,最后触发软中断。这样应用程序就会中断通知kernel它要进行系统调用write。再调用结束后再触发系统调用exit,告诉kernel系统调用结束。

常见的,比如java程序要读网络,首先在jvm层会调用一个read相关的函数,它会调用c库中的read相关函数,这时就会触发软中断,程序由用户态进入内核态,在内核空间执行系统调用处理程序,获取内容后,程序又从内核态恢复到了用户态。

从OS角度来说,一个程序在进行IO操作时,如果在用户态时被阻塞,需要等待,则该操作是BIO,如果用户态时不被阻塞,则该操作是NIO。

内存管理

早期,内存容量有限,程序员必须将写好的代码分段,然后一段一段地转入到内存进行读取。为了解决这个问题,提出了虚拟内存的概念,并引入内存映射机制,将程序员从大量烦琐的管理工作中解放了出来。

早期的DOS,同一时间只能有一个进程在运行;目前的OS都引入了虚拟内存,可以将多个进程转入内存。由于内存容量有限,这就产生了两个主要问题:

  • 内存不够用怎么办
  • 多个进程之间如何避免互相打扰

内存不够用

为了解决内存不够用的问题,引入了分页机制。即将内存分成多份固定大小的页帧(通常为4K),把程序(硬盘上)也分成4K大小的块,在启动程序时,实际上就是将程序进行切分,然后和内存中的地址进行映射,并将内存映射信息保存到页表中。当程序在执行过程中,用到程序中的哪一块,就去加载哪一块。在加载的过程中,如果内存已经满了,通过LRU算法,将内存中最不常用的一块放到硬盘中的swap分区, 并把最新的一块加载到内存。

常用的缓存算法:LRU、LFU、FIFO

互相打扰

虚拟内存解决了多个进程相互打扰的问题。
虚拟内存的优势:

  • 隔离应用程序
    • 每个程序都认为自己有连续可用的内存
    • 突破物理内存限制(64位机的虚拟内存大小为2^64 byte)
    • 应用程序不需要考虑物理内存是否够用,是否能够分配等底层问题
  • 安全
    • 保护物理内存,不被恶意程序访问

由于进程工作在虚拟内存,程序中用到的空间地址不再是直接的物理地址,而是虚拟的地址,这样,A进程永远不可能访问到B进程的空间。每一个进程都觉得自己独享了整个物理内存和CPU。

而实际的内存地址,通过MMU(内存管理单元,硬件),完成内存映射。类似汇编的偏移寻址,即偏移量 + 段的基地址 = 线性地址。

你可能感兴趣的:(java)