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

C++ 内存分配深入解析与实践指南

2024-03-077.3k 阅读

C++ 内存分配基础概念

在 C++ 编程中,内存分配是一个至关重要的环节。理解内存分配的机制对于编写高效、稳定且健壮的程序起着决定性作用。

栈内存分配

栈(Stack)是一种后进先出(LIFO)的数据结构,在函数调用过程中,局部变量会被分配在栈上。当函数被调用时,系统会为该函数在栈上开辟一块空间,用于存放函数的参数、局部变量等。函数执行完毕后,这块栈空间会自动被释放。

下面通过一个简单的代码示例来展示栈内存分配:

#include <iostream>

void stackAllocationExample() {
    int localVar = 10; // localVar 被分配在栈上
    std::cout << "Local variable on stack: " << localVar << std::endl;
}

int main() {
    stackAllocationExample();
    return 0;
}

在上述代码中,stackAllocationExample 函数内的 localVar 变量是在栈上分配的内存。当函数执行结束,localVar 所占用的栈空间会被自动回收。栈内存分配的优点是速度快,因为其分配和释放遵循简单的 LIFO 原则,不需要复杂的内存管理算法。但缺点是栈的大小通常是有限的,如果在函数中定义过多或过大的局部变量,可能会导致栈溢出错误。

堆内存分配

堆(Heap)是一块用于动态内存分配的内存区域,与栈不同,堆上的内存分配和释放由程序员手动控制。这意味着程序员需要使用特定的操作符来分配和释放堆内存,否则可能会导致内存泄漏等问题。

在 C++ 中,使用 new 操作符来分配堆内存,使用 delete 操作符来释放堆内存。下面是一个简单的示例:

#include <iostream>

void heapAllocationExample() {
    int* heapVar = new int; // 在堆上分配一个 int 类型的内存空间
    *heapVar = 20;
    std::cout << "Variable on heap: " << *heapVar << std::endl;
    delete heapVar; // 释放堆内存
}

int main() {
    heapAllocationExample();
    return 0;
}

在上述代码中,通过 new int 在堆上分配了一个 int 类型的内存空间,并将其地址赋给 heapVar 指针。之后可以通过指针来访问这块内存。当使用完这块内存后,必须使用 delete heapVar 来释放它,以避免内存泄漏。

堆内存分配的优点是灵活性高,程序员可以根据需要动态地分配和释放内存,适用于需要在运行时确定大小的数据结构,如动态数组、链表等。然而,由于需要手动管理内存,程序员犯错的几率相对较高,一旦忘记释放内存或者释放已释放的内存,就会引发严重的程序错误。

动态内存分配操作符:new 和 delete

new 操作符

new 操作符用于在堆上分配内存。它有几种不同的形式,包括普通的 newnew[] 以及定位 new

  1. 普通 new:用于分配单个对象的内存。例如:
int* ptr = new int;

这行代码在堆上分配了一个 int 类型的内存空间,并返回该空间的地址,将其赋值给 ptr 指针。如果内存分配失败,new 操作符会抛出一个 std::bad_alloc 异常。

  1. new[]:用于分配数组的内存。例如:
int* arr = new int[5];

上述代码在堆上分配了一个包含 5 个 int 类型元素的数组,并返回数组首元素的地址给 arr 指针。同样,如果内存分配失败,会抛出 std::bad_alloc 异常。

  1. 定位 new(placement new):定位 new 允许在已有的内存块上构造对象。这在一些特定场景下非常有用,比如在内存池或预先分配的缓冲区中创建对象。其语法如下:
#include <new>
#include <iostream>

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

int main() {
    char buffer[sizeof(MyClass)];
    MyClass* obj = new (buffer) MyClass();
    // 使用 obj
    obj->~MyClass(); // 手动调用析构函数
    return 0;
}

在上述代码中,首先定义了一个 MyClass 类。然后创建了一个大小为 MyClass 对象大小的字符数组 buffer。接着使用定位 newbuffer 所指向的内存上构造了一个 MyClass 对象。注意,使用定位 new 构造对象后,需要手动调用对象的析构函数来清理资源,最后这块内存并不会自动释放,因为它不是通过 new 操作符分配的堆内存。

delete 操作符

delete 操作符用于释放由 new 分配的内存。它也有两种形式:deletedelete[],分别对应 newnew[] 分配的内存。

  1. delete:用于释放单个对象的内存。例如:
int* ptr = new int;
*ptr = 10;
delete ptr;

这里通过 delete 释放了之前用 new 分配的 int 类型的内存。

  1. delete[]:用于释放数组的内存。例如:
int* arr = new int[5];
// 使用 arr
delete[] arr;

在释放数组内存时,必须使用 delete[],否则可能会导致未定义行为。这是因为 delete[] 操作符不仅会释放内存,还会调用数组中每个对象的析构函数(如果对象有析构函数的话),而普通的 delete 只会调用单个对象的析构函数并释放内存。

内存管理中的常见问题

内存泄漏

内存泄漏是指程序中已分配的堆内存由于某种原因未被释放,导致该内存无法再被程序使用,随着程序的运行,泄漏的内存会逐渐增多,最终可能耗尽系统内存,导致程序崩溃。

下面是一个简单的内存泄漏示例:

void memoryLeakExample() {
    int* ptr = new int;
    // 这里没有调用 delete ptr,导致内存泄漏
}

int main() {
    for (int i = 0; i < 10000; ++i) {
        memoryLeakExample();
    }
    return 0;
}

在上述代码中,memoryLeakExample 函数每次调用时都会在堆上分配一个 int 类型的内存空间,但没有释放它。当 main 函数多次调用 memoryLeakExample 函数时,内存泄漏就会不断累积。

为了避免内存泄漏,在使用 new 分配内存后,一定要确保在适当的时候使用 deletedelete[] 释放内存。另外,现代 C++ 引入了智能指针(如 std::unique_ptrstd::shared_ptr 等),可以自动管理内存的释放,大大减少了内存泄漏的风险。

悬空指针

悬空指针(Dangling Pointer)是指指向一块已经被释放的内存的指针。当使用 delete 释放内存后,如果没有将指针设置为 nullptr,该指针就变成了悬空指针。访问悬空指针会导致未定义行为,可能引发程序崩溃或其他不可预测的错误。

以下是一个悬空指针的示例:

#include <iostream>

void danglingPointerExample() {
    int* ptr = new int;
    *ptr = 10;
    std::cout << "Value: " << *ptr << std::endl;
    delete ptr;
    // 此时 ptr 成为悬空指针
    std::cout << "Trying to access dangling pointer: " << *ptr << std::endl; // 未定义行为
}

int main() {
    danglingPointerExample();
    return 0;
}

在上述代码中,delete ptr 释放内存后,ptr 成为悬空指针,后续对 *ptr 的访问是未定义行为。为了避免悬空指针问题,在释放内存后,应立即将指针设置为 nullptr,如下所示:

void betterDanglingPointerExample() {
    int* ptr = new int;
    *ptr = 10;
    std::cout << "Value: " << *ptr << std::endl;
    delete ptr;
    ptr = nullptr;
    // 此时访问 ptr 不会导致未定义行为,因为 ptr 是 nullptr
}

或者使用智能指针,智能指针会在其生命周期结束时自动释放所指向的内存,并将自身置为 nullptr,从而避免悬空指针问题。

重复释放

重复释放是指对同一块已经释放的内存再次进行释放操作,这同样会导致未定义行为。例如:

void doubleFreeExample() {
    int* ptr = new int;
    delete ptr;
    delete ptr; // 重复释放,未定义行为
}

为了避免重复释放,在释放内存后要确保不再对该指针进行释放操作。可以通过将指针置为 nullptr 来防止意外的重复释放,或者使用智能指针,智能指针内部有机制防止重复释放同一块内存。

智能指针:自动内存管理

std::unique_ptr

std::unique_ptr 是 C++11 引入的一种智能指针,它采用独占所有权模型,即一个 std::unique_ptr 只能指向一个对象,当 std::unique_ptr 被销毁时,它所指向的对象也会被自动销毁。

以下是 std::unique_ptr 的使用示例:

#include <iostream>
#include <memory>

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

int main() {
    std::unique_ptr<MyClass> ptr(new MyClass());
    // 使用 ptr
    return 0;
}

在上述代码中,std::unique_ptr<MyClass> ptr(new MyClass()) 创建了一个 std::unique_ptr,并让它指向一个新创建的 MyClass 对象。当 ptr 离开其作用域(在 main 函数结束时),MyClass 对象会被自动销毁,无需手动调用 delete

std::unique_ptr 不支持拷贝构造和赋值操作,因为它是独占所有权的。但它支持移动语义,例如:

