搞懂Linux内存管理,最怕的就是把它当成一个"平面"去看。很多人学了free、top、ps这些命令,看到内存占用率高了就紧张,看到Swap用了就慌,但内存管理系统本质上是三个层次叠在一起协同工作的:用户空间的虚拟内存、内核地址空间、物理内存的组织与分配。把这三个层次分开看,你会发现很多"疑难杂症"其实特别简单——比如为什么malloc之后内存没涨、为什么一个进程占了20G虚拟内存但物理内存才用了200M、为什么删了文件之后内存还是没释放。
这篇文章把这三层从用户态讲到内核态再落到物理页,结合我在实际项目里排查内存问题时的真实场景,尽量说人话,遇到关键机制会解释底层原理,文末还有几个高频面试题的解法和内存排查经验,希望对正在啃Linux的开发者、运维还有准备面试的朋友有点帮助。
1. 第一层:用户空间那块"看似独立"的虚拟内存
1.1 每个进程都活在自己的"平行世界"里
先抛一个很反直觉的结论:在Linux里,一个进程真正能直接操作的"内存",并不是物理内存条上的地址,而是一个虚拟地址空间。每个进程都有一套完整的虚拟地址,互不干扰。32位下这个空间是4GB,64位下则是128TB(用户空间部分),这就是为什么你拿一个8GB内存的机器去跑几十个进程,每个进程还能各自认为自己握有几TB的"内存"。
很多初学者会问:虚拟内存是不是"假的"?不是假,它是真实存在的抽象层。CPU发出的地址是虚拟地址,由MMU(内存管理单元)通过页表翻译成物理地址,翻译失败就抛缺页异常。这个机制带来的核心收益有三个:
- 进程隔离:A进程不能直接访问B进程的地址空间,安全性大幅提升。
- 按需分配:malloc申请1GB,并不是立刻分配1GB物理页,而是"记账"记了1GB虚拟地址,真正写入的时候才分配物理页。
- 共享能力:动态库、内核代码段可以映射进多个进程的地址空间,物理内存只保留一份。
这个理解一旦到位,后面你看到进程VSZ(虚拟内存大小)飙到几十G就不会焦虑了,它不一定代表物理内存吃紧。
1.2 进程地址空间"户型图"
一个典型的用户空间布局(x86_64,从低地址到高地址)大致是这样的:
| 区域 | 起始方向 | 说明 |
|---|---|---|
| 代码段(text) | 低地址 | 可执行指令,只读 |
| 数据段(data/bss) | 低地址 | 已初始化/未初始化的全局变量 |
| 堆(heap) | 向上增长 | brk分配的区域,malloc小对象多落这里 |
| 内存映射区(mmap area) | 向下增长 | 动态库、匿名映射、malloc大对象落这里 |
| 栈(stack) | 向下增长 | 局部变量、函数调用帧,一般8MB上限 |
这里面堆和内存映射区是"相向生长"的,中间留一大片空闲区,系统通过mmap_min_addr之类的参数防止非法访问。每次分配内存的路径有两条:
- brk:把堆顶往上推,适合分配小块、频繁申请释放的场景。
- mmap:在映射区找一块空闲空间映射,适合大块分配(glibc默认超过128KB走这条路)。
实际分配的时候,glibc的malloc会维护一堆缓存池,小的对象走brk拿,大的对象走mmap拿,避免频繁系统调用。你写的C/C++代码里new、delete、malloc、free,底层都会汇聚到brk或mmap这两个系统调用上。顺带一提,Qt的内存管理之所以和传统C++体现不同,是因为Qt用父子对象树来做生命周期管理,但它在底层依然依赖malloc这一套,只是包了一层策略。
1.3 用/proc/pid/maps"看"虚拟内存
排查问题时,我经常先去看进程的真实内存映射:
cat /proc/12345/maps | head -20 cat /proc/12345/smaps | grep -A 10 "^[0-9a-f].*r-xp" | head -30maps里每一行代表一段虚拟地址区间,smaps里则细化到每段的RSS、PSS、Swap等指标。RSS是"当前真正占了多少物理页",PSS是按共享比例摊分后的物理内存。这里经常有一个误解:你写代码malloc了500MB,但只是初始化了前10MB,RSS就只在10MB附近波动,500MB会被算进VSZ,不会算进实际物理占用。这也是为什么top里VSZ大不等于内存泄漏。
2. 第二层:内核空间那片"受管控"的高地址区域
2.1 用户态到内核态的"地界"
x86_64下,地址空间的划分大概是这样:0x0000000000000000到0x00007fffffffffff是用户空间,从0xffff800000000000开始是内核空间。用户进程用到的每一条地址,翻译不过去就触发缺页;而内核可以访问用户空间的地址,只是需要借助copy_from_user/copy_to_user这类接口,原因包括安全校验、SMAP隔离等硬件保护。
内核空间内部的布局也不是一锅粥,它被划分为:
- 直接映射区(线性映射区):从PAGE_OFFSET开始,把物理内存按顺序映射到这个区域,virt_to_phys和phys_to_virt就是简单的加减偏移。这个区域的好处是CPU访问效率高,坏处是必须要求物理连续。
- vmalloc区:在这里可以分配虚拟地址连续但物理地址不连续的内存,适合大块内核缓冲区。
- 模块区与fixmap区:加载内核模块、固定映射地址的特殊区段。
- CPU entry area:每个CPU自己的一份入口栈、中断栈区域。
2.2 kmalloc、vmalloc、kvmalloc,怎么选
驱动开发里有个很经典的困惑:内核态分配内存,到底该用kmalloc还是vmalloc?
| 函数 | 物理连续性 | 虚拟连续性 | 性能 | 适用场景 |
|---|---|---|---|---|
| kmalloc | 连续 | 连续 | 高 | 小块、频繁分配,分配后访问频繁 |
| vmalloc | 不连续 | 连续 | 低(需要改页表) | 大块且不常访问的缓冲区 |
| kvmalloc | 先试kmalloc,失败退vmalloc | 视情况 | 中 | 不确定大小,但能接受两种行为 |
kmalloc底层走的是slab/slub分配器,从预分配好的对象桶里取内存;vmalloc则是一页一页地从物理内存拿,然后在页表上把每页串起来。性能上kmalloc明显优于vmalloc,因为vmalloc每次分配都要操作页表,还可能导致TLB刷新。所以常规驱动里,几百KB以下优先kmalloc,超过一两个连续物理页实在申请不到,才考虑vmalloc。
这里有一个非常容易出现的内核内存问题:kmalloc申请不到大块连续物理内存。物理内存有很多剩余,但碎片化严重,凑不出连续的128KB,kmalloc就会失败。这种情况在长时间运行的嵌入式设备上尤其常见。解决办法是尽量在系统刚启动、内存还很"整齐"时做预留,或者使用CMA(连续内存分配器)机制。
2.3 slab/slub:内核里的小对象自动贩卖机
为什么要有slab?因为内核到处都是"相同大小的小对象"——task_struct、inode、dentry,每次创建销毁都从伙伴系统分配释放页,开销高得吓人。slab/slub的做法是:一次性从伙伴系统拿几页,切成统一大小的小块,维护空闲链表,分配时取一块,释放时还回去。默认的kmalloc-8、kmalloc-16、kmalloc-192、kmalloc-4k这一系列桶,就是按对象大小划分的。
排查内核对象异常增长时,我会用slabtop和/proc/slabinfo:
slabtop -s c cat /proc/slabinfo | head -30比如之前遇到一个容器平台问题:一个业务进程频繁创建socket连接又异常断开,每次都会创建新的sock_inode_cache对象,因为异常路径没有正常释放,slab里的对象数持续上涨。看/proc/slabinfo里sock_inode_cache的active_objs一直在涨,基本就能锁定是fd泄漏或者socket异常处理路径的引用计数问题,比傻傻抓日志高效很多。
3. 第三层:物理内存的"家族族谱":node、zone与伙伴系统
3.1 物理内存不是一整块,而是分"家族"的
现代服务器动辄几十个核、几十G内存,物理内存也不是一块板子到底。Linux把物理内存划分成node、zone、page三个层级。
- node:对应一个NUMA节点。每个CPU访问自己node的内存快,访问远端node的内存慢。你可以用numactl --hardware看机器上有几个node。
- zone:每个node内部继续画区,x86_64上常见的有DMA区(低16MB,老设备DMA用)、DMA32区(低4GB)、Normal区(普通内存)、Movable区(专门给可迁移的页面)。32位时代的HighMem在64位下基本退场,但嵌入式32位内核依然会遇到。
- page:物理内存的最小管理单位,默认4KB。每个物理页对应一个struct page结构,内核通过mem_map数组管理所有页。
管理物理页的核心算法叫伙伴系统(Buddy System)。它的思想特别朴素:把空闲页按2的幂次分成不同大小的链表,order=0是1页(4KB),order=1是2页(8KB)……一直到order=10(4MB)。分配时从小到大找够用的块,没有就拆大块;释放时看隔壁"小伙伴"是否空闲,空闲则合并成更大块。
举个例子:驱动调kmalloc想分配64KB。64KB就是16页,order=4。分配器先去看order=4链表中是否有空闲块,如果没有,去order=5链表借一块32页的块,劈成两半,一半返回给你,另一半挂回order=4链表。释放时类似,找不到buddy就一直挂着,找到了就往上合并。这个机制保障了Linux分配连续物理内存的能力,但也会因为长时间分配释放不均衡导致内存碎片。
3.2 GFP标志:告诉内核"我可以等多久"
内核内存分配API的第二个参数——gfp_mask,常常被新手忽略,但它决定了分配器的行为和成败。几个最常用的:
| 标志 | 含义 | 典型场景 |
|---|---|---|
| GFP_KERNEL | 普通内核分配,可能睡眠,可能触发回收 | 进程上下文 |
| GFP_ATOMIC | 原子分配,绝不放睡,内存不够就失败 | 中断上下文、自旋锁内 |
| __GFP_DMA | 必须在DMA zone分配 | DMA描述符 |
| __GFP_HIGH | 允许使用紧急预留内存 | 内存回收路径 |
调用kmalloc时选错标志,后果很典型:在中断上下文用GFP_KERNEL,会出现休眠导致的oops或者死锁。在普通进程上下文用GFP_ATOMIC,虽然能跑,但莫名地分配失败概率变高,因为原子分配不能触发页回收,内存不足时失败率陡增。我的习惯是:能明确上下文就选匹配标志,不确定要不要休眠就选GFP_ATOMIC并做好失败兜底。
3.3 overcommit与OOM:系统是怎么"赖账"的
很多时候你看到top里内存已经满了,但进程还能继续malloc成功,这是因为Linux默认启用了启发式内存过度分配(overcommit_memory=0)。简单说,系统允许"高估"自己的内存能力,malloc只做记账,并没有做真实承诺。只有overcommit_memory设为2时,系统才会严格拒绝超过(swap + ram * ratio)的虚拟内存申请。
过度承诺必然带来风险——当多进程一起真实写入内存时,物理内存不够了,内核就得启动OOM Killer挑进程杀掉。选谁?看oom_score,它综合了进程内存占用、CPU占用、存活时间、oom_score_adj调整值。这里有一个大坑:ssh、数据库、监控agent这些你不想杀的进程,如果内存吃得多,也可能被选中。生产环境我会用systemd或echo命令给关键进程设置oom_score_adj=-999,尽量排除误杀。
4. 三层次之间的"传动轴":缺页异常、写时复制与回收机制
4.1 一次malloc,一次读取,背后发生什么
把三个层次串起来,可以讲一条完整的链路。假设一个进程执行:
char *p = malloc(100 * 1024 * 1024); // 申请100MB p[0] = 'a'; // 第一次写第一句malloc执行完,内核只是把进程的vm_area_struct(VMA)列表里加了一段100MB的区间,页表里根本没有对应条目。打开/proc/PID/smaps会看到这段RSS是0,VSZ多了100MB。真正发生物理页分配的是第二句p[0]='a',CPU去翻译p[0]这个地址时,页表查不到,触发缺页异常。
缺页异常根据情况分两种:
- 次缺页(minor fault):物理页已经在page cache里,或者只需要从伙伴系统分配一个新页,填上页表就能返回。
- 主缺页(major fault):内容在磁盘上,比如mmap读文件,需要发起磁盘I/O,开销很大。
你可以用以下命令观察缺页情况:
cat /proc/PID/status | grep -E "VmRSS|RssAnon|RssShmem|voluntary"voluntary_ctxt_switches和nonvoluntary_ctxt_switches是任务切换计数,缺页统计则要看性能工具perf。
按需分配的核心思想是:不访问不分配,一访问立刻给。所以你的程序如果只malloc而不碰内存,物理内存压力几乎没有。
4.2 写时复制:fork一个进程不等于复制整块内存
fork()创建子进程时,如果老老实实把父进程的全部物理页复制一份,一个大进程fork一次就够系统喝一壶了。Linux的做法是:父子进程先共享所有私有页,但页表项标记为只读。任何一方尝试写入时触发缺页异常,内核这才把物理页复制一份,更新写方的页表指向新页,恢复写权限。这就是COW(Copy-On-Write)。
COW的代价也很明显:fork之后父子进程都会去碰同一片内存时,会触发大量缺页,性能反而低下。这种情况下,更好的做法是改用posix_spawn或者vfork,或者确保fork前主动预热写入,提前分裂页面。我在自研服务里做过一个优化:fork前先用madvise(MADV_WIPEONFORK)标记不需要继承的缓存区,减少子进程初始化时的COW压力。
4.3 回收与swap:知道谁先走、谁后走
当系统发现内存紧张,会触发回收机制。回收的优先级是:
- page cache:干净页直接丢弃,脏页面写回磁盘后丢弃。这是为什么文件缓存占内存高时,系统一般还能撑住,因为可以随时回收。
- 匿名页:如果配置了swap,会换出到交换分区/交换文件;没有swap就只能靠OOM。
- 内核slab/不可回收内存:这里最难办,一旦内核对象泄漏,再怎么回收都收不回来,只能重启或定位泄漏源。
回收由kswapd内核线程在后台触发,在分配路径上被迫直接回收的情况叫direct reclaim。当系统中出现大量direct reclaim计数增长,说明kswapd已经来不及回收,内存压力非常大。可以看:
cat /proc/vmstat | grep pgscan_directswap的调优也有讲究。默认的swappiness是60,这个值容易让人误判:系统明明还有内存,却把不常用的匿名页换出到磁盘。服务器场景我一般建议调到10以下,桌面端可以稍微高一点。这样能减少不必要的swap I/O,同时保证内存压力真正大的时候依然可以换出。
5. 用三层次思维排查几个典型内存问题
5.1 buff/cache高,到底是不是问题
很多人一跑free -h看到buff/cache占了十几个G就开始紧张,其实这大概率是好事。page cache本来就是Linux用空闲内存来缓存文件数据,你需要时它会主动让出来。可以用available列判断:
total used free shared buff/cache available Mem: 15Gi 4.1Gi 8.2Gi 211Mi 3.2Gi 10Giavailable是估算的"在不触发swap的情况下,还能给新进程多少内存",它已经把可回收的缓存算了进去。如果available还很充足,buff/cache高一点完全不用管。
不建议动不动手动执行echo 3 > /proc/sys/vm/drop_caches,你只是把缓存清了,看起来内存free多了,实际下次访问那些文件又要重新读盘,性能更差。只有在你刚做完大文件复制、准备立刻跑一个内存密集型测试时,才值得主动清理。
5.2 明明"还有内存",进程却OOM了
这类问题经常发生在开启了overcommit_memory=2或者cgroup内存限制的容器里。宿主机free还有大量可用,但容器cgroup的内存限制已经到了上限,进程在容器里被OOM Kill。排查顺序是:
- 先看dmesg里有没有Out of memory: Kill process记录,确认是哪个限制触发的。
- 看/proc/PID/cgroup确认进程在哪个cgroup。
- 用cat /sys/fs/cgroup/memory.max和memory.current确认cgroup限制和当前用量。
容器场景里,page cache也计入cgroup的memory.current。如果业务大量读写文件,page cache会把memory.current推高,即使进程本身不泄漏,也会被误杀。解决方案是给容器设置memory.high上限,或者在应用层控制好缓存行为。
5.3 RSS不降,是泄漏还是缓存
程序运行一段时间,RSS越涨越高不回落,第一反应是内存泄漏。但有另一种可能:glibc的malloc释放内存后并不会立刻把内存还给操作系统,而是留在进程的缓存池里,方便后面复用。这就是"内存碎片+释放不归还"的假象。
怎么判断真假泄漏?看smaps里的Private_Dirty和Swap字段:
grep -E "^Pss|^Private_Dirty|^Swap" /proc/PID/smaps | awk '{print $2}' | paste - - - | awk '{sum += $2} END {print sum}'如果Private_Dirty持续上涨,很可能真泄漏;如果是Pss稳定而远端映射多,则更可能是缓存行为。还有一种办法是连续跑两次内存快照,间隔一段时间观察增长是否线性,如果线性增长且没有对应业务增长,再去valgrind或ASan查泄漏点。
5.4 slab内存居高不下的定位
free里buff/cache高一般没问题,但如果slab一项占了很多,就不是page cache回收能解决的了。用slabtop看是哪个对象占用最高,常见有:
- dentry/inode:文件系统元数据缓存,大量小文件操作后容易出现,可以用drop_caches回收一部分。
- sock_inode_cache/kmalloc-1k:大概率是socket或网络缓冲区泄漏。
- kernfs_node_cache:sysfs/cgroupfs相关,某种内核子系统反复创建删除节点。
我的习惯是:先记录/proc/slabinfo两次间隔半小时的差值,看哪个对象持续增长,再结合业务代码去猜是哪个子系统造成的。定位到对象类型后,再使用perf、ftrace跟踪相关调用路径,比盲目看代码快得多。
5.5 关于WSL删除文件后空间不释放这个坑
这个热词看起来和内存管理无关,但它其实是一个典型的"缓存未释放"认知问题。WSL的虚拟磁盘(vhdx)不会因为你在Linux里删了文件就自动缩小,而且文件系统的truncate操作并不会立刻把ext4的块释放到Windows层。这和内存回收在思路上非常相似:系统都倾向于"保持已分配的资源,以备复用"。想释放WSL磁盘空间,需要执行:
sudo fstrim -av然后在Windows侧用diskpart或wsl --manage缩小虚拟磁盘。别把它当作内存问题,但从"资源回收"视角看,思维路径是一模一样的。
6. 面试里最常被问的四个"内存题",我的答法
这几个问题几乎每次面试都会碰到,整理一下我的答法,实际上就是把三层次思维落到具体问题上的过程。
6.1 malloc返回NULL就是内存不足?
不一定。malloc失败通常是虚拟地址空间不足、overcommit限制或glibc堆管理异常,并不代表物理内存耗尽。真正物理内存不足时,系统更明显的表现是OOM Kill,而不是malloc返回NULL。所以写的代码要同时考虑两种情况:malloc失败要处理,OOM被kill也要有兜底方案(比如关键进程的oom_score_adj设置)。
6.2 free后内存为什么不降
前面说的glibc内存池机制。小块内存释放后不会真正munmap归还系统,而是留在进程的heap里。大块内存(mmap分配的)释放后会立即munmap,RSS才会降。如果想让malloc尽快归还内存,可以把环境变量MALLOC_TRIM_THRESHOLD_调小,或者用malloc_trim(0),但不建议在生产环境频繁调用,性能损耗明显。
6.3 一个进程到底占多少内存
这是个典型的"看VSZ还是RSS还是PSS"问题。单个进程请用PSS,因为它能正确反映共享库的分摊;容量规划和OOM分析请用RSS加上swap计数;排查泄漏则重点看Private_Dirty和Swap的变化趋势。没有"唯一正确"的指标,只有"当前问题最合适的指标"。
6.4 为什么内存碎片化严重,该怎么解决
伙伴系统的拆分和合并机制决定了碎片化无法彻底避免。应对手段有:
- 尽量复用长期存活的大块内存,避免反复申请释放。
- 使用CMA预留一段连续的物理内存,给需要大块连续内存的驱动。
- 借助madvise(MADV_MERGEABLE)做内存合并,或者把进程做长时间稳定运行测试观察碎片化趋势。
7. 最后:我把三层次落到排查路径上的习惯
把三层彻底想清楚后,我处理内存问题的顺序基本固定了:
- 先用free -h、top、vmstat 1判断当前是"总量不足"还是"单进程异常"。
- 如果是单进程,看/proc/pid/status、smaps,区分RSS、VSZ、Swap的变化趋势。
- 如果嫌疑在内核对象,用slabtop和/proc/slabinfo看是哪个缓存类在涨。
- 如果还定位不到,用perf trace跟踪特定进程的page fault和mmap行为,定位具体代码路径。
这套流程不一定每一步都能直指根因,但它能把"大海捞针"变成"按图索骥"。毕竟Linux内存管理不是一门靠背命令就能掌握的学科,核心是理解那三层抽象是如何隔离、映射、分配和回收的。把这三层次刻在脑子里,再去看那些内存监控数据和内核日志,你会觉得一切都有章可循。