操作系统学习笔记
# 内核
# 操作系统是什么
操作系统是拥有文件管理、内存管理、设备管理、进程管理等管理系统资源的、对用户以及其他应用友好、对硬件进行操作的系统软件
# 内核是什么
操作系统的内核是操作系统的核心部分,它最接近硬件,不同架构的内核承担着不同的职责:
- 微内核:仅保留核心功能,如系统原语(原子指令)、时钟管理、中断处理,其他功能作为用户态服务运行
- 宏内核:集成更多系统服务,如内存管理、I/O管理、进程调度等,这些功能直接在内核空间运行
比如一些用户进程,做一些底层操作的时候,需要转换为内核态,调用内核的线程实现。为什么要这样呢?
- 内核维护的内核空间一定是多线程安全的
- 多线程应用中,用户态的线程调度需要内核支持:内核维护全局的进程/线程状态视图,确保公平调度和资源分配,处理跨进程的同步与通信
- 内核对硬件封装:直接硬件访问可能导致系统崩溃或安全漏洞,内核作为单一访问点,统一管理硬件资源
因此,我们在调用一些操作系统功能的时候,需要做内核态的转换
# 内核态和用户态
用户态:用户态运行的进程或可以直接读取用户程序的数据,并执行一些不调用系统资源的指令(比如加减乘除以及调用用户态内存的指令),即非特权指令
内核态:可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制,即特权指令
# 系统调用
在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成
执行系统调用之前,首先应用程序向操作系统发送中断信号(信号是常见通信方式之一)
注意,系统调用是非常消耗资源的,它的成本主要来自用户态与内核态的转换,而转换需要环境切换才可以让线程运行,环境的切换需要响应中断、恢复执行现场,比如进行一些把环境从内存 copy 到寄存器等一系列操作,因此成本非常大
# Linux 操作系统内存管理
在内核空间主要有这些区域
用户空间则分堆栈

