编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 序卷方法论

  • 数据的本质

  • 运行时模型

  • 并发的设计

  • 内存的真相

    • README
    • 1.虚拟内存与地址空间
      • 00.真实事故引入
        • 0.1 凌晨四点排查记
        • 0.2 申请10G之谜
        • 0.3 灵魂三问
        • 0.4 五个递进追问
        • 0.5 探索路径
        • 0.6 五语言视图
        • 0.7 为什么值得讲透
      • 01.虚拟内存的诞生
        • 1.1 无虚拟内存时代
        • 1.2 物理内存的独裁
        • 1.3 间接层的力量
        • 1.4 三大矛盾化解
        • 1.5 善意的欺骗
      • 02.地址翻译核心
        • 2.1 分页机制
        • 2.2 MMU翻译芯片
        • 2.3 多级页表
        • 2.4 TLB加速缓存
        • 2.5 缺页中断
      • 03.虚拟内存超能力
        • 3.1 写时复制COW
        • 3.2 mmap内存映射
        • 3.3 共享内存通信
        • 3.4 ASLR安全防御
      • 04.进程地址空间骨架
        • 4.1 标准布局
        • 4.2 栈堆为何对长
        • 4.3 top内存为何虚胖
        • 4.4 64位为何用不完
      • 05.换页与OOM
        • 5.1 物理内存取舍
        • 5.2 生产为何关Swap
        • 5.3 OOM杀手算法
      • 06.跨平台实现对照
        • 6.1 x86 四级页表
        • 6.2 ARM64 翻译表
        • 6.3 操作系统API对比
        • 6.4 JVM虚拟内存
        • 6.5 Go运行时用法
        • 6.6 五语言入口速查
      • 07.经典陷阱反模式
        • 7.1 返NULL才不足
        • 7.2 64位地址泄漏
        • 7.3 误用大页
        • 7.4 COW意外失效
        • 7.5 RSS多进程双计
        • 7.6 碎片致假性OOM
        • 7.7 容器限制盲区
      • 08.综合案例串讲
        • 8.1 八G服务器背景
        • 8.2 pmap看真实布局
        • 8.3 定时任务触发
        • 8.4 触发瞬间分析
        • 8.5 杀手分数验证
        • 8.6 分层防御方案
        • 8.7 容器场景特殊性
        • 8.8 知识点回归映射
        • 8.9 一句话提炼
      • 09.一句话总结
        • 9.1 三层认知阶梯
        • 9.2 全卷的奠基
        • 9.3 七字真言
        • 9.4 与下篇承接
      • 🔗 延伸阅读
    • 2.内存模型技术设计
    • 3.堆和栈内存的设计
    • 4.内存对齐与缓存局部性
    • 5.内存回收机制设计
    • 6.多种引用技术设计
    • 7.内存泄漏与诊断原理
    • 8.数据拷贝设计原理
  • 交互和系统

  • 内功
  • 内存的真相
杨充
2026-05-14
目录

1.虚拟内存与地址空间

# 4.1 虚拟内存与地址空间

📍 本篇位置:第 4 卷 · 内存的真相 · 第 1 篇(全卷开篇) 🎯 核心矛盾:两个进程都打印出 0x7ffeefbff5a8,但里面装的内容毫不相干——物理内存只有一份,地址凭什么能"重复"?这不是 bug,这是一个被现代操作系统"骗"了 50 年的设计 🧭 设计灵魂:在程序员看到的"地址"和真实的物理内存之间插入一层翻译——这层翻译撑起了进程隔离、内存共享、写时复制、按需分页、内存映射文件、ASLR 安全防御。间接性是计算机科学万灵药这句格言的最强例证 🌐 跨平台覆盖:x86_64 四级页表 · ARM64 翻译表 · Linux mmap · macOS Mach VM · Windows VirtualAlloc · JVM 直接内存 · Go runtime 🔗 延伸阅读:← 3.18 结构化并发设计思想 (opens new window) · → 4.2 内存模型技术设计 (opens new window) · → 4.3 堆和栈内存的设计 (opens new window) · → 4.4 内存对齐与缓存局部性 (opens new window)


第 3 卷我们用 18 篇拆解了"并发"——CPU 多核同时跑代码引发的所有矛盾。但有一个更基础的问题被我们悬置了:这些线程读写的"内存",到底是什么?

这一卷我们要钻入"内存的真相"——而所有真相的起点就是这一篇:虚拟内存。当你写下 int *p = malloc(1024) 拿到的那个地址,它根本不是真实的物理地址。这个看似简单的"间接性",是过去 50 年操作系统设计中最重要的一项发明——重要程度不亚于"进程"或"文件"。

# 目录介绍

  • 00.真实事故引入
    • 0.1 凌晨四点排查记
    • 0.2 申请10G之谜
    • 0.3 灵魂三问
    • 0.4 五个递进追问
    • 0.5 探索路径
    • 0.6 五语言视图
    • 0.7 为什么值得讲透
  • 01.虚拟内存的诞生
    • 1.1 无虚拟内存时代
    • 1.2 物理内存的独裁
    • 1.3 间接层的力量
    • 1.4 三大矛盾化解
    • 1.5 善意的欺骗
  • 02.地址翻译核心
    • 2.1 分页机制
    • 2.2 MMU翻译芯片
    • 2.3 多级页表
    • 2.4 TLB加速缓存
    • 2.5 缺页中断
  • 03.虚拟内存超能力
    • 3.1 写时复制COW
    • 3.2 mmap内存映射
    • 3.3 共享内存通信
    • 3.4 ASLR安全防御
  • 04.进程地址空间骨架
    • 4.1 标准布局
    • 4.2 栈堆为何对长
    • 4.3 top内存为何虚胖
    • 4.4 64位为何用不完
  • 05.换页与OOM
    • 5.1 物理内存取舍
    • 5.2 生产为何关Swap
    • 5.3 OOM杀手算法
  • 06.跨平台实现对照
    • 6.1 x86 四级页表
    • 6.2 ARM64 翻译表
    • 6.3 操作系统API对比
    • 6.4 JVM虚拟内存
    • 6.5 Go运行时用法
    • 6.6 五语言入口速查
  • 07.经典陷阱反模式
    • 7.1 返NULL才不足
    • 7.2 64位地址泄漏
    • 7.3 误用大页
    • 7.4 COW意外失效
    • 7.5 RSS多进程双计
    • 7.6 碎片致假性OOM
    • 7.7 容器限制盲区
  • 08.综合案例串讲
    • 8.1 八G服务器背景
    • 8.2 pmap看真实布局
    • 8.3 定时任务触发
    • 8.4 触发瞬间分析
    • 8.5 杀手分数验证
    • 8.6 分层防御方案
    • 8.7 容器场景特殊性
    • 8.8 知识点回归映射
    • 8.9 一句话提炼
  • 09.一句话总结
    • 9.1 三层认知阶梯
    • 9.2 全卷的奠基
    • 9.3 七字真言
    • 9.4 与下篇承接

