一、为何需要动态内存分配
C++ 支持三种基本的内存分配方式,其中两种您已见过:
- 静态内存分配:用于 static 与全局变量。此类变量在程序启动时一次性分配内存,并在整个程序生命周期内保持有效。
- 自动内存分配:用于函数形参和局部变量。进入相应作用域时分配,退出作用域时释放,可反复进行。
- 动态内存分配:本文讨论的主题。
静态与自动分配的共同限制:
- 变量 / 数组的大小必须在编译期已知;
- 内存的分配与释放完全由系统自动完成。
多数情况下,这些限制并无大碍。然而,当处理外部输入(用户或文件)时,上述约束往往成为障碍。
示例:
- 需要字符串保存人名,但名字长度需在用户输入后才能确定;
- 需从磁盘读取若干记录,但事先不知记录条数;
- 游戏中怪物数量随时间增减,无法在编译期固定。
若必须在编译期声明大小,只能猜测一个最大值并祈祷够用:
char name[25]; // 但愿名字不超过 25 字符!
Record record[500]; // 但愿记录不超过 500 条!
Monster monster[40]; // 最多 40 只怪物
Polygon rendering[30000]; // 3D 场景最好别超过 3 万个多边形!
这种方案至少有四大缺陷:
- 内存浪费:若实际使用远小于最大值,则多余内存闲置。
- 使用追踪困难:如何区分哪些元素真正有效?需额外状态标记,增加复杂度与内存开销。
- 栈空间受限:常规变量(含定长数组)位于栈上,而默认栈空间通常很小(Visual Studio 默认为 1 MB)。超出即触发栈溢出,操作系统可能直接终止程序。
- 人为限制与越界风险:用户若尝试读取 600 条记录,而程序仅预留 500 条,只能报错、截断或导致数组越界。
动态内存分配可彻底解决上述问题:运行期按需向操作系统申请内存。该内存并非来自受限制的栈,而是来自由操作系统管理的、容量可达 GB 级别的堆(heap)。
二、单个变量的动态分配
使用 new 的标量(非数组)形式可为单个变量申请动态内存:
new int; // 动态分配一个 int,但丢弃返回地址
通常我们将返回地址存入指针以便后续访问:
int* ptr{ new int }; // 分配 int 并将地址赋给 ptr
*ptr = 7; // 通过解引用写入值 7
由此可见指针的实用场景之一:若无指针保存地址,我们将无法访问刚获得的内存。
注意:访问堆对象通常比访问栈对象慢。栈对象地址在编译期即可确定,可直接寻址;堆对象需通过指针二次寻址。
三、动态分配底层机制
计算机拥有大量可供应用程序使用的内存。操作系统加载程序后,将其映射到若干区域:代码区、运行期数据区(记录函数调用、管理全局/局部变量等)。剩余空间则等待程序申请。
程序调用 new 时,向操作系统请求预留部分内存;若请求成功,系统返回该内存地址。程序使用完毕后,应显式归还,以便系统再分配。
关键洞察
- 栈对象的分配与释放由编译器自动生成代码完成,无需程序员干预。
- 堆对象的分配与释放需程序员介入,因此必须通过地址唯一标识对象,以便后续释放。
- new 返回的地址通常存入指针,供后续访问与 delete 使用。
四、动态分配时的初始化
可在分配时直接初始化:
int* ptr1{ new int(5) }; // 直接初始化
int* ptr2{ new int{ 6 } }; // 统一初始化(列表初始化)
五、单个变量的释放
使用 delete 的标量形式释放:
delete ptr; // 将 ptr 指向的内存归还系统
ptr = nullptr; // 置空指针,避免悬空
六、“删除”内存的含义
delete 并非销毁变量,而是将内存归还操作系统。指针变量本身作用域不变,可重新赋值(如置 nullptr)。
若 delete 非动态分配的地址,将产生未定义行为。
七、悬空指针(dangling pointer)
C++ 不保证释放后的内存内容,也不修改指针值。指向已释放内存的指针即为悬空指针。解引用或二次 delete 均导致未定义行为:
int* ptr{ new int };
*ptr = 7;
delete ptr; // ptr 现为悬空指针
std::cout << *ptr; // 未定义行为
delete ptr; // 重复释放,未定义行为
当多个指针指向同一块动态内存时,释放后所有指针均悬空:
int* ptr{ new int{} };
int* otherPtr{ ptr };
delete ptr; // ptr 与 otherPtr 均悬空
ptr = nullptr; // ptr 安全,otherPtr 仍悬空
最佳实践:
- 尽量避免多指针共享同一动态内存;若必须共享,明确“所有者”。
- delete 后立即将指针置 nullptr(除非指针立即离开作用域)。
八、new 可能失败
极端情况下,系统无法满足内存请求,默认行为是抛出 std::bad_alloc 异常。若未捕获,程序将异常终止。
可用 nothrow 版本让 new 失败时返回 nullptr:
int* value{ new (std::nothrow) int };
if (!value) {
std::cerr << "内存分配失败\n";
}
九、空指针与动态内存
空指针(nullptr)表示“未分配内存”,可用于条件分配:
if (!ptr)
ptr = new int;
删除空指针是安全的,不会产生任何效果,因此无需事先判断:
delete ptr; // 若 ptr 为 nullptr,则无操作
十、内存泄漏
动态内存必须显式释放,否则将持续占用直至程序结束(操作系统回收)。若指针超出作用域或被重新赋值,而原地址未释放,则发生内存泄漏:
void doSomething() {
int* ptr{ new int{} }; // 未 delete
} // ptr 离开作用域,地址丢失,内存泄漏
其他泄漏场景:
- 重新赋值未先释放:
int value = 5;
int* ptr{ new int{} };
ptr = &value; // 原地址丢失,泄漏
- 连续多次 new 而不释放:
int* ptr{ new int{} };
ptr = new int{}; // 第一次地址丢失,泄漏
修正方法:在重新赋值前 delete。
十一、结论
- new 与 delete 支持单个变量的动态分配与释放。
- 动态分配的内存具有动态存储期,直到显式释放或程序终止。
- 切勿解引用悬空或空指针。
本节我们将讨论使用 new 与 delete 分配与释放数组。