第 9 章 · 指针与动态内存

栈上对象生命周期由作用域决定;堆上内存手动申请、手动释放——核心四兄弟:malloc/calloc/realloc/free

8.1 malloc / calloc:申请

int *p = malloc(100 * sizeof(int));   // 申请 100 个 int,不初始化(垃圾值)
int *q = calloc(100, sizeof(int));    // 申请并清零,等价 malloc + memset 0

if (!p) { /* 分配失败:返回 NULL */ }
  • malloc(n):分配 n 字节,不初始化,内容是垃圾。
  • calloc(cnt, size):分配 cnt × size 字节,全部清零(还能防乘法溢出)。
  • 失败返回 NULL——必须检查,否则解引用空指针就是 UB。
  • 永远写 malloc(n * sizeof(int)) 而不是 malloc(n),避免手算字节出错。

8.2 free:释放

free(p);
p = NULL;      // 好习惯:防止再用成悬空指针
  • free 之后指针仍存着旧地址,但指向的内存已经还给堆——这叫悬空指针(dangling pointer)
  • 再解引用悬空指针是 UB;再 free 一次是 double free,也是 UB。
  • free(NULL) 是安全的(什么都不做),所以「置 NULL」能让后续误用变得相对可控。

8.3 内存泄漏(memory leak)

申请了却从不释放,内存就永远占着——程序跑久了越吃越多:

void leak() {
    int *p = malloc(1024);   // 没 free,函数返回后 p 丢失,1024 字节泄漏
}
  • 泄漏不一定立刻崩,但长时间运行(服务器、守护进程)会耗尽内存。
  • 排查工具:ASan(AddressSanitizer)valgrindLeakSanitizer

8.4 realloc:调整大小

int *p = malloc(4 * sizeof(int));
int *tmp = realloc(p, 8 * sizeof(int));  // 扩到 8 个
if (!tmp) { /* 失败:p 仍有效,别覆盖 */ }
p = tmp;

realloc 有两个经典陷阱:

  1. 可能移动:原地扩不下时,会分配新块、拷贝旧数据、释放旧块——所以旧指针会失效。
  2. 失败时旧指针仍有效:所以必须先存到临时变量,确认成功再赋值给 p
// 错误写法:失败时 p 变成 NULL,原来的内存地址丢失 → 泄漏
p = realloc(p, new_size);

8.5 动态内存的四大常见错误

错误后果
忘记 free内存泄漏
free 后继续用(悬空)UB,可能读到脏数据/崩
重复 free(double free)UB,堆损坏
越界写(缓冲区溢出)UB,破坏相邻内存/堆元数据
不检查 malloc 返回值解引用 NULL,段错误

8.6 用二级指针实现「安全的分配函数」

封装一个不会忘记检查、不会泄漏的分配函数:

#include <stdlib.h>
// 分配 cnt 个 size 字节,失败直接退出(或按你的策略处理)
void *xmalloc(size_t cnt, size_t size) {
    void *p = calloc(cnt, size);
    if (!p) {
        fprintf(stderr, "out of memory\n");
        exit(1);
    }
    return p;
}

8.7 与汇编 / 其他语言对照

  • 汇编malloc 是库函数,内部向 OS 要内存(brk/mmap),维护空闲链表/分配器结构;free 把块放回空闲列表,两者都只是普通函数调用。
  • Java:堆对象由 GC 自动回收,没有手动 free、没有悬空指针、没有 double free;代价是 GC 暂停和不可控的回收时机。
  • C++new/deletemalloc/free 对应,但 RAII + 智能指针(unique_ptr/shared_ptr)让手动释放几乎消失。
  • Python:引用计数 + 循环 GC,程序员完全不碰内存,但对象头开销大、大量小对象压力高。

谁 malloc 谁 free;free 后置 NULL;realloc 先存临时变量;每次分配都检查 NULL。