# 00.真实事故引入

# 0.1 凌晨四点排查记

我曾在金融交易系统接过一个排查任务。某次新版本上线后,监控发现两个无关进程的日志里,同一个内存地址反复出现:

2024-XX-XX 04:00:01 [trader-A] order received at 0x7ffeefbff5a8: BUY 1000 AAPL @ 150
2024-XX-XX 04:00:01 [risk-B]   order received at 0x7ffeefbff5a8: SELL 500 GOOG @ 2800

新人 SRE 看到这个日志吓坏了:

"两个进程居然在同一个地址写不同的内容?这是不是内存越界 bug?
是不是哪里有共享内存配置错了?要不要立刻止损?"

我们花了 5 小时排查,最后发现——这根本不是 bug。两个进程用同样的库(jemalloc),同样的对象池配置,所以分配到的虚拟地址确实可能完全相同。但那是两个进程各自独立的虚拟地址空间——

trader-A 的 0x7ffeefbff5a8 → 物理地址 0x1F3A0000
risk-B   的 0x7ffeefbff5a8 → 物理地址 0x2C4F0000

物理内存只有一份,但每个进程有自己的"地址翻译表"
两个进程哪怕用同一个虚拟地址,也是访问完全不同的物理内存

这位 SRE 的困惑暴露了一个普遍的认知盲区——99% 的程序员把"指针的值"当作"物理地址",但事实完全不是这样。指针的值只是一个"虚拟编号"——必须经过一层硬件翻译才知道真正的物理位置。

# 0.2 申请10G之谜

更诡异的场景。我在一台只有 4GB 物理内存的机器上跑:

#include <stdio.h>
#include <stdlib.h>

int main() {
    void* p = malloc(10L * 1024 * 1024 * 1024);   // 10 GB
    printf("malloc returned: %p\n", p);
    return 0;
}

结果:

malloc returned: 0x7f8a2c000010
进程正常退出,没有任何错误

10GB > 物理内存 4GB——这怎么可能?答案是:

malloc 返回的"地址"只是预订了一段"虚拟地址范围"
真正的物理内存到"读写时"才分配——这叫"按需分页(demand paging)"
我们 malloc 完就退出,从来没真正写过 → 所以一字节物理内存都没消耗

如果加上一句:

memset(p, 0, 10L * 1024 * 1024 * 1024);   // 真正写入

进程会立刻被 OOM Killer 杀掉,因为这次"动真格"了——10GB 物理需求,机器扛不住。

# 0.3 灵魂三问

这两个事故让我反复追问:

  1. 为什么物理内存就一份,但每个进程都觉得自己"独占了所有内存"?这层"骗局"是怎么搭起来的? —— 这层抽象的物理实现是什么?
  2. 为什么 malloc(10GB) 在 4GB 机器上能成功——malloc 到底"分配"了什么? —— 它的承诺和兑现机制如何分离?
  3. 为什么 fork() 创建一个完整的进程拷贝只需要几微秒——而拷贝 1GB 内存按理应该要秒级? —— 这个"快得不合理"背后是什么魔法?

# 0.4 五个递进追问

要把虚拟内存讲透,需要先回答 5 个递进问题:

  1. 没有虚拟内存的时代是怎么过的? —— 真实历史中遇到了什么不可调和的矛盾
  2. MMU 怎么工作? —— CPU 内部那个"翻译机"的物理设计
  3. 页表为什么是"多级"的? —— 单级页表的什么问题逼出了多级
  4. 为什么会"缺页"? —— 缺页中断到底解决什么问题
  5. 虚拟内存有哪些"副产品"? —— mmap、COW、ASLR 是怎么"白送"的

# 0.5 探索路径

# 0.6 五语言视图

虚拟内存是 OS 给所有进程的"通用服务"——但每种语言的 runtime 把这套服务"包装"成了不同的抽象,导致同一行代码("我想申请 1GB 内存")在五语言里走的路径千差万别。先建立全景:

语言 申请大内存的入口 是否经过 runtime 中介 物理兑现时机 程序员"看得见"虚拟地址吗
C malloc(1L<<30) / mmap glibc / jemalloc / tcmalloc 等分配器 写入时(按需分页) 看得见,指针就是虚拟地址
Java -Xmx1g 或 ByteBuffer.allocateDirect JVM 启动时 mmap 大块 arena 写入时;JVM 内部还有 GC 区域分配 看不见,引用是 JVM 自己的 handle/oop
Go make([]byte, 1<<30) Go runtime 启动时 mmap 几百 GB 虚拟地址 arena 写入时;free 后 madvise(MADV_DONTNEED) 看不见,引用是 runtime 管理的
JavaScript new ArrayBuffer(1<<30) V8 内部 mmap,受 isolate 配额限制 写入时;V8 可能预 commit 完全看不见
Python np.zeros(10**9, dtype='b') / bytearray(1<<30) CPython 通过 PyMem_Malloc → libc malloc 写入时 部分可见(id(obj) 是虚拟地址但不可算术运算)

两条普适规律:

规律 1(无人例外):所有语言申请的"大内存"都是虚拟地址承诺,
                  物理消耗都遵守"按需分页"——这是 OS 层的统一行为。

规律 2(runtime 越厚,地址越隐形):
  C:完全裸奔,指针就是虚拟地址
  Java/Go/JS:runtime 自己 mmap 一大块再切给应用,应用看不到底层地址
  Python:介于两者之间,原始数据可经 ctypes 拿到地址

