四、内存管理
约 5602 字大约 19 分钟
2025-11-05
4.1 为什么要有虚拟内存?
Q:操作系统是如何管理虚拟地址与物理地址之间的关系?
主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的。
Q:分段机制下,虚拟地址和物理地址是如何映射的?
分段机制下的虚拟地址由两部分组成,段选择因子和段内偏移量。

段选择因子和段内偏移量:
- 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量应该位于0和段界限之间,如果段内偏移量是合法的就将段基地址加上段内偏移量得到物理内存地址。
Q:分段为什么会产生内存碎片的问题?
内存碎片主要分为,内部内存碎片和外部内存碎片。
内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片。
但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片的问题。 解决「外部内存碎片」的问题就是内存交换。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
Q:再来看看,分段为什么会导致内存交换效率低的问题?
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 5wap 内存区域,这个过程会产生性能瓶颈。
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。
Q:分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题?
内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。 但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。 如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(SwapIn)。所以,一次性写入磁盘的也只有少数的-个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。

更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
Q:分页机制下,虚拟地址和物理地址是如何映射的?
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。

总结一下,对于一个内存地址转换,其实就是这样三个步骤:
- 把虚拟内存地址,切分成页号和偏移量;
- 根据页号,从页表里面,查询对应的物理页号;
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
Q:简单的分页有什么缺陷吗?
有空间上的缺陷。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12)那么就需要大约 100 万 (2^20)个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。
那么, 108 个进程的话,就需要48BMB的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
要解决上面的问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。
Q:分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+4MB(二级页表)的内存,这样占用空间不是更大了吗?
当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。
其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?
每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20%的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表)+20%*4MB(二级页表)=8.884M8,这对比单级页表的4M是不是一个巨大的节约?
那么为什么不分级的页表就做不到这样节约内存呢?
我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。
总结
为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。
那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。
那么对于虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。
内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致外部内存碎片和内存交换效率低的问题。
于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux系统中,每一页的大小为 4KB。由于分了页后,就不会产生细小的内存碎片,解决了内存分段的外部内存碎片问题。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。
再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在CPU 芯片中加入了TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。
Linux 系统主要采用了分页管理,但是由于Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为8,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了CPU逻辑地址的概念,所以段只被用于访问控制和内存保护。
另外,Linux 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。
最后,说下虚拟内存有什么作用?
- 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
4.2 malloc 是如何分配内存的?
实际上,malloc0 并不是系统调用,而是C库里的函数,用于动态分配内存。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 方式一:通过 brk0 系统调用从堆分配内存
- 方式二:通过 mmap0 系统调用在文件映射区域分配内存;
方式一实现的方式很简单,就是通过 brk0 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图:

方式二通过 mmap0 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷"了一块内存。如下图:

