MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ delete与delete []的关键区别

2021-11-136.3k 阅读

一、delete 与 delete [] 的基本概念

在 C++ 中,deletedelete [] 都是用于释放动态分配内存的运算符。动态内存分配是指在程序运行时根据需要分配内存空间,使用完毕后再释放这些空间,以避免内存泄漏。

1.1 delete 运算符

delete 主要用于释放通过 new 运算符分配的单个对象的内存。例如:

int* num = new int;
*num = 10;
delete num;

在上述代码中,首先使用 new 为一个 int 类型的变量分配了内存,并将其地址赋给 num 指针。之后,对这个 int 变量进行赋值操作。最后,使用 delete 释放 num 所指向的内存空间。

1.2 delete [] 运算符

delete [] 则专门用于释放通过 new [] 分配的数组的内存。例如:

int* arr = new int[5];
for (int i = 0; i < 5; i++) {
    arr[i] = i;
}
delete [] arr;

这里通过 new [] 为一个包含 5 个 int 类型元素的数组分配了内存,将数组首地址赋给 arr 指针。然后对数组中的每个元素进行赋值。最后,使用 delete [] 释放整个数组占用的内存空间。

二、内存管理机制的差异

2.1 单个对象内存释放

当使用 new 分配单个对象的内存时,delete 会按照以下步骤进行操作:

  1. 调用对象的析构函数(如果对象有析构函数的话)。
  2. 释放对象占用的内存空间。

例如,对于一个简单的类 MyClass

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass constructor" << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass destructor" << std::endl;
    }
};

使用 delete 释放单个 MyClass 对象的内存:

MyClass* obj = new MyClass();
delete obj;

输出结果会先显示 “MyClass constructor”,然后显示 “MyClass destructor”,这表明 delete 先调用了析构函数,然后释放了内存。

2.2 数组内存释放

new [] 分配数组内存时,delete [] 的操作更为复杂:

  1. 对于数组中的每个元素(如果元素是对象类型且有析构函数),依次调用其析构函数。
  2. 释放整个数组占用的内存空间。

继续以 MyClass 类为例,创建一个 MyClass 对象数组并释放:

MyClass* arr = new MyClass[3];
delete [] arr;

输出结果会依次显示三次 “MyClass constructor”,然后依次显示三次 “MyClass destructor”,这说明 delete [] 为数组中的每个对象都调用了析构函数,然后释放了整个数组的内存。

三、误用带来的问题

3.1 使用 delete 释放数组内存

如果使用 delete 来释放通过 new [] 分配的数组内存,会导致严重的问题。因为 delete 只会调用数组第一个元素的析构函数(如果有析构函数),而不会调用其他元素的析构函数,这将导致资源泄漏。

class Resource {
public:
    Resource() {
        data = new int[10];
        std::cout << "Resource constructor" << std::endl;
    }
    ~Resource() {
        delete [] data;
        std::cout << "Resource destructor" << std::endl;
    }
private:
    int* data;
};

创建一个 Resource 对象数组并使用 delete 释放:

Resource* arr = new Resource[2];
delete arr; // 错误的释放方式

在这个例子中,使用 delete 释放 arr 只会调用 arr[0] 的析构函数,arr[1] 的析构函数不会被调用,从而导致 arr[1] 中的 data 所指向的内存无法释放,造成内存泄漏。

3.2 使用 delete [] 释放单个对象内存

同样,如果使用 delete [] 来释放通过 new 分配的单个对象的内存,也会出现问题。delete [] 会尝试调用多次析构函数(通常以未定义行为的方式),因为它期望处理的是一个数组结构。

Resource* obj = new Resource();
delete [] obj; // 错误的释放方式

这种情况下,行为是未定义的,可能会导致程序崩溃或其他难以调试的错误。

四、编译器与运行时的处理

4.1 编译器的记录方式

编译器在处理 newnew [] 时,会在内部记录一些信息,以便 deletedelete [] 正确地释放内存。对于 new,编译器只需要记录分配的单个对象的大小和地址。而对于 new [],编译器不仅要记录数组的起始地址,还需要记录数组元素的数量(通常会在内存布局中某个特定位置存储这个信息)。

例如,在一些编译器实现中,new [] 分配的内存布局可能如下:

  1. 数组元素数量(隐藏存储)。
  2. 数组第一个元素的内存。
  3. 数组第二个元素的内存。 ... N. 数组第 N 个元素的内存。

4.2 运行时的执行逻辑

在运行时,deletedelete [] 根据编译器记录的信息来执行不同的操作。delete 简单地根据对象地址调用析构函数(如果有)并释放内存。而 delete [] 首先根据记录的数组元素数量,依次调用每个元素的析构函数,然后释放整个数组占用的内存空间。