这两条规律会贯穿全章——§3 讲"超能力(mmap/COW/ASLR)"时,C 程序员能直接调用,但 Java/Go/JS 程序员要通过 MappedByteBuffer、mmap.Mmap、SharedArrayBuffer 这些 runtime 包装层来"间接享用"。带着这张视图往下读,所有"为什么 Java 没有 mmap API" 之类的问题都会自然有答案。

# 0.7 为什么值得讲透

我想抛三个问题:

  1. 为什么所有计算机科学的经典格言里都有"any problem can be solved by adding a layer of indirection"? —— 因为虚拟内存就是这句格言最"金光闪闪"的范例。
  2. 为什么 top 看到进程占用 8GB,但实际"真用"的只有 1GB? —— 因为虚拟内存把"承诺"和"兑现"分离了。
  3. 为什么云时代的容器(Docker、k8s)依然完全依赖虚拟内存? —— 因为容器隔离的根基依然是进程隔离,进程隔离的根基就是虚拟地址空间。

读完本章你会懂:虚拟内存不是"操作系统的一个特性"——它是从硬件、操作系统、库到应用程序所有层级的"共同假设"。理解了它,你才能开始理解上面这些层为什么这样设计。


# 01.虚拟内存的诞生

# 1.1 无虚拟内存时代

让我们倒回 1981 年,那时的 MS-DOS 没有虚拟内存:

程序看到的地址 = 物理内存地址(直接对应)

这意味着:

// 程序 A
char* p = (char*)0x10000;
*p = 'A';

// 程序 B(同时运行)  
char* q = (char*)0x10000;
*q = 'B';

// 结果:A 看到的 *p 变成了 'B'!互相覆盖!

这就是为什么 MS-DOS 是"单任务系统"——多个程序同时跑必然互相破坏。

# 1.2 物理内存的独裁

问题清单(直接寻址带来的三大矛盾):

矛盾 症状 后果
多进程冲突 两个程序写同一地址 互相破坏
大于物理内存 程序需要 100MB,机器 64MB 程序跑不起来
碎片化 反复分配释放后地址支离破碎 大块分配失败

1960 年代英国曼彻斯特大学的工程师们提出了一个革命性思路:

在程序看到的地址和真实物理地址之间——插入一层翻译。

# 1.3 间接层的力量

加上一层翻译后:

间接层带来的"魔法":

1. 进程隔离:每个进程有自己的翻译表,互不干扰
2. 内存压缩:物理内存可以"分散"映射,逻辑上连续
3. 按需分配:虚拟地址承诺存在,物理只在用时分配
4. 透明扩展:物理不够可以"换出"到磁盘

这就是 §0.6 第一题的答案——间接性是计算机科学的"万灵药"。这句话来自 David Wheeler:

All problems in computer science can be solved by another level of indirection.

虚拟内存是这句话最辉煌的注脚——仅仅加了一层翻译,前面三大矛盾全部消解。

# 1.4 三大矛盾化解

矛盾一:多进程冲突 → 进程隔离

每个进程有独立的页表
进程 A 的 0x10000 → 物理 X
进程 B 的 0x10000 → 物理 Y(完全不同的物理位置)

矛盾二:程序大于物理内存 → 按需分页 + Swap

程序声明用 1GB 虚拟内存(页表设置了 1GB 的"占位")
真正访问到的部分才映射到物理(其他保持"未分配"状态)
不够时把不常用的页换到磁盘

矛盾三:内存碎片 → 物理碎片不影响虚拟连续

程序看到连续的 1GB 虚拟地址
物理上可以是 1024 个 1MB 的小块拼接
甚至可以是不连续的 4KB 页!

# 1.5 善意的欺骗

虚拟内存的本质,是操作系统精心设计的一场骗局:

对每个进程说:"你拥有整个 0 到 2^48 的内存空间"
对每个程序员说:"指针 p 的值就是内存地址"
对每个 malloc 说:"你要多少我给多少"

但真相是:

每个进程:分到的物理内存只是冰山一角
每个指针:必须经过 MMU 翻译才有意义
每次 malloc:只是更新了一张"虚拟内存簿记表"

整个软件栈都在"假定虚拟内存为真"上运行——这场骗局是操作系统给应用程序的"基础信任契约"。


# 02.地址翻译核心

# 2.1 分页机制

虚拟内存能成立的物理基础——分页(paging)。

不再以"字节"为单位管理内存
而是以"页(page)"为单位——通常 4KB

为什么是 4KB?

太小(如 16 字节):页表太大,元数据爆炸
太大(如 1MB):内部碎片严重,浪费

4KB 是 1970 年代权衡空间/性能后的"魔数"——一直沿用至今
现代系统也支持 2MB / 1GB 的"大页(huge page)"——为特殊场景

# 2.2 MMU翻译芯片

**MMU(Memory Management Unit)**是 CPU 的一部分,专门做地址翻译:

翻译过程的物理细节(x86_64 简化版):

虚拟地址(48 位):
[47-39] [38-30] [29-21] [20-12] [11-0]
  L4      L3      L2      L1      偏移

L4 索引 → CR3 寄存器指向的页表 → L4 页表项 → L3 页表基址
L3 索引 → L3 页表项 → L2 页表基址
L2 索引 → L2 页表项 → L1 页表基址
L1 索引 → L1 页表项 → 物理页基址
+ 偏移 = 物理地址

这就是 §0.4 第二题的答案——MMU 是 CPU 内的硬件电路,每次内存访问都要做这个翻译。

# 2.3 多级页表

§0.4 第三题。为什么是"多级"而不是单级?

单级页表的灾难:

64 位地址空间,每页 4KB
页表项数 = 2^64 / 2^12 = 2^52 个
每项 8 字节 → 页表大小 = 2^55 字节 = 32 PB

每个进程要 32 PB 的页表——内存还没装下页表!

多级页表的解法:稀疏映射。

虚拟地址分成几段,每段索引一级页表
程序实际只用了 GB 级地址
所以只有"用到的部分"需要分配下级页表
没用到的部分——上级页表项就是空的,下级根本不存在!

实际页表大小:通常几 MB(每个进程)

