2. 操作系统内存管理
大家好,我是老张。今天咱们聊聊操作系统内存管理这块硬骨头。
说实话,我早年做嵌入式开发时,对内存管理理解得特别肤浅。总觉得不就是malloc和free嘛,有啥好学的?直到有一次,一个跑了几天的服务突然崩了,查了三天才发现是内存碎片导致的。嗯,从那以后,我老老实实把操作系统内存管理啃了一遍。
今天这一章,咱们就把虚拟内存、物理内存、页表、内存分配与回收这几个核心概念讲透。你想想看,搞懂了这些,你写代码时心里就有底了。
1. 物理内存 vs 虚拟内存:一个经典的障眼法
先问个问题:你的程序能直接访问物理内存吗?
答案是:不能。或者说,现代操作系统不允许你直接碰物理内存。
为什么?我举个例子。假设你的电脑只有8GB内存,但你同时开了Chrome(吃了3GB)、VS Code(吃了1GB)、Docker(吃了2GB),还有一个你正在写的C程序。如果每个程序都直接操作物理内存,那不乱套了?A程序不小心把B程序的数据覆盖了,系统立马崩溃。
所以操作系统搞了个障眼法——虚拟内存。
说白了,每个进程都以为自己独占整个地址空间。32位系统下,每个进程看到的是4GB的连续空间。64位下就更夸张了,理论上16EB。但实际上,物理内存可能只有8GB、16GB。
这个「骗局」是怎么实现的?靠的就是页表。
核心概念:虚拟地址是进程看到的假地址,物理地址是内存条上的真地址。页表就是这两者之间的翻译官。
2. 页表:地址翻译的幕后英雄
页表这东西,说白了就是个映射表。它把虚拟页号(VPN)映射到物理页框号(PFN)。
我习惯把页表想象成一个字典:你查「虚拟地址0x1234」,页表告诉你「对应的物理地址在0xABCD」。就这么简单。
但实际实现时,有几个坑要注意:
- 多级页表:32位系统常用两级页表,64位系统可能用到四级甚至五级。为什么?因为单级页表太占内存了。你想想,4GB虚拟空间,每页4KB,就需要1M个页表项。每个页表项4字节,光页表就占4MB。如果每个进程都搞一个4MB的页表,100个进程就是400MB,太浪费了。
- TLB(快表):每次地址翻译都查页表,性能受不了。所以CPU里有个小缓存叫TLB,专门缓存最近用过的页表项。我调过的一个性能问题,就是TLB miss率太高,导致程序跑得比蜗牛还慢。
- 页表项标志位:每个页表项除了存物理地址,还带一堆标志位。比如R/W(读写权限)、U/S(用户/内核权限)、P(存在位)。这些标志位是实现内存保护的关键。
下面我用SVG画一张图,帮你理清虚拟地址到物理地址的翻译流程:
我的小技巧:调试内存问题时,我经常用/proc/<pid>/maps查看进程的虚拟内存布局。这个文件会告诉你每个段的起始地址、权限和映射文件。配合/proc/<pid>/pagemap还能查到虚拟页对应的物理页框号。不过后者需要root权限。
3. 内存分配机制:谁在管你的malloc?
你写C代码时调个malloc(100),背后发生了什么?
我简单梳理一下:
- 用户态分配器(如glibc的ptmalloc):先从自己的空闲链表里找一块合适的内存。如果有,直接返回。如果没有,就调用系统调用向内核申请。
- 系统调用(brk或mmap):brk用于小内存分配,它移动程序堆的边界。mmap用于大内存分配(默认超过128KB),它在进程地址空间里找一块空闲区域映射。
- 内核分配器(buddy system + slab):内核收到请求后,用伙伴系统分配连续的物理页框。对于小对象(比如几十字节),用slab分配器更高效。
这里有个坑,我曾经踩过:
我曾经犯过的错:在一个长期运行的服务里频繁malloc/free小对象(几十字节),结果内存碎片越来越严重。明明free了,但进程的RSS(驻留内存)只增不减。后来改用内存池(memory pool)预分配大块内存,自己管理小对象分配,问题就解决了。
4. 内存回收机制:谁在帮你擦屁股?
内存回收分两个层面:
- 用户态回收:free()只是把内存块标记为「空闲」,放回分配器的空闲链表。它不会立即归还给操作系统。所以你看进程的RSS可能一直很高,但实际可用的内存还有很多。
- 内核态回收:当系统内存紧张时,内核会启动回收机制。包括:
- 页面置换(LRU算法):把不常用的页面换到swap分区
- 脏页回写:把修改过的文件映射页写回磁盘
- slab回收:回收内核对象缓存
- OOM Killer:实在没内存了,杀进程保系统
我记得有一次线上告警,说内存使用率飙到98%。我上去一看,发现是某个进程的page cache占了几十GB。其实这不是内存泄漏,是内核在缓存文件数据。但运维同学不懂,非要重启服务。我跟他说:你跑个sync && echo 3 > /proc/sys/vm/drop_caches试试,立马释放了30GB。嗯,从那以后我就在监控系统里加了个「可回收内存」的指标。
5. 实战:用代码验证内存分配行为
光说不练假把式。我写个小程序,让你亲眼看看虚拟内存和物理内存的区别:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/resource.h>
int main() {
// 分配100MB虚拟内存
char *p = (char *)malloc(100 * 1024 * 1024);
if (!p) {
perror("malloc failed");
return 1;
}
printf("分配了100MB虚拟内存\n");
printf("虚拟地址范围: %p - %p\n", p, p + 100*1024*1024);
// 此时物理内存还没分配(写时复制)
printf("按回车键开始写入数据...\n");
getchar();
// 逐页写入,触发物理内存分配
for (int i = 0; i < 100 * 1024 * 1024; i += 4096) {
p[i] = 'A'; // 写操作触发缺页中断
}
printf("写入完成,物理内存已分配\n");
printf("按回车键退出...\n");
getchar();
free(p);
return 0;
}
你运行这个程序时,用top观察RES(物理内存)和VIRT(虚拟内存)的变化。你会发现:malloc之后VIRT涨了100MB,但RES几乎没变。只有当你真正写入数据时,RES才涨上去。
重要结论:malloc分配的是虚拟内存,不是物理内存。物理内存是在你真正访问(读或写)时才按页分配的。这就是所谓的「按需分页」(demand paging)。
6. 避坑指南:内存管理常见误区
最后,我总结几个我见过的坑:
| 误区 | 真相 | 我的建议 |
|---|---|---|
| free后内存立即归还系统 | 不一定,glibc可能缓存起来复用 | 用malloc_trim()可以强制归还 |
| 虚拟内存占用就是物理内存占用 | 虚拟内存只是地址空间,物理内存按需分配 | 监控时看RES而不是VIRT |
| 内存碎片只影响性能 | 严重时会导致分配失败 | 大内存用mmap,小对象用内存池 |
| OOM Killer会杀最占内存的进程 | 它有一套评分机制,不一定杀最大的 | 重要进程设置oom_score_adj为-1000 |
好了,这一章的内容就到这儿。内存管理是操作系统的核心,也是我们做内存泄漏检测的基础。搞懂了虚拟内存和物理内存的关系,你就能理解为什么有些「内存泄漏」其实是假象,为什么有些程序明明free了内存却不见减少。
可可精品资料库