ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

第 9 天:malloc 申请的内存从哪来,free 之后又去了哪里?

第 9 天:malloc 申请的内存从哪来,free 之后又去了哪里? 写 C 程序时申请内存只需要一行int*pmalloc(100*sizeof*p);但这一行很容易让人产生一个错觉程序向操作系统要了一块内存操作系统立刻从内存条上切出对应大小再把地址交回来。实际过程多了一层。**malloc通常先找进程里的内存分配器分配器管理的空间不够时才需要向操作系统申请更多。**至于这些虚拟地址何时真正占用物理内存又要接上昨天讲的按需分配。把这几层分清才能解释两个常见现象为什么申请几十个字节也可能有额外开销以及为什么明明调用了free任务管理器里的内存占用却没降下来。先从一小段代码看起voidexample(void){intlocal10;int*pmalloc(100*sizeof*p);if(pNULL){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*pmalloc(sizeof*p);int*qp;假设申请成功执行free(p);pNULL;只把p改成了空指针。q没有跟着改变但它原先指向的对象已经结束生命周期不能再用来读写。这就是悬空指针可能出现的地方。所以管理动态内存时必须约定谁负责释放、其他使用者什么时候停止访问。只在释放后写一句p NULL不能替代这份约定。我们用一个实际错误看看这些问题怎样暴露出来。把下面的代码保存为memory_bug.c。代码故意保留了一个越界错误#includestdio.h#includestdlib.hintmain(void){constsize_tcount8;int*valuesmalloc(count*sizeof*values);if(valuesNULL){fputs(内存申请失败\n,stderr);return1;}for(size_ti0;icount;i){values[i](int)i;}printf(第一个元素%d\n,values[0]);free(values);return0;}在 Linux 或 WSL 中使用支持 AddressSanitizer 的 GCC 编译gcc-stdc11-g-O0-Wall-Wextra\-fsanitizeaddress -fno-omit-frame-pointer\memory_bug.c-omemory_bug ./memory_bugAddressSanitizer通常简称 ASan会在程序运行时检查多种内存访问错误。这个例子应当报告ERROR: AddressSanitizer: heap-buffer-overflow报告通常还会指出发生写入的位置以及对应内存块最初在哪里分配。顺着报告回到循环for(size_ti0;icount;i)我们申请了 8 个元素合法下标是0到7。却让循环执行到了values[8]于是写出了申请范围。把它改成for(size_ti0;icount;i)重新编译运行这次越界就消失了。这个错误很短却能解释一个重要现象为什么有些越界访问没有马上让程序崩溃第 7 天讲过硬件主要按照页和页权限检查访问。一页里可能装着多个小对象。你越过一个数组的末尾地址可能仍落在可写的那一页里硬件没有足够的信息判断“这里已经超过了malloc给你的边界”。越界仍然是错误可能破坏旁边的数据或分配器的记录只是后果不一定在出错当场出现。ASan 通过额外的检查帮助我们把问题定位到那次非法访问而不是等程序稍后在别处崩溃。另外两类错误也可以沿着同样的思路理解错误代码发生了什么为什么有问题越界访问访问了申请范围之外的位置对象的大小不允许这次访问释放后使用free后仍通过旧指针读写对象的生命周期已经结束内存泄漏不再需要的动态内存没有释放例如丢失了唯一指针分配器仍认为它在使用无法重新分配排查时地址是不是空指针只是第一步。更关键的是确认这次访问落在哪个对象里、是否超过边界、对象此刻是否还活着。今天的越界程序只错了一个等号。如果它藏在一个持续运行几天的服务里错误可能到很久之后才表现出来。以后看到偶发崩溃、莫名变化的数据除了盯着崩溃那一行也要回头检查更早发生的内存读写。明天我们离开内存去看另一种熟悉的名字输入一个文件路径后操作系统怎样找到文件里的那些字节。
返回列表