关键洞察:只为"实际使用"的虚拟地址分配页表项——这种"惰性"设计让 64 位地址空间变得可行。

# 2.4 TLB加速缓存

但每次访问都做四级查表——慢得不可接受。所以 CPU 在 MMU 旁边放了一个TLB(Translation Lookaside Buffer,翻译后备缓冲):

TLB 是一个小型缓存(通常几十到几百项)
存最近用过的"虚拟页 → 物理页"映射

访问内存时:
  1. 先查 TLB——命中?直接得到物理地址(1 周期)
  2. 没命中?走多级页表(几十周期)
  3. 拿到结果回填 TLB

典型 TLB 命中率 > 99%——所以多级页表的"慢"几乎隐藏了。

TLB 的关键代价——"刷新":

进程切换时 → CR3 切换 → TLB 整个失效 → 性能下降几百微秒
这是上下文切换昂贵的核心原因之一

现代 CPU 加了 ASID(Address Space ID):
  TLB 项带上进程 ID
  切换进程时不用全刷——节省了大量时间

# 2.5 缺页中断

§0.4 第四题。为什么有"缺页"?因为虚拟地址映射可能根本不存在。

缺页的三种原因:

类型 说明 处理
Major Fault 数据在 Swap 读磁盘(毫秒级)
Minor Fault 物理页未分配但映射有效 内核分配(微秒级)
Invalid 访问未授权地址 SIGSEGV,进程崩溃

这就解释了 §0.2:malloc(10GB) 只是设置了页表的"虚拟范围"——没有分配物理页,没有任何 Major/Minor Fault。直到真正写入才触发 Minor Fault → 分配物理页 → 物理内存才被消耗。


# 03.虚拟内存超能力

虚拟内存的间接层不只解决了"内存够不够"——它顺带带来了一系列"超能力"。

# 3.1 写时复制COW

§0.3 第三题。fork() 拷贝整个进程为什么只要几微秒?

朴素实现:复制父进程的所有内存到子进程。1GB 进程要拷贝 1GB → 几秒。

实际实现(COW):

COW 的精妙:

1. fork:拷贝页表(小,几 KB),物理页共享,全部标记为只读
2. 任意一方写入:触发缺页(写保护)→ 内核分配新物理页 → 拷贝过去 → 改成读写
3. 没写过的页:永远共享!

→ 实际拷贝量 = 子进程"写过"的页面,远小于总内存
→ Linux 上 fork 1GB 进程,常常只拷贝几 MB

这是"延迟到必要时刻才付代价"的极致设计——你以为 fork 慢,但它把慢分摊到了"真正需要修改"的时刻。

# 3.2 mmap内存映射

int fd = open("huge.dat", O_RDWR);
char* data = mmap(NULL, 10*1024*1024*1024L,    // 10GB
                  PROT_READ | PROT_WRITE,
                  MAP_SHARED, fd, 0);

// 现在可以像访问内存一样访问文件
data[1234567890] = 'A';   // 自动 read 文件对应位置

mmap 的工作原理:

1. mmap 不真正读文件——只是建立"虚拟地址 → 文件"的映射
2. 访问 data[i] → 缺页 → 内核读取文件对应的 4KB → 映射到物理页 → 重新执行
3. 修改 data[i] → 标记 dirty → 后续异步 flush 到磁盘

对比传统 read/write:

read:[磁盘] → [内核缓冲区] → [用户缓冲区]   两次拷贝
mmap:[磁盘] → [页缓存]                    零拷贝

这就是"内存映射文件"的威力——mmap 把内存和磁盘的边界模糊化了。Redis 的 BGSAVE、Kafka 的 zero-copy、SQLite 的查询,全都依赖 mmap。

# 3.3 共享内存通信

// 进程 A
int fd = shm_open("/myshm", O_CREAT|O_RDWR, 0666);
ftruncate(fd, 4096);
char* p = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
strcpy(p, "Hello from A");

// 进程 B
int fd = shm_open("/myshm", O_RDWR, 0666);
char* q = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
printf("%s\n", q);    // "Hello from A"

关键:

A 的 p(虚拟地址)→ 物理页 X
B 的 q(虚拟地址)→ 物理页 X(同一物理页!)

写入物理页 X 的内容立刻被双方看到——零拷贝、零开销

虚拟内存允许"不同进程的不同虚拟地址映射到同一物理页"——这就是共享内存的物理基础。

# 3.4 ASLR安全防御

问题:黑客的攻击常常依赖"已知地址"——

// 缓冲区溢出攻击
char buf[64];
gets(buf);                    // ⚠️ 可写超长输入
// 攻击者写入 64 字节后覆盖返回地址 → 让函数返回到 0x12345(已知 shellcode 地址)

ASLR(Address Space Layout Randomization):

每次进程启动,栈、堆、共享库的基址都随机化
攻击者不知道 shellcode 在哪 → 攻击难度指数级上升

虚拟内存让这成为可能——因为虚拟地址本来就是任意指定的:

# 第一次跑
$ ./prog
stack base: 0x7ffe5a3b2000
heap base:  0x55c8a1e3a000

# 第二次跑
$ ./prog
stack base: 0x7ffce8c4d000   # 完全不同
heap base:  0x564f3b289000

这是"间接层"白送的安全红利——没有虚拟内存就没有 ASLR。


# 04.进程地址空间骨架

# 4.1 标准布局

每个进程都有一个标准的地址空间布局:

高地址 ─┐
        │  内核空间(用户态不可见)
        │─────────────────────  0x7fff_ffff_ffff
        │  栈(Stack)↓ 向下增长
        │
        │  ↕ 巨大的空隙
        │
        │  mmap 区(动态库、mmap 文件)
        │
        │  ↕ 巨大的空隙
        │
        │  堆(Heap)↑ 向上增长
        │─────────────────────
        │  BSS 段(未初始化静态/全局变量)
        │  数据段(已初始化静态/全局变量)
        │  代码段(.text,只读+可执行)
        │─────────────────────  0x0040_0000
低地址 ─┘

Linux x86_64 的具体布局:

