先区分两个“堆”
跳转到“先区分两个“堆””运行时堆通常指动态内存分配器管理的内存及相关机制,用来满足大小、数量或生命周期在运行中才确定的分配请求。它与实现优先队列的堆数据结构是不同概念。
动态分配也不保证所有内存都来自一个连续区域。操作系统、语言运行时和分配器可能组合使用多个区域,甚至为不同大小的对象采用不同路径。
| 问题 | 自动对象与调用栈 | 动态分配 |
|---|---|---|
| 生命周期由什么决定 | 语言作用域和调用行为 | 分配、所有权以及释放或回收规则 |
| 大小何时确定 | 取决于语言与实现,并非一律编译期 | 通常可在运行时请求 |
| 谁负责回收 | 编译器和运行时按语言规则处理 | C 中通常显式配对;其他语言可采用所有权或垃圾回收 |
| 常见错误 | 返回已结束生命周期的对象地址、栈耗尽 | 泄漏、重复释放、释放后使用、分配大小溢出 |
“面向对象的实例一定在堆上”也不成立:例如 C++ 可以定义自动存储期对象。C++ 的容器和智能指针能管理动态资源的所有权;可结合策略对象与 unique_ptr理解。Java、Python 的对象管理属于各自运行时,不能用 C 的 malloc/free 规则直接替代。
C 的分配和释放约定
跳转到“C 的分配和释放约定”malloc(n) 成功时提供至少可用于所请求 n 字节的分配块,返回其中的起始地址;失败时返回空指针。新分配的字节没有由 malloc 初始化。free() 只能接受空指针,或尚未释放且来自相应分配函数的有效指针;不能传入栈对象地址或分配块中间的地址。GNU C Library:基础分配
下面是独立的 C11 示例。先检查乘法上界,避免 count * sizeof *values 溢出后只分配很小的缓冲区:
#include <assert.h>#include <stdint.h>#include <stdlib.h>
int main(void){ const size_t count = 10; if (count > SIZE_MAX / sizeof(int)) { return 1; }
int *values = malloc(count * sizeof *values); if (values == NULL) { return 1; }
for (size_t i = 0; i < count; ++i) { values[i] = (int)(i * i); } assert(values[0] == 0 && values[9] == 81);
free(values); values = NULL; return 0;}在 C 中,void * 可隐式转换成对象指针,不需要给 malloc 的结果强制转换。把自己的变量置空只能防止后续误用该变量;此前复制出去的别名不会自动变空。
扩容时保留旧指针
跳转到“扩容时保留旧指针”若把 realloc() 的返回值直接覆盖唯一的原指针,失败后就可能失去释放旧分配块的地址。对于非零的新大小,应先接到临时变量:
/* 片段:values 是有效的已分配 int 数组;new_count 已确定。 */if (new_count == 0 || new_count > SIZE_MAX / sizeof *values) { /* 根据接口约定处理空数组或拒绝请求。 */} else { int *resized = realloc(values, new_count * sizeof *values); if (resized != NULL) { values = resized; } else { /* 原分配块仍然有效,values 仍可用于访问或释放。 */ }}成功后应使用返回的新指针,并重新建立需要的内部指针;块可能已经搬迁。新增部分也不会自动初始化。零大小请求在不同标准版本和实现间存在差异,示例显式排除它。GNU C Library:调整分配大小
一个范围明确的字节 arena
跳转到“一个范围明确的字节 arena”原稿将一个不断增加数组偏移量的分配演示归于韦东山,但未给出具体课程出处。这个思路可以演示“从一块缓冲区切出片段”,但不能直接替代 malloc:它原本缺少边界检查、完整类型声明和回收约定。
下面的 arena 只借出字节片段。它不提供任意类型所需的对齐,也没有单块释放和线程同步;需要不同类型对象时,应选择相应分配器,而不是把这些返回值随意转换成 int *。
#include <assert.h>#include <stddef.h>#include <string.h>
typedef struct { unsigned char bytes[100]; size_t used;} ByteArena;
static unsigned char *arena_take(ByteArena *arena, size_t size){ if (size == 0 || size > sizeof arena->bytes - arena->used) { return NULL; } unsigned char *result = arena->bytes + arena->used; arena->used += size; return result;}
static void arena_reset(ByteArena *arena){ arena->used = 0;}
int main(void){ ByteArena arena = {0}; assert(arena_take(&arena, 0) == NULL); assert(arena_take(&arena, 101) == NULL);
unsigned char *name = arena_take(&arena, 27); assert(name != NULL); memcpy(name, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 27); /* 含末尾零字节 */ assert(strcmp((const char *)name, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") == 0);
assert(arena_take(&arena, 73) != NULL); /* 正好耗尽 */ assert(arena_take(&arena, 1) == NULL); assert(arena.used == 100);
/* 确定所有借出的片段都不再被使用后,才能整体重置。 */ arena_reset(&arena); assert(arena_take(&arena, 100) != NULL); return 0;}接口内部保持 used <= sizeof bytes,先比较剩余容量再相加,因此不会通过整数回绕绕过边界检查。调用者必须以零初始化 arena,也不能私自修改 used。重置之后,旧片段可被重新分配,所有旧借用都应按约定作废。
memset 填充的是字节
跳转到“memset 填充的是字节”memset(ptr, value, count) 将 value 转成 unsigned char 后重复填入指定字节数。它不是“给每个整数赋同一个整数值”,也不负责构造 C++ 对象。例如下面明确检查对象表示的字节:
#include <assert.h>#include <string.h>
int main(void){ unsigned char bytes[4]; memset(bytes, 1, sizeof bytes); for (size_t i = 0; i < sizeof bytes; ++i) { assert(bytes[i] == 1); } return 0;}要给整数数组逐元素赋 1,应循环赋值或使用语言提供的填充算法。原稿把 0/-1 都当作对任意非字符类型通用的 memset 赋值规则,这不成立;浮点、指针、结构体和类各有类型及表示约束。对 C 聚合对象需要按语言规则初始化时,优先考虑 = {0} 等初始化方式。
分配成功之后仍需管理什么
跳转到“分配成功之后仍需管理什么”- 所有权:谁最终负责释放,函数失败路径是否也释放?
- 边界:实际分配的元素数是否覆盖每次访问,字符串是否包含终止字节?
- 时间:对象是否仍存活,异步回调或其他线程是否还持有它?
- 成本:频繁分配可能带来元数据、碎片和同步成本;没有具体平台测量时,不能保证分配或释放一定是常数时间。
本页三个独立 C 示例已用 GCC 编译并运行;字节 arena 已覆盖零长度、超容量、刚好耗尽和重置后的重新分配。realloc 段是接口使用片段,另通过包装后的有效大小扩容示例验证,不假装已经模拟真实内存耗尽。