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

C++ new和delete进行内存管理

2022-02-231.7k 阅读

C++ 中的内存管理基础

在深入探讨 newdelete 操作符之前,我们先来了解一下 C++ 内存管理的基本概念。在 C++ 中,内存主要分为三个区域:栈(Stack)、堆(Heap)和静态存储区(Static Storage Area)。

  • :栈是一种后进先出(LIFO)的数据结构,用于存储局部变量、函数参数等。栈上的内存分配和释放由编译器自动管理,速度很快,但栈的大小通常是有限的。例如,以下代码中的变量 ab 就是存储在栈上的:
void function() {
    int a = 10;
    double b = 20.5;
    // a 和 b 在栈上分配内存
}
// 函数结束时,a 和 b 占用的栈内存自动释放
  • :堆是一块自由存储区域,用于动态分配内存。与栈不同,堆上的内存分配和释放需要程序员手动控制。这就需要使用 newdelete 操作符,或者 mallocfree 函数(C 语言风格)。例如,我们可以使用 new 在堆上分配一个 int 类型的变量:
int* ptr = new int;
*ptr = 30;
// 这里使用 new 在堆上分配了一个 int 类型的空间,并通过指针 ptr 来访问它
delete ptr;
// 使用 delete 释放 ptr 指向的堆内存
  • 静态存储区:静态存储区用于存储全局变量和静态局部变量。这些变量在程序开始执行时分配内存,在程序结束时释放内存。例如:
int globalVar;
// globalVar 是全局变量,存储在静态存储区

void anotherFunction() {
    static int staticLocalVar;
    // staticLocalVar 是静态局部变量,也存储在静态存储区
}

new 操作符

new 的基本用法

new 操作符用于在堆上分配内存。它的基本语法有两种形式:

  • 普通 new:用于分配单个对象。例如,分配一个 int 类型的变量:
int* numPtr = new int;
*numPtr = 42;
std::cout << "The value is: " << *numPtr << std::endl;
delete numPtr;

在这个例子中,new int 在堆上分配了足够存储一个 int 类型数据的内存空间,并返回一个指向该内存空间的指针 numPtr。我们通过指针给这个空间赋值为 42,最后使用 delete 释放内存。

  • 数组 new:用于分配数组。例如,分配一个包含 5 个 int 类型元素的数组:
int* arrPtr = new int[5];
for (int i = 0; i < 5; i++) {
    arrPtr[i] = i * 2;
}
for (int i = 0; i < 5; i++) {
    std::cout << "Element at index " << i << " is: " << arrPtr[i] << std::endl;
}
delete[] arrPtr;

这里 new int[5] 在堆上分配了足够存储 5 个 int 类型元素的连续内存空间,并返回一个指向第一个元素的指针 arrPtr。我们可以像访问普通数组一样访问这个动态数组的元素。注意,释放动态数组时要使用 delete[],而不是 delete,否则可能会导致内存泄漏。

new 的工作原理

当使用 new 操作符时,它实际上做了两件事:

  1. 分配内存new 操作符调用 operator new 函数(这是一个全局函数,也可以在类中重载)来在堆上分配足够的内存。operator new 函数的原型如下:
void* operator new(std::size_t size);

size 参数表示需要分配的内存大小(以字节为单位)。operator new 函数负责从堆中找到一块合适大小的空闲内存,并返回指向这块内存的指针。如果找不到足够的内存,operator new 会抛出 std::bad_alloc 异常。

  1. 调用构造函数:在分配好内存之后,new 操作符会在这块内存上调用对象的构造函数来初始化对象。例如,如果我们使用 new MyClass 来创建一个 MyClass 类型的对象,new 操作符会先调用 operator new 分配内存,然后调用 MyClass 的构造函数来初始化这块内存。

