C++ 动态内存管理总结
为什么强烈不建议重载系统自身的 operator new 和 operator delete 行为
在 C++ 中,new 和 delete 是内存分配和释放的核心操作,默认由标准库提供行为实现。这些行为经过了高度优化,能够很好地与操作系统、编译器以及运行时环境协同工作,高度耦合。虽然允许开发者通过重载全局的 operator new 和 operator delete 来自定义内存管理行为,但这会带来很多问题。比如,破坏标准库的组件,性能问题,调试和维护难度,与第三方库的兼容性问题等。
-
破坏标准库的组件
C++ 标准库中的容器类 std::vector、std::string 等依赖于默认的 new 和 delete 行为来分配和释放内存。如果开发者调整了全局的 new 和 delete,标准库可能无法正常工作。例如,自定义的内存分配器可能不满足标准库对内存对齐(alignment)或异常安全性(exception safety)的要求,导致未定义行为。
假如,我重载了全局的 operator new,使其总是分配固定大小的内存块:
#include <iostream> #include <cstdlib> void* operator new(size_t size) { std::cout << "Custom new allocating " << size << " bytes\n"; return std::malloc(1024); // 无论请求多少,总是分配 1024 字节 } void operator delete(void* ptr) { std::cout << "Custom delete\n"; std::free(ptr); } int main() { int* p = new int; // 假设 int 占用 4 字节,请求 4 字节 delete p; }
new int 请求 4 字节,实际分配的却是 1024 字节。这会导致内存浪费。如果标准库组件 std::vector 尝试分配动态大小的内存,这种固定分配会导致崩溃或未定义行为。
由标准库提供的全局的操作符,默认实现与操作系统、编译器和运行时环境紧密耦合,如果重载了全局的 operator new 和 operator delete 会替换系统中所有的默认内存分配和释放行为,再以兼容性为例,许多第三方库会动态分配内存,并认为分配器支持标准特性,如 std::nothrow 或异常安全性,如果自定义的 new 不支持这些特性,会导致库的行为异常。
#include <iostream>
#include <new>
void* operator new(size_t size) {
std::cout << "Custom new: " << size << " bytes\n";
void* ptr = std::malloc(size);
if (!ptr) throw std::bad_alloc(); // 只支持抛异常
return ptr;
}
void operator delete(void* ptr) {
std::cout << "Custom delete\n";
std::free(ptr);
}
int main() {
// 第三方库使用 std::nothrow,期望不抛异常
int* p = new (std::nothrow) int;
if (!p) {
std::cout << "Allocation failed safely\n";
} else {
std::cout << "Allocation succeeded\n";
delete p;
}
}
在动态链接的程序中,自定义的 new 和 delete 可能会与链接库冲突,如果一个库使用默认的内存分配器,而你的主程序使用自定义的分配器,内存管理的职责可能出现混乱。
// lib.cpp(编译为动态库)
#include <iostream>
void* operator new(size_t size) {
std::cout << "Library new: " << size << " bytes\n";
return std::malloc(size);
}
void operator delete(void* ptr) {
std::cout << "Library delete\n";
std::free(ptr);
}
void lib_function() {
int* p = new int(42);
std::cout << "Library allocated: " << *p << "\n";
delete p;
}
// main.cpp
#include <iostream>
void* operator new(size_t size) {
std::cout << "Main new: " << size << " bytes\n";
return std::malloc(size);
}
void operator delete(void* ptr) {
std::cout << "Main delete\n";
std::free(ptr);
}
extern void lib_function();
int main() {
int* p = new int(10);
std::cout << "Main allocated: " << *p << "\n";
lib_function(); // 调用动态库
delete p;
}
主程序和动态库各自定义了全局 new 和 delete,但链接时只有一个版本生效(通常是主程序的)。如果动态库的代码假设使用它自己的 new 和 delete,但实际调用了主程序的版本,可能会出现不一致。例如,内存分配和释放的日志输出可能混淆,或者如果两个分配器的实现不兼容(例如一个使用内存池,另一个使用堆),会导致崩溃。
某些运行时环境(如嵌入式系统或特定的操作系统)可能对内存分配器有额外要求。例如,默认的 new 可能与系统的线程支持或调试工具集成,自定义版本如果忽略这些要求,会导致不兼容。
#include <iostream>
#include <thread>
void* operator new(size_t size) {
static char pool[4096];
static size_t offset = 0;
void* ptr = pool + offset;
offset += size; // 无锁,线程不安全
return ptr;
}
void operator delete(void*) {
// 无法释放
}
void thread_func() {
int* p = new int(42);
std::cout << "Thread allocated: " << *p << "\n";
delete p;
}
int main() {
std::thread t1(thread_func);
std::thread t2(thread_func);
t1.join();
t2.join();
}
默认的 new 在支持多线程的环境中是线程安全的,而你的自定义版本使用静态变量 offset,在多线程下会导致竞争条件。
两个线程可能同时修改 offset,导致内存分配重叠,进而引发数据一致性问题。
优雅地使用智能指针
C++ 提供用于管理动态分配内存的工具智能指针,智能指针本质上是对裸指针(raw pointer)的封装,通过自动管理内存的分配和释放,避免手动使用 new 和 delete 带来的内存泄漏或悬垂指针等问题。
在使用手动管理动态分配的内存时会遇到一些问题,比如忘记使用 delete 导致动态分配的内存没有正确释放;内存被释放后,指针仍指向已释放的内存,导致后续不应该发生的内存访问产生未定义的行为;同一块内存被多次 delete,导致程序崩溃;手动管理动态内存资源的所有权和声明周期非常困难。
智能指针在其生命周期结束时(比如离开作用域)自动释放内存,无需手动 delete;明确区分动态内存的所有权。
上面所说的在使用裸指针和 new / delete 时出现的错误,都是源于无法明确资源的所有权导致的。正常是 new 和 delete 同时使用的,声明内存资源后就要释放内存资源,避免内存泄露的问题。
#include <iostream>
class Resource {
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
void func(Resource* ptr) {
std::cout << "Function using resource\n";
// 这里没有 delete,谁负责释放?
}
int main() {
Resource* ptr = new Resource();
Resource* ptr2 = ptr; // 两个指针指向同一块内存
func(ptr); // 函数使用指针,但不释放
delete ptr; // main 中释放了,但 ptr2 怎么办?
std::cout << "ptr2 still exists\n";
// ptr2 现在是悬垂指针,继续使用会导致未定义行为
return 0;
}
比如上面这个例子,ptr 和 ptr2 指向同一块内存,都能访问这块内存,但所有权不明确。在执行了 func 后应该 delete 但由于所有权不明确不知道谁该负责 delete,要么不进行 delete 导致内存泄漏,要么就会重复进行 delete 导致程序崩溃,如果在 main 中 delete ptr 后,ptr2 变成悬垂指针,继续访问会导致未定义行为,如果 ptr2 再执行 delete 就会重复 delete 导致崩溃,当然会说我们只让一个变量进行 new 和 delete 不就好了,只让一个变量拥有这个内存的所有权就解决了,但是这个例子很短我们当然能决定让谁拥有所有权,并由我们自己记住这个拥有所有权的变量,但是如果是个很长很复杂的程序呢?你能记住所有变量是谁拥有所有权吗?谁该负责 new 和 delete 保证在这前后不会让其他拥有访问权限的变量非法访问呢?不如交给程序自己记住,因此 C++ 引入了智能指针。
智能指针通过其设计(所有权模型)明确了动态分配内存的归属,避免了传统原始指针(raw pointer)在所有权上的模糊。
首先,智能指针本质上就是一个抽象数据类型,那么抽象数据类型有一个好处,抽象数据类型它能够提供一个东西叫做析构函数。析构函数就是说在这个对象被销毁的时候,它能够去调用这个析构函数,那么就完全可以把这个调用 delete 进行内存释放的这件事情,放到析构函数里面,只要这个对象被析构了,就能够去被销毁掉,用户不需要去显示的调用这个 delete 这就能够让我们不需要再去考虑内存所有权的是事情。只要这个表示这块内存的智能指针的对象,它被销毁了,它的析构函数被调用了这块内存就会被释放掉,那么这样就能够保证不会产生多次 delete,也不会产生不 delete 的情况。
std::shared_ptr:共享所有权
shared_ptr 作为一个抽象数据类型使用引用计数允许多个指针共享同一块内存,所有权由所有 shared_ptr 实例共同持有,当最后一个 shared_ptr 销毁时才会释放内存。引用计数清晰地追踪有多少个指针共享资源,所有权状态是透明的。
#include <iostream>
#include <memory>
class Resource {
public:
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
int main() {
std::shared_ptr<Resource> ptr1{new Resource};
std::cout << "ptr1 use_count: " << ptr1.use_count() << "\n"; // 1
{
std::shared_ptr<Resource> ptr2 = ptr1; // 共享所有权
std::cout << "ptr1 use_count: " << ptr1.use_count() << "\n"; // 2
std::cout << "ptr2 use_count: " << ptr2.use_count() << "\n"; // 2
} // ptr2 销毁,引用计数减 1
std::cout << "ptr1 use_count: " << ptr1.use_count() << "\n"; // 1
return 0; // ptr1 销毁,资源释放
}
上面的例子中,我们可以猜到 shared_ptr 这个抽象数据类型中,至少应该包含指向内存资源的指针,以及指针的基类型,同时应该包含一个指向引用计数的指针而非引用计数值,声明多个变量要维护同一个值,那一定是通过一个指针指向相同的地方,当执行 std::shared_ptr<Resource> ptr2 = ptr1
这行时,将表达式 ptr1 作为右操作数,通过操作符 = 对左操作数整体进行表达式计算即拷贝初始化,当然可以使用 std::shared_ptr<Resource> ptr1{new Resource};
这行的方式进行构造初始化,区别在于是否会有临时的内存开销,总之都会将这个引用计数的指针传递过去,通过 shared_ptr 可以看到此时引用计数的值,当 ptr2 离开作用域(代码块)后,会调用 shared_ptr 的析构函数,析构函数的大概逻辑是将资源的引用计数减1,引用计数不为 0 不会使用 delete 释放内存,当 main 结束时,调用 shared_ptr 的析构函数将资源的引用计数减 1,引用计数为 0 使用 delete 释放内存。
有兴趣的朋友可以自行查阅 shared_ptr 不同的构造函数,比如可以指定释放内存资源时使用我们自定义的行为 deleter,比如下面的例子:
#include <iostream>
#include <memory>
void dummy(int*){}
std::shared_ptr<int> fun()
{
static int res = 3;
return std::shared_ptr<int>{&res, dummy}; /* 返回有 deleter 的 shared_ptr 的构造对象 */
}
int main()
{
{
std::shared_ptr<int> p{fun()};
}
return 0;
}
res 在数据段中,我们并不想释放这个内存,当 p 离开作用域后,最终引用计数值为0,使用析构函数会执行 dummy,dummy 没有具体的 delete 行为因此并不会释放 res 映射的内存。
make_shared 更佳优化的 shared_ptr 构造方式
相比于直接使用 new
和 shared_ptr
构造函数:
std::shared_ptr<Resource> ptr(new Resource(42));
更加推荐使用 std::make_shared
:
std::shared_ptr<Resource> ptr = std::make_shared<Resource>(42);
尽管两种方式都能工作,但 std::make_shared
被设计为更优的选择,包括:
-
性能优化:单次内存分配
- 原理:
shared_ptr
需要维护两部分数据:- 指向的动态对象(
Resource
实例)。 - 控制块(control block),包含引用计数和其他元数据。
- 指向的动态对象(
- 不使用
make_shared
:调用new
分配对象内存,再调用shared_ptr
构造函数分配控制块内存,总共两次分配。 - 使用
make_shared
:一次性分配一块连续内存,同时容纳对象和控制块。 - 好处:
-
减少内存分配次数(从两次变为一次)。
-
提高内存局部性(对象和控制块相邻,缓存命中率更高,减低cache miss)。
-
降低内存碎片化风险。
#include <memory> class Resource { public: Resource() {} }; int main() { // 两次分配 std::shared_ptr<Resource> ptr1(new Resource()); // 一次分配 std::shared_ptr<Resource> ptr2 = std::make_shared<Resource>(); }
-
- 原理:
-
异常安全性
- 问题:不使用
make_shared
时,如果构造过程中抛出异常,可能导致内存泄漏。
#include <memory> #include <stdexcept> void risky_function() { throw std::runtime_error("Error"); } class Resource { public: Resource() {} }; int main() { std::shared_ptr<Resource> ptr(new Resource(), [](Resource* p) { delete p; }); risky_function(); // 如果这里抛异常,ptr 未构造完成,new Resource 泄漏 return 0; }
- 分析:
new Resource()
分配内存。- 如果
risky_function()
在shared_ptr
构造完成前抛异常,内存不会被shared_ptr
接管,导致泄漏。
- 使用
make_shared
的安全版本:
std::shared_ptr<Resource> ptr = std::make_shared<Resource>(); risky_function(); // 即使抛异常,make_shared 已完成构造,不会泄漏
- 好处:
make_shared
是一个原子操作,对象和控制块一起构造,异常不会导致资源泄漏。
- 问题:不使用
注意使用 shared_ptr 指向对象还是对象数组
int main()
{
std::shared_ptr<int> ptr(new int[10]); /* 可以编译运行,但是会导致未定义行为 */
/* 正确做法 */
std::shared_ptr<int[10]> ptr1(new int[10]);
auto x = std::make_shared<int[]>(5);
return 0;
}
不要使用同一个裸指针构造多个 shared_ptr 对象
int main()
{
int* p = new int(42);
std::shared_ptr<int> ptr1(p); /* 正确 */
// std::shared_ptr<int> ptr2(p); /* 错误 */
std::shared_ptr<int> ptr2(ptr1); /* 正确 */
return 0;
}