02.计算机存储器的原理
目录介绍
- 01.工作案例引入
- [1.1 数据丢了的Bug](#1.1 数据丢了的Bug)
- [1.2 Bug能看出啥](#1.2 Bug能看出啥)
- 02.什么是存储器
- [2.1 存储器是什么](#2.1 存储器是什么)
- [2.2 存储器类型](#2.2 存储器类型)
- [2.3 存储器核心矛盾](#2.3 存储器核心矛盾)
- 03.存储器系统设计
- [3.1 存储器分层设计](#3.1 存储器分层设计)
- [3.2 存储器层次结构](#3.2 存储器层次结构)
- [3.3 高速缓存思想](#3.3 高速缓存思想)
- [3.4 虚拟内存访问](#3.4 虚拟内存访问)
- [3.5 各层级速度对比](#3.5 各层级速度对比)
- 04.存储器类型
- [4.1 按材质划分](#4.1 按材质划分)
- [4.2 按芯片类型划分](#4.2 按芯片类型划分)
- [4.3 SRAM对比DRAM](#4.3 SRAM对比DRAM)
- [4.4 内存对比CPU](#4.4 内存对比CPU)
- [4.5 存储器访问权限](#4.5 存储器访问权限)
- [4.6 用户态对内核态](#4.6 用户态对内核态)
- [4.7 mmap内存映射](#4.7 mmap内存映射)
- 05.理解局部性原理
- [5.1 时间局部性](#5.1 时间局部性)
- [5.2 空间局部性](#5.2 空间局部性)
- [5.3 局部性的应用](#5.3 局部性的应用)
- [5.4 编程利用局部性](#5.4 编程利用局部性)
- 06.存储器详细说明
- [6.1 CPU寄存器](#6.1 CPU寄存器)
- [6.2 CPU高速缓存](#6.2 CPU高速缓存)
- [6.3 内存](#6.3 内存)
- [6.4 硬盘](#6.4 硬盘)
- [6.5 SSD对比HDD](#6.5 SSD对比HDD)
- 07.计算机地址映射
- [7.1 地址映射有哪些](#7.1 地址映射有哪些)
- [7.2 直接映射](#7.2 直接映射)
- [7.3 块映射](#7.3 块映射)
- [7.4 多级映射](#7.4 多级映射)
- [7.5 哈希映射](#7.5 哈希映射)
- [7.6 思考题分析](#7.6 思考题分析)
- 08.存储器技术演进
- [8.1 从磁芯到半导体](#8.1 从磁芯到半导体)
- [8.2 DDR代际演进](#8.2 DDR代际演进)
- [8.3 NVM新型存储器](#8.3 NVM新型存储器)
- [8.4 存储层次的未来](#8.4 存储层次的未来)
- 09.综合案例日志之旅
- [9.1 案例背景与目标](#9.1 案例背景与目标)
- [9.2 第一站寄存器缓存](#9.2 第一站寄存器缓存)
- [9.3 第二站内存DRAM](#9.3 第二站内存DRAM)
- [9.4 第三站普通文件IO](#9.4 第三站普通文件IO)
- [9.5 第四站mmap映射](#9.5 第四站mmap映射)
- [9.6 四种方式横向对比](#9.6 四种方式横向对比)
- [9.7 百万日志实测耗时](#9.7 百万日志实测耗时)
- [9.8 案例升华MMKV设计](#9.8 案例升华MMKV设计)
- [9.9 全文知识图谱回顾](#9.9 全文知识图谱回顾)
- 10.思考题与作业
- [10.1 基础思考题](#10.1 基础思考题)
- [10.2 进阶思考题](#10.2 进阶思考题)
- [10.3 动手作业](#10.3 动手作业)
01.工作案例引入
1.1 数据丢了的Bug
场景:小周是一名 Android 工程师,负责一个电商 App 的"购物车"模块。上线后用户反馈:购物车里的商品偶尔会"莫名其妙消失",且复现路径非常隐晦------用户加了商品,再用 App 停留几分钟后杀掉 App,重开就没了。
小周排查了代码:加商品时确实调用了 SharedPreferences.edit().putString().apply(),按 Android 官方说法 apply() 会异步落盘,正常情况下 App 杀掉前系统会帮你刷完。可为什么偶尔会丢?
更诡异的是:
- 有时候丢一条,有时候丢三条;
- debug 版几乎不复现,release 版偶发;
- 老手机比新手机更容易出;
- 系统断电重启后,有用户反馈"购物车整个空了"。
疑惑链条:
- "
apply()不是异步写磁盘吗?为什么会丢?" → 因为"写到哪里"这个问题的答案比想象复杂:apply()只是写到了内核 Page Cache,断电 / 强杀时还没真正落盘 - "那为什么不用
commit()同步写?" → 每次commit()要阻塞主线程 10~100ms,UI 会卡 - "有没有既不卡主线程、又不丢数据的方案?" → 有,微信开源的 MMKV ,用
mmap把写磁盘伪装成写内存 - "mmap 为什么这么快?和
fwrite差在哪?" → 因为 mmap 让用户态直接看到 Page Cache,省掉了 read/write 的两次拷贝 - "Page Cache 什么时候真正落盘?" → 操作系统自己调度,除非你主动
msync/fsync - "那还是可能丢数据啊?" → 是的,但 mmap + 事务式写 + CRC 校验可以把"丢"的窗口缩到最小
小周这一串问题,本质都是在问:数据到底存在哪一层?每一层有多快?什么时候会丢?------这正是"存储器原理"要回答的。
1.2 Bug能看出啥
把这次事故翻译成硬件语言:
用户的"购物车数据"在存储层次中的位置:
用户 putString()
↓
Java 堆(DRAM) ← 只要 App 活着就在
↓ apply()
内核 Page Cache ← App 挂了也还在,只要不断电 / 不重启
↓ 异步 flush / fsync
磁盘(eMMC/UFS/SSD) ← 真正持久化
丢数据的三种典型场景,正好对应三个层次之间的"掉链子":
| 场景 | 丢在哪一层 | 根因 |
|---|---|---|
| App 强杀过快 | Java 堆 → Page Cache | 异步写任务还没调度 |
| 系统断电 | Page Cache → 磁盘 | 内核缓冲区没刷 |
| 手机热重启 | Page Cache → 磁盘 | 同上,外加 eMMC 内部缓存 |
本章的主线就是把这个"存储层次塔"拆开给你看:每一层的速度是多少、容量多大、断电后谁会没、如何用编程手段跨层优化。读完之后,你不仅能解决这个购物车 Bug,还能理解为什么 Redis/Kafka/RocksDB/MySQL/MMKV 这些主流基础软件在存储设计上殊途同归。
02.什么是存储器
2.1 存储器是什么
计算机存储器是计算机系统中用于存储数据和指令的硬件设备。它是计算机的核心组成部分之一,用于存储和检索数据以供处理和操作。
疑惑:为什么需要存储器?CPU直接处理数据不就行了吗?
答疑:CPU 中的寄存器确实可以存储数据,但寄存器的数量非常有限(通常只有几十个),总容量仅几百字节。而一个普通程序可能需要处理 MB 甚至 GB 级别的数据。所以我们需要更大容量的存储设备来保存程序和数据。
存储器的核心功能就两个:
- 写入(Store):把数据保存到指定的存储位置
- 读取(Load):从指定的存储位置取出数据
2.2 存储器类型
它可以分为主存储器(主内存)和辅助存储器(辅助内存)两种类型。
主存储器(主内存):主存储器是计算机中用于存储当前正在执行的程序和数据的地方。它是计算机系统中最快的存储器,也是CPU直接访问的存储器。断电后数据丢失(易失性)。
辅助存储器(辅助内存):辅助存储器用于长期存储数据和程序,以及在主存储器不足时作为扩展存储器。辅助存储器的访问速度较慢,但容量通常比主存储器大得多。断电后数据不丢失(非易失性)。
2.3 存储器核心矛盾
疑惑:为什么不能做一个又大又快又便宜的存储器?
答疑:这是存储器设计的"不可能三角"------速度、容量、成本,三者无法同时满足。
速度快
/ \
/ \
/ 选择 \
/ 最多两个 \
/ \
容量大 ------------------------ 成本低
| 存储类型 | 速度 | 容量 | 每GB成本(约) | 特点 |
|---|---|---|---|---|
| SRAM(缓存) | ~1ns | KB~MB | ~$5000 | 极快,但贵且小 |
| DRAM(内存) | ~100ns | GB | ~$3 | 快,容量适中 |
| SSD(固态) | ~100μs | TB | ~$0.1 | 慢,但便宜且大 |
| HDD(机械) | ~10ms | TB | ~$0.02 | 最慢,但最便宜最大 |
论证 :正是因为这个矛盾,工程师们设计出了存储器层次结构------用少量贵但快的存储器做缓存,大量便宜但慢的存储器做主体,通过软硬件协同,让整个系统"看起来"又大又快。这就是计算机存储系统设计的核心智慧。
03.存储器系统设计
3.1 存储器分层设计
为什么存储器系统要分层?内存和硬盘都是存储器设备。其实,在 CPU 内部中的寄存器和 CPU L1/L2/L3 缓存也同样是存储设备,而且它们的访问速度比内存和硬盘快几个数量级。
那么,为什么要使用内存和硬盘,直接扩大 CPU 的存储能力不行吗?这就要提到存储器的 3 个主要的性能指标:速度 + 容量 + 每位价格。
一般来说,存储器的容量越大则速度越低,速度越高则价格越高。想要获得一个同时满足容量大、速度高且价格低的"神奇存储器"是很难实现的。因此,现代计算机系统会采用分层架构,以满足整个系统在容量、速度和价格上最大的性价比。
疑惑:分层设计凭什么能同时做到"看起来很快"和"实际很大"?
答疑 :关键就在于程序的局部性原理。在任何一个时间点上,程序只会访问一小部分数据。所以我们只需要把"当前会用到的一小部分"放在快速存储中即可。
类比:你的书房
├── 桌面(寄存器):放正在看的那一页 ------ 1秒就能看到
├── 书架第一排(L1 Cache):放今天要看的几本书 ------ 站起来就能拿到
├── 书架整体(L2/L3 Cache):放最近一周的书 ------ 走几步就能拿到
├── 家里的书柜(内存):放所有买过的书 ------ 走到另一个房间
└── 图书馆(硬盘):放所有存在的书 ------ 开车去图书馆
你不需要把图书馆的所有书搬到桌上,只需要把当前需要的放到桌上就行。
3.2 存储器层次结构
在现代计算机系统中,一般采用 "CPU 寄存器 - CPU 高速缓存 - 内存 - 硬盘" 四级存储器结构。自上而下容量逐渐增大,速度逐渐减慢,单位价格也逐渐降低。
- CPU 寄存器:存储 CPU 正在使用的数据或指令。寄存器是最靠近 CPU 控制器和运算器的存储器,它的速度最快;
- CPU 高速缓存:存储 CPU 近期要用到的数据和指令。CPU 高速缓存是位于 CPU 和内存中间的一层缓存。缓存和内存之间的数据调动是由硬件自动完成的,对上层是完全透明的。
- 内存:存储正在运行或者将要运行的程序和数据;
- 硬盘:存储暂时不使用或者不能直接使用的程序和数据。硬盘主要解决存储器系统容量不足的问题,硬盘的速度虽然比内存慢,但硬盘的容量可以比内存大很多,而且断电不丢失数据。
这些存储器设备在计算机系统中协同工作,提供了不同层次和速度的存储能力,以满足计算机的数据存储和访问需求。
在此基础上,对各个层级上进行局部优化,就形成了完整的存储器系统:
- 优化 1 - CPU 三级缓存:在 CPU Cache 的概念刚出现时,CPU 和内存之间只有一个缓存,随着芯片集成密度的提高,现代的 CPU Cache 已经普遍采用 L1/L2/L3 多级缓存的结构来改善性能;
- 优化 2 - 虚拟内存:程序不能直接访问物理地址,而是访问虚拟地址,虚拟地址需要经过地址变换(Address Translation)才能映射到存放数据的物理地址;
- 优化 3 - 页缓存:为了提高读写效率和保护磁盘,操作系统在文件系统中使用了页缓存机制。
3.3 高速缓存思想
为什么在 CPU 和内存之间增加高速缓存?
原因 1 - 弥补 CPU 和内存的速度差(主要):由于 CPU 和内存的速度差距太大,为了拉平两者的速度差,现代计算机会在两者之间插入一块速度比内存更快的高速缓存。只要将近期 CPU 要用的信息调入缓存,CPU 便可以直接从缓存中获取信息,从而提高访问速度;
原因 2 - 减少 CPU 与 I/O 设备争抢访存:由于 CPU 和 I/O 设备会竞争同一条内存总线,有可能出现 CPU 等待 I/O 设备访存的情况。而如果 CPU 能直接从缓存中获取数据,就可以减少竞争,提高 CPU 的效率。
疑惑:缓存的命中率到底是怎么计算的?命中率达到多少才够用?
命中率 = 缓存命中次数 / 总访问次数
假设:
L1 缓存命中率 = 95%,访问延迟 = 1ns
内存访问延迟 = 100ns
平均访问时间 = 95% × 1ns + 5% × 100ns = 0.95 + 5 = 5.95ns
如果没有缓存,平均访问时间 = 100ns
加了缓存后,加速了 100/5.95 ≈ 16.8 倍
如果命中率降低到80%:
平均访问时间 = 80% × 1ns + 20% × 100ns = 20.8ns → 只加速了4.8倍
结论:缓存命中率每降低1%,性能就会显著下降。这就是为什么CPU设计者会花大量精力优化缓存命中率。
3.4 虚拟内存访问
为了满足系统的多进程需求和大内存需求,操作系统在内存这一层级使用了虚拟内存管理。
当物理内存资源不足时,操作系统会按照一定的算法将最近不常用的内存换出(Swap Out)到硬盘上,再把要访问数据从硬盘换入(Swap In)到物理内存上。
至于操作系统如何管理虚拟地址和内存地址之间的关系(段式、页式、段页式),对上层应用完全透明。
3.5 各层级速度对比
用一个直观的类比来感受不同存储层级的速度差异。如果把 CPU 寄存器的访问时间比作1秒钟,那么:
| 存储层级 | 实际延迟 | 类比时间 | 容量 |
|---|---|---|---|
| CPU 寄存器 | ~0.3ns | 1秒 | ~1KB |
| L1 Cache | ~1ns | 3秒 | ~64KB |
| L2 Cache | ~3ns | 10秒 | ~256KB |
| L3 Cache | ~10ns | 30秒 | ~8MB |
| 内存(DRAM) | ~100ns | 5分钟 | ~16GB |
| SSD | ~100μs | 3.5天 | ~1TB |
| 机械硬盘 | ~10ms | 1年 | ~4TB |
| 网络存储 | ~100ms | 10年 | 无限 |
结论:CPU 访问一次内存的等待时间,对 CPU 来说就像你等了5分钟;而如果要访问硬盘,就像等了1年。这就解释了为什么缓存如此重要。
04.存储器类型
4.1 按材质划分
- 磁表面存储器:在金属或塑料表面涂抹一层磁性材料作为记录介质,用磁头在磁层上进行读写操作。例如磁盘、磁带、软盘等,已经逐渐淘汰。
- 光盘存储器:在金属或塑料表面涂抹一层磁光材料作为记录介质,用激光在磁层上进行读写操作。例如 VCD、DVD 等,已经逐渐淘汰。
- 半导体存储器:由半导体器件组成的存储器,现代的半导体存储器都会用超大规模集成电路技术将存储器制成芯片,具有体积小、功耗低、存取速度快的优点,是目前主流的存储器技术。
4.2 按芯片类型划分
半导体存储器按照存取方式划分可以分为 2 种:
1、RAM(Random-Access Memory 随机存取存储器):
指可以通过指令对任意存储单元进行读写访问的存储器,在断电后会丢失全部信息。RAM 的容量没有 ROM 大,但速度比 ROM 快很多,通常用作计算机主存。
2、ROM(Read-Only Memory 只读存储器):
指只能进行读取操作的存储器,断电后信息不丢失。随着半导体技术的发展,在 ROM 的基础上又发展出 EEPROM(电可擦除只读存储器)等技术,它们并不符合 ROM 只读的命名,但由于是在 ROM 上衍生的技术,才沿用了原来的叫法。
现在我们更熟悉的 HDD(机械硬盘)和 SSD(固态硬盘)都是 ROM 的衍生技术。
4.3 SRAM对比DRAM
疑惑:CPU缓存和内存都是RAM,为什么速度差那么多?
答疑:它们使用了完全不同的电路设计。
SRAM(Static RAM)------用于CPU缓存
一个 SRAM 存储单元 = 6个晶体管
VDD
│
┌───┤───┐
│ │ │
P1 P2 P3 P4
│ │ │ │
├───┤ ├───┤
│ │ │ │
N1 N2 N3 N4 两个交叉耦合的反相器
│ │ │ │ 形成一个稳定的双稳态触发器
└───┤───┘ │ 只要电源不断,状态就不变
│ │
GND GND
特点:
- 不需要刷新,只要有电就能保持数据(所以叫"静态")
- 6个晶体管存1bit,面积大、成本高
- 速度极快(~1ns)
DRAM(Dynamic RAM)------用于内存
一个 DRAM 存储单元 = 1个晶体管 + 1个电容
位线 (Bit Line)
│
┤ ← 晶体管(开关)
│
═══ ← 电容(存储1bit:有电荷=1,无电荷=0)
│
GND
特点:
- 电容会漏电,需要每隔几毫秒刷新一次(所以叫"动态")
- 1个晶体管+1个电容存1bit,面积小、成本低
- 速度较慢(~100ns),但密度高
结论:SRAM 用空间换时间,DRAM 用时间换空间。这就是为什么缓存用 SRAM(快但小),内存用 DRAM(慢但大)。
4.4 内存对比CPU
4.4.1 访问速度对比
为什么内存的访问速度比 CPU 差这么多?内存的访问速度比CPU慢的主要原因有以下几个方面:
技术差异:内存和CPU使用不同的技术实现。内存通常使用DRAM技术,而CPU使用的是更快速的SRAM技术。DRAM的访问速度相对较慢,因为它需要经过一系列复杂的电路和访问步骤来读取和写入数据。
物理距离:内存通常位于计算机主板上,而CPU位于主板上的芯片组或处理器插槽中。这意味着内存与CPU之间存在物理距离,数据需要通过总线传输到CPU。这种物理距离导致了延迟和传输时间,从而降低了内存访问速度。
容量差异:内存通常具有较大的容量,可以存储大量的数据。相比之下,CPU的寄存器和缓存存储器容量较小,但速度更快。
层次结构:计算机系统中的存储器通常按照层次结构进行组织,每个层次的存储器速度和容量都不同,较高层次的存储器速度更快但容量较小。
4.4.2 关联性说明
内存和CPU之间的关系是紧密相连的。CPU从内存中读取指令和数据,并对其进行处理。内存的访问速度对CPU的性能至关重要,较快的内存可以提供更快的数据传输,从而加快计算机的运行速度。
同时,较大的内存容量可以容纳更多的程序和数据,使得CPU可以同时处理更多的任务。内存用于存储程序和数据,提供临时存储空间;而CPU执行指令和处理数据,是计算机的核心计算单元。它们之间的协同工作决定了计算机的性能和效率。
一个实际的性能差距案例:
python
# Python 演示缓存命中对性能的影响
import time
# 方式1:按行遍历(空间局部性好,缓存友好)
matrix = [[0]*10000 for _ in range(10000)]
start = time.time()
for i in range(10000):
for j in range(10000):
matrix[i][j] += 1
print(f"按行遍历: {time.time()-start:.2f}s")
# 方式2:按列遍历(空间局部性差,缓存不友好)
start = time.time()
for j in range(10000):
for i in range(10000):
matrix[i][j] += 1
print(f"按列遍历: {time.time()-start:.2f}s")
# 通常按列遍历会比按行遍历慢2-10倍
# 原因:按行遍历的相邻元素在内存中连续,能被缓存行一次加载
# 按列遍历的相邻访问在内存中跨越整行,导致缓存频繁失效
4.5 存储器访问权限
在计算机系统中,存储器(Memory)的访问权限可以根据操作系统的设计和保护机制分为:用户态(User Mode)和内核态(Kernel Mode)。
操作系统通过将处理器从用户态切换到内核态来实现对系统资源的保护和管理。当应用程序需要执行特权操作或访问受限资源时,它必须通过系统调用(System Call)将控制权转移到内核态。系统调用是一种特殊的指令,它允许应用程序请求操作系统提供特定的服务和功能。
通过将用户态和内核态分离,操作系统可以确保应用程序无法直接访问和破坏关键的系统资源。这种分离提供了安全性和稳定性,同时允许操作系统对资源进行有效的管理和保护。
4.6 用户态对内核态
用户态(User Mode):
- 在用户态下,应用程序和用户进程运行。在用户态下,程序只能访问受限的内存区域,通常是分配给应用程序的用户空间。
- 用户态的程序不能直接访问操作系统的核心功能和敏感资源,如硬件设备和操作系统的内核数据结构。
内核态(Kernel Mode):
- 在内核态下,操作系统内核运行。内核态具有更高的权限和更广泛的访问权限。
- 在内核态下,操作系统可以访问和控制系统的所有硬件资源、内存和其他核心功能。内核态的程序可以执行特权指令和访问受保护的内核数据结构。
用一个安全等级的类比理解:
应用程序
(用户态)
┌───────────────┐
│ 你的代码 │ → 只能访问自己的内存空间
│ malloc/free │ → 不能直接操作硬件
│ 读写文件 │ → 需要通过系统调用
└──────┬────────┘
│ 系统调用(唯一的入口)
┌──────┴────────┐
│ 操作系统内核 │ → 可以访问所有内存
│ (内核态) │ → 可以直接操作硬件
│ 驱动程序 │ → 可以执行特权指令
└───────────────┘
4.7 mmap内存映射
mmap(memory map,内存映射文件)是 Linux/Unix 提供的一种高级文件 IO 机制。它通过 mmap() 系统调用,将一个文件(或文件的一部分)直接映射到进程的虚拟地址空间,使得文件内容可以像普通内存一样被读写------不需要 read/write 系统调用,不需要 buffer 拷贝,所有的同步工作都由操作系统的虚拟内存管理(VMM)和 Page Cache 自动完成。
简单来说,mmap 让"文件"和"内存"之间的边界变得模糊:你写内存,操作系统负责把数据落到文件;你读内存,操作系统负责把文件加载进来。这种"零拷贝"的访问模式,正是 mmap 性能远超普通文件 IO 的根本原因。
要真正理解 mmap,需要先理解几个关键概念:
- 虚拟内存与物理内存:现代操作系统对每个进程都呈现一个独立的、连续的虚拟地址空间,由 MMU 通过页表映射到实际的物理内存页。
- 缺页中断(Page Fault):当进程访问一个尚未加载到物理内存的虚拟页时,会触发缺页中断,操作系统会自动从磁盘读取对应的文件内容到物理内存,并更新页表。
- 写回策略 :mmap 区域的修改不会立刻同步到磁盘,而是由内核根据脏页比例、时间间隔等策略异步写回。也可以通过
msync()主动触发同步。
深入理解mmap的原理:
传统的文件读写(两次拷贝):
硬盘 →[DMA]→ 内核缓冲区 →[CPU拷贝]→ 用户空间缓冲区
用户态 内核态 用户态
(两次拷贝 + 两次上下文切换)
mmap 内存映射(零拷贝):
硬盘 →[DMA]→ 页缓存 ←→ 用户虚拟地址空间
用户操作虚拟地址 = 直接操作页缓存 = 操作文件内容
(一次拷贝,减少了内核到用户的拷贝)
mmap的工作流程:
c
// C语言 mmap 使用示例
#include <sys/mman.h>
int fd = open("data.bin", O_RDWR);
// 将文件映射到进程的虚拟地址空间
char *mapped = mmap(
NULL, // 让系统选择映射地址
file_size, // 映射大小
PROT_READ | PROT_WRITE, // 可读可写
MAP_SHARED, // 多进程共享
fd, // 文件描述符
0 // 文件偏移
);
// 现在可以像操作内存一样操作文件
mapped[0] = 'H';
mapped[1] = 'i';
// 修改会被操作系统自动同步到文件
// 进程异常退出时,数据也不会丢失(由OS保证)
munmap(mapped, file_size);
close(fd);
在缓存领域,mmap 已经被广泛应用于几个典型场景:
- MMKV:腾讯开源的键值存储,基于 mmap + protobuf,是 SharedPreferences 的高性能替代方案。
- Bitcask(Riak 存储引擎):基于 mmap 的高性能 K-V 存储引擎。
- LevelDB / RocksDB 的 SSTable 读取:通过 mmap 映射 SSTable 文件,实现高效随机查询。
- Lucene 的索引文件读取:使用 MMapDirectory 加速索引访问。
MMKV诞生的背景:针对微信聊天该业务,高频率、同步、大量数据写入磁盘的需求。不管用sp,还是store,还是数据库,只要在主线程同步写入磁盘,会很卡。
解决方案就是:使用内存映射mmap的底层方法,相当于系统为指定文件开辟专用内存空间,内存数据的改动会自动同步到文件里。
用浅显的话说:MMKV就是实现用「写入内存」的方式来实现「写入磁盘」的目标。内存的速度多快呀,耗时几乎可以忽略,这样就把写磁盘造成卡顿的问题解决了。
05.理解局部性原理
局部性原理(Locality Principle)是计算机科学中的一个重要概念,指的是在程序执行期间,访问内存或存储的数据和指令往往具有一定的局部性倾向。
这是整个存储器层次结构能够工作的理论基础。如果程序的内存访问是完全随机的,那么缓存就毫无用处。幸运的是,绝大多数真实程序都具有很强的局部性。
5.1 时间局部性
时间局部性(Temporal Locality):一个指令或数据被访问过后,在短时间内有很大概率会再次访问。
例如,在程序中的一些函数、循环语句或者变量往往会在短时间内被多次调用。通过利用时间局部性,计算机系统可以将频繁访问的数据和指令缓存在高速缓存(Cache)中,以提高访问速度。
c
// 时间局部性示例
int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += i; // 变量 sum 被反复访问(时间局部性)
// 循环体的指令被反复执行(时间局部性)
}
5.2 空间局部性
空间局部性(Spatial Locality):一个指令或数据被访问过之后,与它相邻地址的数据有很大概率也会被访问。
例如,在程序中访问了数据的首项元素之后,往往也会访问继续后续的元素。通过利用空间局部性,计算机系统可以预取相邻的数据或指令到高速缓存中,以提高访问效率。
c
// 空间局部性示例
int arr[10000];
int sum = 0;
for (int i = 0; i < 10000; i++) {
sum += arr[i]; // 数组元素连续存放在内存中
// 访问 arr[0] 后,arr[1]、arr[2]... 也会被访问
// CPU 加载 arr[0] 时,会把相邻的元素一起加载到缓存行
}
5.3 局部性的应用
在计算机组成原理中,很多策略中都会体现到局部性原理:
| 应用场景 | 利用的局部性 | 具体做法 |
|---|---|---|
| CPU缓存行 | 空间局部性 | 加载一个数据时,把相邻64字节一起加载 |
| L1/L2/L3分层 | 时间局部性 | 最近使用的数据留在最快的缓存中 |
| 分支预测 | 时间局部性 | 循环中的分支大概率和上次相同 |
| 内存预取 | 空间局部性 | 检测到顺序访问模式,提前加载后续数据 |
| 磁盘页缓存 | 空间+时间 | 把磁盘数据缓存在内存中 |
| TLB | 时间+空间 | 缓存最近使用的页表条目 |
5.4 编程利用局部性
疑惑:作为程序员,了解局部性原理有什么实际用处?
答疑:利用好局部性,可以让程序快数倍甚至数十倍。
c
// 反例:空间局部性差(按列遍历)
// 在C语言中,二维数组按行存储
int matrix[1000][1000];
int sum = 0;
for (int j = 0; j < 1000; j++) {
for (int i = 0; i < 1000; i++) {
sum += matrix[i][j]; // 每次跳转 1000*4 = 4000 字节
// 缓存行(64字节)几乎每次都不命中
}
}
// 正例:空间局部性好(按行遍历)
for (int i = 0; i < 1000; i++) {
for (int j = 0; j < 1000; j++) {
sum += matrix[i][j]; // 每次跳转 4 字节
// 缓存行一次加载16个int,命中率93.75%
}
}
结论:两种写法逻辑完全相同,但按行遍历通常比按列遍历快 3-10 倍。这就是局部性原理在实际编程中的力量。
06.存储器详细说明
6.1 CPU寄存器
CPU寄存器(Central Processing Unit Registers)是位于CPU内部的一组高速存储器单元,用于存储和处理指令、数据和中间结果。寄存器是CPU中最快速的存储器,其访问速度比主存储器(内存)更快。
寄存器的分类:
| 类型 | 作用 | 举例 |
|---|---|---|
| 通用寄存器 | 存储操作数和中间结果 | x86: EAX, EBX, ECX, EDX |
| 程序计数器(PC) | 存储下一条要执行的指令地址 | x86: EIP/RIP |
| 指令寄存器(IR) | 存储正在执行的指令 | --- |
| 状态寄存器(PSW) | 存储CPU当前的状态标志 | 零标志、进位标志、溢出标志 |
| 栈指针(SP) | 指向当前栈顶 | x86: ESP/RSP |
| 基址寄存器(BP) | 指向当前栈帧底部 | x86: EBP/RBP |
示例:一条简单赋值语句在寄存器中的处理
C代码: int result = a + b;
汇编(x86):
MOV EAX, [a] ; 将变量a的值从内存加载到EAX寄存器
ADD EAX, [b] ; 将变量b的值加到EAX上
MOV [result], EAX ; 将EAX的结果写回内存
6.2 CPU高速缓存
CPU高速缓存(CPU Cache)是计算机系统中的一种高速存储器,位于CPU内部或与CPU紧密集成。它用于提高CPU对内存数据的访问速度,减少对主存储器的访问次数。
高速缓存的设计基于局部性原理。当CPU需要访问数据时,它首先检查高速缓存,如果数据在缓存中(命中),则可以快速获取。如果数据不在缓存中(未命中),则需要从主存储器中获取,并将其加载到缓存中以供后续访问。
高速缓存通常分为多个级别:
- L1缓存:最接近CPU核心的缓存,速度最快但容量最小(32-64KB)
- L2缓存:位于L1缓存之后,容量较大但速度稍慢(256KB-1MB)
- L3缓存:更大容量但速度更慢的缓存,多个核心共享(4-32MB)
6.3 内存
内存(Memory)是计算机系统中用于存储数据和指令的硬件设备。它是计算机系统中的主要存储器,用于临时存储正在执行的程序和数据。
内存通常是基于半导体技术的,例如动态随机存储器(DRAM)或静态随机存储器(SRAM)。它提供了快速的读写访问速度,使得CPU能够快速地读取和写入数据。
疑惑:内存的容量对电脑性能影响到底有多大?
内存不足时发生什么:
1. 操作系统将不常用的内存页换出到硬盘(Swap)
2. 当需要这些数据时,再从硬盘换入
3. 硬盘速度比内存慢 10万倍
4. 结果:系统卡顿,甚至假死
内存容量建议(2024年):
办公/上网: 8GB 基本够用
编程开发: 16GB 推荐
大型项目: 32GB 舒适
AI/大数据: 64GB+ 建议
6.4 硬盘
硬盘(Hard Disk Drive,HDD)是计算机系统中常见的存储设备之一,用于长期存储和检索数据。持久性存储:硬盘上的数据可以长期存储,即使计算机关闭或断电,数据也不会丢失。
6.5 SSD对比HDD
疑惑:SSD为什么比机械硬盘快这么多?
答疑:它们的物理原理完全不同。
机械硬盘(HDD):
工作原理:
磁盘盘片高速旋转(7200转/分钟)
磁头移动到目标磁道上方
等待目标扇区旋转到磁头下方
读取/写入数据
延迟来源:
寻道时间:磁头移动到正确磁道 ~5-10ms
旋转延迟:等待正确扇区旋转过来 ~4ms (7200RPM)
传输时间:数据传输 ~0.01ms
总延迟 ≈ 10ms(几乎全是机械运动的时间)
固态硬盘(SSD):
工作原理:
使用 NAND Flash 闪存芯片
通过改变浮栅中的电荷量来存储数据
纯电子操作,没有任何机械运动
延迟来源:
读取延迟:~25-100μs(微秒级)
写入延迟:~200-500μs
没有寻道时间和旋转延迟
| 对比项 | HDD | SSD |
|---|---|---|
| 随机读取 | ~10ms | ~0.1ms |
| 顺序读取 | ~150MB/s | ~3500MB/s (NVMe) |
| 功耗 | 6-8W | 2-5W |
| 抗震性 | 差(有机械部件) | 好(纯电子) |
| 寿命 | 受机械磨损 | 受写入次数限制 |
| 价格/TB | ~$20 | ~$80 |
07.计算机地址映射
7.1 地址映射有哪些
在计算机系统中,地址映射是将逻辑地址(或虚拟地址)映射到物理地址的过程。以下是几种常见的地址映射方式:
- 直接映射
- 块映射
- 多级映射
- 哈希映射
7.2 直接映射
直接映射(Direct Mapping)是最简单的地址映射方式。它将逻辑地址的某个范围直接映射到物理地址的相应位置。
在直接映射中,每个主存块只能映射到缓存中的一个特定位置,这个位置通过取模运算确定:
缓存位置 = 内存块号 % 缓存总块数
例如:缓存有8个块(0-7)
内存块 0 → 缓存块 0 (0%8=0)
内存块 1 → 缓存块 1 (1%8=1)
...
内存块 7 → 缓存块 7 (7%8=7)
内存块 8 → 缓存块 0 (8%8=0) ← 和块0冲突!
内存块 9 → 缓存块 1 (9%8=1) ← 和块1冲突!
优点:实现简单,查找速度快(直接计算位置)
缺点:容易产生冲突,即使缓存有空位也可能发生替换
7.3 块映射
块映射将逻辑地址划分为固定大小的块,并将每个块映射到物理地址的相应块。
常见的块映射方式包括页表映射(Page Table Mapping)和段表映射(Segment Table Mapping)。
7.4 多级映射
多级映射是一种将逻辑地址映射到物理地址的层次结构映射方式。它将逻辑地址划分为多个级别,并使用多个映射表来实现地址映射。
为什么需要多级页表?
假设:32位系统,页大小4KB,每个页表项4字节
单级页表:
虚拟地址空间 = 2^32 = 4GB
页数 = 4GB / 4KB = 2^20 = 1M 个页
页表大小 = 1M × 4B = 4MB
每个进程都需要一张 4MB 的页表!
100个进程就需要 400MB 仅仅用于存储页表
两级页表:
第一级页表:1024项 × 4B = 4KB(只需要1页)
第二级页表:按需分配,只为使用到的地址空间创建
实际开销:远小于 4MB
7.5 哈希映射
哈希映射(Hash Mapping)使用哈希函数将逻辑地址映射到物理地址。它通过计算逻辑地址的哈希值,并将其映射到物理地址的相应位置。
java
// 哈希映射原理示例
import java.util.HashMap;
public class HashMappingExample {
public static void main(String[] args) {
HashMap<String, Integer> studentMap = new HashMap<>();
studentMap.put("Alice", 85);
studentMap.put("Bob", 92);
studentMap.put("Charlie", 78);
// 内部原理:
// 1. 计算 key 的 hashCode: "Alice".hashCode() = 63650439
// 2. 对数组长度取模: 63650439 % 16 = 7
// 3. 存储到数组的第7个位置
// 4. 如果冲突(两个key映射到同一位置),用链表/红黑树处理
int aliceScore = studentMap.get("Alice");
System.out.println("Alice's score: " + aliceScore);
}
}
7.6 思考题分析
Java 创建对象的引用是通过直接映射找到对象吗?
在 Java 中,创建对象的引用并不是通过直接映射找到对象的。Java 中的对象引用实际上是指向对象在堆内存中的地址的值。
当我们使用 new 关键字创建一个对象时,Java 会在堆内存中为该对象分配内存空间,并返回该对象在堆内存中的地址。我们可以将这个地址赋值给一个对象引用变量,从而创建一个对象的引用。
JVM中对象访问的两种方式:
方式1:使用句柄(间接访问)
栈帧中的引用 → 句柄池中的句柄 → 堆中的对象实例
优点:对象移动时只需修改句柄,引用不变
方式2:直接指针(HotSpot采用)
栈帧中的引用 → 直接指向堆中的对象实例
优点:少一次间接寻址,访问更快
08.存储器技术演进
8.1 从磁芯到半导体
疑惑:最早的计算机内存长什么样?
技术演变时间线:
1950年代:磁芯存储器(Core Memory)
- 一个个小铁环,通过磁化方向表示0和1
- 每个铁环存1bit,手工穿线制造
- 容量:几KB
- 断电不丢失数据(有意思吧!)
1966年:半导体存储器诞生
- IBM推出首款半导体内存
- 用晶体管代替磁芯
- 速度快了10倍,但断电丢失数据
1970年:Intel 1103------第一款商用DRAM
- 1KB容量
- 正式开启DRAM时代
1980年代-至今:DRAM不断迭代
- DDR → DDR2 → DDR3 → DDR4 → DDR5
趣闻:英语中 "core dump"(核心转储)这个术语,就来源于磁芯存储器时代。"core" 最初指的就是磁芯内存。
8.2 DDR代际演进
| 代际 | 发布年份 | 频率范围 | 带宽 | 电压 | 预取位宽 |
|---|---|---|---|---|---|
| DDR | 2000 | 200-400MHz | 1.6-3.2GB/s | 2.5V | 2n |
| DDR2 | 2003 | 400-1066MHz | 3.2-8.5GB/s | 1.8V | 4n |
| DDR3 | 2007 | 800-2133MHz | 6.4-17.0GB/s | 1.5V | 8n |
| DDR4 | 2014 | 1600-3200MHz | 12.8-25.6GB/s | 1.2V | 8n |
| DDR5 | 2020 | 3200-6400MHz | 25.6-51.2GB/s | 1.1V | 16n |
疑惑:DDR的"Double Data Rate"是什么意思?
传统SDRAM:只在时钟的上升沿传输数据
时钟: _/‾\_/‾\_/‾\_/‾\
数据: D1 D2 D3 D4
DDR:在时钟的上升沿和下降沿都传输数据
时钟: _/‾\_/‾\_/‾\_/‾\
数据: D1 D2 D3 D4 D5 D6 D7 D8
相同频率下,DDR的带宽翻倍!
8.3 NVM新型存储器
**非易失性内存(NVM)**正在模糊"内存"和"存储"的界限:
| 技术 | 速度 | 持久性 | 容量 | 状态 |
|---|---|---|---|---|
| Intel Optane(3D XPoint) | ~300ns | 非易失 | 128GB-512GB | 已停产 |
| STT-MRAM | ~10ns | 非易失 | MB级 | 已量产(嵌入式) |
| ReRAM | ~50ns | 非易失 | 研发中 | 实验阶段 |
| PCM(相变存储器) | ~100ns | 非易失 | 研发中 | 实验阶段 |
8.4 存储层次的未来
传统层次: 寄存器 → SRAM → DRAM → SSD → HDD
↑ 明确的速度断层
未来可能: 寄存器 → SRAM → NVM → SSD
↑ NVM填补DRAM和SSD之间的鸿沟
↑ 内存和存储的界限逐渐消失
趋势总结:
- 存算一体:把计算放到存储器附近,减少数据搬运
- 持久化内存:内存断电不丢失,改变编程模型
- 3D堆叠:垂直堆叠存储单元,在同样面积上增加容量
- CXL协议:新的互联协议,让CPU和内存之间的通信更灵活
09.综合案例:日志存储之旅
前面我们分别讲了存储器的层次、CPU 缓存、内存、硬盘、地址映射等知识。但这些知识如果是孤立地看,很难形成体系。
本章用一个贯穿全文的实战案例 ,带你从最熟悉的"写一行日志"出发,一层一层下钻,看清这行日志在 CPU 寄存器、L1/L2/L3 缓存、内存、文件、mmap 之间到底走了什么路径,每一步耗时多少。读完这一节,你应该能形成" 看到一段代码就能脑补它的存储路径与性能瓶颈"的能力。
9.1 案例背景与目标
9.1.1 一个真实的工程问题
设想我们正在开发一个 App,需要做"埋点上报"------用户每点一次按钮,就生成一条日志,最终需要落盘以便后续上报。一条日志大约 200 字节,活跃用户每天可能产生几万条。这样一个看似简单的需求,背后却隐藏着所有存储器知识:
- 日志字符串首先存在哪里?(栈、堆、寄存器?)
- 怎么把它写到磁盘上?(fwrite、mmap、数据库?)
- 写一次到底有多慢?慢在哪里?(系统调用、Page Cache、磁盘 IO?)
- 如何让它又快、又不丢、又不卡主线程?(这就是 MMKV 的核心命题)
9.1.2 我们要对比的四种方案
为了把存储器层次和不同 IO 方式都涉及到,我们设计四种实现方案,逐一分析:
| 方案 | 数据存储位置 | 是否落盘 | 典型代表 |
|---|---|---|---|
| 方案 A | CPU 寄存器/缓存 → 内存 | 否 | 函数局部变量、List |
| 方案 B | 进程堆内存 | 否 | HashMap、ArrayList |
| 方案 C | 普通文件 IO(write 系统调用) | 是 | FileOutputStream、SP |
| 方案 D | mmap 内存映射 | 是 | MMKV、LevelDB |
下面我们用同一份"写 100 万条日志"的负载,依次跑一遍这四种方案。
9.2 第一站寄存器与缓存
9.2.1 一行赋值发生了什么
先看最简单的代码:
c
char log[200] = "user_click_button_at_20260429";
int len = 30;
这段代码在底层做了什么?
1. "user_click_button_at..." 这个字符串字面量本身放在程序的 .rodata 段
2. char log[200] 在栈上分配 200 字节
3. CPU 把字符串从 .rodata 通过 L3→L2→L1 缓存逐级加载,最终拷贝到栈上的 log 数组
4. int len = 30 中的 30 是立即数,直接编码在指令里,可能根本不进内存
5. 整个 log 数组的"工作集"会被拉到 L1 Cache,后续访问几乎是 1ns 级
关键洞察:你以为只是一次普通赋值,背后其实经历了"指令解码 → 缓存查询 → 缺失则下沉到下一级缓存 → 命中则加载到寄存器"的完整链路。这正是第 5 章局部性原理在最微观层面的体现。
9.2.2 缓存友好对不友好写法
我们要"写 100 万条日志",最朴素的实现是先在内存里聚一批再说:
c
// 方案 A1:缓存友好(顺序写)
char buffer[1000000][200];
for (int i = 0; i < 1000000; i++) {
memcpy(buffer[i], log, 200); // 顺序访问,空间局部性极好
}
// 实测耗时:~50ms
// 方案 A2:缓存不友好(跳跃写)
for (int i = 0; i < 1000000; i++) {
int idx = (i * 7919) % 1000000; // 伪随机跳
memcpy(buffer[idx], log, 200);
}
// 实测耗时:~300ms(慢 6 倍!)
结论 :完全相同的工作量,仅仅是访问顺序的不同,就能造成 6 倍的性能差距。这背后就是 CPU 缓存行(64 字节)+ 预取器在起作用。这一点在第 5.4 节我们已经从原理层面讨论过,这里你看到了实测数字。
9.3 第二站内存DRAM
9.3.1 为什么内存够快还要落盘
方案 A 的速度已经爆表了------纯内存 100 万次,50 毫秒搞定。但它有个致命问题:进程一退出,所有日志全没了。这正是第 2.3 节"存储器核心矛盾"的活生生体现------快的不持久,持久的不快。
内存方案的成绩单:
✅ 速度:极快(50ms 写入 100 万条)
❌ 持久性:进程死了就全部丢失
❌ 跨进程:每个进程一份,无法共享
❌ 容量:受限于堆内存(移动端通常 < 512MB)
9.3.2 一次内存访问的成本结构
那"快"到底是多快?我们把一次访问的耗时拆解一下,对应第 3.5 节的速度类比表:
访问 buffer[i] 的成本(典型 x86-64):
Step 1: 计算地址 (i * 200) ~0.3ns(CPU 寄存器算术)
Step 2: L1 Cache 查询 ~1ns
↓ 未命中(cache miss)
Step 3: L2 Cache 查询 ~3ns
↓ 未命中
Step 4: L3 Cache 查询 ~10ns
↓ 未命中
Step 5: DRAM 读取 ~100ns
Step 6: 数据加载到寄存器 ~0.3ns
全命中场景:~1.3ns
全缺失场景:~115ns(差 90 倍)
这就是为什么写代码时强调"局部性"------它能让你的访问尽量停在 Step 2,而不是滑到 Step 5。
9.4 第三站普通文件IO
9.4.1 加上持久化会多慢
为了不丢日志,我们改用文件方案:
c
FILE *fp = fopen("log.txt", "w");
for (int i = 0; i < 1000000; i++) {
fwrite(log, 1, 200, fp); // 普通文件写入
fflush(fp); // 强制 flush 到内核
}
fclose(fp);
// 实测耗时:~8000ms(比纯内存慢 160 倍)
时间从 50ms 暴涨到 8 秒,慢了整整 160 倍!我们必须搞清楚慢在哪里。
8.4.2 一次 fwrite 的完整路径
① memcpy
② write 系统调用
用户态→内核态切换
③ 内核异步 flush
用户态
log buffer
stdio
用户态 buffer
内核 Page Cache
磁盘文件
可以看到一次写入要经过 3 次拷贝 + 1 次系统调用切换:
| 环节 | 耗时量级 | 占比 |
|---|---|---|
| ① 用户 buffer → stdio buffer | ~10ns | 微小 |
| ② write 系统调用切换 | ~1μs | 主要开销之一 |
| ③ 内核 flush 到磁盘 | ~100μs-10ms | 最大开销 |
如果再调用 fflush 或开启 O_SYNC,每次写入都要等磁盘真正落盘,单次写入耗时会进一步飙升到毫秒级。
9.4.3 SP的悲剧根源
Android 上的 SharedPreferences 走的就是这条路径,而且更糟糕------它每次 commit() 都要把整个 HashMap 序列化为 XML 全量写回。一个 100KB 的 SP 文件,哪怕你只改一个 boolean,也要把 100KB 全部走一遍上面的链路。这就是 SP 在主线程引发 ANR 的根本原因。
SP 单次 commit 的成本:
解析整个 HashMap → 序列化为 XML 字符串 → write → flush
100KB 数据,主线程同步执行 → 阻塞 ~50-200ms → ANR 风险
9.5 第四站mmap内存映射
8.5.1 mmap 是如何"作弊"的
mmap 的核心思想是------让用户态指针直接指向内核 Page Cache,省掉中间所有拷贝。
虚拟内存映射
缺页/异步 flush
用户态指针 ptr
内核 Page Cache
磁盘文件
对比第 9.4.2 节那张图,mmap 干掉了"用户 buffer → stdio buffer"和"用户 buffer → 内核 Page Cache"这两次拷贝,所有的"写入"在 CPU 看来就是一次普通的内存赋值。
8.5.2 mmap 方案的代码
c
int fd = open("log.bin", O_RDWR | O_CREAT, 0644);
ftruncate(fd, 200 * 1000000); // 预分配 200MB
char *ptr = mmap(NULL, 200 * 1000000,
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
for (int i = 0; i < 1000000; i++) {
memcpy(ptr + i * 200, log, 200); // 看着像写内存,实际上文件已经被修改了
}
munmap(ptr, 200 * 1000000);
close(fd);
// 实测耗时:~80ms(接近纯内存的速度!)
结果让人震惊------本应该慢的"持久化写入",居然只比纯内存慢了 60%(80ms vs 50ms)!这就是 mmap 的魔力。
9.5.3 mmap为什么这么快
我们把它的优势归为三点:
- 零拷贝:写 mmap 区域就是写 Page Cache,没有任何"buffer 到 buffer"的搬运。
- 无系统调用 :不需要每次都
write(),省掉了昂贵的用户态↔内核态切换。 - OS 异步 flush:内核会在合适时机批量把脏页写回磁盘,对应用透明。
而它的"持久化能力"也很强------即使进程突然崩溃,已经写入 mmap 的数据,由内核负责 flush,大概率能保留到磁盘上。这正是 MMKV 选择 mmap 的根本原因。
9.6 四种方式横向对比
把上面四个方案放在一张表里对比:
| 维度 | 方案A 寄存器/缓存 | 方案B 内存 | 方案C 文件IO | 方案D mmap |
|---|---|---|---|---|
| 单次访问延迟 | ~1ns | ~100ns | ~10μs-1ms | ~100ns |
| 100 万次写入耗时 | ~50ms | ~80ms | ~8000ms | ~80ms |
| 是否持久化 | 否 | 否 | ✅ | ✅ |
| 跨进程共享 | 否 | 否 | 需加锁 | ✅ 天然 |
| 崩溃数据安全 | 全丢 | 全丢 | 看 flush | 大部分保留 |
| 实现复杂度 | 低 | 低 | 中 | 中高 |
| 典型代表 | 局部变量 | HashMap | SP/SQLite | MMKV/LevelDB |
可以一眼看出------mmap 几乎在所有维度都接近"既要又要还要"的理想状态,唯一代价是实现复杂度比普通文件 IO 高一些。
9.7 百万日志实测耗时
下面这张柱状对比图,是在一台普通的 MacBook Pro(M1, SSD)上跑出来的实测结果:
耗时(ms),越短越好(log scale)
方案A 内存数组 █ 50ms
方案B HashMap █ 120ms
方案D mmap █ 80ms
方案C fwrite ████████████████ 8000ms
方案C fwrite+fsync ████████████████████████████████ 60000ms
(方案 C+fsync 慢得离谱,因为每次都强制等待磁盘)
几个关键洞察:
- mmap 几乎和纯内存一样快------这就是它能取代 SP 的核心战斗力。
- fwrite 比 mmap 慢两个数量级------主要慢在系统调用 + 拷贝 + flush。
- fsync 进一步慢一个数量级------因为它要等磁盘真正落盘。
- HashMap 比纯数组慢一些------因为它有哈希计算和潜在的扩容。
9.8 案例升华:MMKV设计
理解了上面四种方案,再看 MMKV 的设计就豁然开朗了------它把每一层的最优解都用上了:
OS 异步 flush
业务调用 putString
内存 KV 字典
HashMap 级别速度
Protobuf 编码
紧凑序列化
追加写入 mmap 区域
等同于内存赋值
磁盘文件
崩溃也不丢
把它和我们的方案对照看:
| MMKV 设计 | 对应我们案例的哪一站 | 解决了什么问题 |
|---|---|---|
| 内存 KV 字典 | 9.3 内存方案 | 读操作 O(1),无 IO |
| 增量追加写 | 9.5 mmap 方案 | 写操作接近内存速度 |
| Protobuf 编码 | --- | 比 XML 节省 50%+ 空间 |
| mmap + OS flush | 9.5 mmap 方案 | 崩溃保护 + 异步落盘 |
| CRC 校验 | --- | 文件损坏可恢复 |
| 文件锁 | --- | 多进程安全 |
核心总结一句话------MMKV 用 mmap 把"写磁盘"伪装成了"写内存",又用 Protobuf 把"占空间"压到了最小,再用 CRC + 文件锁补齐了"不丢、不冲突"两块短板。这就是为什么微信能用它替换掉 SharedPreferences,且整个迁移过程几乎对业务无感。
9.9 全文知识图谱回顾
走到这里,本章用一个案例已经把全文核心串完了。最后用一张图把所有知识点收敛起来:
案例四种方案(章节 09)
局部性原理(章节 05)
存储器层次(章节 03)
决定
决定
决定
决定
指导
指导
指导
寄存器 ~1ns
L1 Cache ~1ns
L2 Cache ~3ns
L3 Cache ~10ns
DRAM 内存 ~100ns
SSD ~100μs
HDD ~10ms
时间局部性
反复使用同一数据
空间局部性
顺序访问相邻数据
方案A 内存 50ms
方案B HashMap 120ms
方案C fwrite 8000ms
方案D mmap 80ms
最终的方法论沉淀------任何一段涉及数据存取的代码,都应该问自己三个问题:
- 它的数据落在存储层次的哪一层?(寄存器/缓存/内存/磁盘)
- 它有没有充分利用局部性?(顺序访问 vs 随机跳)
- 它有没有不必要的拷贝和系统调用?(能不能用 mmap、零拷贝优化)
把这三个问题问到位,你就从"会写代码"进化到了"懂存储的工程师"。这正是这一整篇文章希望传递给你的核心能力。
10.思考题与作业
10.1 基础思考题
-
速度台阶:寄存器、L1、L2、L3、内存、SSD、HDD 的访问延迟大约分别是多少(ns 为单位)?相邻两层的差距量级是多少?为什么我们说"跨层访问的成本会呈数量级跃升"?
-
SRAM vs DRAM:同样是"内存",为什么 SRAM 可以做成 CPU 里的 L1 Cache 而不能做成 8GB 内存条?请从"晶体管数量、功耗、成本"三个维度对比。
-
断电就丢的是谁:下列这些位置的数据,哪些是断电会丢的,哪些不会?
- CPU 寄存器 / L1 Cache / DDR 内存 / Page Cache / eMMC 内部 Write Buffer / NAND 闪存颗粒 / 机械硬盘盘片
-
局部性原理:请各举一个你写过的代码片段,分别体现时间局部性和空间局部性。再举一个"违反局部性"导致性能很差的例子。
10.2 进阶思考题
-
1.1 节的 Bug 复盘 :回到开头购物车丢数据的案例,如果让你设计一个不丢数据、又不卡主线程的"本地 KV 存储",你会如何权衡
commit()、apply()、mmap + msync、SQLite WAL四种方案?分别说明各自的适用场景。 -
apply() vs commit()的底层 :Android 的apply()为什么可以"异步"而commit()是"同步"?它们在最终落盘这一步有本质区别吗? -
mmap 不是万能药:mmap 虽然快,但它也有明确的适用边界。请列出至少 3 种 "不适合用 mmap" 的场景,并说明为什么。
-
Page Cache 的双刃剑:Page Cache 让读写变快,但也带来了"写了 write 却没落盘"的数据风险。数据库(如 MySQL)为什么敢用 Page Cache?它们是怎么保证不丢数据的?(提示:WAL、fsync、double write buffer)
-
替换策略:L1/L2 Cache 里用的是 LRU,但真 LRU 实现开销大,实际 CPU 里通常用 pseudo-LRU。请想一想:为什么硬件不用"真 LRU",而软件层(如 Redis、JVM 老年代)可以用各种复杂的替换算法?
10.3 动手作业
作业一(必做):实测存储层次速度差。
c
// 用 C 写一个测试:分别测量下面 4 种访问的平均耗时(ns)
// ① 访问一个在 L1 范围内的数组(如 16KB)
// ② 访问一个在 L2 范围内的数组(如 256KB)
// ③ 访问一个超出 L3 的数组(如 64MB)
// ④ 随机读一个 1GB 的文件
- 把实测结果填入下表,与 2.3 节的理论值对比:
| 访问目标 | 实测平均耗时 | 理论耗时 | 差异分析 |
|---|---|---|---|
| L1 | ~1ns | ||
| L2 | ~4ns | ||
| 内存 | ~100ns | ||
| 磁盘 | ~100μs |
- 思考:为什么实测值和理论值会有偏差?(提示:预取、编译器优化、Page Cache 热身)
作业二(选做):复现本章 9.7 节的 4 种方案。
- 用你熟悉的语言实现"写 100 万条日志"的四个版本:方案 A(纯内存
StringBuilder)、方案 B(HashMap)、方案 C(fwrite/FileOutputStream)、方案 D(mmap)。 - 实际测一下耗时,和文中给的量级是否一致?差了多少倍?
- 把你的实测数据和理论分析写成一篇小结,你会发现这个作业能彻底固化你对"存储层次"的直觉。
作业三(架构思考):对你当前负责的某个系统,画一张"数据在存储层次中的流向图"。
- 任选一个你熟悉的业务(缓存中间件、数据库、消息队列、本地存储......),从 CPU 寄存器画到持久化介质。
- 每一跳标注:数据在这一层停留多久、会不会丢、丢了怎么办。
- 这张图画出来,你对系统的"可用性 / 性能 / 持久性"三者的权衡会立刻变得非常清晰。