Q:什么场景下 malloc0 会通过 brk0 分配内存?又是什么场景下通过mmap0 分配内存?
malloc() 源码里默认定义了一个阈值!
- 如果用户分配的内存小于 128 KB,则通过 brk0 申请内存;
- 如果用户分配的内存大于 128 KB,则通过 mmap0 申请内存;
注意,不同的 glibc 版本定义的阈值也是不同的。
Q:malloc()分配的是物理内存吗?
不是的,malloc()分配的是虚拟内存。
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。 只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。
Q:malloc(1)会分配多大的虚拟内存?
maloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。
具体会预分配多大的空间,跟 malloc 使用的内存管理器有关系,我们就以 malloc默认的内存管理器(Ptmalloc2)来分析。
这个例子分配的内存小于 128 KB,所以是通过 brk0 系统调用向堆空间申请的内存,因此可以看到最右边有 [heap] 的标识。
可以看到,堆空间的内存地址范围是 00d73000-00d94000,这个范围大小是132KB,也就说明了 malloc(1)实际上预分配 132K 字节的内存,
Q:free 释放内存,会归还给操作系统吗?
通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统。
这是因为与其把这1字节释放给操作系统,不如先缓存着放进maloc 的内存池里当进程再次申请1字节的内存时就可以直接复用,这样速度快了很多。当然,当进程退出后,操作系统就会回收进程的所有资源。 上面说的 free 内存后堆内存还存在,是针对 malloc 通过 brk0 方式申请的内存的情况。
如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。
对于「malloc 申请的内存,free 释放内存会归还给操作系统吗?」这个问题,我们可以做个总结了:
- malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;
- malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
Q:为什么不全部使用 mmap 来分配内存?
咱们来聊聊 ma11oc 为啥不一股脑儿全用 mmap 来分内存,非得搞个 brkmmap 的组合拳。这事儿吧,说白了就是性能和资源管理上的一种权衡,没有一招鲜吃遍天的好事儿。
你想啊, mmap 确实挺酷的,每次都能从操作系统那儿划拉一块全新的、独立的虚拟内存区域给你,用完了直接munmap 还回去,干干净净,碎片问题也少。但问题就在于,这“酷"是有代价的!每次调用 mmap,都得劳烦操作系统内核跑一趟,做一大堆事情:找个没人用的地址空间、设置好页表项、可能还要清空内存页(确保安全)、更新内核数据结构.. 这一套流程下来,开销可比在用户态捣鼓点指针大多了。要是你程序里动不动就分配释放一堆小块儿内存(比如链表节点、小对象啥的),每次都来这么一趟mmap/munmap ,那性能可就真得慢得掉渣了,系统调用本身、TLB(快表)刷新的开销都能把你拖垮。
这时候 brk 的价值就体现出来了。它本质上是挪动一个叫“program break”的指针,把进程堆区的尾巴伸长或者缩短。分配小块内存时,ma11oc在用户空间自己管理堆区这块地盘就行了。它预先通过 brk扩大堆区(比如一次申请一大块),然后在这块连续的内存里,像切豆腐一样,根据你的请求切出合适的小块给你。释放的时候呢,也不是立刻还给操作系统,而是记录起来(放进空闲链表之类的结构),等下次有人再要小块内存时直接复用。只有当堆顶一大块连续内存都空闲了, ma11oc才可能用 brk把尾巴缩回去,把内存真正还给系统。这么搞,好处太明显了:对于大量、频繁的小内存申请释放,绝大部分操作都在用户态搞定,速度快得飞起,系统调用的开销被摊得非常薄。碎片问题虽然存在,但a11oc自己会努力合并相邻的空闲块来缓解。
当然, brk 也不是万金油。堆区是连续的,要是中间被零零碎碎的小块占着,即使总空闲空间够,也可能找不到一块连续的大空间来满足大内存申请(这就是内部碎片)。而且,堆区理论上只能向一个方向长(通常向上),管理起来没那么灵活。
所以,malloc的智慧就在于“看人下菜碟”:
- 小内存、频繁请求:主要靠 brk 管理的堆区。用户态搞定,速度快如闪电。
- 大内存(通常超过一个阈值,比如几百KB):直接用单独映射一块。这mmap样避免了在堆区造成难以忍受的大洞(外部碎片),释放时也能干净利落地立刻归还给系统,不拖累堆区。
总结起来就是:灵活干净但开销大,brk管理堆速度快但对大块和碎片敏mmap感。 ma11oc 混着用,让小内存分配享受 brk的速度红利,让大内存分配享受mmap 的独立与干净,各取所长,才能在各种内存分配需求下都交出比较均衡、高效的答卷。
要是全用 mmap ,小内存分配的频繁开销会让程序慢得怀疑人生,要是全用brk,遇上大内存或者长期运行产生大量碎片,程序可能就“卡死"在明明有内存却分配不出来的尴尬境地。所以,这个组合拳,打得有理!
Q:既然 brk 那么牛逼,为什么不全部使用 brk 来分配?
前面我们提到通过 brk 从堆空间分配的内存,并不会归还给操作系统。
因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露"现象使用 valgrind(内测泄漏检测工具) 是无法检测出来的。 所以,malloc 实现中,充分考虑了 brk和 mmap 行为上的差异及优缺点,默认分配大块内存(128KB) 才使用 mmap 分配内存空间。
Q:free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?
还记得,我前面提到,malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节吗?
这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。

这样当执行 free0 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。
Q:malloc内存分配器是怎样实现的?
面试官:malloc内存分配器是怎样实现的?