$ cat /proc/self/maps
00400000-00452000 r-xp 00000000 ...  # 代码段
00652000-00653000 r--p 00052000 ...  # 只读数据
00653000-00654000 rw-p 00053000 ...  # 数据段
01a36000-01a57000 rw-p 00000000 ...  # 堆
7f3a8b1cc000-7f3a8b1d0000 r-xp ...   # 共享库
7ffeefbff000-7ffeefc20000 rw-p ...   # 栈
ffffffffff600000-ffffffffff601000 ... # vsyscall

# 4.2 栈堆为何对长

理由:

1. 栈大小不确定(深递归会涨)
2. 堆大小不确定(malloc 会涨)
3. 让两者从地址空间两端"对着长" → 中间留巨大空间,谁也撞不到谁

32 位时代的悲伤:

4GB 地址空间,扣除内核 1-2GB
实际可用 ~2-3GB
栈 + 堆 + 共享库 + mmap 全在这点空间里挤
→ 大数据应用很容易触顶(Java -Xmx 在 32 位最多 ~1.5GB)

64 位时代的舒适:

2^48 = 256TB 可用虚拟空间
栈和堆永远撞不到

# 4.3 top内存为何虚胖

$ ps aux | grep myapp
USER  PID  %CPU  %MEM  VSZ      RSS    
me    100  10.0  20.0  8000000  500000   # VSZ=8GB, RSS=500MB

两个关键指标:

指标 含义
VSZ(Virtual Size) 虚拟地址空间大小——"承诺"了多少
RSS(Resident Set Size) 真正驻留物理内存的大小——"兑现"了多少

所以 VSZ=8GB / RSS=500MB:

进程的虚拟地址空间分配了 8GB
但其中只有 500MB 真正映射到物理内存
其余 7.5GB 是:
  - mmap 但未访问的文件
  - malloc 但未写入的内存
  - 共享库(与其他进程共享)
  - 栈/堆的预留空间

这就是"虚拟"的精髓——承诺先行,兑现按需。

# 4.4 64位为何用不完

x86_64 实际只用了48 位虚拟地址:

理论:2^64 = 16 EB
实际:2^48 = 256 TB(高 16 位强制为 0 或 1)

为什么这么"浪费"?
1. 多级页表的层数会爆炸(48 位已经 4 级,64 位要 6 级)
2. 物理内存暂时也只有 TB 级,48 位足够用 100 年
3. 高位留作"未来扩展"

ARM64:理论上支持 48 位、52 位、甚至 56 位——但默认通常 48 位。


# 05.换页与OOM

# 5.1 物理内存取舍

Swap 的工作:

内存紧张时 → 内核选"近期不用"的页 → 写到磁盘的 Swap 区域
进程访问该页 → Major Fault → 从 Swap 读回

Swap 的代价:磁盘比内存慢 1 万倍——一旦频繁 Swap(thrashing),系统几乎卡死。

# 5.2 生产为何关Swap

数据库、Redis、Kafka 的最佳实践:

# 永久关闭 Swap
swapoff -a
sed -i '/swap/s/^/#/' /etc/fstab

理由:

1. 这些应用对延迟极度敏感
2. 一旦数据被 Swap → 访问延迟从微秒级跳到毫秒级
3. 不如让进程"硬失败"(OOM)也比"软挂死"好
4. 现代云服务器内存已大到"Swap 没必要"

Linux 的 swappiness:

# 0 表示尽量不用 swap
# 100 表示积极用 swap
echo 1 > /proc/sys/vm/swappiness

# 5.3 OOM杀手算法

当 Swap 也不够时,OOM Killer 出场——杀掉一个进程腾出内存。

它怎么选?

// 简化版
score = process.memory + process.vm_size
       + (process.is_root ? -3000 : 0)        // root 进程减分
       + adj_score;                            // /proc/[pid]/oom_score_adj

// 杀分数最高的

生产经验:

# 让重要进程"豁免"
echo -1000 > /proc/PID/oom_score_adj          # 永不被杀
echo -500  > /proc/PID/oom_score_adj          # 优先级低(不易被杀)

# 06.跨平台实现对照

# 6.1 x86 四级页表

虚拟地址 48 位
→ PML4 (9b) → PDPT (9b) → PD (9b) → PT (9b) → 偏移 (12b)
4KB 页 / 2MB 大页 / 1GB 巨页

# 6.2 ARM64 翻译表

TTBR0_EL1(用户空间)/ TTBR1_EL1(内核空间)双指针
4 级翻译表(48 位)或 5 级(52 位)
4KB / 16KB / 64KB 三种页大小可选

ARM 的特色——支持 16KB / 64KB 大页,Apple Silicon 用 16KB:

更大的页 → 同样大小内存所需页表项更少 → TLB 命中率更高
但内部碎片增加
苹果选 16KB 是平衡点

# 6.3 操作系统API对比

平台 分配 释放 映射文件
POSIX mmap munmap mmap(fd)
Linux brk/sbrk + mmap 同 mmap(MAP_PRIVATE\|MAP_SHARED)
macOS mmap(Mach 内核) 同 mmap
Windows VirtualAlloc VirtualFree MapViewOfFile

# 6.4 JVM虚拟内存

java -Xmx 8G -XX:+UseG1GC MyApp

JVM 内部的内存布局:

[Java Heap] ── -Xmx 控制,主体大头
[Metaspace] ── 类元数据
[Direct Memory] ── ByteBuffer.allocateDirect 用的,不走 GC
[Code Cache] ── JIT 编译产物
[Thread Stacks] ── 每个线程一个,-Xss 控制

JVM 启动时:

mmap 了 -Xmx 大小的虚拟内存(VSZ 看起来很大)
但 RSS 慢慢涨——只有真正用到的页才进入物理

# 6.5 Go运行时用法

// Go 启动时 mmap 一大段地址作为"arena"
// 实际分配从这里切

为什么 Go 的内存看起来"占用大但不真用"?

Go runtime 倾向于保留虚拟地址(避免反复 mmap/munmap 的代价)
free 后页不立即归还 OS——而是异步用 madvise(MADV_DONTNEED) 标记可回收
导致 RSS 长期看起来高,但实际内核可以回收

# 6.6 五语言入口速查

§3 讲过虚拟内存赋予的四个"超能力"——但每种语言怎么用它们入口完全不同。下面这张表对照五语言,遇到具体场景按行索引即可:

