☰
第 9 天:malloc 申请的内存从哪来,free 之后又去了哪里?
2026/10/8 13:30:40 网站建设 项目流程

写 C 程序时,申请内存只需要一行:

int*p=malloc(100*sizeof*p);

但这一行很容易让人产生一个错觉:程序向操作系统要了一块内存,操作系统立刻从内存条上切出对应大小,再把地址交回来。

实际过程多了一层。**malloc通常先找进程里的内存分配器,分配器管理的空间不够时,才需要向操作系统申请更多。**至于这些虚拟地址何时真正占用物理内存,又要接上昨天讲的按需分配。

把这几层分清,才能解释两个常见现象:为什么申请几十个字节也可能有额外开销,以及为什么明明调用了free,任务管理器里的内存占用却没降下来。

先从一小段代码看起:

voidexample(void){intlocal=10;int*p=malloc(100*sizeof*p);if(p==NULL){return;}p[0]=local;free(p);}

这里有三个不同的对象:局部变量local、指针变量p,以及p指向的那块动态内存。

local和p都是函数里的自动变量,通常放在栈上,也可能被编译器放进寄存器或优化掉。函数调用结束,它们的生命周期也就结束了。

malloc分配出来的那块空间则不同。它不会因为example返回就自动释放。如果函数没有调用free,也没有把地址交给别的代码保存,这块空间就可能再也找不到,却仍然被分配器视为“正在使用”。

指针变量消失,不等于它指向的内存被释放。

这个区别也解释了为什么不能返回局部数组的地址:

int*wrong(void){intvalues[4]={1,2,3,4};returnvalues;/* 错误:函数返回后,数组的生命周期已经结束 */}

调用者即使拿到了一个看起来正常的地址,也不能继续使用它。地址数字还在,并不能证明对应对象仍然有效。

在日常交流中,我们把malloc管理的动态分配空间叫作“堆”。不过在 Linux 中,一块动态内存未必位于/proc/PID/maps标注的[heap]区域;分配器也可能使用单独的内存映射。理解生命周期,比死记“某种变量一定放在哪一段地址”更有用。


假设程序连续申请了三块小内存。分配器可能已经从操作系统取得一大片空间,于是直接在里面挑出合适的空闲块:

分配器管理的一片空间 ┌────────┬────────┬────────────┬──────────────┐ │ 已分配 A │ 已分配 B │ 空闲块 │ 已分配 C │ └────────┴────────┴────────────┴──────────────┘

它需要记录哪些块正在使用、哪些已经空闲,还可能把大空闲块拆小,把相邻的空闲块合并。具体做法随分配器而变化,但目的相同:让大量申请和释放尽量快地完成。

因此,调用一次malloc,不一定发生一次系统调用。

在 Linux 上,分配器可能通过调整堆的范围,或使用mmap等方式获得空间。得到虚拟地址后,部分物理页还可能等到真正访问时才准备好。昨天我们观察到的首次写入缺页,就发生在更下面这一层。

可以把整条关系连起来:

程序:需要 100 个 int ↓ 内存分配器:寻找并分配合适的空闲块 ↓ 空间不足时 操作系统:提供更多虚拟内存区域 ↓ 按需要 建立到物理页的映射

操作系统通常按页管理内存;分配器则把空间细分成应用需要的大小。程序只申请几十个字节时,没必要独占一个完整的物理页。

这也带来碎片问题。

例如,为了满足对齐和大小分类,申请的块可能比请求稍大,块内部多出来的部分无法由应用按申请之外的范围使用,这属于内部碎片。分配器还可能有自己的管理开销。

另一种情况是:空闲空间加起来不少,却被仍在使用的小块隔开,缺少足够大的连续空闲块。这属于外部碎片。这里说的是分配器所管理空间中的连续范围,不能据此推断底层物理页也必须连续。

