C/C++ 入门:动态内存管理

栈上的变量由编译器自动管理,但程序运行时常常需要”自己决定分配多少内存、什么时候释放”,这就是动态内存。

📚 基本概念速读

名称 定义 省流
栈(stack) 局部变量所在的内存区域,由编译器自动分配和释放 自动管
堆(heap) 程序运行时手动申请和释放的内存区域 手动管
new / delete C++ 中分配/释放单个对象内存的运算符 单对象配对
new[] / delete[] 分配/释放对象数组的运算符 数组配对
浅拷贝 只复制指针的地址,多个对象共享同一块资源 复制”地址”
深拷贝 复制资源本身,每个对象持有自己的副本 复制”内容”
RAII 资源获取即初始化:构造时获取资源,析构时释放资源 生命周期管资源
unique_ptr C++11 提供的独占所有权智能指针 自动释放、不可复制

🧭 为什么需要动态内存

之前见到的局部变量大多在栈上:

1
2
3
4
5
int main()
{
int x = 10; // 栈上分配,离开 main 自动回收
return 0;
}

栈内存由编译器自动管理:进入作用域分配,离开作用域释放。这很方便,但有两个限制:

限制 说明
大小编译期基本确定 数组长度通常是编译期常量,运行时才知道大小的情况不好处理
生命周期跟着作用域走 函数返回后局部变量就没了,无法把数据带出函数

堆内存由程序员手动管理,可以解决这两个问题:大小可以在运行时决定,生命周期也可以由我们自己控制。



flowchart LR
    A[程序运行] --> B[栈:自动分配自动释放]
    A --> C[堆:new 手动分配]
    C --> D[delete 手动释放]
    C -->|忘记释放| E[内存泄漏]

代价是:谁分配,谁负责释放。一旦忘记释放或释放错误,就会出问题。

🛠️ new/delete:单对象分配与释放

基本用法

new 在堆上分配一块内存,并在上面构造一个对象;delete 调用对象的析构函数并释放这块内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

int main()
{
// 分配一个 int 并初始化为 42
int* p = new int(42);

cout << *p << endl; // 42

// 使用完毕后释放
delete p;

return 0;
}

new int(42) 做了两件事:

  1. 在堆上分配能放下一个 int 的内存;
  2. 在这块内存上构造对象,把值设为 42。

delete p 也做了两件事:

  1. 调用对象的析构函数(这里 int 没有析构逻辑);
  2. 把这块内存还给系统。

对象的 new/delete

对自定义类同样适用,而且更能看出”构造/析构”的顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

class Logger {
public:
Logger() { cout << "构造" << endl; }
~Logger() { cout << "析构" << endl; }
};

int main()
{
Logger* log = new Logger(); // 堆上构造
delete log; // 堆上析构并释放
return 0;
}

输出:

1
2
构造
析构

常见错误

错误 后果
忘记 delete 内存泄漏:内存一直占用,程序越大越明显
重复 delete 同一个指针 未定义行为,可能崩溃
delete 后继续用指针(悬空指针) 未定义行为,读到已释放的内存

一个缓解手段:delete 之后把指针置空,提醒自己别再用了。

1
2
delete p;
p = nullptr;

悬空指针(dangling pointer)指的是指向已释放内存的指针。delete 只释放内存,不会自动把指针变成空指针。

🧮 new[]/delete[]:数组分配与释放匹配

基本用法

动态数组用 new[] 分配,长度可以是运行时变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

int main()
{
int n = 10;
int* arr = new int[n]; // 运行时决定数组大小

for (int i = 0; i < n; i++) {
arr[i] = i;
}

delete[] arr; // 必须用 delete[] 释放
return 0;
}

为什么必须配对

newdeletenew[]delete[],这是 C++ 的硬性约定:

分配方式 释放方式 是否合法
new delete 正确
new[] delete[] 正确
new[] delete 未定义行为
new delete[] 未定义行为

对内置类型(如 int)来说,new[]delete 有时”碰巧能跑”,但对自定义类型几乎必然出问题,因为释放机制不同。不要依赖碰巧,严格配对。

现代 C++ 里,动态数组优先用 std::vector,它自己管理内存:

1
2
3
#include <vector>

vector<int> arr(n); // 不用手动 new[]/delete[]

🧬 深拷贝与浅拷贝

上一章的拷贝构造提到:类里有裸指针时,默认拷贝可能出问题。这里展开看。

浅拷贝:只复制地址

默认拷贝构造函数的行为是”逐成员复制”。对于指针成员,复制的是地址本身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Buffer {
public:
int* data;

Buffer(int size)
{
data = new int[size];
}

~Buffer()
{
delete[] data;
}
};

发生默认拷贝时:

1
2
Buffer a(10);
Buffer b = a; // 默认拷贝:只复制指针地址

结果是 a.datab.data 指向同一块堆内存:

1
2
3
a.data ----+
+---- 同一块堆内存
b.data ----+

