目录
1. DRAM vs. NVRAM vs. SSD vs. HDD
一、硬件与程序执行基础
(一)图灵机与冯·诺依曼模型
1. 图灵机

图灵机是计算理论中的抽象模型,用来刻画可计算性——它描述了一个有限控制器与无限长的纸带、读写头等概念。图灵机强调算法的可行性,而非物理实现细节。
也就是说,图灵机实际上是一个理想化模型,是用来描述可以被计算的抽象事物,只有能通过图灵机且在有限步骤下证明的事物,那就是可被计算的。
现在的CPU、JVM等等本质上都是图灵机。
2. 冯·诺依曼模型

相对的,冯·诺依曼模型是现代计算机的基本模型。
其核心思想就是将程序也视作为数据,与普通数据放在同一块存储器当中,由CPU来解释执行指令。这使得程序可以被编译解释,也可以被动态地改变并加载,使得高级语言得以出现。
虽然说冯·诺依曼模型构建了现代计算机的架构,但是其本身是拥有一个很致命的性能瓶颈的:CPU从内存取指、处理数据等行为都是在一条总线上完成的,虽然CPU的运行速度非常快,但是内存相对来说比较慢,这就导致了CPU会经常空转等待内存传输指令数据,也就是我们常说的阻塞。
这说明了现代计算机的运行瓶颈并不在CPU,而在于内存的带宽与延迟。至于现代计算机是如何优化这个问题的,我们会在后文进行展开。
(二)CPU架构
1. 寄存器、流水线、缓存等级
(1)寄存器
CPU内部的高速存储单元,访问延迟最小。常见寄存器有通用寄存器、程序计数器(PC)、栈指针、标志寄存器等。
寄存器越有效利用,内存访问越少,性能越好。
编译器/ JIT 会尽量把热变量放寄存器,减少下次访问延迟。
(2)流水线
将一条指令的执行拆成多个阶段(取指、译码、执行、访存、写回),每个时钟周期执行一个阶段,不同阶段的多条指令可以并行处理,提升指令吞吐量。
虽然取指同时只能由一条指令进行,但是第一条指令在译码阶段时第二条指令就可以开始取指,依次类推达到并行的效果。
但是存在流水线气泡,会给执行效率带来影响。
什么是流水线气泡?
因为一条指令分成了多个阶段,一旦其中一个阶段停止,那么整个指令的执行也不会继续推进,但是由于时钟脉冲还在继续,所以就会出现多个空周期,就像一个个气泡一样,里面什么都没有,就只是空占位。
什么会导致流水线气泡?
主要原因有三个:数据冒险、控制冒险、结构冒险。
数据冒险指的是后面指令需要前面指令的结果,所以CPU就会暂停等待结果。
控制冒险是CPU遇到了不确定的分支路径,需要预测如何跳转,一旦跳错,就导致目前流水线的指令全部错误,都需要刷新。听上去开销就很大,事实上控制冒险也是现代CPU性能损失的最主要来源之一。
结构冒险则是多个指令竞争同一个硬件资源,竞争失败则需要暂停等待。
(3)CPU缓存
CPU缓存是用于缓解冯·诺依曼模型瓶颈的,将热点数据存放在更小且更靠近CPU的小存储器中,传输效率就会大大提升。
现代CPU通常存在三层缓存。
| 缓存层级 | 典型容量 | 是否每核独占 | 大概延迟(CPU 周期) | 速度 |
|---|---|---|---|---|
| L1 | 64KB | ✔ | 1–4 | 极快 |
| L2 | 256KB–1MB | ✔ | 4–12 | 快 |
| L3 | 4MB–64MB | ✘(通常共享) | 30–50 | 中等 |
| DRAM | 几 GB | ✘ | 150–300 | 慢 |
为什么要分成三层,全部用L1不就好了?
最主要的原因就是L1太贵了……其次就是太耗电。
2. 超标量、乱序执行
(1)超标量
每个时钟周期可以发射多条指令到不同执行单元,提高指令级并行性,也就是我们之前所说的流水线。
(2)乱序执行
CPU 不必严格按程序顺序执行指令,而是在保持数据依赖正确性的前提下乱序执行以提高资源利用率。当所有前置依赖满足时即可执行该指令。
之所以这样做就是因为可以最大程度地提高吞吐量。
不过坏处也存在,指令重排序可能会导致一些程序错误,最典型的就是DLC问题:线程A正在创建对象时,其中第三步的指针指向目标地址和第二步的初始化对象次序被调换了,导致指针先指向了地址,此时线程B开始访问该对象,由于指针已经存在引用,所以该对象此时不为null,线程B成功拿到了对象,但此时的对象却只是一个半成品,后续逻辑执行可能会抛出空指针异常。
(三)内存与磁盘
1. DRAM vs. NVRAM vs. SSD vs. HDD
| 存储类型 | 本质 | 是否断电丢失? | 速度 | 使用场景 |
|---|---|---|---|---|
| DRAM | 内存(RAM) | 会 | 极快(纳秒级) | CPU 主内存(运行程序) |
| NVRAM | 不丢电的RAM | 不会 | 接近 DRAM(纳秒级) | 内存级存储 |
| SSD | 闪存 | 不会 | 快(微秒级) | 系统盘、数据库、缓存层 |
| HDD | 机械硬盘 | 不会 | 慢(毫秒级) | 海量冷数据 |
2. 存储接口与IOPS/带宽概念
-
带宽:单位时间内可传输的数据量(如 MB/s),适合衡量顺序读写性能。
-
IOPS:每秒处理的 I/O 次数,衡量随机 I/O 性能。小随机 I/O会使 IOPS 成为瓶颈。
-
队列深度:并发 I/O 请求数,影响设备并行处理能力;现代NVMe可以支持高队列深度来提高吞吐。
3. 磁盘寻道与顺序/随机IO
磁盘寻道就是让HDD上的磁头移动到正确磁道的过程。
因为磁盘是机械结构,所以要读一个数据,会先让磁头移动到目标磁道,再等待盘片旋转到正确的扇区,此时才能开始读数据。
之所以HDD访问速度慢,最大的原因就是整个寻道和旋转的延迟太大了。
所以随机IO才比顺序IO要慢得多,因为随机IO每次都需要寻道并旋转,而顺序IO大部分只需要旋转甚至不需要旋转,效率极高,所以大部分操作系统,以及各种与HDD有交互的中间件(例如Kafka、MySQL)都采用了磁盘顺序写。
SSD不是机械结构,那为什么顺序IO与随机IO的访问效率仍存在差别?
首先要知道的是,SSD内部拥有多条通道用来访问数据,而随机IO只会随机跳转其中的1-2条通道进行访问,并行度很差,相对的顺序IO则一般会填充满全部通道,自然访问效率就大大提升了。
那么为什么随机IO很难填充满所有通道?
SSD寻址依赖于FTL映射表,每个IO请求,都会让控制器查询映射表,并把该请求调度到对应物理块的通道上进行访问。
顺序IO由于地址连续映射连续,控制器通常会批量调度,因此一次性就可以利用很多通道。
随机IO则很难做到这一点。
(四)总线、DMA 与零拷贝
1. 总线
总线是CPU、内存、IO 设备之间数据传输的公共通道。
现代主要有三种:
-
内存总线:CPU ↔ 内存
-
PCIe总线:CPU ↔ 网卡 / SSD / 显卡
-
系统总线:包含地址、数据、控制线等
所有的设备都需要通过总线进行通信。
2. DMA
早期磁盘、网卡读取数据时,CPU 要把每个字节搬运到内存,CPU的职责直接从解释执行指令变成了一个单纯传输数据的搬运工了,太浪费CPU的性能了。
于是出现了DMA,是一种让设备直接向内存读写的技术,不需要CPU搬运数据。
有了DMA,CPU在需要搬运数据的时候,就只需要通知DMA进行搬运,而DMA在搬运结束后再反馈给CPU。
最大程度减少了CPU搬运数据的次数。
3. 零拷贝
传统的IO数据拷贝十分低效:
磁盘 → 内核缓冲区
内核缓冲区 → 用户缓冲区
用户缓冲区 → 内核socket缓冲区
内核socket缓冲区 → 网卡
总共 4 次数据拷贝:
- 2 次 DMA
- 2 次 CPU 内存拷贝
还要 4 次上下文切换,极其低效。
可见传统拷贝之所以抵消,最主要原因在于数据一直在不停地搬运到不同的地方。
因此零拷贝的核心思想就是让数据尽可能留在原地,减少拷贝次数。
典型技术包括:
-
技术1:mmap + write
- 传统拷贝存在从内核出发最后又回到内核的流程,多余了依次拷贝
- 该技术减少了多余的这次内核 → 用户空间拷贝
-
技术2:sendfile
- 减少2次拷贝,几乎直接从内核到网卡。
- 用户态甚至不触碰数据,等于0拷贝。
- Kafka、Nginx、Redis、Netty都使用的是这个零拷贝技术
-
技术3:DPDK、XDP
- 直接绕过内核,把网卡映射到用户空间。
二、操作系统结构
(一)操作系统职责概览
操作系统是用户程序和硬件之间的抽象层与管理者,负责把有限的硬件资源变成易用、安全、高效的服务。
-
资源管理:操作系统负责 CPU、内存、I/O 设备等有限资源的分配与回收,确保多个程序公平、高效地共享硬件。
-
抽象提供:将复杂硬件抽象为更易用的接口(如进程、线程、文件、套接字),屏蔽细节并提高可移植性。
-
隔离与保护:通过内核/用户和内存保护隔离不同进程,防止恶意或错误代码影响系统稳定性。
-
并发与同步:提供进程间/线程间通信与同步机制。
-
设备管理:通过驱动管理各种硬件,提供统一的设备接口与策略。
(二)内核模式与用户模式,系统调用实现
1. 用户模式
用户模式的特点如下:
-
权限受限:不能直接访问硬件。
-
不能执行特权指令:如切换页表、关中断、I/O 读写等。
-
安全:即使程序崩了,也不会影响整个系统。
因此,所有的用户应用程序都默认运行在用户模式下。
2. 内核模式
内核模式的特点如下:
-
权限最高:可以直接操作所有硬件。
-
可以执行特权指令。
-
内核代码崩溃会导致全系统崩溃。
操作系统内核运行在内核模式下。
为什么要分成两种模式?
是为了保护系统安全。
用户程序大多是第三方的程序,如果其能随意操作用户设备硬件, 那么首先用户的隐私可能泄露,其次可能会被植入木马导致一些隐私文件或系统文件被删除,最后就是其代码只要崩溃就会导致全系统崩溃。
所以重要的核心权限都归操作系统管理,用户想访问内核只能请求操作系统帮忙。
3. 系统调用
系统调用是用户程序访问内核功能的唯一方式。
系统调用的流程如下:
- 用户态调用open函数想要打开一个系统文件
- 首先会把open函数的参数放入寄存器
- 然后会将open函数对应的系统调用号放入rax寄存器
- 最后准备执行特权指令syscall
- 执行特权指令syscall
- 清除部分寄存器
- 切换到内核栈
- 切换CPU权限级别
- 此时进入了内核态,os根据系统调用号找到open对应的内核函数
- 此时会检查路径是否合法、权限是否足够
- 然后会分配文件描述符
- 内核函数执行完毕
- os将返回值写入rax寄存器
- 切换到用户栈
- 切换CPU权限级别
以上就是用户态到内核态的转换流程,十分耗时,所以才有上文提到的零拷贝技术。
(三)进程与线程模型
- 进程:进程是资源分配的基本单位,包含独立的虚拟地址空间、打开文件表、信号处理表等。
- 线程:线程是调度的基本单位,同一进程内的线程共享地址空间与资源,但拥有独立的寄存器/栈。
这里就先简单说明一下概念,详细会在下文展开。
(四)内核子系统
os内核实际上是由多个子系统构成的,每个子系统都负责一类的核心功能。
以Linux为例,其内核重要的子系统如下:
1. 进程管理子系统
作用:
- 创建 / 销毁进程
- 上下文切换
- 管理进程状态
- 管理线程
目的是让多个程序能够并发地运行。
2. 调度子系统
作用:
- 决定哪个进程使用 CPU
- 多核调度
- 负载均衡
目的是为了让有限的CPU时间片能够高效地分配给所有进程。
3. 内存管理子系统
作用:
- 管理物理内存
- 管理虚拟内存、页表、TLB
- 分配/释放内核内存
- 页缓存、swap
目的是为了让有限的物理内存转化为几乎无限的虚拟内存。
4. 文件系统子系统
作用:
- 提供统一文件接口
- 支持多种文件系统
- 管理 inode、dentry
- 实现缓存
目的是让文件管理交给内核,用户程序无需关心这类实现。
5. IO子系统
包括:
- 块设备(SSD/HDD)
- 字符设备(串口、键盘)
- buffer cache
- elevator I/O调度
目的是让不同类型的硬件设备的读写高效、统一
6. 网络子系统
作用:
- 实现 TCP/IP 协议栈
- socket实现
- 路由、过滤
- 网卡驱动管理
目的是让网络包发送和接收变的尽量可靠。
7. 驱动子系统
负责:
- 管理所有硬件的驱动
- PCIe/USB子系统
- 网卡、显卡、硬盘驱动
- 通过中断与硬件通信
目的是为了分离硬件差异,让上层不用关心设备实现细节。
(五)驱动与中断
1. 驱动
(1)为什么需要驱动?
硬件的内部实现是非常复杂的,而且每个厂商生产的硬件布局、命令格式等等都不一样,os根本不可能兼容所有厂商的硬件,用户程序又无法直接与硬件交互,那这个时候我们就需要加一层适配层来兼容它们,这一层适配层我们就叫做驱动。
(2)什么是驱动?
驱动就是一套os内核对外暴露的接口规范,os不关心不同硬件的内部实现,只要求其驱动必须实现这套规范,使得变成硬件来适配os而不是os去适配硬件。
2. 中断
(1)为什么需要中断?
当CPU让一个硬件处理数据,CPU想要知道是否处理完成就只能一直轮询该硬件,但是如果这个硬件的运行速度很慢,那么就会造成大量空轮询,浪费了CPU的性能。
所以我们需要一个通知机制,无需CPU轮询,而是让硬件自主通知CPU已完成,这种机制就叫做中断。
(2)什么是中断?
中断就是一个硬件提供的机制。当硬件完成任务后,会通过内部的中断控制器向CPU发送中断信号,此时CPU就会先暂停当前任务并保存上下文,然后切换到内核态跳转该硬件驱动提供的中断处理程序。
明明是通知,那为什么叫做中断呢?
当CPU需要调度一个IO到硬件中执行任务,此时就会将对应的线程先挂起,让该时间片去执行其他IO。当任务完成后硬件发起中断信号通知CPU任务已经完成了,此时CPU会立刻放下手中的任务去通过驱动唤醒挂起的线程。
因为打断了CPU正在执行的任务,所以才称作为中断。
(3)中断类型
A. 硬中断
硬中断就是所谓的硬件中断,也就是上文提到的硬件通过中断控制器发出的通知信号,通常用于通知CPU调用驱动中对应的中断处理程序。
B. 软中断
软中断就是软件中断,由内核触发,是一种延迟处理机制。
硬中断需要CPU中断,但是这个中断的时间不能过长,不然会导致CPU后续中断阻塞,整体的性能下降。
所以对于大部分复杂且耗时较长的中断处理程序,执行的并不是硬中断,而是软中断。
软中断由于是os内核触发,所以可以被调度,可以并行处理,自然执行耗时较久的任务效率就高得多了。
C. 异常
异常则是由于CPU检测出错误后触发的,本质上和上面两种中断不一样,异常就是直接中断当前线程任务了。
(六)微内核 vs 单内核 vs 模块化内核
1. 单内核
所有核心功能都运行在内核态,放在一个巨大的内核空间里。
内核里包含:进程管理、内存管理、文件系统、网络协议栈、设备驱动、中断处理、系统调用。
- 优点
- 性能高:所有模块共享地址空间,无需用户态/内核态通信开销
- 实现简单,数据共享方便
- 缺点
- 安全性差:驱动只要崩溃了整个内核就崩溃了
- 难以维护,模块耦合度极高
2. 微内核
只把最小的 OS 必须逻辑放入内核,其他部分全部放到用户态。
内核中只保留:进程间通信、调度器、内存管理核心部分、基本的中断处理;其他全部转移到了用户态。
- 优点
- 安全性强:驱动转移到了用户态,崩溃了也不会影响到内核
- 稳定性强
- 模块化强
- 缺点
- 性能开销大:需要频繁的内核态-用户态切换
- 系统设计复杂
3. 模块化内核
基于单内核架构,但允许驱动/文件系统/协议以模块形式动态加载和卸载。
本质上仍是单内核,但是在其基础上添加了模块化机制。
- 优点
- 性能高:所有模块共享地址空间,无需用户态/内核态通信开销
- 易扩展,可动态添加卸载驱动
- 缺点
- 安全性差:驱动只要崩溃了整个内核就崩溃了
| 特性 | 单内核 | 模块化内核 | 微内核 |
|---|---|---|---|
| 架构 | 所有功能都在内核 | 内核大 + 模块可加载 | 最小内核 + 大量用户态服务 |
| 驱动位置 | 内核态 | 内核态 | 用户态 |
| 稳定性 | 最差 | 较好 | 最好 |
| 性能 | 最高 | 高 | 最低 |
| 安全性 | 差 | 较好 | 最好 |
| 易扩展性 | 差 | 好 | 一般 |
三、内存
(一)虚拟内存与地址空间
虚拟内存是一种抽象的、假的、看不见的内存地址空间,操作系统让每个进程都以为自己拥有一整块连续的大内存,实际需要通过MMU转换成真实的物理内存地址。
当物理内存不够用时,会将暂时不需要使用的内存页转移到硬盘上,由于硬盘可以不断扩展,所以虚拟内存可以说是几乎无限大。
1. 页
页是虚拟内存用于管理内存时的最小连续单位。
把虚拟地址空间和物理内存都切成固定大小的块,每块叫页,一般为:
-
x86:4 KB(最常用)
-
大页:2 MB
-
超大页:1 GB
将内存划分成页有什么作用?
首先就是方便分配连续的虚拟内存地址,因为物理内存地址无需连续。
其次就是方便换页,也就是物理内存和硬盘之间的交换。
最后就是提高整体利用效率,减少了内存碎片。
2. 页表
页表是存储虚拟页到物理页映射关系的数据结构。
其实就是一个映射表,也很好理解。
CPU 在访问一个虚拟地址时,需要:
- 计算虚拟地址:虚拟页号 + 偏移量
- 查寻页表
- 拼接物理地址:物理页号 + 偏移量
如果页表查询不到会发生什么?
页表查询不到的现象我们称作为页错误。
此时CPU会触发异常中断,转为内核态,从磁盘中将该页读到RAM中,然后再更新当前页表,最后就可以转为用户态恢复CPU运行,返回查询结果了。
现代操作系统大多采用多级页表。
为什么要采用多级页表?
假设虚拟内存空间大小为4GB,一个页大小为4KB,那么虚拟内存空间就需要被分成4GB/4KB,约等于一百万页,如果一个页表项需要4B,那么整个虚拟内存空间对于的页表大小就要4MB。
也就是说每一个进程都需要4MB的页表。
但实际上进程用到的虚拟内存空间大小可能实际上只有几十MB,这就导致页表大部分映射关系是完全多余的。
为了解决这个问题,多级页表出现了。
我们以二级页表为例,虚拟地址先查一级页表得到二级页表的位置,再查二级页表得到物理页号,最后拼上偏移组成物理地址。
一级页表只存目前存在的二级页表索引,而只有被访问过的地址,才会为其创建一个二级页表。也就是说多级页表只会为需要访问的地址创建页表,因此节省了非常多的内存空间。
但是此时又存在一个问题:我将存储在内存的数据一般都是程序进程数据或者一些热点缓存数据,我需要的是快速的低延迟访问。可是现在访问内存首先需要查页表,而页表又在内存里,且还是多级页表需要多次查询,等于我本来一次访问变成多次访问才能获取到数据了,效率太低了。
3. TLB
为了加速内存访问速度,我们需要CPU缓存页表的查询结果,这样就无需重复访问都需要查询页表了。而TLB是CPU内部的高速缓存,用来缓存虚拟页号到物理页号的映射。
它不是内存中的结构,而是在CPU芯片上的SRAM,访问速度接近寄存器。所以可以说TLB就是CPU的页表缓存。
只要虚拟地址在TLB上命中了,就可以直接拿到物理内存地址,无需访问页表。
4. 交换的成本与何时触发
(1)什么是交换?
交换指的是当内存不足时,操作系统将不常用的内存页写入磁盘的swap分区,以腾出内存用于更紧急的任务。
一般的交换单位都是页。
(2)交换成本
交换成本主要由三个部分构成:
- 磁盘IO
- 页错误开销
- 进程暂停时间:CPU中断时当前线程被挂起的时间
(3)交换时机
A. 内存不足
虚拟内存管理器会持续监控剩余内存,当剩余内存低于阈值时,os内核会开始回收页缓存以及最近不常用的页(LRU算法)。当回收完后依然低于阈值时则会开始交换。
B. OOM
如果交换后仍旧内存不足或者已经溢出时,内核会强制杀死占用内存最多或者代价最小的进程。
(二)内存管理策略
1. 分页
这里不再展开,仅用作说明分页时内存管理策略之一。
2. 段式
将内存划分成逻辑性更强的段,比如代码段、栈段等,每段大小不一。
- 优点
-
逻辑清晰
-
支持不同段不同权限
-
- 缺点
-
会产生外碎片
-
分配、回收复杂
-
现在很少单独使用分段,出现常见基本是与分页一起使用,不过更常见的还是单一的分页。
3. 伙伴分配器
这是Linux底层物理页分配器,用于分配2^n大小的物理内存块。
- 优点
- 分配速度快
- 合并容易
- 内存不会高度碎片化(相比段式)
- 缺点
- 会产生内部碎片(你要 6KB,它只能给你 8KB)
4. slab分配器
这是Linux对象分配器,用于分配小对象的物理内存。
之所以需要单独这样一个分配器分配小对象内存就是因为如果使用伙伴分配器给小对象分配内存就会出现大量的内部碎片。
slab分配器会为每类对象建立专属的cache,每个cache中包含多个slab,每个slab包含多个slot,每个slot存放一个对象的数据。
- 优点
- 避免频繁分配与释放(对象复用)
- 基本无碎片
- 分配极快
(三)malloc分配器
malloc分配器是用户态内存分配器,负责管理进程的堆区。
1. 内存碎片
(1)内碎片
当malloc分配的chunk大小 > 用户实际需要的大小,就会产生内碎片。
举个例子:
glibc 会将不同大小的内存分成多个bin,例如:32B bin、64B bin……
如果你 malloc(50B),分配器会给你64B的chunk,会浪费14B的内存空间。
(2)外碎片
外碎片指的是空闲块被已分配块隔开,无法合并,导致无法满足大块分配需求。
例如:[A][free][B][free][C],两个free块的大小均为7KB
此时中间两个free块虽然相加够大,但不连续,无法合并。
此时如果你请求一个大块 8KB,即使两个free块相加为14KB,因为不连续,所以无法分配出去。导致需要重新分配一块新的内存区域。
如果重复上述情况,就会导致堆内存不足甚至溢出。
2. 线程局部缓存(tcache)
在没有tcache前,每个线程分配内存都需要加锁访问共享堆内存,高并发时效率很差。
tcache的工作机制如下:
- 每个线程拥有自己的一套小型 free list(按 bin 分类)。
- 小对象分配优先从tcache取。
- free() 出来的小对象通常先放入tcache,而不是还给os管理
优点就是极大地提升了高并发时的性能。
但是同时也存在缺点:由于释放后的小对象内存会优先放回tcache而不是共享堆内存,这就导致出现外碎片。
不过大对象会直接释放还给共享堆内存,所以内存溢出的概率还是很小的。
(四)CPU 缓存与缓存一致性
1. 缓存行
CPU从内存加载数据到缓存时,不是按字节加载,而是按缓存行加载,通常一个缓存行大小为64B。
也就是说即使你只访问一个int,CPU也会加载包含其在内的64B到缓存,因为默认认为下次访问很可能会是连续或相近的内存地址,所以一次性加载就无需多次访问内存,这被称作为预读。
如果一个数据结构的大小不是64B的整数倍,就可能跨两个缓存行。
CPU访问时必须访问两个缓存行,开销更大。
所以为了减少开销,大多数数据结构即使实际大小并没有64B的整数倍,最后也会用占位符对齐。
2. 伪共享
当两个线程访问不同变量,但这两个变量落在同一个缓存行时,虽然逻辑上两个操作独立,但是缓存一致性协议会认为它们共用同一个缓存行,这就会导致其中一个线程如果对这个变量进行了写操作就会导致另一个线程的缓存行立刻失效,最终导致多线程访问同一缓存行时需要频繁地从内存中读取缓存行,使CPU性能暴跌。
下面举一个Java中伪共享的例子:
public class FalseSharingTest {
static class Data {
public volatile long value = 0L;
}
static Data[] arr = { new Data(), new Data() };
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (long i = 0; i < 1_000_000_000L; i++)
arr[0].value = i;
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < 1_000_000_000L; i++)
arr[1].value = i;
});
t1.start(); t2.start();
t1.join(); t2.join();
}
}
arr[0]和arr[1]在地址上是连续的,它们极可能在同一个缓存行上,所以此时两个线程同时写的话就会触发伪共享问题,导致性能暴跌。
不过也无需担心,解决方案还是有很多的,针对并发写JUC提供了大量并发数据结构,比如ConcurrentHashMap、LongAdder,它们都做了分段的处理,能够大幅度避免并发写同时落在同一缓存行上的情况。
除此之外Java也提供了@Contended注解,JVM会自动填充字段,减少落在同一缓存行的概率。
不过最主要的还是编程时尽量避免高并发的接口使用volatile写。
3. 性能损失
| 现象 | 原因 | 成本 |
|---|---|---|
| 缓存行跨界 | 访问跨两个缓存行 | L1/L2 miss 增多 |
| 伪共享 | 多核写同一缓存行不同变量 | 频繁 MESI 同步,缓存失效 |
| 未对齐访问 | 数据分布不对齐 64B 边界 | 两行加载、更多 miss |
| 高并发下的热点变量 | 多线程频繁读写同一缓存行 | 缓存抖动、性能暴跌 |
(五)预读失效与缓存污染问题
1. 预读失效
当CPU从内存中读取一个缓存行缓存到L1,但是用户之后的访问都再也没有访问过这个缓存行,这就是预读失效,CPU预测你接下来还会读取这个缓存行,但是你没有读,所以就失效了。
这就导致了带宽的浪费,而且还可能会把下次要访问的缓存行给淘汰了。
之所以链表的遍历效率不及数组,其中一个原因就在此,链表的节点的内存地址都是分散的,是靠指针将其联系起来,所以在同一个缓存行的概率就很小,自然会频繁地预读失效。
避免预读失效的方式也很简单,就按顺序读呗。在读接口当中常用地址连续的数据结构即可,如果业务非要频繁地随机读(例如抽奖),可以将读的对象都放入一个内存池当中。
2. 缓存污染
预读失效的危害之一就是缓存污染,也就是大量冷数据淘汰掉了热数据。LRU的痛点之一也是缓存污染。
避免缓存污染的话,首先要尽量避免读取大对象,其次多使用流式处理。
四、进程、线程与调度
(一)进程与线程
1. 进程
进程是资源分配的基本单位。
每个进程拥有:
- 独立虚拟地址空间
- 文件描述符表
- 打开的网络连接
- 内存
- 进程控制块
特点:
- 进程之间相互隔离
- 一个进程崩溃不会影响其他进程
- 上下文切换开销大
2. 线程
线程是CPU调度的基本单位。
多个线程共享所属进程的资源:
- 同地址空间
- 同文件描述符
- 同堆区
但每个线程有:
- 私有栈
- 私有寄存器
- 私有线程本地缓存
特点:
- 线程切换更轻量
- 线程共享内存、通信成本低
- 线程过多会产生上下文切换开销
(1)用户线程
调度发生在用户态,完全不涉及内核态。
多个用户线程可以映射到一个内核线程。
- 优点
- 切换快(不陷入内核)
- 数量可达百万级
- 无内核调度开销
- 缺点
- 如果映射的内核线程阻塞就会导致整个用户线程组都阻塞
是不是感觉很像Java的虚拟线程?但其实并不是,因为用户线程与虚拟线程之间还差了一个很关键的机制:挂起/恢复机制。
如果对Java虚拟线程还不是很了解的同学可以阅读以下博客:
带你轻松学习虚拟线程和StructuredTaskScope-CSDN博客
(2)内核线程
完全由内核调度,内核知道每一个线程。
- 优点:
- 内核调度,可真正利用多核
- 一个线程阻塞不会影响其他线程
- 缺点:
- 切换成本高
- 数量有限
- 创建成本大
Java普通线程就是内核线程,也就是常说的平台线程。
(3)协程
协程可以视作为轻量级的用户线程,属于用户线程的更轻量化版本,其特有的挂起/恢复机制可实现非阻塞式编程。
所以说Java的虚拟线程其实属于协程的一个变种,其与内核线程有M:N的映射关系,可以轻松实现MVC的非阻塞式编程,消除了MVC在IO密集型下的阻塞延迟的痛点。
(二)调度基础
1. 抢占式调度
操作系统通过时钟中断强制打断当前运行的线程,从而让调度器接管 CPU。
触发方式:
- 时间片耗尽(最常见)
- 更高优先级线程就绪
- 系统调用退出
- 中断处理结束
2. 时间片
操作系统为每个线程分配一个 CPU 时间额度。
Linux默认的一个时间片是1-4ms,当时间片耗尽就会触发抢占式调度。
优点:
- 防止一个线程长期霸占 CPU
- 实现公平调度
4. 负载均衡
在多核系统中,os必须让负载均匀分布在不同CPU核上。
常见策略:
- Push model:忙的 CPU 把任务推到空闲 CPU
- Pull model:空闲 CPU 主动来偷任务
- NUMA-aware:尽量不要跨 CPU 节点移动线程(会损失性能)
Linux的CFS就用工作窃取+周期性平衡的组合。
(三)锁
os中的锁与用户态锁很不一样,因为os不能随便阻塞,否则中断无法处理、调度器无法调度、内核代码被卡死
因此os会根据不同场景选择不同类型锁。
常见的os锁如下:
1. 自旋锁
在锁被占用时不停自旋(while(lock==locked){})。
特点
- 不会睡眠
- 不会触发调度
- 锁时间极短
典型应用
- 中断上下文
- 内核临界区很短的地方
- 调度器中
- 多核共享数据结构
缺点
- 浪费CPU
- 不能在持锁期间sleep,否则死锁
2. 禁中断的自旋锁
为什么要禁止中断?因为如果中断处理程序也需要拿同一把锁,就会发生CPU持有锁时被中断,此时中断处理程序需要锁,但是锁却还在CPU手中,导致死锁。
在驱动、时钟中断处理等有应用场景。
3. 睡眠锁
特点
- 持锁时间长
- 调度器会把持锁线程挂起
典型应用
- 文件系统
- 内存管理
- 用户态系统调用路径
注意睡眠锁绝不能用于中断上下文,因为中断不能sleep。
4. 读写锁
多个Reader可同时持锁,Writer互斥。
典型应用
- 页表锁
- 文件系统元数据锁
- inode cache 锁
5. RCU
这是Linux最常用的读多写少方案。
读永不加锁,写复制一份修改,等读过渡期之后再释放旧版本。
典型应用
- Linux 内核进程表
- 网络子系统
- 文件系统
6. 序列锁
读者不阻塞写者,写者增加一个 sequence number。
读流程:
- 读取 seq
- 读数据
- 再读seq,如果不一致就重读
用于读极多,写极少的场景。
7. 公平自旋锁
公平锁都是为了解决非公平锁的线程饥饿问题而生的。
Linux中是让每个线程拿一个ticket按号排队。
虽然避免了线程饥饿问题,但是性能相对低下。
五、文件系统
(一)文件系统基础
概念与职责
-
把块设备抽象为文件和目录,提供命名、权限、元数据和持久化存储。
-
管理空间分配。
-
提供一致性保证。
-
提供接口给用户进程。
核心数据结构
-
Superblock:文件系统全局元信息。
-
Inode:存放文件元数据。
-
数据块:实际存储文件内容的最小单位(常见 4KB)。
-
目录项:把名字映射到inode。
-
日志:用于保证崩溃恢复一致性。
(二)虚拟文件系统层次VFS与挂载
VFS(Virtual Filesystem Switch)
-
内核提供统一抽象层,屏蔽不同文件系统实现差异。用户程序只调用统一API,内核把请求路由到实际的文件系统驱动。
挂载(mount)
-
把一个文件系统实例连接到目录树的某个路径。挂载时内核读取superblock、初始化VFS结构、将根dentry连接到挂载点。
-
支持多层挂载。
(三)文件 IO 基本操作与一致性保证
1.常见IO路径
- read()/write():通过文件描述符,经过 VFS、page cache(读优先)、写通常先写入 page cache(写回模式)。
- mmap():把文件映射到进程地址空间,按页访问触发页表/页刷写,修改通过页面回写或 msync 刷回。
- O_DIRECT:绕过 page cache,直接对块设备做 IO(但有严格的对齐与大小要求)。
- 异步 IO:提交 IO 后不用阻塞等待完成,内核/设备完成后通过回调/事件通知。
2. 一致性与持久性
- write-back:写入先进入 page cache,内核在后台刷盘。吞吐高、延迟低,但是崩溃会导致未刷盘数据丢失。
- write-through:写入同时写入缓存并等待写入底层设备,减少丢失窗口,但性能较差。
- O_SYNC / O_DSYNC:在调用 write() 时等待数据写入稳定。
- fsync(fd):把指定文件的数据与必要的元数据写入持久存储。调用者必须使用 fsync 来保证崩溃后数据仍在磁盘。
- fdatasync(fd):只确保数据块到磁盘。
- barrier / write barriers:存储设备或文件系统使用写屏障保证写顺序。
- O_DIRECT 与 mmap:O_DIRECT 需要应用保证对齐并会产生同步 I/O 成本;mmap 让应用通过普通内存读写接口操作文件,但必须用 msync 或 munmap + msync 行为来强制持久化。
(四)目录
1. 目录的功能
主要是把文件名映射到Inode,方便高效的增删改查。
针对查找速度的优化,os一般会提供目录项缓存,减少重复查询IO次数。
2. 实现方式
- 简单线性表:时间复杂度为 O(n),小目录可行,随着目录项的增多效率暴跌。
- 哈希目录:对大量目录项支持快速查找,但是存在哈希碰撞的风险,目录项越多概率越大。
- B-tree/B+tree:适合非常大的目录,支持有序遍历和范围查询。
(五)软链接与硬链接
1. 软链接
软链接就是一个 普通文件,里面保存的是另一个文件的路径,类似于 Windows 的快捷方式。
特点:
- 指向路径,而不是文件本体的inode。
- 可以跨分区、跨文件系统。
- 可以指向不存在的文件(变成坏链)。
- 删除源文件后,软链接会失效。
- 权限由软链接本身决定,但访问目标文件时仍受目标文件权限控制。
2. 硬链接
硬链接是对同一个文件 inode 的另一个引用,也就是说多个文件名,共享同一个 inode 和内容。
特点:
- 只能在同一个分区内。
- 删除其中一个硬链接不影响文件实体。
- 只有当所有硬链接都删除后,文件内容才真正被删除。
- 不能对目录创建硬链接,避免形成环。
- 各个硬链接是完全平等的,互相不可区分。
(六)文件 IO 模型
BIO、NIO、AIO,详细见以下博客:
六、网络IO与高性能网络机制
(一)IO多路复用
IO 多路复用是现代Server端网络编程的核心。
这个机制使得线程避免了阻塞与空轮询,使其可以同时处理多个socket。
现在主流的有三种方式:
1. select
特点:
-
传入一个 FD 集合(最多 1024 个)
-
内核扫描整个 FD 集合
-
返回哪些 FD 就绪
缺点:
-
最大 FD 数量限制(1024)
-
每次调用都复制一大堆 FD 到内核(O(n))
-
需要重复构造 fd_set
-
大量空轮询时性能低
2. poll
poll是select的改良版,虽然其去掉了FD的最大限制,且改为使用数组来存储FD,但是时间复杂度为O(n)以及空轮询的问题也没有得到解决。
3. epoll
核心机制:
-
注册回调(event → fd)
-
事件触发时内核推送到 epoll 的事件队列
-
用户态只需要处理真正发生事件的 FD
也就是说其自带一个回调函数,会在事件触发时调用回调函数让内核将该FD放入事件队列。
避免了前两种方式的所有缺点,时间复杂度还仅有O(1),因为仅需出队操作即可。
综上,epoll得以支持百万级连接,不仅操作系统中的Linux使用了它,很多知名中间件例如Redis等等的底层也都与其有关。
(二)Reactor & Proactor
1. Reactor(同步非阻塞)
事件到来 → 回调触发 → 用户代码主动读取/写入数据。
也就是说内核只负责通知用户线程有事件来了,真正的IO操作还是由用户线程来完成。
典型实现:Java NIO、Netty、Redis、Nginx……
2. Proactor(异步回调)
应用程序提前发起异步 IO → 内核自己做 IO → 完成后发事件通知。
也就是说内核不仅通知用户线程,还帮忙完成了IO操作。
典型实现:Windows IOCP、 Linux NIO
| 项目 | Reactor | Proactor |
|---|---|---|
| I/O 是谁完成? | 用户线程主动 read/write | 内核执行 read/write |
| 是同步还是异步? | 同步非阻塞 | 真正异步 |
| 事件含义 | I/O 就绪 | I/O 完成 |
| 应用负责的事情 | 读写数据、解析 | 处理结果 |
| 常见场景 | 大多数高性能服务器 | Windows 服务器、部分 Linux AIO |
~码文不易,留个赞再走吧~
深入浅出操作系统核心原理

被折叠的 条评论
为什么被折叠?