# 软中断和硬中断
硬中断:硬中断是由硬件产生的,比如,像磁盘,网卡,键盘,时钟等。每个设备或设备集都有它自己的 IRQ(中断请求)
处理中断的驱动是需要运行在 CPU 上的,因此,当中断产生的时候,CPU 会立即中断当前正在运行的任务,来处理中断
硬中断可以直接中断 CPU。它会引起内核中相关的代码被触发。对于那些需要花费一些时间去处理的进程,中断代码本身也可以被其他的硬中断中断
硬中断也叫异步中断。硬中断是由硬件设备依照 CPU 时钟信号产生的,即意味着中断发生具备随机性和突发性,可以在指令正在执行时发生
软中断:编程异常一般叫作软中断,软中断是通信进程之间用来模拟硬中断的 一种信号通信方式。中断源发中断请求或软中断信号后,CPU 或接收进程在适当的时机自动进行中断处理或完成软中断信号对应的功能
软中断,也叫同步中断。软中断是由 CPU 执行中断产生指令时产生,是由程序预先实现好的,不是随机的
# 信息的表示与存储
对于计算机系统而言,从机器的角度来看,程序仅仅是一个个的字节序列,按此序列机器执行相应的动作
# 虚拟地址空间与字长
程序以字节(8 bite)为单位编址,这些微不足道的二进制数字,构成了数字革命的基础。机器级程序将内存看作一个非常的大的字节数组,在数组中存储所有信息。内存中的每个字节都会由一个唯一的数字来表示,称为他们的地址,所有可能的地址集合就称为虚拟地址空间
字长 = CPU一次能处理的二进制位数 = 寄存器宽度 = 数据总线宽度
64位程序和32位程序区别在于运行程序的机器类型字长不一样,他们的字长为64或者32,因此该程序的编译方式也不一样。比如,数据类型 long 在32位程序中占据4字节,在64位程序中占据8字节。int 类型数据在64位和32位系统中通常都占用4字节。为了提升程序可移植性,将数据类型大小固定,不随编译器和机器设置变化是不错的选择。不然我们只能出两款程序来适配两个不同的机器了,同时,32位程序是可以在64位机器上运行的,这是一种向后兼容
对于跨越多字节的程序对象,地址采用所使用的字节中最小的地址,同时附上他们占了多少位,由此来获取完整的数据。程序对象在多字节中的存储顺序分为大端法和小端法,他们的区别只是在内存中是从前往后存储的还是从后往前存储的
# 位运算
虽然用一比特就可以表示 boolean 了,但是在大多数编程语言中,Boolean 还是占一字节的,该值是位运算的起源,普通的 int 类型也可以使用位运算,比如 &,|,^(异或),~(取反),<<、>>()这些
额外聊一下位运算中比较有趣的符号 << 与 >>。这是位移操作,有两种位移方式,一种是逻辑位移,移位时连同符号位一起位移。即左移和右移都对空闲位补0,第二种是算数右移,空闲位数值与符号位相同,但是如果该值是一个有符号数并且为负数(最高位为1),则进行右移后所有的高位都为1
实际上,几乎所有的编译器/机器组合都对有符号数进行算数右移。java 中可以使用 >> 来指定机器使用逻辑位移(按位移动,不考虑符号),用 >>> 来指定机器使用算数位移(保持数值的符号意义)
逻辑运算符相对于位运算符是成对出现的,结果只能为 0x00(false)或 0x01(true)。在逻辑运算中,如果只对第一个参数求值就可以确定表达式结果,则不对第二个参数求值
# 字符串、整数与浮点的表示
C 语言中字符串字符用 ASCII 码表示,并且自动在末尾补上 null(值为0)字符。那如何区分 "" 与 null 呢?ASCII 中有正文开始与正文结束的符号表示,因此这不算什么难事
同样的文本代码,在使用 ASCII 码作为字符码的任意系统都可以得到相同的结果。但是对二进制代码,在不同的系统中即使是相同的进程也有不一样的二进制码,不同机器类型使用不同的不兼容的指令和编码方式。因此,文本程序比二进制程序有更好的平台独立性和移植性
无符号编码以及补码编码都具有唯一性,可以区分出原码+0和-0的不同。补码的表示范围是不对称的| TMin | = | TMax | + 1,补码所有位都是1时表示十进制数字-1,补码的出现是为了方便计算,使减法运算可以当作加法运算,a – b 可以看做 a补 + (-b)补,在上述表达式中,-b 的补码就是将其二进制原码变换成一个无符号的二进制数,使该无符号二进制数 +a 的结果与 a – b 的结果等同
浮点数的表示相对麻烦,之前的厂商有各种千奇百怪的实现方式,我们一般使用 IEEE 对应标准实现。大致是以下过程,并且这么表示是不准确的,如果存储金额等等比较重要的数据,推荐使用大精度对象
补码与 IEEE 表示都是为了方便计算
# 进程与线程
# 进程与线程有什么区别
一个进程可以拥有系统为它分配的各种资源(内存、CPU资源)
在 JVM 层面,java 程序的执行就是一个进程,里面包括了多个线程(栈),以及它们共享的资源(堆,方法区)
在操作系统层面,进程是各种资源分配的基本单位(把一个程序从磁盘拉到内存中),线程是 CPU 中寄存器执行的基本单位(内存中的数据读到 CPU 中)
# 内存与 CPU 组成
内存中存放的数据被 CPU 读取的时候,是一行一行读的。经过缓存时,缓存中的单位称为缓存行(cache line),一行数据8bit(2的幂,最常见的是8bit,对应64位操作系统 CPU 读取与处理一次数据的大小,遵守缓存行对齐会让程序执行变快,比如 java 的对齐填充,以及 disruptor 环形队列)
内存就是 RAM 随机存取存储器(random access memory),是与 CPU 直接交换数据的内部存储器,也叫主存(内存)
除了内存外还有 ROM 只读存储器。在制造 ROM 的时候,信息(数据或程序)就被存入并永久保存。这些信息只能读出,一般不能写入,即使机器掉电,这些数据也不会丢失。ROM 一般用于存放计算机的基本程序和数据
CPU 由 ALU(算术逻辑单元),寄存器(数据存放在 CPU 的寄存器中),PC 寄存器(指向线程下一条指令的地址)组成
多核 CPU 中,每一个核包括 ALU 寄存器 PC 还有一级缓存(L1)、二级缓存(L2),一个 CPU 只有一个三级缓存(L3)
超线程是指,一个核中的 ALU 对应两个寄存器与 PC,这样在两个线程之间切换起来十分容易,平时说的四核八线程就是这个
CPU 的能力由基础频率来决定,表示 CPU 每秒可执行的时钟周期数,理论上频率越高,单位时间内处理的指令越多,性能越强。时钟周期是 CPU 内部计时的最小单位,由主板上晶振产生的时钟信号驱动,一个时钟周期就是时钟信号的一个完整脉冲,从高电平到低电平再回到高电平
# 进程和线程切换时需要进行哪些动作
线程切换:
用户态线程执行 → 中断/系统调用 → 进入内核态 → 保存当前线程上下文(寄存器等等信息)→ 选择下一个线程 → 恢复新线程上下文 → 切换地址空间(可选,执行这一步可能需要切换页表) → 返回用户态执行
进程切换也类似,不过需要维护进程的状态,并且将进程移到合适的优先级队列中,切换原因很可能是时间片执行完毕了
# 缓存一致性协议
MESI 协议是确保多核 CPU 中各个核心的缓存数据一致性的核心机制,MESI 代表缓存行的四种状态:
- M (Modified):已修改,与主内存不一致
- E (Exclusive):独占,与主内存一致,只有本核心有缓存
- S (Shared):共享,与主内存一致,多个核心可能有副本
- I (Invalid):无效,缓存行数据不可用
缓存行(Cache Line):
// MESI以缓存行为单位管理
// 典型缓存行大小:64字节
struct cache_line {
uint64_t tag; // 地址标签
uint8_t data[64]; // 数据
mesi_state_t state;// M/E/S/I状态
bool dirty; // 脏位
};
// 伪代码:缓存行状态检查
mesi_state_t check_cache_line(uintptr_t addr) {
cache_line *line = find_in_cache(addr);
if (!line) return I; // 未命中
return line->state;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
每个缓存行都有自己的 MESI 状态,通过在读写直接发送修改 MESI 状态的指令,来保证各个核心中的缓存数据一致性,我们一般会配合内存屏障使用,内存屏障类型
// 1. 写屏障 (Write Barrier / Store Barrier)
// 确保屏障前的所有写操作完成后,才执行屏障后的写操作
asm volatile("sfence" ::: "memory");
// 2. 读屏障 (Read Barrier / Load Barrier)
// 确保屏障前的所有读操作完成后,才执行屏障后的读操作
asm volatile("lfence" ::: "memory");
// 3. 全屏障 (Full Barrier / Memory Barrier)
// 确保屏障前的所有内存操作完成后,才执行屏障后的操作
asm volatile("mfence" ::: "memory");
2
3
4
5
6
7
8
9
10
11
如何使用:
// 内存屏障通过影响MESI状态转换实现顺序一致性
// 示例:写屏障的实现
store x = 10;
// MESI: 发送Invalidate,等待Ack
// 内存屏障sfence
// 效果:等待所有pending的Invalidate完成
// 即等待x=10对其他核心可见
store y = 20;
// 可以保证:如果其他核心看到y=20,则一定能看到x=10
2
3
4
5
6
7
8
9
10
11
12
# 进程的状态
1,新建:进程正在被创建,系统为这个进程分配各种资源 2,就绪:进程其他的资源已就绪,得到 CPU 资源即可运行 3,执行:进程运行中 4,阻塞:进程被阻塞,一般是因为系统调用或者等待其他优先级高的进程执行 5,死亡:进程正在被撤销,系统回收这个进程的资源
还有七状态模型,多了两种——就绪挂起和阻塞挂起,它们的区别就是就绪挂起状态其实还是在内存中的,而后者是在外存中的
阻塞(pend)就是任务释放 CPU,其他任务可以运行,一般在等待某种资源或信号量的时候出现。挂起(suspend)不释放 CPU,如果任务优先级高就永远轮不到其他任务运行。一般挂起用于程序调试中的条件中断,当出现某个条件的情况下挂起,然后进行单步调试
进程的状态和线程的状态相似
# 线程的状态
操作系统层面的线程五种状态,可以对应进程的状态记忆
1,新建(new):创建了一个新的线程对象 2,就绪(runnable):调用线程的 start 方法,处于就绪状态 3,运行(running):获得了 CPU 时间片,执行程序代码,就绪状态是进入到运行状态的唯一入口 4,阻塞(block):因为某种原因,线程放弃对 CPU 的使用权,停止执行,直到进入就绪状态在有可能再次被 CPU 调度
阻塞在 java 中又分为三种:
- 1)等待阻塞:运行状态的线程执行 wait 方法,JVM 会把线程放在等待队列中,使本线程进入阻塞状态
- 2)同步阻塞:线程在获得 synchronized 同步锁失败,JVM 会把线程放入锁池中,线程进入同步阻塞
- 3)等待:调用线程的 sleep 或者 join 后,线程会进入道阻塞状态,当 sleep 超时或者 join 终止或超时,线程重新转入就绪状态
5,死亡(dead):线程 run、main 方法执行结束,或者因为异常退出了 run 方法,则该线程结束生命周期
因此 java 中对线程的定义有6种,操作系统中的定义更加贴近硬件层面
# 进程间的通信方式
在计算机操作系统中,进程间的通信方式应该分为四类
1,共享内存:两个进程共享同一块物理内存空间,这种方式需要依靠某种同步操作,如互斥锁和信号量等,因为可能会出现修改丢失问题,所以两个进程对内存空间的访问必须是互斥的
2,管道通信:管道是一种在内存中具有一定空间的缓冲区,在 Linux 系统中叫 pipe 文件,与在磁盘中的.txt等文件相似,只能一个进程向文件中写入数据,另一个进程从文件中读取数据,一个进程写数据时另外一个进程不能读数据,两个进程需要互斥访问管道;管道又分匿名管道和有名管道
3,消息队列:直接将消息从一个进程传到另一个进程中,消息(message)是一种封装了数据的东西,比如报文;这种通信方式又分直接通信(通过系统源语实现,一个进程通过发送源语将消息挂到另一个进程的消息访问队列中,进程通过接受源语接受)和间接通信(消息队列),信号和消息队列应该是属于消息传递的
4,socket:主要实现为套接字(一个存放了目的地址、目的端口、传输层协议、自己地址的数据结构)、RPC,主要用与网络间进程的通信,不过也可以在同一台主机上通信(原始套接字)
# 进程的调度算法
为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率,计算机科学家已经定义了一些算法
1,先来先服务 2,短作业优先 3,优先级调度 4,时间片轮转
5,多级反馈队列:融合了上面四个算法的优点,算法如下:
有多个优先级不同的队列,每个队列遵循的先来先服务算法;
每一进程分配一定的时间片,若时间片运行完时进程未结束,则进入下一优先级队列的末尾;
优先级越低,所能分配到的时间片越多
有调度算法就有调度器,CFS(Completely Fair Scheduler,完全公平调度器)是 Linux 内核中默认的进程调度器,自 Linux 2.6.23 版本起取代了之前的 O(1) 调度器
思路为,每个进程维护一个虚拟运行时间,按进程权重加权计算实际运行时间。虚拟运行时间 = 实际运行时间 × (NICE_0_LOAD / 进程权重),最后得出来的值越小越先执行
# 孤儿进程,僵尸进程,守护进程
孤儿进程:父进程如果不等待子进程退出,在子进程之前就结束了自己的“生命”此时的子进程叫做孤儿进程。Linux 为了避免系统存在过多的孤儿进程,init 进程会收留孤儿进程,变成孤儿进程的父进程。这些子线程一般还有用
僵尸进程:创建子进程后,子进程退出状态不被收集,变成僵尸进程。除非爹死后变孤儿 init 养父接收。如果父进程是死循环,那么该僵尸进程就变成游魂野鬼消耗空间
准确的说,一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。僵尸进程将会导致资源浪费,而孤儿则不会
守护进程:守护进程(Daemon)是在一类脱离终端在后台执行的程序,在后台长期运行的独立进程,通常作为系统服务,通常以 d 结尾, 随系统启动, 其父进程通常是 init 进程
# 内存管理
操作系统的内存管理主要负责:
1,为进程进行内存的分配与回收 2,将不同进程的虚拟地址和不同内存的物理地址映射起来,也叫 CPU 寻址 3,使用虚拟内存技术让程序的内存空间从逻辑上增加,一般使用覆盖技术或者交换技术 4,内存保护,让一个进程的指令不能访问其他进程,一般由上下限来判断
基本概念:
存储单元:存放数据或者指令的基础大小,一般按字节储存是8bit 内存地址:用来找到对应存储单元的地址,又叫物理地址,地址的长度和内存条大小有关(比如4GB就是2的32次方,因此需要32bit的地址) 逻辑地址:又叫虚拟地址,通过逻辑地址和程序的起始地址可以找到对应的物理地址 覆盖技术和交换技术:内存不够时将内存中的部分数据放入外存,外存数据进入内存;两者的区别是,覆盖侧重单一程序的交换,交换将其他进程挂起后再进行外存置入内存的操作
# 内存管理机制
将内存分成块、页、段等进行管理,并且执行逻辑地址与物理地址的转化过程,主要有以下两个方式
# 连续分配管理
划分一个连续的物理内存空间给进程使用,这样分配会生成很多碎片空间,有以下两种方法
1,单一连续分配:将内存分成多个固定大小的块,每个块只运行一个进程 2,固定分区分配:将内存先分成多个长度可变的块,每个块自动适应进程大小 3,动态分区分配:在程序装入内存时才将内存进行划分,更加灵活,根据内存回收与分配一定会产生内存碎片,因此有了各种适应算法(空闲链表,使用首次适应、最佳适应、最坏适应算法等)
# 分页管理
将进程打碎放入内存中,这样可以节省很多空间,一般有以下三种方法
1,页式管理:将应用程序分为大小固定的页,内存分为大小相同的页,每个页块非连续的存放在内存中,页式管理通过页表对应逻辑地址和物理地址(页表中存放了每个页块放在哪个物理地址,我们只要根据逻辑地址就可以算出物理地址) 2,段式管理:计算机让程序按逻辑自动拆分为段,段的大小不固定,每个段记录一组完整的消息,离散的放入内存中,段式管理通过段表对应逻辑地址和物理地址,段表中记录了段号与物理地址的对应关系,只要找到偏移量就可以找到对应的物理地址 3,段页式管理:先将程序按逻辑自动拆分为段,将每个段拆分为大小相同的页,通过段表来查找物理地址
# 为什么要使用逻辑(虚拟)地址
1,直接把物理地址暴露给用户会发生很多安全问题 2,程序装入内存条时它的位置是不确定的,程序中的语句如果写死了物理地址,那么只能修改对应物理地址的值,不可以动态变化
# 页式内存管理的优化
不论是快表还是多级页表都与局部性原理有关
# 快表
由于内存管理有虚拟地址到逻辑地址的转化,所以在普通地址变化机制中,CPU 需要访问两次内存才能得到数据(一次访问页表里的虚拟内存,一次访问物理地址上的数据)
使用快表后:
- CPU 发出虚拟地址
- 先在 TLB 中查找:
- 如果命中(TLB hit):直接得到物理地址
- 如果未命中(TLB miss):访问内存页表
- 用物理地址访问数据,访问的数据可能在 cache 中也可能在内存中
- 总共:1次内存访问(TLB命中时)
# 多级页表
较大的进程中,光是页表就要占据很多的连续内存空间,我们可以根据页式管理内存的方式,将页表存放在离散的页块中,在页表之外又加了一层页表,来储存这个进程的页表对应关系(多级页表是因为64位系统中二级页表还是很大,于是又加入了一层页表)
二级页表可以不存在或者不在主存,所有数据必须引入内存,一级页表按需求从磁盘引入内存
# 虚拟内存
它为每个进程分别提供了一个从逻辑上一致的、连续的虚拟地址空间,并保护每个进程的地址空间不会被其他进程破坏
普通的存储器管理必须将所有的数据都装入内存中,虚拟内存技术允许将部分页或者段装入内存中,其他部分留在磁盘。简单来说,虚拟内存使每一个进程可用的内存大大增加
虚拟内存又叫虚拟地址空间,和虚拟地址定义是不一样的
# 局部性原理
时间局部性:访问一个资源之后不久很可能再次访问这个资源 空间局部性:访问一个资源之后很可能访问这个资源附近的其他资源
# 虚拟内存的实现
1,请求分页存储管理:建立在页式储存管理之上,只装入需要运行的页,如果找不到数据,调入需要的页面,如果内存不够,执行缺页中断算法 2,请求分段存储管理 3,请求段页式存储管理
# 页面置换算法
1,最佳置换算法:不可能实现,仅供参考 2,先进先出算法:将最早的页面淘汰 3,LRU 最近最久未使用算法:按使用顺序将最久没有使用的页面淘汰 4,最少使用算法:将使用的最少的页面淘汰
# LRU 算法实现
设计一种数据结构,满足以下条件
一个是 put(key, val) 方法插入新的或更新已有键值对,如果缓存已满的话,要删除那个最久没用过的键值对以腾出位置插入
另一个是 get(key) 方法获取 key 对应的 val,如果 key 不存在则返回 -1
LRU 可以使用 LinkedHashMap 来实现,get 和 put 时间复杂度都为1,这个算法只有一句话,哈希表里存节点
