news 2026/9/13 3:54:00

Linux内存管理三层次:从虚拟内存到物理页分配

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux内存管理三层次:从虚拟内存到物理页分配

搞懂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 -30

maps里每一行代表一段虚拟地址区间,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:知道谁先走、谁后走

当系统发现内存紧张,会触发回收机制。回收的优先级是:

  1. page cache:干净页直接丢弃,脏页面写回磁盘后丢弃。这是为什么文件缓存占内存高时,系统一般还能撑住,因为可以随时回收。
  2. 匿名页:如果配置了swap,会换出到交换分区/交换文件;没有swap就只能靠OOM。
  3. 内核slab/不可回收内存:这里最难办,一旦内核对象泄漏,再怎么回收都收不回来,只能重启或定位泄漏源。

回收由kswapd内核线程在后台触发,在分配路径上被迫直接回收的情况叫direct reclaim。当系统中出现大量direct reclaim计数增长,说明kswapd已经来不及回收,内存压力非常大。可以看:

cat /proc/vmstat | grep pgscan_direct

swap的调优也有讲究。默认的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 10Gi

available是估算的"在不触发swap的情况下,还能给新进程多少内存",它已经把可回收的缓存算了进去。如果available还很充足,buff/cache高一点完全不用管。

不建议动不动手动执行echo 3 > /proc/sys/vm/drop_caches,你只是把缓存清了,看起来内存free多了,实际下次访问那些文件又要重新读盘,性能更差。只有在你刚做完大文件复制、准备立刻跑一个内存密集型测试时,才值得主动清理。

5.2 明明"还有内存",进程却OOM了

这类问题经常发生在开启了overcommit_memory=2或者cgroup内存限制的容器里。宿主机free还有大量可用,但容器cgroup的内存限制已经到了上限,进程在容器里被OOM Kill。排查顺序是:

  1. 先看dmesg里有没有Out of memory: Kill process记录,确认是哪个限制触发的。
  2. 看/proc/PID/cgroup确认进程在哪个cgroup。
  3. 用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. 最后:我把三层次落到排查路径上的习惯

把三层彻底想清楚后,我处理内存问题的顺序基本固定了:

  1. 先用free -h、top、vmstat 1判断当前是"总量不足"还是"单进程异常"。
  2. 如果是单进程,看/proc/pid/status、smaps,区分RSS、VSZ、Swap的变化趋势。
  3. 如果嫌疑在内核对象,用slabtop和/proc/slabinfo看是哪个缓存类在涨。
  4. 如果还定位不到,用perf trace跟踪特定进程的page fault和mmap行为,定位具体代码路径。

这套流程不一定每一步都能直指根因,但它能把"大海捞针"变成"按图索骥"。毕竟Linux内存管理不是一门靠背命令就能掌握的学科,核心是理解那三层抽象是如何隔离、映射、分配和回收的。把这三层次刻在脑子里,再去看那些内存监控数据和内核日志,你会觉得一切都有章可循。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 3:53:22

UUID字符串压缩原理与工程实践:熵值、长度、唯一性三重平衡

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 3:52:53

11.28数字文化现象解析与营销应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 3:46:25

COMSOL在电力变压器电磁场仿真中的优势与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 3:40:56

OpenCode实战指南:终端AI编程助手的安装配置与高效玩法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华