能力 C (POSIX) Java Go Rust Python JavaScript (Node)
匿名 mmap(替代大块 malloc) mmap(NULL, len, ..., MAP_ANON, ...) ByteBuffer.allocateDirect(len) syscall.Mmap / 自动(make) mmap crate mmap.mmap(-1, len, flags=MAP_ANON) Buffer.alloc(len) / FFI
文件映射(mmap fd) mmap(NULL, len, ..., fd, 0) FileChannel.map(MODE, 0, len) → MappedByteBuffer syscall.Mmap(fd, ...) / mmap-go memmap2::Mmap::map(&file) mmap.mmap(fd, len) Buffer.from(fs.openSync, ...) 间接
共享内存(跨进程) shm_open + mmap(MAP_SHARED) MappedByteBuffer(MapMode.READ_WRITE) syscall.Mmap(..., MAP_SHARED) shared_memory crate multiprocessing.shared_memory worker_threads + SharedArrayBuffer
fork + COW fork() Runtime.exec(非真 fork,是新进程) (不推荐 fork,用 exec) nix::unistd::fork os.fork() child_process.fork(其实是 spawn)
建议内核行为(madvise) madvise(addr, len, MADV_DONTNEED/WILLNEED/RANDOM) (JDK 21 才有 Memory API,之前需 JNI) unix.Madvise(b, MADV_*) MmapMut::advise mmap.madvise(MADV_*) (Node 无原生,需 N-API)
页面保护(mprotect) mprotect(addr, len, PROT_READ\|PROT_WRITE) (需 JNI) unix.Mprotect MmapMut::protect (需 ctypes) (需 N-API)
关闭 ASLR(调试用) personality(ADDR_NO_RANDOMIZE) + execve (需 OS 级配置) 同 C 同 C 同 C (不可能)
当前 RSS 查询 /proc/self/statm 或 getrusage ManagementFactory.getMemoryMXBean runtime.ReadMemStats procfs crate psutil.Process().memory_info() process.memoryUsage()

3 条工程经验:

1. Java 21 之前,所有"绕过 GC 直接玩虚拟内存"的需求都要 ByteBuffer.allocateDirect
   + Unsafe + JNI。21 引入了正式 Foreign Function & Memory API(JEP 442),
   首次让 Java 程序员能像 C 程序员一样直接 mmap、对齐、释放——这是分水岭。

2. Go 的 madvise 必须自己调 unix.Madvise,runtime 不会替你做。
   大块只读 cache 场景,主动 madvise(MADV_WILLNEED) 能让缺页中断减少 30%+。

3. Node.js 的 Buffer 是 V8 外的内存(不进 V8 堆),所以分配 4GB Buffer 不算
   --max-old-space-size。这是 Node 处理大文件比纯 JS 快的核心原因——
   但 Buffer 释放依赖 GC 触发 finalizer,泄漏更难查(要用 process.memoryUsage().external 监控)。

# 07.经典陷阱反模式

# 7.1 返NULL才不足

void* p = malloc(huge_size);
if (p != NULL) {
    memset(p, 0, huge_size);   // ⚠️ 这里可能 OOM 被杀
}

原因:Linux 默认 overcommit——malloc 总是"成功",写入时才真正分配,那时才会 OOM。

解决:

# 严格模式
echo 2 > /proc/sys/vm/overcommit_memory

或在程序中预触摸(pre-fault):

void* p = malloc(size);
memset(p, 0, size);   // 立即 fault all → 一次性确定 OOM

# 7.2 64位地址泄漏

char* p = mmap(...);
// 用完没 munmap
// 进程虚拟地址空间慢慢被占满

症状:进程 VSZ 飙升、ulimit -v 限制下崩溃。

修复:必须 munmap,或用 RAII 包装。

# 7.3 误用大页

# 显式分配大页
echo 1024 > /proc/sys/vm/nr_hugepages

陷阱:大页一旦分配就预留,且不能 swap。配错会浪费大量内存。

适用:数据库、JVM 大堆——确认能从 TLB 受益再开。

# 7.4 COW意外失效

fork();
// 父进程紧接着写所有内存 → COW 失效 → 物理内存翻倍

经典案例:Redis 的 RDB BGSAVE。Redis 使用 fork + COW 实现"快照不阻塞"——但写多读少的工作负载会让 COW 失效,物理内存激增。

缓解:

# 关闭 THP(透明大页)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# THP 让 COW 单位变成 2MB——一个写操作"复制 2MB"——大幅放大问题

# 7.5 RSS多进程双计

进程 A 占用 1GB(其中 500MB 共享库)
进程 B 占用 1GB(其中 500MB 共享库——和 A 共享!)

top 显示 RSS:
  A: 1GB
  B: 1GB
  总和:2GB

但实际物理内存:
  A 私有 500MB + B 私有 500MB + 共享 500MB
  = 1.5GB

正确指标——PSS(Proportional Set Size):共享内存按比例摊到每个进程。

# 7.6 碎片致假性OOM

进程总共用了 2GB
但碎片导致没法分配连续 100MB
malloc(100MB) 失败 → "OOM"

原因:虚拟内存有碎片(地址空间)+ 物理内存有碎片(连续物理页)。

缓解:

- 用对象池减少碎片产生
- 用 jemalloc / mimalloc 替代 glibc malloc
- 关键任务预先一次性分配大块

# 7.7 容器限制盲区

docker run --memory=512m myapp

陷阱:JVM 在容器里看到的是宿主机内存而不是容器限制——容易开太大堆而被 OOM Killer 杀。

修复(Java 10+):

java -XX:+UseContainerSupport ...   # 默认开启

# 08.综合案例串讲

前面 7 节把"虚拟内存 / MMU / COW / 地址空间 / Swap / OOM Killer / 陷阱"逐项拆开。这一节用一个真实的 8GB 服务器场景——4 个 Java 进程 + Redis 共存,凌晨触发 OOM Killer——把全章 7 个 H2 串成一条因果链。

# 8.1 八G服务器背景

