内存分层与地址空间
环境:Windows 11 x64(build 26200)、GCC 13.1.0(MinGW-w64,
D:/Qt/Tools/mingw1310_64/bin/gcc.exe),-std=gnu11 -O2 -static;i5-11400H,物理内存 65371 MB、当时刻内存负载 67%(GlobalMemoryStatusEx原样读数)。观测手段只有三个只读入口:VirtualQuery逐区扫描、GetProcessMemoryInfo(工作集 / 私有提交 / 缺页计数)、GetLogicalProcessorInformationEx(缓存拓扑)。时间类数字受同期其他编译任务影响明显,同一次运行内的比值(冷/热、顺序/随机、请求/实付)比绝对值可靠。/proc/<pid>/smaps、perf、mincore、cgroup 内存配额在本机不存在,涉及处明写”未在本机实测”。
一、页是 4096,粒度是 65536:这两件事各管各的
pageSize=4096 (4 KB) allocationGranularity=65536 (64 KB) minApp=0000000000010000 maxApp=00007ffffffeffff 逻辑处理器=12
“我要 1 个字节”付多少?
申请 1 B -> 返回 0000017a99ff0000 AllocationBase=0000017a99ff0000 差=0 B
逐页查:属于这块保留区的页 = 1 个,基址起第一区 RegionSize=4096
申请 4096 B -> 返回 0000017a99ff0000 ... 页 = 1 个,RegionSize=4096
申请 100000 B -> 返回 0000017a9a010000 ... 页 = 25 个,RegionSize=102400
100000 B 不是 65536 的倍数,被圆到 25 页 = 102400 B——保留阶段的单位是页(4096),不是粒度。另一份探针把三段代价拆开量:
VirtualAlloc(1 字节, MEM_RESERVE) -> 000002aca2c70000 VirtualQuery: State=RESERVE RegionSize=4096 B
私有提交增量 = 0 B 工作集增量 = 12288 B
再 MEM_COMMIT 1 字节 -> 000002aca2c70000 State=COMMIT RegionSize=4096 B 私有提交增量=4096 B
写这 1 个字节: 缺页计数 +=1 工作集增量=4096 B 私有提交增量=0 B
三次动作分别记账:预留只动地址空间(配额 +0);提交动页面文件配额(+4096 B,物理内存 0);第一次触碰才动物理内存(缺页 +1、工作集 +4096 B、配额不再涨)。提交 256MB 也是同一件事——RESERVE 256 MB 时”私有提交增量 = 0.000 MB 工作集增量 = 0.012 MB”,COMMIT 32MB 时”PageFaultCount 增量=0 … 工作集增量=0.0 MB”(第四节)。
那 65536 管什么?管基址对齐:上两次 VirtualAlloc 的返回值 0x…99ff0000、0x…9a010000 都是 64KB 的整数倍,而块的大小按页圆整。
二、一个空控制台的地址空间长什么样
VirtualQuery 从头扫到尾(进程就是探针自己,没干别的事):
区总数=107 FREE=27 RESERVE=16 COMMIT=64(其中 PAGE_GUARD=2)
已提交合计=9.8 MB IMAGE=8.0 MB(36 区) MAPPED=1.65 MB(15 区) PRIVATE=0.2 MB(13 区) 仅保留=4136.8 MB
最大空闲块=129266.9 GB 地址范围 0000000000010000 – 00007ffffffeffff
最大的几个已提交区(原样):
7ffa2f9aa000 1.99 MB COMMIT IMAGE RD KernelBase.dll
7ffa32601000 1.45 MB COMMIT IMAGE RX ntdll.dll
1b750eb0000 0.82 MB COMMIT MAPPED RD locale.nls
1b750d20000 0.07 MB COMMIT PRIVATE RW @alloc 000001b750d20000
三个可以带走的判断:“进程占用 4GB 地址空间”和”占用 9.8MB 内存”是同一件事的两种说法,前者那 4136.8 MB 全是 RESERVE;8.0 MB 的 IMAGE(36 区)是模块的 RX/RD 段,属于可共享的映射页,所以它几乎不进私有提交(《进程、线程与上下文切换》里同一台机器上类似进程的私有页只有 3.1 MB);MAPPED 的 1.65 MB(15 区)是 locale.nls 和两份 C_936.NLS 代码页表。PAGE_GUARD=2 那两块就是《进程、线程与上下文切换》第四节里护栏页的现场。地址范围下界 0x10000(NULL 页 + 粒度)解释了为什么解引用 0x1 一定炸、而 0x10000 可能被合法映射占用。
三、分层:拓扑不是猜的,依赖载入延迟是量的
GetLogicalProcessorInformationEx 把缓存树原样读出来(每颗物理核一段,这里贴第一颗和共享层):
ProcessorCore 组=0 掩码=0x3 逻辑处理器数=2 Flags=1
L1 Data 大小=48 KB 行=64 B 关联=12 组=0 掩码=0x3
L2 Unified 大小=1280 KB 行=64 B 关联=20 组=0 掩码=0x3
L3 Unified 大小=12288 KB 行=64 B 关联=8 组=0 掩码=0xfff
掩码=0x3 且 逻辑处理器数=2 ⇒ 每颗物理核两个超线程、L1/L2 按核分(掩码 0x3、0xc、0x30…),L3 掩码 0xfff 是 12 个逻辑处理器共享。所以”我的 L2 是 1.25MB”其实是”我这颗核的 1.25MB,和兄弟超线程对半分”。
依赖载入延迟(指针追逐:每次 load 的地址依赖上一次结果,编译器无法预取):
| 数组 | 占用 | ns/次载入 |
|---|---|---|
| 16384 项 | 0.12 MB | 2.84 |
| 65536 项 | 0.50 MB | 4.51 |
| 131072 项 | 1.00 MB | 5.69 |
| 524288 项 | 4.00 MB | 89.53 |
| 8388608 项 | 64.00 MB | 127.90 |
1MB→4MB 之间出现 5.69 → 89.53 ns 的 15 倍悬崖。4MB 已经超出这颗核的 1.25MB L2、逼近共享 L3 的可用份额(同期机器上有其他任务在抢 L3,这一档的绝对值只能说明”出 L2 就贵一个数量级”,具体是 L3 还是内存我不敢下定论——这台的 L3 命中/缺失计数器需要 ETW 或 perf,本机未实测)。
同一份内存,只是走法不同(c.out 第 7 节,单位 ns/次访问):
| 工作集 | 顺序 8B 步长 | 跨行 64B 步长 | 跨 4KB 页 | MB/s(顺序) | MB/s(64B) |
|---|---|---|---|---|---|
| 64 KB | 0.52 | 0.51 | 1.09 | 15259 | 125428 |
| 512 KB | 0.41 | 0.37 | 0.64 | 19692 | 175201 |
| 4096 KB | 0.44 | 1.35 | 2.15 | 18241 | 47501 |
| 32768 KB | 0.72 | 4.34 | 9.32 | 11039 | 14759 |
32MB 工作集下,顺序 8 字节步长 0.72 ns,跨 4KB 页 9.32 ns——差 13 倍。同一个数组、同一批数据,代价全在”访问模式”上。64KB 那一行的”跨 4KB 页”(1.09 ns)比 32MB 的(9.32)便宜 8.5 倍,因为前者整个数组都在 L1/L2 里,页跨越只是 TLB 里多一条项。分层缓存的作用不是让单次访问更快,而是让”局部性好的程序”几乎摸不到内存。
四、按需零页:首次触碰的真实单价
提交 32MB 未触碰: PageFaultCount 增量=0 私有提交增量=32.1 MB 工作集增量=0.0 MB
冷触碰 8192 页: 总 9.4 ms,缺页计数增量=8208 → 每页 1147 ns
热触碰同 8192 页: 每页 12 ns(冷/热比 95 倍),这一遍缺页增量=9245
此刻工作集=35.7 MB 私有提交=32.6 MB
EmptyWorkingSet 返回=1 用时 5.01 ms → 工作集=0.1 MB 私有提交=32.6 MB(提交没降)
再触碰同 32MB: 每页 1060 ns(被丢出去的页要重新故障回来)缺页增量=8228 工作集回到 32.2 MB
- 提交不等于占用:32.1 MB 提交配额、工作集 0.0 MB、缺页 0 次。
- 首次触碰 = 一次缺页 = 1147 ns/页(4KB / 1147ns ≈ 3.5 GB/s 的”清零 + 建映射”吞吐)。注意”冷触碰缺页 +8208”比页数 8192 多 16 次,那 16 次是遍历代码自己踩到的页。
- 热触碰 12 ns/页 = 95 倍差距,且这一遍缺页增量 9245 说明”遍历本身还会持续产生缺页”(页被回收/换出),不是纯粹的热路径。
EmptyWorkingSet把物理页全丢出去(35.7 → 0.1 MB)只花 5.01 ms,而再摸回来要 1060 ns/页——按 4KB/页算是 278 ms/GB,重新触碰这 32MB 记录到 8228 次缺页。丢页是批量解映射,摸页是每页一次缺页,两者不能互相替代。这条也是”内存压力测试不要拿EmptyWorkingSet假装 swap”的原因:它跳过了真正的写回与换出路径。- 私有提交在整段实验里始终是 32.6 MB,工作集涨落、配额不动——OOM 判定看的是配额那一侧。
Linux 上对应的”清零页 vs 全局零页映射”、madvise(MADV_FREE) 的延迟回收、/proc/<pid>/smaps 的 Dirty/AnonHugePages 未在本机实测。
五、改保护位的钱:按页算才看得清
VirtualProtect 一次改一整块,代价随块长递减但递减得很慢(中位数):
区域 4 KB = 1 页 → 300.0 ns/次 (300.0 ns/页)
区域 64 KB = 16 页 → 500.0 ns/次 (31.2 ns/页)
区域 1024 KB = 256 页 → 1700.0 ns/次 (6.6 ns/页)
区域 16384 KB = 4096 页 → 21500.0 ns/次 (5.2 ns/页)
区域 65536 KB = 16384 页 → 97400.0 ns/次 (5.9 ns/页)
300 ns 是陷入 + 系统调用 + 一次 TLB 失效的固定成本(对照《操作系统在做什么》里 NtProtectVirtualMemory 服务号 0x50 的陷入底价 82–110 ns),5–6 ns/页是改页表项的钱。合起来的实用结论:
- 要改的页多,就一次调用改整块(16MB 只要 21.5 µs,拆成 4096 次单页调用要 1.23 ms,差 57 倍)。改保护位不是免费的元数据操作:每页 5–6 ns 里大头是刷新 TLB,而 TLB 一刷,后面第一次访问又要页表遍历——第三节那张局部性表里”32MB 工作集、跨 4KB 页 9.32 ns/次”和这里是同一笔账的两端。
PAGE_GUARD、VirtualAlloc(..., PAGE_NOACCESS)+ VEH 这类”手写按需提交”的玩法,成本就是这里 5–6 ns/页 + 《操作系统在做什么》里那次 2638 ns 的异常派发,比内核自己按需提交贵一个数量级。
六、同一份数据,走 WriteFile 还是走视图
32MB 数据写进临时文件,两种写法各测一遍(文件随后删除):
WriteFile 4KB x 8192 次 = 102.9 ms → 311 MB/s(含系统缓冲)
MapViewOfFile: 视图 000001b751450000 区大小 32.0 MB State=COMMIT Type=MAPPED Protect=RW
视图 memset 32MB: 15.8 ms → 2027 MB/s 缺页增量=8208 工作集=33.2 MB 私有提交=0.6 MB
FlushViewOfFile(32MB) 返回=1 用时 17.04 ms(脏页此刻才异步回写)
同视图二次遍历(已在内存)0.09 ms,校验字节=0
视图写快 6.5 倍(102.9 vs 15.8 ms),因为省掉了”从用户缓冲区拷进页缓存”这一趟:memset 直接写在页缓存页上。这 15.8ms 里含 8208 次缺页(映射区也是按需建映射,和第四节同一个机制)。但两种写法的”落盘”含义不同:WriteFile 返回时数据已交给系统缓冲,视图写完只是把页标脏,得再花 17.04 ms 调 FlushViewOfFile 才排进回写队列(异步,仍不等于落盘)。注意映射写完后”私有提交=0.6 MB”——文件映射的页不算私有提交,它的内存挂在系统页缓存上,这就是任务管理器里”专用工作集”和”工作集”两个数差别最大的场景。FlushViewOfFile 的 17.04 ms 只是把脏页交给写回线程,真正落盘要等多久取决于设备的写回策略,本机无法只靠这两个 API 测准(fsutil persistenceQuery / 强制 FlushFileBuffers 的对照实验本节未做)。
七、分配器在”请求”和”实付”之间做了什么
对每种大小 malloc 20 万次(1KB 以上 2 万次),用 GetProcessMemoryInfo 的私有提交增量除以块数:
| 请求 | 实付 B/块 | 倍数 | _msize | 内部碎片 | malloc 单价 |
|---|---|---|---|---|---|
| 1 B | 18.1 | 18.06x | 1 | +17.1 B | 33.3 ns |
| 8 B | 15.9 | 1.99x | 8 | +7.9 B | 29.2 ns |
| 64 B | 84.4 | 1.32x | 64 | +20.4 B | 97.2 ns |
| 128 B | 150.1 | 1.17x | 128 | +22.1 B | 177.9 ns |
| 256 B | 282.4 | 1.10x | 256 | +26.4 B | 280.4 ns |
| 1024 B | 1093.6 | 1.07x | 1024 | +69.6 B | 409.0 ns |
| 4096 B | 4294.2 | 1.05x | 4096 | +198.2 B | 1263.9 ns |
_msize(可用字节)总是等于请求值,但内核侧的私有提交比它大 8–22 字节:那是块头 + 对齐 + 分派开销。所以”内部碎片”要看私有提交增量,不能信 _msize。malloc(1) 付 18.1 字节(18 倍),malloc(8) 只付 15.9(2 倍)——固定开销摊在小块上才是杀手。
free 之后还多少?
malloc(64) x 200000: 私有提交 +17.2 MB → 90.0 B/块(1.41 倍请求量)
全部 free 用时 9033.6 ms: 相对基线仍多用 1.71 MB(残留 9.0 B/块) 工作集=5.7 MB
连指针数组也 free: 相对基线 +0.19 MB 工作集=4.2 MB
→ 同进程内第二次同样规模只多用 15.57 MB(81.7 B/块):池子被复用,没再问系统要
20 万个块全 free 后进程仍比基线多占 1.71 MB(那 9 B/块是分配器留在池里的元数据/对齐洞),并且第二轮同样规模只要 15.57 MB——分配器把空闲块留着复用,而不是还给系统。这是”free 了但内存没降”不是泄漏的机制原因,也是”判断泄漏要看趋势”的原因(《句柄、消息与窗口过程》里同样的结论)。free 那一轮花掉 9033.6 ms(同期机器很忙,这个绝对值不可复现,我只把它当”释放 20 万块不是 O(1)“的证据)。
堆区长什么样,VirtualQuery 直接看穿:
malloc(16) -> 0000017a9a002460 在 0000017a9a002000..+24576 这块里,AllocBase=0000017a9a000000 Type=PRIVATE
malloc( 65536) 区 0000017a9a010000+73728 B AllocBase=另一块
malloc( 1048576) 区 0000017a9a110000+1052672 B AllocBase=另一块
malloc( 8388608) 区 0000017a9a114000+8392704 B AllocBase=另一块
小请求共用基堆那几块 PRIVATE 区;到 65536 起换一块独立区;1MB 及以上每块单独一个区,大小 = 请求 + 4096(1048576 → 1052672、8388608 → 8392704,多出来的一页是块头/护栏)。连开 24 次 1MB,每次 AllocBase 都在变——大块是”每次一问 VirtualAlloc”,所以没有碎片问题,只有提交问题。外部碎片(交替 free 掉一半 8KB 块,再要 4MB):
交替 free 一半(8192 x 8192 B)后:私有提交增量=66.4 MB 工作集=36.3 MB(释放的那一半并没有还给系统)
交替 free 后连续要三次 4MB:0000017a9ee5a040(区 4198400 B) 0000017a9f267040(区 4198400 B) 0000017a9f679040(区 4198400 B)
三次的 AllocationBase 是否相同 = 0/0;这 3 次共多用提交 78.41 MB(=12MB+,没有利用那些 8KB 的洞)
手里有 4096 个 8KB 的空洞(合计 32MB),要 12MB 却新问了 78.41 MB 提交,三个 4MB 落在三个不同的新 AllocationBase 上——分配器不会把散洞拼成大块。这是”内部碎片看倍数、外部碎片看申请失败”里外部的现场:不是失败,是明明有洞还要新内存。
小结
- 两套单位:
pageSize=4096管块长(100000 B → 102400 B),allocationGranularity=65536管基址对齐。三段账要分开看:RESERVE 不动配额、COMMIT 动配额不动物理页(+4096 B 提交)、首次触碰动工作集(缺页 +1、+4096 B)。 - 一个”什么都没干”的进程:107 个区域、提交 9.8 MB、仅保留 4136.8 MB、最大空闲块 129266.9 GB。IMAGE 8.0 MB(36 区)是共享的模块页、MAPPED 1.65 MB 是
.nls代码页表。“地址空间占用”和”内存占用”差三个数量级是常态。 - 分层实测:1MB→4MB 之间依赖载入从 5.69 ns 跳到 89.53 ns(15 倍);32MB 工作集下顺序访问 0.72 ns/次 vs 跨页 9.32 ns/次(13 倍)——局部性比容量重要。同一份内存的按需零页:提交 32MB 缺页 0 次;冷触碰 1147 ns/页、热 12 ns/页(95 倍);
EmptyWorkingSet丢 35.7MB 只花 5.01 ms,摸回来要 1060 ns/页;私有提交全程 32.6 MB 不动。 - 改保护位:固定成本 300 ns + 每页 5–6 ns(TLB 失效),合并调用比逐页调用便宜 57 倍。文件映射 vs
WriteFile:2027 MB/s vs 311 MB/s(6.5 倍,省一次拷贝),FlushViewOfFile再 +17.04 ms 且只是异步排写;映射页不计入私有提交。 - 分配器:
malloc(1)实付 18.1 B(18 倍)、malloc(64)84.4 B;65536 B 起换独立区、1MB 以上”请求 +4096”独占一个区;free20 万块后仍残留 1.71 MB,第二轮只多用 15.57 MB(池内复用);4096 个 8KB 空洞在手,要 12MB 仍新问 78.41 MB——分配器不拼洞。