【底层机制】为什么 delete 或 free 不需要指定释放的大小?

2 阅读5分钟

核心答案:

deletefree() 不需要我们指定大小,是因为内存分配器在分配内存时,已经暗中记录下了这块内存的大小等元信息(通常记录在分配的内存块之前或之后)。释放时,内存分配器通过我们提供的指针,反向查找这些元信息,从而知道需要释放多少内存。


详细原理与底层实现

这个过程可以分解为以下几个关键点:

1. 内存分配器的幕后工作

当你调用 newmalloc() 时,内存分配器所做的远不止是找到一块空闲内存给你。

  1. 请求与分配:你请求 N 字节的内存。
  2. 记录元数据:分配器会分配一块大于 N 字节的内存块。这多出来的部分用于存储元数据。元数据通常包括:
    • 分配的内存块大小
    • 用于调试的信息(如分配所在的文件、行号)
    • 用于维护内存块链表结构的指针(如前一个块、后一个块)
    • 校验和或魔术数字(用于检测内存损坏)
  3. 返回指针:分配器将指向用户数据区起始地址的指针返回给你。这个地址通常是“元数据起始地址 + 元数据大小”。

假设元数据需要 M 字节,分配器实际分配的内存结构在底层大致如下:

低地址方向
...
——————————————————————————————————————————————
| 元数据 (M字节) | 用户数据区 (N字节) | 填充/对齐 |
——————————————————————————————————————————————
^                 ^
|                 |
元数据起始地址     返回给用户的指针 (ptr)
(alloc_ptr)

图:内存分配器返回的指针 (ptr) 指向用户数据区,分配器通过 ptr - M 来定位元数据。

2. 释放时的逆向操作

当你调用 delete ptrfree(ptr) 时:

  1. 查找元数据:内存分配器接收到的 ptr 是你提供的、指向用户数据区的指针。分配器根据预定义的规则(例如,ptr - M),找到这块内存对应的元数据起始地址。
  2. 读取信息:从元数据中读取当初分配的大小 N 以及其他信息(用于完整性检查)。
  3. 执行释放:分配器现在知道了要释放的内存块的确切起始地址和大小,于是可以将这块内存标记为空闲,并回归到空闲内存池中,供后续分配使用。同时,它可能会检查校验和以确保内存没有被意外破坏(在调试模式下)。

3. 一个简单的类比

这就像你去停车场停车:

  • 分配 (malloc/new):管理员给你一张票(指针),并在他自己的本子(元数据)上记录下:“票号A123,对应车位B区05号(大小)”。
  • 释放 (free/delete):你归还票A123。管理员不需要你告诉他你的车有多大,他根据票号A123去查他的本子,就知道要去B区05号车位把车移走(释放),并且那个车位能停多大的车(大小)他也一清二楚。

深入探讨与重要细节

1. C++ 的 deletedelete[]

对于简单类型(如 int),deletedelete[] 通常可以混用,因为释放内存只需要知道起始地址和大小。

但对于类对象数组,它们绝不能混用。原因如下:

  • 当你使用 new MyClass[10] 时,分配器除了分配10个对象的内存,还可能分配额外的空间来存储数组的元素个数(这是另一种元数据),供 delete[] 使用。
  • delete[] ptr:它会根据存储的数组元素个数 n,对每个对象(ptr[0]ptr[n-1])调用析构函数,然后再释放整个内存块。
  • delete ptr:它只会对 ptr[0] 调用析构函数,然后释放内存。这将导致后面9个对象的析构函数未被调用(资源泄漏),并且释放的地址可能也不对(因为 new[] 返回的地址可能不是分配的真实起始地址)。

这就是为什么必须配对使用 new/deletenew[]/delete[]

2. 为什么不能 free() 一个 new 来的指针,反之亦然?

new/deletemalloc()/free() 可能使用不同的内存分配器

  • C++ 的 new/delete 通常会使用它自己的运行时库来进行分配和释放。
  • C 的 malloc()/free() 使用 C 运行时库的分配器。

这两个分配器记录元数据的方式、位置和格式很可能完全不同。用 free() 去释放一个由 new 分配的对象,free() 会试图用C分配器的规则去解析它认为的“元数据”,结果必然是解析出一堆垃圾信息,导致未定义行为(通常是程序崩溃)。

3. 内存分配器的实现策略

不同的分配器记录元数据的方式不同,常见策略有:

  • 显式长度存储:在最常见的实现中,元数据(包括大小)就存储在返回给用户的指针之前的几个字节。
  • segregated free lists:分配器维护多个不同大小的内存块链表。释放时,分配器可以根据指针所在的内存区域推断出它所属的块大小类别。

总结

操作用户视角内存分配器视角
分配 (new/malloc)请求大小 N,得到一个指针 ptr分配 N + M 字节。记录大小等元数据。返回 ptr
释放 (delete/free)提供指针 ptr通过 ptr 找到元数据,读出大小 N。释放 N + M 字节。

所以,内存分配器通过“暗中记录”的方式,让用户从手动记录大小的繁琐和易错中解放出来。这是一种经典的“以空间换时间”和“抽象化”的设计,极大地简化了程序员的工作。但同时,它也要求我们必须遵守规则:分配和释放的方式必须匹配,否则分配器精心维护的元数据就会被破坏,导致灾难性后果。