std::unique_ptr<MyClass> ptr1(new MyClass());
std::unique_ptr<MyClass> ptr2 = std::move(ptr1);
// 此时 ptr1 不再指向任何对象,ptr2 获得了对象的所有权

std::shared_ptr

std::shared_ptr 也是 C++11 引入的智能指针,它采用引用计数的方式来管理对象的生命周期。多个 std::shared_ptr 可以指向同一个对象,当指向对象的所有 std::shared_ptr 都被销毁时,对象才会被自动释放。

以下是 std::shared_ptr 的使用示例:

#include <iostream>
#include <memory>

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

int main() {
    std::shared_ptr<MyClass> ptr1(new MyClass());
    std::shared_ptr<MyClass> ptr2 = ptr1; // ptr2 和 ptr1 指向同一个对象,引用计数增加
    std::cout << "Reference count: " << ptr1.use_count() << std::endl;
    return 0;
}

在上述代码中,ptr1ptr2 都指向同一个 MyClass 对象,通过 ptr1.use_count() 可以获取当前指向对象的 std::shared_ptr 的数量。当 ptr1ptr2 都离开其作用域时,MyClass 对象会被自动释放。

虽然 std::shared_ptr 提供了方便的共享所有权机制,但由于引用计数的维护需要额外的开销,在性能敏感的场景下需要谨慎使用。同时,使用 std::shared_ptr 时要注意避免循环引用问题,循环引用会导致对象无法被正确释放,造成内存泄漏。

std::weak_ptr

std::weak_ptr 是与 std::shared_ptr 配合使用的一种智能指针,它不增加对象的引用计数,主要用于解决 std::shared_ptr 的循环引用问题。std::weak_ptr 可以从 std::shared_ptr 创建,通过 lock 成员函数可以尝试获取一个有效的 std::shared_ptr

以下是一个使用 std::weak_ptr 解决循环引用的示例:

#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> b;
    ~A() { std::cout << "A destructor called" << std::endl; }
};

class B {
public:
    std::weak_ptr<A> a;
    ~B() { std::cout << "B destructor called" << std::endl; }
};

int main() {
    std::shared_ptr<A> ptrA = std::make_shared<A>();
    std::shared_ptr<B> ptrB = std::make_shared<B>();
    ptrA->b = ptrB;
    ptrB->a = ptrA;
    return 0;
}

在上述代码中,如果 B 中的 a 也是 std::shared_ptr,就会形成循环引用,导致 AB 对象无法被正确释放。而使用 std::weak_ptr,当 ptrAptrB 离开作用域时,AB 对象能够被正确销毁。

内存池:优化内存分配

内存池的概念

内存池(Memory Pool)是一种内存管理技术,它预先分配一块较大的内存空间作为池,程序在需要分配内存时,直接从内存池中获取小块内存,而不是每次都向操作系统申请新的内存。当程序释放内存时,内存块被返回内存池,而不是立即归还给操作系统。

内存池的优点主要有以下几点:

  1. 减少系统调用开销:频繁的内存分配和释放操作会导致大量的系统调用,而内存池通过预先分配和重复利用内存块,减少了对操作系统的内存分配请求,从而提高了效率。
  2. 避免内存碎片:连续的小块内存分配和释放容易产生内存碎片,内存池通过合理的内存管理策略,可以有效减少内存碎片的产生,提高内存利用率。

简单内存池实现示例

下面是一个简单的内存池实现示例,用于分配固定大小的内存块:

#include <iostream>
#include <vector>

class MemoryPool {
private:
    const size_t blockSize;
    const size_t poolSize;
    std::vector<char*> blocks;
    char* pool;
    size_t usedBlocks;

public:
    MemoryPool(size_t blockSize, size_t poolSize)
        : blockSize(blockSize), poolSize(poolSize), usedBlocks(0) {
        pool = new char[blockSize * poolSize];
        for (size_t i = 0; i < poolSize; ++i) {
            blocks.push_back(pool + i * blockSize);
        }
    }

    ~MemoryPool() {
        delete[] pool;
    }

    void* allocate() {
        if (usedBlocks >= poolSize) {
            return nullptr;
        }
        void* block = blocks[usedBlocks];
        ++usedBlocks;
        return block;
    }

    void deallocate(void* block) {
        if (block < pool || block >= pool + blockSize * poolSize) {
            return;
        }
        --usedBlocks;
        blocks[usedBlocks] = static_cast<char*>(block);
    }
};