五、智能指针与内存释放

5.1 std::unique_ptr

C++ 11 引入了智能指针,如 std::unique_ptr,它可以自动管理动态分配的内存,避免手动调用 deletedelete [] 带来的错误。std::unique_ptr 有两种形式:一种用于单个对象,另一种用于数组。

std::unique_ptr<int> numPtr(new int(10));
std::unique_ptr<int[]> arrPtr(new int[5]);

numPtrarrPtr 超出作用域时,它们会自动调用对应的 deletedelete [] 来释放内存。例如:

{
    std::unique_ptr<MyClass> objPtr(new MyClass());
    std::unique_ptr<MyClass[]> arrObjPtr(new MyClass[3]);
} // 这里 objPtr 和 arrObjPtr 超出作用域,自动释放内存

5.2 std::shared_ptr

std::shared_ptr 同样可以管理动态分配的内存,它使用引用计数来跟踪有多少个 std::shared_ptr 指向同一块内存。当引用计数为 0 时,内存会自动释放。对于数组,std::shared_ptr 也有相应的特化版本。

std::shared_ptr<int> sharedNumPtr(new int(20));
std::shared_ptr<int[]> sharedArrPtr(new int[3]);

在多线程环境下,std::shared_ptr 的引用计数操作是线程安全的,这使得它在复杂的多线程程序中使用起来更加安全可靠。

六、跨平台与编译器兼容性

6.1 不同编译器的实现差异

不同的编译器在实现 deletedelete [] 时可能会有一些细微的差异。例如,在内存布局和记录数组元素数量的方式上可能会有所不同。一些编译器可能会采用更紧凑的内存布局,而另一些编译器可能会在内存管理上添加更多的调试信息。

以 GCC 和 Visual Studio 编译器为例,虽然它们都遵循 C++ 标准,但在内部实现上可能存在差异。这些差异可能会影响到程序在不同编译器下的行为,特别是在处理一些边界情况或未定义行为时。

6.2 跨平台考虑

在编写跨平台的 C++ 代码时,需要特别注意 deletedelete [] 的使用。因为不同操作系统对内存管理的底层机制也有所不同,编译器需要在这些底层机制之上实现 deletedelete [] 的功能。

例如,在 Windows 和 Linux 系统上,内存分配和释放的底层系统调用是不同的。编译器需要将 deletedelete [] 的操作映射到相应的系统调用上。因此,在编写跨平台代码时,确保正确使用 deletedelete [] 对于程序的稳定性和可移植性至关重要。

七、特殊情况与优化策略

7.1 内存池与对象复用

在一些性能敏感的应用中,频繁地使用 newdelete(或 new []delete [])可能会导致内存碎片和性能下降。为了解决这个问题,可以使用内存池技术。

内存池是一种预先分配一定大小内存块的机制,程序在需要时从内存池中获取内存,使用完毕后再将内存归还到内存池中,而不是直接调用 newdelete。这样可以减少系统级的内存分配和释放次数,提高性能。

例如,对于一个频繁创建和销毁 MyClass 对象的程序,可以实现一个简单的内存池:

class MyClassMemoryPool {
public:
    MyClassMemoryPool(int poolSize) : poolSize(poolSize) {
        pool = new MyClass*[poolSize];
        for (int i = 0; i < poolSize; i++) {
            pool[i] = new MyClass();
            freeList.push_back(pool[i]);
        }
    }
    ~MyClassMemoryPool() {
        for (int i = 0; i < poolSize; i++) {
            delete pool[i];
        }
        delete [] pool;
    }
    MyClass* getObject() {
        if (freeList.empty()) {
            return nullptr;
        }
        MyClass* obj = freeList.back();
        freeList.pop_back();
        return obj;
    }
    void returnObject(MyClass* obj) {
        freeList.push_back(obj);
    }
private:
    MyClass** pool;
    int poolSize;
    std::vector<MyClass*> freeList;
};

在这个内存池实现中,预先创建了一定数量的 MyClass 对象,并将它们存储在一个数组中。当程序需要一个 MyClass 对象时,可以从内存池中获取,使用完毕后再归还到内存池中,而不是直接使用 newdelete

7.2 定制内存分配器

C++ 允许用户定制内存分配器,通过重载 operator newoperator delete(以及 operator new[]operator delete[])来实现特定的内存分配和释放策略。

例如,可以实现一个简单的定制内存分配器,用于记录内存分配和释放的次数:

class MemoryLogger {
public:
    static void* operator new(size_t size) {
        ++allocCount;
        return ::operator new(size);
    }
    static void operator delete(void* ptr) {
        --allocCount;
        ::operator delete(ptr);
    }
    static void* operator new[](size_t size) {
        ++allocCount;
        return ::operator new[](size);
    }
    static void operator delete[](void* ptr) {
        --allocCount;
        ::operator delete[](ptr);
    }
    static size_t getAllocCount() {
        return allocCount;
    }
private:
    static size_t allocCount;
};
size_t MemoryLogger::allocCount = 0;

在这个例子中,通过重载 operator newoperator delete(以及数组版本),在每次内存分配和释放时增加或减少计数器。这样可以方便地统计程序中动态内存分配的次数,有助于性能分析和内存管理优化。

八、与其他语言内存管理的对比

8.1 与 Java 的对比

Java 采用自动垃圾回收机制,开发者无需手动释放内存。Java 虚拟机(JVM)会在后台运行一个垃圾回收器,定期扫描堆内存,回收不再被引用的对象所占用的内存。

与 C++ 的 deletedelete [] 相比,Java 的垃圾回收机制简化了开发者的工作,降低了内存泄漏的风险。然而,垃圾回收也有一些缺点,比如垃圾回收过程可能会导致程序暂停(STW,Stop - The - World),影响程序的实时性。而且,由于垃圾回收是自动进行的,开发者对内存释放的时机和方式控制较少。

8.2 与 Python 的对比

Python 同样采用自动内存管理,通过引用计数和垃圾回收相结合的方式来释放内存。当一个对象的引用计数变为 0 时,Python 会立即释放该对象占用的内存。此外,Python 还会定期运行一个垃圾回收器,处理循环引用等复杂情况。

与 C++ 相比,Python 的内存管理更加自动化,开发者无需像在 C++ 中那样手动调用 deletedelete []。但这种自动化也意味着在一些对性能和内存控制要求极高的场景下,Python 可能不如 C++ 灵活。例如,在实时系统或对内存使用有严格限制的嵌入式系统中,C++ 的手动内存管理方式可以更精确地控制内存的分配和释放。

九、常见错误与调试技巧

9.1 常见错误

  1. 混用 deletedelete []:如前文所述,使用 delete 释放数组内存或使用 delete [] 释放单个对象内存会导致未定义行为和内存泄漏。
  2. 悬空指针:在使用 deletedelete [] 释放内存后,没有将指针设置为 nullptr,导致指针成为悬空指针。如果后续不小心再次使用该悬空指针,会引发程序崩溃或其他难以调试的错误。
int* num = new int(10);
delete num;
// num 此时成为悬空指针
int value = *num; // 未定义行为
  1. 重复释放:多次对同一个指针调用 deletedelete [] 会导致未定义行为。这通常发生在复杂的代码逻辑中,多个地方可能会尝试释放同一个内存块。

9.2 调试技巧

  1. 使用智能指针:如 std::unique_ptrstd::shared_ptr,它们可以自动管理内存,减少手动释放内存带来的错误。并且,智能指针在析构时会输出一些调试信息,有助于定位内存释放问题。
  2. 内存调试工具:可以使用一些内存调试工具,如 Valgrind(在 Linux 系统上)。Valgrind 可以检测内存泄漏、悬空指针和重复释放等问题,并给出详细的错误信息和调用栈,帮助开发者快速定位问题所在。
  3. 自定义日志输出:在类的构造函数和析构函数中添加日志输出,记录对象的创建和销毁过程。这样在调试时可以通过日志信息了解对象的生命周期,判断是否存在内存管理问题。
class DebugClass {
public:
    DebugClass() {
        std::cout << "DebugClass constructor" << std::endl;
    }
    ~DebugClass() {
        std::cout << "DebugClass destructor" << std::endl;
    }
};

通过查看日志中对象构造和析构的顺序及次数,可以发现一些潜在的内存管理错误。

十、总结与最佳实践

  1. 严格匹配使用:始终确保使用 delete 释放通过 new 分配的单个对象内存,使用 delete [] 释放通过 new [] 分配的数组内存。这是避免未定义行为和内存泄漏的关键。
  2. 使用智能指针:在现代 C++ 编程中,优先使用智能指针如 std::unique_ptrstd::shared_ptr 来管理动态分配的内存。智能指针可以自动处理内存释放,减少手动管理内存带来的错误。
  3. 内存管理策略:对于性能敏感的应用,可以考虑使用内存池或定制内存分配器等技术来优化内存管理。但这些技术需要谨慎使用,因为它们增加了代码的复杂性。
  4. 调试与测试:在开发过程中,使用内存调试工具和自定义日志输出等方法,及时发现和解决内存管理问题。进行全面的单元测试和集成测试,确保内存管理在各种情况下都能正确工作。

通过遵循这些最佳实践,可以有效地避免 deletedelete [] 使用不当带来的问题,提高 C++ 程序的稳定性和性能。