这就是程序“总共好像还有不少空闲空间”,却不一定能高效满足下一次申请的原因之一。


再看free(p)。

它告诉分配器:这块空间可以重新使用了。分配器可能先把它留在自己的空闲结构或缓存中,等下一次malloc再分出去。某些情况下,它也会把空间归还操作系统。

因此,释放了一批对象后,进程的物理内存占用没有立即下降,并不能单独证明发生了泄漏。判断泄漏需要继续查:那些对象是否仍然存活?空间是否已经交还分配器?是不是程序保留了越来越多不再需要的数据?

但对程序来说,free的边界很明确:释放之后,就不能再访问原来的对象。

下面两行代码没有复制数据,只是让两个指针指向同一块内存:

int*p=malloc(sizeof*p);int*q=p;

假设申请成功,执行:

free(p);p=NULL;

只把p改成了空指针。q没有跟着改变,但它原先指向的对象已经结束生命周期,不能再用来读写。这就是悬空指针可能出现的地方。

所以,管理动态内存时,必须约定谁负责释放、其他使用者什么时候停止访问。只在释放后写一句p = NULL,不能替代这份约定。


我们用一个实际错误看看,这些问题怎样暴露出来。

把下面的代码保存为memory_bug.c。代码故意保留了一个越界错误:

#include<stdio.h>#include<stdlib.h>intmain(void){constsize_tcount=8;int*values=malloc(count*sizeof*values);if(values==NULL){fputs("内存申请失败\n",stderr);return1;}for(size_ti=0;i<=count;i++){values[i]=(int)i;}printf("第一个元素:%d\n",values[0]);free(values);return0;}

在 Linux 或 WSL 中,使用支持 AddressSanitizer 的 GCC 编译:

gcc-std=c11-g-O0-Wall-Wextra\-fsanitize=address -fno-omit-frame-pointer\memory_bug.c-omemory_bug ./memory_bug

AddressSanitizer,通常简称 ASan,会在程序运行时检查多种内存访问错误。这个例子应当报告:

ERROR: AddressSanitizer: heap-buffer-overflow

报告通常还会指出发生写入的位置,以及对应内存块最初在哪里分配。

顺着报告回到循环:

for(size_ti=0;i<=count;i++)

我们申请了 8 个元素,合法下标是0到7。<=却让循环执行到了values[8],于是写出了申请范围。

把它改成:

for(size_ti=0;i<count;i++)

重新编译运行,这次越界就消失了。

这个错误很短,却能解释一个重要现象:为什么有些越界访问没有马上让程序崩溃?

第 7 天讲过,硬件主要按照页和页权限检查访问。一页里可能装着多个小对象。你越过一个数组的末尾,地址可能仍落在可写的那一页里,硬件没有足够的信息判断“这里已经超过了malloc给你的边界”。

越界仍然是错误,可能破坏旁边的数据或分配器的记录,只是后果不一定在出错当场出现。ASan 通过额外的检查,帮助我们把问题定位到那次非法访问,而不是等程序稍后在别处崩溃。

另外两类错误,也可以沿着同样的思路理解:

错误代码发生了什么为什么有问题
越界访问访问了申请范围之外的位置对象的大小不允许这次访问
释放后使用free后仍通过旧指针读写对象的生命周期已经结束
内存泄漏不再需要的动态内存没有释放,例如丢失了唯一指针分配器仍认为它在使用,无法重新分配

排查时,地址是不是空指针只是第一步。更关键的是确认:这次访问落在哪个对象里、是否超过边界、对象此刻是否还活着。

今天的越界程序只错了一个等号。如果它藏在一个持续运行几天的服务里,错误可能到很久之后才表现出来。以后看到偶发崩溃、莫名变化的数据,除了盯着崩溃那一行,也要回头检查更早发生的内存读写。

明天我们离开内存,去看另一种熟悉的名字:输入一个文件路径后,操作系统怎样找到文件里的那些字节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询