机器:     8 GB 物理内存 + 4 GB Swap,无 cgroup
部署:     4 个 Java 进程(每个 -Xmx 2g)+ 1 个 Redis(maxmemory 1.5g)
预算:     4×2 + 1.5 = 9.5 GB(heap)+ 各自 metaspace/native ~1GB ≈ 10.5 GB
现状:     白天稳定,凌晨 04:00 准时一个 Java 进程被 kill -9(容器无日志)
工程师困惑:堆没爆(heap 只用了 1.6g),为什么进程被杀?

# 8.2 pmap看真实布局

被杀进程重启后用 pmap 看其虚拟地址空间——这正是 §04 地址空间骨架的实战版:

$ pmap -x 12345 | tail -20
   Address      Kbytes     RSS   Dirty Mode  Mapping
   ...
   00007fab40000000   2097152  1638400  1638400 rw---   [ heap ]      ← Java heap 1.6 GB
   00007fab80000000    524288   312000   312000 rw---   [ anon ]      ← Direct Buffer 305 MB
   00007fabd0000000   1048576   823000   823000 rw---   [ anon ]      ← Metaspace 800 MB
   00007fabf0000000    262144   180000   180000 rw---   [ stack ]     ← 各线程栈 175 MB
   00007fac00000000    524288   480000        0 r-x--   /tmp/code.so  ← mmap 只读代码段
   ...
   ─────────────────────────────────────────────
   total kB     6815744 5230000  3700000   ← VSZ 6.6G / RSS 5.1G / Dirty 3.6G

关键观察:

  • 进程的 VSZ(虚拟大小)= 6.6 GB,但 RSS(驻留物理)= 5.1 GB
  • Java heap 部分 RSS 只有 1.6 GB——Java 没"爆"
  • 真正吃物理内存的是 metaspace + DirectBuffer + 线程栈 + mmap 代码段(合计 3.5 GB)

对应章节:§04 地址空间骨架——一个进程不只是堆,还有栈/代码段/mmap/匿名映射等多个段共同消耗 RSS

# 8.3 定时任务触发

排查 cron 日志发现:04:00:00 准时执行的备份脚本:

# /etc/cron.d/backup
0 4 * * * /usr/bin/redis-cli BGSAVE

Redis 的 BGSAVE 实现:

// redis 源码(简化)
int rdbSaveBackground() {
    pid_t childpid = fork();   // ← 关键:fork 一个子进程做磁盘持久化
    if (childpid == 0) {
        // 子进程:dump 内存到 RDB 文件
        rdbSave(filename);
        exit(0);
    }
    // 父进程:继续服务请求
    return REDIS_OK;
}

fork() 在 §03 已讲——Linux fork 是 COW,不立即复制内存:

但这里有个关键陷阱:fork 不立即复制,但内核为了"防止 COW 失败"会做 overcommit 检查。

# 8.4 触发瞬间分析

凌晨 04:00 的内存账本:

Total RAM:   8 GB
Total Swap:  4 GB
─────────────────────────
Used:
  Java × 4   :  4 × 1.5 GB RSS  =  6.0 GB
  Redis      :  1.5 GB RSS      =  1.5 GB
  其它系统   :  ~0.5 GB         =  0.5 GB
  ──────────────────────────
  小计       :  8.0 GB(物理已满!)
─────────────────────────

04:00:00.001  Redis 调用 fork()
              内核检查 overcommit:
              "如果子进程立即写所有页,需要再分配 1.5 GB
              当前可用:物理 0 + Swap 4 GB = 4 GB"
              判定:4 GB > 1.5 GB → fork 允许
              
04:00:00.002  fork 返回成功,子进程开始 dump RDB
              dump 过程读取所有 key → 触发大量 Page 访问
              内核需要把许多冷页从 Swap 换回物理内存
              
04:00:01.500  Redis 父进程继续接 SET 请求
              每次写都触发 COW → 必须分配新物理页
              新页只能从 Swap 拿(物理已满)
              
04:00:02.300  Swap 也开始紧张(已用 3.2 GB / 4 GB)
              内核进入 Memory Reclaim:
              ① 回收 page cache
              ② Swap out 冷的匿名页
              ③ 仍不够 → 触发 OOM Killer
              
04:00:02.401  OOM Killer 计算 oom_score:
              对每个进程 score = RSS + oom_score_adj
              4 个 Java 进程 RSS 都 ~1.5GB(接近)
              oom_score_adj 都是 0(没人配过)
              → 选了 RSS 最大那个:Java#3 (1.62GB)
              → kill -9

根因:

  1. fork 本身瞬间完成(COW),但dump 过程的读 + 父进程的 SET 写让物理内存压力陡增
  2. Swap 不够快、page cache 已无可释放
  3. 内核选了"对降低内存压力贡献最大"的进程下手——就是 RSS 最大的 Java 进程

对应章节:§03 COW + §05 Swap/OOM Killer 完整呼应

# 8.5 杀手分数验证

修复前给所有 Java 进程加监控:

$ for pid in $(jps | awk '{print $1}'); do
    score=$(cat /proc/$pid/oom_score)
    adj=$(cat /proc/$pid/oom_score_adj)
    echo "pid=$pid score=$score adj=$adj"
done
   pid=12345 score=287  adj=0
   pid=12346 score=312  adj=0    ← 最高,下次 OOM 第一个被杀
   pid=12347 score=276  adj=0
   pid=12348 score=298  adj=0

也可以看 dmesg:

$ dmesg | grep -i "killed process"
[Aug  1 04:00:02] Out of memory: Killed process 12346 (java) total-vm:6815744kB,
   anon-rss:1623100kB, file-rss:0kB, shmem-rss:0kB, UID:1000
   pgtables:9240kB oom_score_adj:0

# 8.6 分层防御方案

四层防御,每层都对应章节:

层级 措施 命令 / 配置 对应章节
L1 调度 Redis BGSAVE 错开 Java GC cron 改到 02:00(GC 低峰) §06 跨平台调度
L2 内存 用 cgroup v2 给每个进程定上限 systemd-run --scope -p MemoryMax=2G java ... §04 地址空间隔离
L3 进程 给关键 Java 设负 oom_score_adj echo -500 > /proc/$pid/oom_score_adj §05 OOM Killer
L4 应用 Redis 改 RDB → AOF(无 fork);JVM 关 Direct Buffer 池 appendonly yes;-XX:MaxDirectMemorySize=256m §03 COW + §07 陷阱

