总结 C++ 动态内存管理_0

C++ 动态内存管理总结

为什么强烈不建议重载系统自身的 operator new 和 operator delete 行为

在 C++ 中,new 和 delete 是内存分配和释放的核心操作,默认由标准库提供行为实现。这些行为经过了高度优化,能够很好地与操作系统、编译器以及运行时环境协同工作,高度耦合。虽然允许开发者通过重载全局的 operator new 和 operator delete 来自定义内存管理行为,但这会带来很多问题。比如,破坏标准库的组件,性能问题,调试和维护难度,与第三方库的兼容性问题等。

  1. 破坏标准库的组件

    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 构造方式

相比于直接使用 newshared_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 被设计为更优的选择,包括:

  1. 性能优化:单次内存分配

    • 原理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>();
        }
        
  2. 异常安全性

    • 问题:不使用 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;
}