int main() {
    MemoryPool pool(1024, 10); // 10 个 1024 字节的内存块
    void* ptr1 = pool.allocate();
    void* ptr2 = pool.allocate();
    pool.deallocate(ptr1);
    void* ptr3 = pool.allocate();
    return 0;
}

在上述代码中,MemoryPool 类实现了一个简单的内存池。构造函数中预先分配了一块大小为 blockSize * poolSize 的内存作为内存池,并将其划分为 poolSize 个大小为 blockSize 的内存块。allocate 函数从内存池中获取一个可用的内存块,deallocate 函数将释放的内存块返回内存池。

实际应用中,内存池的实现可能会更加复杂,需要考虑线程安全、动态调整内存池大小等问题,但基本原理是相似的。内存池在一些对性能要求较高且需要频繁进行小块内存分配和释放的场景,如游戏开发、网络编程等,具有显著的优势。

内存对齐

内存对齐的概念

内存对齐(Memory Alignment)是指数据在内存中存储时,按照一定的规则排列,使得数据的起始地址是其自身大小的整数倍。不同的硬件平台对内存对齐有不同的要求,在 C++ 中,内存对齐主要由编译器自动处理,但程序员也需要了解其原理,以便编写高效且可移植的代码。

内存对齐的主要原因有以下几点:

  1. 硬件访问效率:现代计算机硬件在访问内存时,通常以特定的字节数(如 4 字节、8 字节等)为单位进行操作。如果数据存储在对齐的地址上,硬件可以更高效地访问数据,减少内存访问次数。
  2. 硬件兼容性:某些硬件平台要求特定类型的数据必须存储在对齐的地址上,否则会引发硬件异常。

内存对齐示例及规则

在 C++ 中,基本数据类型的对齐规则通常是:

  1. 基本数据类型(如 charshortintlongfloatdouble 等)的对齐值等于其自身大小。例如,int 类型通常是 4 字节,其对齐值也是 4 字节,意味着 int 类型的数据应该存储在 4 字节对齐的地址上。
  2. 结构体和类的对齐值是其成员中最大对齐值的整数倍。例如:
struct MyStruct {
    char c; // 1 字节,对齐值 1 字节
    int i;  // 4 字节,对齐值 4 字节
    short s; // 2 字节,对齐值 2 字节
};

在上述 MyStruct 结构体中,最大对齐值是 int 类型的 4 字节。因此,MyStruct 结构体的对齐值是 4 字节。编译器会在 ci 之间填充 3 个字节,使得 i 存储在 4 字节对齐的地址上。整个结构体的大小为 8 字节(1 字节 c + 3 字节填充 + 4 字节 i + 2 字节 s)。

程序员可以通过 #pragma pack 指令或 alignas 关键字来调整结构体或类的对齐方式。例如:

#pragma pack(push, 1)
struct PackedStruct {
    char c;
    int i;
    short s;
};
#pragma pack(pop)

struct AlignedStruct {
    char c;
    int i;
    short s;
} alignas(8);

在上述代码中,PackedStruct 使用 #pragma pack(1) 将对齐值设置为 1 字节,此时结构体大小为 7 字节(1 字节 c + 4 字节 i + 2 字节 s),没有填充字节。AlignedStruct 使用 alignas(8) 将对齐值设置为 8 字节,编译器会在结构体末尾填充 2 个字节,使其大小为 16 字节(1 字节 c + 3 字节填充 + 4 字节 i + 2 字节 s + 6 字节填充),以满足 8 字节对齐的要求。

了解内存对齐规则有助于优化程序的内存使用和性能,特别是在处理大型结构体或类,以及需要与硬件交互的场景中。

总结

C++ 内存分配是一个复杂而重要的主题,涉及栈内存分配、堆内存分配、动态内存操作符、内存管理问题、智能指针、内存池以及内存对齐等多个方面。掌握这些知识对于编写高效、稳定且可移植的 C++ 程序至关重要。通过合理使用内存分配和管理机制,如智能指针和内存池,可以有效减少内存泄漏、悬空指针等问题,提高程序的性能和可靠性。同时,了解内存对齐规则可以优化内存使用,提升硬件访问效率。在实际编程中,应根据具体的应用场景和需求,选择合适的内存管理策略,以实现最佳的程序性能和资源利用。