两个对象销毁时都会 delete[] 同一块内存,这就是重复释放,属于未定义行为。

深拷贝:复制内容

深拷贝的做法是:自己写拷贝构造函数,为新对象重新分配内存,再把内容复制过去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <algorithm>   // std::copy

class Buffer {
private:
int* data;
int size;

public:
Buffer(int n)
: size(n)
{
data = new int[size];
}

// 深拷贝:重新分配内存,复制内容
Buffer(const Buffer& other)
: size(other.size)
{
data = new int[size];
copy(other.data, other.data + size, data);
}

~Buffer()
{
delete[] data;
}
};

这样每个 Buffer 对象持有自己独立的内存:

1
2
a.data --> [独立内存 A]
b.data --> [独立内存 B] (内容相同,地址不同)

资源所有权

深拷贝背后是一个更根本的问题:这块资源到底归谁所有、由谁负责释放

拷贝方式 所有权 风险
浅拷贝 多个对象共享同一资源,各自都”以为”自己拥有 重复释放
深拷贝 每个对象拥有自己的资源副本 内存开销更大
禁止拷贝 资源只属于一个对象,不允许复制 需要移动语义配合

入门阶段先记住结论:类里有裸指针和析构函数时,默认拷贝通常不安全

🧱 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
2
3
4
5
void process()
{
Logger log; // 构造:获取资源
// 即使这里抛异常,log 的析构函数也会被调用
} // 离开作用域:自动析构、自动释放

🚀 unique_ptr 入门

手动管理裸指针太容易出错,C++11 提供了智能指针(smart pointer)来把 RAII 落到实处。最常用的是 unique_ptr

独占所有权

unique_ptr 表示”这块内存只属于这一个指针”:不能复制,只能转移(move)。它离开作用域时自动释放内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <memory>
using namespace std;

int main()
{
// make_unique 创建对象,返回 unique_ptr(C++14 起)
unique_ptr<int> p = make_unique<int>(42);

cout << *p << endl; // 42,用法和普通指针类似

// 不需要手动 delete,离开作用域自动释放
return 0;
}

make_unique 比直接 new 更安全:它保证在构造异常时也不会泄漏内存。

不能复制,可以转移

1
2
3
4
unique_ptr<int> p = make_unique<int>(1);
// unique_ptr<int> q = p; // 错误:unique_ptr 不可复制

unique_ptr<int> q = move(p); // 转移所有权,p 变为空

转移之后,p 不再拥有这块内存,访问 p 之前要先判断是否为空。

使用裸指针的场景

需要把裸指针传给旧接口时,用 get()

1
2
3
4
void legacy(int* p);          // 旧接口,只读不接管

unique_ptr<int> p = make_unique<int>(42);
legacy(p.get()); // 传入裸指针

注意:get() 拿到的指针不拥有所有权,绝不能对它调用 delete,否则会和 unique_ptr 的析构重复释放。

自定义类的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <memory>
using namespace std;

class Logger {
public:
Logger() { /* 获取资源 */ }
~Logger() { /* 释放资源 */ }
};

int main()
{
unique_ptr<Logger> log = make_unique<Logger>();
// 用完后自动析构、自动释放
return 0;
}

动态数组

unique_ptr 也支持数组,且自动使用 delete[]

1
2
unique_ptr<int[]> arr = make_unique<int[]>(10);
arr[0] = 1;

裸指针 vs unique_ptr

对比 裸指针 new/delete unique_ptr
释放时机 手动 delete 析构自动释放
忘记释放 内存泄漏 不会发生
重复释放 容易踩坑 所有权唯一,天然避免
复制 可以随意复制 禁止复制,只能移动
开销 无额外开销 基本无额外开销

优先原则:能用 unique_ptr 就不用裸指针;需要多个所有者共享时才考虑 shared_ptrvectorstring 能解决的场景连智能指针都不用写。

⚠️ 常见误区

误区 正解
new/deletemalloc/free 一样 new 会调用构造函数,delete 会调用析构函数;malloc/free 只处理内存
new[]delete 释放没关系 严格配对,否则是未定义行为
浅拷贝只是”效率低一点” 含指针成员时浅拷贝会导致重复释放
RAII 就是”在析构函数里 delete” 核心是资源与对象生命周期绑定,从而获得自动释放和异常安全
unique_ptrshared_ptr 随便换 unique_ptr 独占所有权;需要共享时才用 shared_ptr
动态内存都该手动 new/delete 现代 C++ 优先 vectorstringunique_ptr 等自动管理

✅ 总结

动态内存管理的核心是”谁分配、谁释放、何时释放”:手动 new/delete 容易出错,RAII 把资源生命周期绑定到对象上,unique_ptr 让释放自动化。

学完这一章,再看前面 Buffer 类的拷贝风险,就有了完整的解决思路:要么深拷贝,要么禁止拷贝,要么直接用 unique_ptrvector 替我们管理。

Happy Hacking! 🎉