C/C++ 入门:动态内存管理
栈上的变量由编译器自动管理,但程序运行时常常需要”自己决定分配多少内存、什么时候释放”,这就是动态内存。
📚 基本概念速读
| 名称 | 定义 | 省流 |
|---|---|---|
| 栈(stack) | 局部变量所在的内存区域,由编译器自动分配和释放 | 自动管 |
| 堆(heap) | 程序运行时手动申请和释放的内存区域 | 手动管 |
new / delete |
C++ 中分配/释放单个对象内存的运算符 | 单对象配对 |
new[] / delete[] |
分配/释放对象数组的运算符 | 数组配对 |
| 浅拷贝 | 只复制指针的地址,多个对象共享同一块资源 | 复制”地址” |
| 深拷贝 | 复制资源本身,每个对象持有自己的副本 | 复制”内容” |
| RAII | 资源获取即初始化:构造时获取资源,析构时释放资源 | 生命周期管资源 |
unique_ptr |
C++11 提供的独占所有权智能指针 | 自动释放、不可复制 |
🧭 为什么需要动态内存
之前见到的局部变量大多在栈上:
1 | int main() |
栈内存由编译器自动管理:进入作用域分配,离开作用域释放。这很方便,但有两个限制:
| 限制 | 说明 |
|---|---|
| 大小编译期基本确定 | 数组长度通常是编译期常量,运行时才知道大小的情况不好处理 |
| 生命周期跟着作用域走 | 函数返回后局部变量就没了,无法把数据带出函数 |
堆内存由程序员手动管理,可以解决这两个问题:大小可以在运行时决定,生命周期也可以由我们自己控制。
flowchart LR
A[程序运行] --> B[栈:自动分配自动释放]
A --> C[堆:new 手动分配]
C --> D[delete 手动释放]
C -->|忘记释放| E[内存泄漏]
代价是:谁分配,谁负责释放。一旦忘记释放或释放错误,就会出问题。
🛠️ new/delete:单对象分配与释放
基本用法
new
在堆上分配一块内存,并在上面构造一个对象;delete
调用对象的析构函数并释放这块内存。
1 |
|
new int(42) 做了两件事:
- 在堆上分配能放下一个
int的内存; - 在这块内存上构造对象,把值设为 42。
delete p 也做了两件事:
- 调用对象的析构函数(这里
int没有析构逻辑); - 把这块内存还给系统。
对象的 new/delete
对自定义类同样适用,而且更能看出”构造/析构”的顺序:
1 |
|
输出:
1 | 构造 |
常见错误
| 错误 | 后果 |
|---|---|
忘记 delete |
内存泄漏:内存一直占用,程序越大越明显 |
重复 delete 同一个指针 |
未定义行为,可能崩溃 |
delete 后继续用指针(悬空指针) |
未定义行为,读到已释放的内存 |
一个缓解手段:delete
之后把指针置空,提醒自己别再用了。
1 | delete p; |
悬空指针(dangling pointer)指的是指向已释放内存的指针。
delete只释放内存,不会自动把指针变成空指针。
🧮 new[]/delete[]:数组分配与释放匹配
基本用法
动态数组用 new[] 分配,长度可以是运行时变量:
1 |
|
为什么必须配对
new 配 delete,new[] 配
delete[],这是 C++ 的硬性约定:
| 分配方式 | 释放方式 | 是否合法 |
|---|---|---|
new |
delete |
正确 |
new[] |
delete[] |
正确 |
new[] |
delete |
未定义行为 |
new |
delete[] |
未定义行为 |
对内置类型(如
int)来说,new[]配delete有时”碰巧能跑”,但对自定义类型几乎必然出问题,因为释放机制不同。不要依赖碰巧,严格配对。
现代 C++ 里,动态数组优先用
std::vector,它自己管理内存:
1 |
|
🧬 深拷贝与浅拷贝
上一章的拷贝构造提到:类里有裸指针时,默认拷贝可能出问题。这里展开看。
浅拷贝:只复制地址
默认拷贝构造函数的行为是”逐成员复制”。对于指针成员,复制的是地址本身:
1 | class Buffer { |
发生默认拷贝时:
1 | Buffer a(10); |
结果是 a.data 和 b.data
指向同一块堆内存:
1 | a.data ----+ |
两个对象销毁时都会 delete[]
同一块内存,这就是重复释放,属于未定义行为。
深拷贝:复制内容
深拷贝的做法是:自己写拷贝构造函数,为新对象重新分配内存,再把内容复制过去。
1 |
|
这样每个 Buffer 对象持有自己独立的内存:
1 | a.data --> [独立内存 A] |
资源所有权
深拷贝背后是一个更根本的问题:这块资源到底归谁所有、由谁负责释放。
| 拷贝方式 | 所有权 | 风险 |
|---|---|---|
| 浅拷贝 | 多个对象共享同一资源,各自都”以为”自己拥有 | 重复释放 |
| 深拷贝 | 每个对象拥有自己的资源副本 | 内存开销更大 |
| 禁止拷贝 | 资源只属于一个对象,不允许复制 | 需要移动语义配合 |
入门阶段先记住结论:类里有裸指针和析构函数时,默认拷贝通常不安全。
🧱 RAII:构造获取资源,析构释放资源
RAII(Resource Acquisition Is Initialization,资源获取即初始化)是 C++ 管理资源的核心思想:让资源的生命周期和对象的生命周期绑定。
- 对象构造时获取资源(比如
new出来的内存、打开的文件、申请的锁); - 对象析构时自动释放资源(
delete、关闭文件、释放锁); - 资源不用手动释放,对象销毁时自动完成。
上面写的 Buffer 类其实就是 RAII 的雏形。标准库里到处是
RAII:
| 类型 | 获取的资源 | 释放时机 |
|---|---|---|
std::vector |
堆上的数组 | 对象析构时自动释放 |
std::string |
堆上的字符缓冲 | 对象析构时自动释放 |
std::fstream |
打开的文件 | 对象析构时自动关闭 |
std::lock_guard |
互斥锁 | 对象析构时自动解锁 |
flowchart TD
A[对象构造] --> B[获取资源]
B --> C[对象正常使用]
C --> D[对象离开作用域/被销毁]
D --> E[析构函数自动释放资源]
RAII
最大的好处是异常安全:即使程序中途抛出异常,栈上的对象也会在退栈时被析构,资源照样释放。如果靠手动
delete,异常路径上的释放很容易被跳过。
1 | void process() |
🚀 unique_ptr 入门
手动管理裸指针太容易出错,C++11 提供了智能指针(smart pointer)来把
RAII 落到实处。最常用的是 unique_ptr。
独占所有权
unique_ptr
表示”这块内存只属于这一个指针”:不能复制,只能转移(move)。它离开作用域时自动释放内存。
1 |
|
make_unique 比直接 new
更安全:它保证在构造异常时也不会泄漏内存。
不能复制,可以转移
1 | unique_ptr<int> p = make_unique<int>(1); |
转移之后,p 不再拥有这块内存,访问 p
之前要先判断是否为空。
使用裸指针的场景
需要把裸指针传给旧接口时,用 get():
1 | void legacy(int* p); // 旧接口,只读不接管 |
注意:
get()拿到的指针不拥有所有权,绝不能对它调用delete,否则会和unique_ptr的析构重复释放。
自定义类的例子
1 |
|
动态数组
unique_ptr 也支持数组,且自动使用
delete[]:
1 | unique_ptr<int[]> arr = make_unique<int[]>(10); |
裸指针 vs unique_ptr
| 对比 | 裸指针 new/delete |
unique_ptr |
|---|---|---|
| 释放时机 | 手动 delete |
析构自动释放 |
| 忘记释放 | 内存泄漏 | 不会发生 |
| 重复释放 | 容易踩坑 | 所有权唯一,天然避免 |
| 复制 | 可以随意复制 | 禁止复制,只能移动 |
| 开销 | 无额外开销 | 基本无额外开销 |
优先原则:能用
unique_ptr就不用裸指针;需要多个所有者共享时才考虑shared_ptr;vector、string能解决的场景连智能指针都不用写。
⚠️ 常见误区
| 误区 | 正解 |
|---|---|
new/delete 和
malloc/free 一样 |
new 会调用构造函数,delete
会调用析构函数;malloc/free 只处理内存 |
new[] 用 delete 释放没关系 |
严格配对,否则是未定义行为 |
| 浅拷贝只是”效率低一点” | 含指针成员时浅拷贝会导致重复释放 |
| RAII 就是”在析构函数里 delete” | 核心是资源与对象生命周期绑定,从而获得自动释放和异常安全 |
unique_ptr 和 shared_ptr 随便换 |
unique_ptr 独占所有权;需要共享时才用
shared_ptr |
动态内存都该手动 new/delete |
现代 C++ 优先
vector、string、unique_ptr
等自动管理 |
✅ 总结
动态内存管理的核心是”谁分配、谁释放、何时释放”:手动
new/delete容易出错,RAII 把资源生命周期绑定到对象上,unique_ptr让释放自动化。
学完这一章,再看前面 Buffer
类的拷贝风险,就有了完整的解决思路:要么深拷贝,要么禁止拷贝,要么直接用
unique_ptr 或 vector 替我们管理。
Happy Hacking! 🎉