落地修复后再没出现凌晨 OOM——4 周稳定。

# 8.7 容器场景特殊性

如果上述场景搬到 K8s:

# Deployment 资源限制
resources:
  limits:
    memory: 2Gi    # ← 容器层 cgroup 硬限制
  requests:
    memory: 1.5Gi

容器内 OOM ≠ 宿主机 OOM:

情形 触发主体 表现 排查
容器超 limit cgroup memory controller 容器内进程被 kill 137 kubectl describe pod 看 OOMKilled
宿主机超物理内存 kernel OOM Killer 宿主机选 oom_score 最高 dmesg \| grep "killed process"

JVM 在容器里的额外坑:

# 不加 -XX:+UseContainerSupport(Java 10 前)
JVM 看到宿主机的 32GB → 默认 -Xmx 设为 8GB
但容器 limit 只有 2GB → 启动后立即 OOMKilled

# Java 10+ 默认开启 UseContainerSupport
JVM 读取 /sys/fs/cgroup/memory/memory.limit_in_bytes
得到 2GB → 自动按 25% 设 -Xmx512m

对应章节:§07 容器陷阱(Java 看到宿主机内存)的完整修复方案

# 8.8 知识点回归映射

走到这里,本章 7 个 H2 + §00 事故都在这一案例里到齐:

§00 真实事故           → 凌晨 04:00 进程消失,与本案例完全一致
§01 三大矛盾           → 容量×安全×效率,OOM 是"容量矛盾"破溃的具象
§02 MMU 与页表         → §8.2 pmap 看到的虚拟地址翻译就是 MMU 的输出
§03 COW 等超能力       → §8.3 Redis BGSAVE 完全靠 fork+COW 才能"凌空"备份
§04 地址空间骨架       → §8.2 pmap 看到的 heap/stack/anon/mmap 段
§05 Swap 与 OOM Killer  → §8.4 完整复盘 + §8.5 oom_score 实战
§06 跨平台对照         → §8.7 容器内 OOM 与宿主机 OOM 的差异
§07 经典陷阱           → §8.6 修复方案的 L4 应用层就是陷阱章的实战版

# 8.9 一句话提炼

OOM Kill 不是"内存爆了",而是"内核在所有进程里选了一个'让你最不痛'的来杀"——它的判定基础是 oom_score = RSS × 1 + oom_score_adj。要避免被杀,要么减少自己的 RSS,要么调低 oom_score_adj,要么用 cgroup 让自己根本不进入"竞争池"。虚拟内存的所有超能力(COW / overcommit / Swap),最终都在 OOM Killer 这一刻清算账本。

带回 §00 的事故:那个被消灭得无影无踪的进程,真正的死因不是它自己写得有问题,而是它"在错误的时间出现在错误的内存账本上"——这就是系统级编程必须建立的"全局内存视图"。


# 09.一句话总结

# 9.1 三层认知阶梯

第一层(知其然):知道有"虚拟地址"和"物理地址"两种概念
  ↓
第二层(知其所以然):理解 MMU、多级页表、TLB、缺页中断
  ↓
第三层(知其将所以然):能解释 fork/COW、mmap、ASLR、OOM、Swap 全套机制

读完本章后,你应该能回答开头§0.3 提出的三个问题:

  1. 物理内存只有一份,每个进程怎么"独占内存"? → 因为每个进程有自己的页表,虚拟地址相同也映射到不同物理位置。
  2. malloc(10GB) 在 4GB 机器上为什么成功? → malloc 只更新虚拟地址空间,没分配物理。按需分页让物理消耗等到真正写入时才发生。
  3. fork() 拷贝 1GB 进程为什么几微秒? → COW 让 fork 只拷贝页表,物理内存继续共享,只在写入时才真正复制。

# 9.2 全卷的奠基

4.1 是整卷的"地基"——后面所有章节(堆/栈、GC、缓存、拷贝)都依赖这一章的虚拟地址空间假设。

# 9.3 七字真言

  1. 指针不是物理地址——它是虚拟地址,必须翻译。
  2. VSZ ≠ RSS——承诺和兑现是两回事。
  3. malloc 是"借条"——写入才"取钱"。
  4. fork 是"页表复制"——COW 让它便宜。
  5. mmap 是"懒加载"——只在访问时读盘。
  6. TLB 命中率决定性能——别让上下文切换太频繁。
  7. Swap 在生产环境通常关闭——硬失败优于软挂死。

# 9.4 与下篇承接

本篇我们建立了"虚拟地址 → 物理地址"的完整图景——但有一个问题被我们小心地避开了:多核 CPU 各自有自己的缓存,同一个物理地址在不同核里可能值不一样。

这就引出了下一篇 4.2 内存模型技术设计 (opens new window) 要解决的问题——当虚拟内存把"地址翻译"理顺后,"内存可见性"成了新战场。3.6 我们碰过这个问题,4.2 我们要彻底深入它的硬件基础。


# 🔗 延伸阅读

  • 上一卷收束:3.18 结构化并发设计思想 (opens new window)
  • 同卷下篇:4.2 内存模型技术设计 (opens new window) | 4.3 堆和栈内存的设计 (opens new window) | 4.4 内存对齐与缓存局部性 (opens new window)
  • 经典文献:
    • Operating Systems: Three Easy Pieces(Remzi & Andrea Arpaci-Dusseau)—— 第 13-23 章是虚拟内存权威教材
    • Modern Operating Systems(Tanenbaum)—— 第 3 章
    • Linux Memory Management(kernel.org 文档)
    • What Every Programmer Should Know About Memory(Ulrich Drepper, 2007)—— 内存设计的圣经
    • Intel SDM Volume 3 Chapter 4(页表和地址翻译的硬件细节)
    • Cathedrals and Bazaar(jemalloc 设计哲学)
上次更新: 2026/06/28, 17:55:19
README
2.内存模型技术设计

← README 2.内存模型技术设计→

最近更新
01
科学方法实践论法
06-28
02
辩证思维矛盾论法
06-28
03
毛选中的调查观念
06-28
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式