定位 new(Placement new

定位 newnew 操作符的一种特殊形式,它允许我们在已有的内存位置上构造对象。其语法如下:

void* preallocatedMemory = operator new( sizeof(MyClass) );
MyClass* objPtr = new (preallocatedMemory) MyClass();

在这个例子中,我们首先使用 operator new 分配了一块大小为 MyClass 对象大小的内存。然后,我们使用定位 new 在这块已分配的内存上构造了一个 MyClass 对象。定位 new 不会分配新的内存,只是在指定的内存位置上调用对象的构造函数。

定位 new 主要用于以下场景:

  • 内存池:在内存池机制中,预先分配一大块内存,然后使用定位 new 在这块内存上创建对象,避免频繁的内存分配和释放。

  • 嵌入对象:当我们需要在已有的数据结构(如结构体)中嵌入对象时,可以使用定位 new。例如:

struct Container {
    char buffer[sizeof(MyClass)];
};

Container cont;
MyClass* embeddedObj = new (cont.buffer) MyClass();

delete 操作符

delete 的基本用法

delete 操作符用于释放由 new 分配的内存。它有两种形式,与 new 的两种形式相对应:

  • 普通 delete:用于释放单个对象。例如,释放之前通过 new 分配的 int 类型变量:
int* numPtr = new int;
*numPtr = 50;
delete numPtr;

这里 delete numPtr 释放了 numPtr 指向的堆内存,并调用 int 类型的析构函数(对于基本类型,析构函数为空操作)。

  • 数组 deletedelete[]:用于释放动态分配的数组。例如,释放之前通过 new[] 分配的 int 数组:
int* arrPtr = new int[10];
delete[] arrPtr;

使用 delete[] 会释放整个数组占用的内存,并依次调用数组中每个元素的析构函数(如果元素类型有析构函数)。如果使用 delete 而不是 delete[] 来释放数组,只会调用数组第一个元素的析构函数(如果有),并且可能导致内存泄漏。

delete 的工作原理

当使用 delete 操作符时,它做了两件事:

  1. 调用析构函数delete 操作符首先调用对象的析构函数(如果对象有析构函数)来清理对象内部的资源,例如关闭文件、释放锁等。

  2. 释放内存:在调用析构函数之后,delete 操作符调用 operator delete 函数(这也是一个全局函数,也可以在类中重载)来释放对象占用的堆内存。operator delete 函数的原型如下:

void operator delete(void* ptr);

ptr 参数是指向要释放的内存块的指针。operator delete 函数负责将这块内存归还给堆,以便后续重新分配。

内存释放的注意事项

在使用 delete 释放内存时,有几个重要的注意事项:

  • 避免多次释放:释放同一个指针多次会导致未定义行为。例如:
int* ptr = new int;
delete ptr;
delete ptr; // 第二次释放,未定义行为

为了避免这种情况,可以在释放指针后将指针设置为 nullptr

int* ptr = new int;
delete ptr;
ptr = nullptr;
  • 匹配 newdelete:使用 new 分配的内存必须使用 delete 释放,使用 new[] 分配的内存必须使用 delete[] 释放。否则可能会导致内存泄漏或未定义行为。

自定义 operator newoperator delete

为什么要自定义 operator newoperator delete

在某些情况下,默认的 operator newoperator delete 可能无法满足我们的需求。例如:

  • 内存优化:在一些对内存使用非常敏感的应用中,如游戏开发、嵌入式系统等,默认的内存分配器可能效率不高。我们可以自定义 operator newoperator delete 来实现更高效的内存管理,例如使用内存池技术。

  • 调试和错误处理:自定义 operator newoperator delete 可以方便我们添加调试信息和错误处理机制。例如,我们可以记录每次内存分配和释放的信息,以便发现内存泄漏等问题。

自定义全局 operator newoperator delete

要自定义全局的 operator newoperator delete,我们只需要定义与它们原型匹配的函数即可。例如,以下是一个简单的自定义 operator newoperator delete 的实现:

#include <iostream>
#include <cstdlib>

void* operator new(std::size_t size) {
    std::cout << "Custom operator new: Allocating " << size << " bytes" << std::endl;
    void* ptr = std::malloc(size);
    if (!ptr) {
        throw std::bad_alloc();
    }
    return ptr;
}

void operator delete(void* ptr) noexcept {
    std::cout << "Custom operator delete: Releasing memory" << std::endl;
    std::free(ptr);
}

在这个例子中,我们的自定义 operator new 函数在分配内存前打印了分配的字节数,并在分配失败时抛出 std::bad_alloc 异常。自定义 operator delete 函数在释放内存前打印了一条信息。

自定义类的 operator newoperator delete

我们还可以为特定的类自定义 operator newoperator delete。这在类对内存管理有特殊需求时非常有用。例如,我们有一个 MyClass 类,我们可以为它定义自己的 operator newoperator delete

class MyClass {
public:
    void* operator new(std::size_t size) {
        std::cout << "MyClass::operator new: Allocating " << size << " bytes" << std::endl;
        void* ptr = std::malloc(size);
        if (!ptr) {
            throw std::bad_alloc();
        }
        return ptr;
    }

    void operator delete(void* ptr) noexcept {
        std::cout << "MyClass::operator delete: Releasing memory" << std::endl;
        std::free(ptr);
    }
};

当我们使用 new MyClass 来创建 MyClass 对象时,会调用这个类的自定义 operator new 函数;当使用 delete 释放 MyClass 对象时,会调用自定义的 operator delete 函数。

智能指针与内存管理

为什么需要智能指针

虽然 newdelete 提供了基本的内存管理功能,但手动管理内存很容易出错,例如忘记释放内存导致内存泄漏,或者释放后再次使用指针导致悬空指针等问题。智能指针(Smart Pointer)是 C++ 提供的一种自动管理动态内存的机制,它可以在对象不再需要时自动释放其占用的内存,从而避免这些问题。

智能指针的类型

C++ 标准库提供了三种智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr

  • std::unique_ptrstd::unique_ptr 是一种独占式智能指针,它拥有对对象的唯一所有权。当 std::unique_ptr 被销毁时,它会自动调用 delete 来释放所指向的对象。例如:
std::unique_ptr<int> uniquePtr(new int(10));
std::cout << "Value: " << *uniquePtr << std::endl;
// uniquePtr 离开作用域时,自动释放指向的内存

std::unique_ptr 不能被复制,但可以被移动。例如:

std::unique_ptr<int> sourcePtr(new int(20));
std::unique_ptr<int> targetPtr = std::move(sourcePtr);
// 此时 sourcePtr 不再拥有对象,targetPtr 拥有对象
  • std::shared_ptrstd::shared_ptr 是一种共享式智能指针,多个 std::shared_ptr 可以指向同一个对象,通过引用计数来管理对象的生命周期。当最后一个指向对象的 std::shared_ptr 被销毁时,对象的内存才会被释放。例如:
std::shared_ptr<int> sharedPtr1(new int(30));
std::shared_ptr<int> sharedPtr2 = sharedPtr1;
std::cout << "Reference count: " << sharedPtr1.use_count() << std::endl;
// sharedPtr1 和 sharedPtr2 指向同一个对象,引用计数为 2
// 当 sharedPtr1 和 sharedPtr2 都离开作用域时,对象的内存被释放
  • std::weak_ptrstd::weak_ptr 是一种弱引用智能指针,它指向由 std::shared_ptr 管理的对象,但不会增加对象的引用计数。std::weak_ptr 主要用于解决 std::shared_ptr 之间的循环引用问题。例如:
std::shared_ptr<int> sharedPtr(new int(40));
std::weak_ptr<int> weakPtr = sharedPtr;
if (auto lockedPtr = weakPtr.lock()) {
    std::cout << "Value: " << *lockedPtr << std::endl;
}

std::weak_ptr 可以通过 lock 方法尝试获取一个 std::shared_ptr,如果对象还存在,则 lock 方法返回一个有效的 std::shared_ptr,否则返回一个空的 std::shared_ptr

智能指针与 newdelete 的关系

智能指针内部使用 newdelete 来分配和释放内存。例如,std::unique_ptr 在构造时使用 new 分配内存,在析构时使用 delete 释放内存。但是,智能指针为我们封装了这些操作,使得内存管理更加安全和便捷。

内存管理中的常见问题及解决方法

内存泄漏

内存泄漏是指程序中动态分配的内存没有被释放,导致这部分内存无法再被使用,从而使可用内存逐渐减少。例如:

void memoryLeakFunction() {
    int* ptr = new int;
    // 没有调用 delete 释放 ptr 指向的内存
}

为了避免内存泄漏,我们应该始终确保在不再需要动态分配的内存时,及时使用 delete(或 delete[])来释放它。使用智能指针也是避免内存泄漏的有效方法,因为智能指针会自动管理内存的释放。

悬空指针

悬空指针是指指向已经被释放的内存的指针。例如:

int* ptr = new int;
delete ptr;
// ptr 现在是悬空指针,它指向的内存已经被释放
int value = *ptr; // 访问悬空指针,未定义行为

为了避免悬空指针问题,在释放指针后,我们应该将指针设置为 nullptr

int* ptr = new int;
delete ptr;
ptr = nullptr;

这样,后续对 ptr 的解引用操作会导致程序崩溃,从而更容易发现问题,而不是产生未定义行为。

堆溢出

堆溢出是指程序在堆上分配的内存超过了系统所能提供的堆内存大小。这通常发生在程序不断分配大量内存而不释放,或者分配的内存块过大时。例如:

while (true) {
    int* largeArray = new int[1000000];
    // 持续分配大量内存,最终会导致堆溢出
}

为了避免堆溢出,我们需要合理规划内存使用,及时释放不再需要的内存。在分配大内存块之前,可以先检查系统可用内存,或者使用内存池等技术来优化内存分配。

总结

在 C++ 中,newdelete 操作符是手动管理动态内存的核心工具。理解它们的工作原理、正确用法以及相关的内存管理问题对于编写高效、健壮的 C++ 程序至关重要。同时,智能指针为我们提供了一种更加安全和便捷的内存管理方式,可以有效地避免内存泄漏和悬空指针等问题。在实际编程中,我们应该根据具体的需求选择合适的内存管理方式,以确保程序的性能和稳定性。

希望通过本文的介绍,你对 C++ 中 newdelete 的内存管理有了更深入的理解和掌握。在实际应用中,不断实践和总结经验,将有助于你编写出高质量的 C++ 代码。