Alex

C 语言中 free 的工作原理

  • C语言
  • 内存管理
  • malloc/free
  • glibc

free() 看起来像是“把内存还回去”,但它通常不会把那块内存清零,也不一定立刻把内存还给操作系统。理解 free() 的关键是:你释放的是分配器(allocator)管理的堆块,而不是直接对内核做“归还”操作。

本文以 Linux 上最常见的实现(glibc 的 ptmalloc 系列思想)为主线讲解。不同平台/库(jemalloc、tcmalloc、musl 等)细节会不同,但大方向一致。


0) free 的“合同”:你给谁、谁负责什么

从 C 标准的角度,free(void *p) 的语义非常克制:

  • p 必须是之前由 malloc/calloc/realloc 返回的指针(或 NULL)。
  • free(NULL) 什么也不做。
  • 释放后,p 变成悬空指针(dangling pointer),继续读写是未定义行为(UAF)。

标准并不规定:

  • 必须清零内存
  • 必须立刻归还给 OS
  • 必须如何实现(链表、bitmap、伙伴系统、分级 bins 都行)

所以当我们讨论“free 的工作原理”,本质是在讨论“某个分配器如何管理堆内存”。


1) 你拿到的指针并不是“整块内存”的起点

典型堆分配器会把一块内存拆成很多“块(block/chunk)”,每个块除了用户可用的 payload 之外,还会在旁边存一些元数据

  • 该块大小(size)
  • 该块是否空闲(inuse/free)
  • 与相邻块合并所需的信息(例如前一块大小 prev_size)
  • 空闲块链表指针(当它处于空闲链表时)

用户拿到的 p 往往指向 payload 开始处,而**块头(header)**在 p 的前面。释放时,分配器会把 p “倒回去”找到 header,读取 size 等信息,把块放回内部结构中。

一个非常粗略的示意(仅帮助理解,真实布局与位标记更复杂):

低地址
┌──────────────┬───────────────────────────┬──────────────┐
│ chunk header │        user payload       │ next chunk…  │
└──────────────┴───────────────────────────┴──────────────┘
               ^
               p 指向这里(payload 起点)
高地址

这也解释了为什么:

  • free(p) 必须传回原样指针:传 p+1、传栈上的地址、传静态区地址,都会让分配器读到“伪造的 header”,轻则崩溃,重则安全漏洞。

2) free 的核心流程(分配器视角)

以下是多数现代分配器在 free() 时会做的几件事(顺序和策略会因实现而异):

  1. 快速返回p == NULL 直接 return。
  2. 定位块元数据:从 p 计算出 chunk/header 地址,读出 size、flags。
  3. 一致性与安全检查(可能有)
    • size 合法性
    • 指针对齐(alignment)
    • 双重释放检测(tcache / fastbin / malloc checks 等)
  4. 放入“空闲结构”
    • 小块:可能进 tcache / fastbins / small bins(以减少锁竞争、提高速度)
    • 大块:可能进 large bins,或直接考虑归还 OS(munmap / trim
  5. 合并(coalescing):尝试与前后相邻空闲块合并,形成更大空闲块,减少碎片。
  6. (可选)归还给 OS:当空闲块足够大、或位于堆顶(top chunk)时,分配器可能把一部分内存通过 brk 回收,或对 mmap 块做 munmap

从性能角度,free() 往往被设计成“尽量快”:先把块放进某个缓存/链表,合并或整理工作要么延迟、要么只对部分场景做。


3) 为什么 free 之后内存还“看起来可读”?

因为 free() 一般不会清零。释放后的那片内存内容往往还在,直到:

  • 分配器把它重新发给另一次 malloc
  • 或内核把对应页回收/换出、再映射给别的用途

示例(不要在生产代码里这样写,它是未定义行为,只用于理解现象):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
int main() {
  char *p = malloc(16);
  strcpy(p, "hello");
  free(p);
  // 未定义行为:可能还能打印 hello,也可能崩溃,也可能打印垃圾
  printf("%s\n", p);
}

现实里“还能打印出来”并不代表正确;它只是“碰巧当前分配器没有覆盖这块内存”。


4) 合并(coalescing):减少碎片的关键

堆碎片主要来自两类:

  • 外部碎片:空闲内存总量够,但被分散成许多小块,满足不了一个大请求。
  • 内部碎片:分配器为了对齐/分级管理,把块按粒度向上取整,导致浪费。

free() 常见优化是与相邻空闲块合并:

  • 若 “前一块” 空闲:合并成更大块
  • 若 “后一块” 空闲:合并成更大块

要做到这点,分配器需要能快速判断相邻块是否空闲,并能快速得到相邻块的大小与位置,所以 header 里经常会存 prev_size、或者用 flag 位表示 prev_inuse

合并能显著降低外部碎片,但合并本身也有成本(需要修改链表/树结构、可能需要加锁),所以很多实现会在“合适的时机”做,而不是每次 free() 都做完整整理。


5) tcache / fastbins:free 为何能这么快

现代 glibc 分配器为了小块性能引入了多级缓存思想:

  • tcache(thread cache):每个线程一份的小块缓存,free() 往往先塞进线程本地的单链表,几乎不需要全局锁。
  • fastbins:更早的“小块快速链表”,多数情况下不立即合并,减少 free() 开销。
  • small/large bins:更通用的空闲块结构,支持更复杂的查找与合并策略。

这解释了两个现象:

  • 小对象频繁 malloc/free 性能很好(命中 tcache,基本是链表 push/pop)。
  • 释放后的块可能暂时不合并,碎片可能在某些时机才被整理(例如 tcache 回填、触发 consolidation)。

如果你在调试“内存一直涨不下来”,要记住:很多时候内存只是留在分配器的缓存里,并没有返还给 OS。


6) brk 堆 vs mmap 大块:free 是否会 munmap?

在 Linux 上,堆内存常见来源大致两类:

  • brk/sbrk 扩出来的堆区:进程的堆顶指针向高地址移动,形成“连续”的 heap 区域
  • mmap 映射的大块:对特别大的分配请求,分配器可能直接 mmap 一段独立的匿名映射

对这两种来源,free() 的“归还策略”通常不同:

  • mmap 块:更可能在 free() 时直接 munmap,把映射交还给内核(释放更“立刻可见”)。
  • brk 堆块free() 通常只是把块放回分配器管理结构里;只有当空闲块位于堆顶附近(top chunk),并满足阈值时,分配器才可能通过 brk 把堆顶缩回去(或 malloc_trim 类操作)。

所以你在 top/ps 里看到 RSS/VSZ 不下降,经常不是“free 没生效”,而是内存仍在分配器手里,供后续复用。


7) 常见踩坑:double free、UAF、越界写

0) double free(重复释放)

char *p = malloc(8);
free(p);
free(p); // ❌ 未定义行为

在某些配置下你会看到:

  • 直接崩溃(检测到 double free)
  • 或看似没事,但内部结构被破坏,后续随机崩溃

建议习惯:释放后立刻置空,减少重复释放与误用:

free(p);
p = NULL;

1) use-after-free(释放后继续用)

UAF 通常更阴险:有时能跑、有时崩,取决于那块内存是否被再次分配并覆盖。

2) heap overflow(堆越界写)

堆块 header 紧挨 payload,越界写可能覆盖相邻块的元数据,导致:

  • free() 读到错误 size/flag -> 崩溃
  • 或在缺乏保护的场景下形成可利用的堆漏洞

8) 一句话总结

free() 的本质是:把某个堆块交还给分配器。它会根据大小与策略把块放入缓存/bins,必要时尝试合并;只有在特定条件下才会把内存真正“还给操作系统”。