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

C++ 内存分配与布局详解

2022-05-146.0k 阅读

C++ 内存分配基础

在 C++ 编程中,内存管理是一项至关重要的任务。理解内存如何分配和布局,对于编写高效、稳定且安全的代码至关重要。C++ 提供了多种内存分配机制,每种机制都有其特点和适用场景。

栈内存分配

栈(Stack)是一种后进先出(LIFO)的数据结构,在函数调用过程中,局部变量通常分配在栈上。当函数被调用时,其局部变量会在栈上分配空间,函数结束时,这些变量所占用的栈空间会自动释放。这种内存分配方式效率极高,因为它只涉及栈指针的移动。

以下是一个简单的示例,展示局部变量在栈上的分配:

#include <iostream>

void stackExample() {
    int localVar = 10; // 局部变量 localVar 分配在栈上
    std::cout << "Local variable on stack: " << localVar << std::endl;
}

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

在上述代码中,stackExample 函数中的 localVar 变量是一个局部变量,它在函数被调用时在栈上分配空间,函数执行完毕后,栈空间自动释放。

堆内存分配

堆(Heap)是一块供程序动态分配内存的区域,与栈不同,堆的内存分配和释放由程序员手动控制。C++ 提供了 newdelete 运算符来进行堆内存的分配和释放。

使用 new 运算符分配内存的示例如下:

#include <iostream>

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

在上述代码中,使用 new int 在堆上分配了一个 int 类型的空间,并将其地址赋给 heapVar 指针。使用完毕后,通过 delete heapVar 释放该内存。如果忘记调用 delete,就会导致内存泄漏,即已分配的内存无法被回收,从而造成内存资源的浪费。

除了 newdelete,C++ 还提供了 new[]delete[] 用于分配和释放数组内存。例如:

#include <iostream>

int main() {
    int* arrayOnHeap = new int[5]; // 在堆上分配一个包含 5 个 int 的数组
    for (int i = 0; i < 5; ++i) {
        arrayOnHeap[i] = i * 2;
    }
    for (int i = 0; i < 5; ++i) {
        std::cout << "Element at index " << i << " : " << arrayOnHeap[i] << std::endl;
    }
    delete[] arrayOnHeap; // 释放数组内存
    return 0;
}

这里使用 new int[5] 在堆上分配了一个包含 5 个 int 类型元素的数组,使用 delete[] 来正确释放这个数组的内存。

内存布局

了解 C++ 程序的内存布局有助于深入理解内存分配的原理。C++ 程序在运行时,内存通常被划分为几个不同的区域。

代码段(Text Segment)

代码段存储程序的可执行指令,这部分内存是只读的,以防止程序在运行过程中意外修改自身的指令。例如,所有函数的代码都存储在代码段中。

数据段(Data Segment)

数据段用于存储已初始化的全局变量和静态变量。这部分内存的生命周期从程序启动开始,到程序结束为止。

#include <iostream>

int globalVar = 10; // 全局变量,存储在数据段
static int staticVar = 20; // 静态变量,也存储在数据段

int main() {
    std::cout << "Global variable: " << globalVar << std::endl;
    std::cout << "Static variable: " << staticVar << std::endl;
    return 0;
}

在上述代码中,globalVarstaticVar 都存储在数据段中。

BSS 段(Block Started by Symbol)

BSS 段用于存储未初始化的全局变量和静态变量。BSS 段在程序加载时会被清零,因此未初始化的全局变量和静态变量默认值为 0。

#include <iostream>

int uninitGlobal; // 未初始化的全局变量,存储在 BSS 段
static int uninitStatic; // 未初始化的静态变量,存储在 BSS 段

int main() {
    std::cout << "Uninitialized global variable: " << uninitGlobal << std::endl;
    std::cout << "Uninitialized static variable: " << uninitStatic << std::endl;
    return 0;
}

在这个例子中,uninitGlobaluninitStatic 虽然没有显式初始化,但由于它们存储在 BSS 段,会被自动初始化为 0。

堆(Heap)

如前文所述,堆是程序运行时动态分配内存的区域。程序通过 new 运算符在堆上分配内存,通过 delete 运算符释放内存。堆的大小在程序运行过程中可以动态变化,以满足程序对内存的不同需求。

栈(Stack)

栈用于存储函数调用过程中的局部变量、函数参数以及返回地址等信息。随着函数的调用和返回,栈空间会动态地增长和收缩。

内存分配策略与优化

在实际编程中,合理的内存分配策略对于提高程序性能和减少内存开销至关重要。

减少堆内存分配

由于堆内存分配涉及系统调用和复杂的内存管理算法,相比栈内存分配,其开销较大。因此,应尽量减少不必要的堆内存分配。例如,在可能的情况下,使用栈上分配的数组代替堆上分配的动态数组。

#include <iostream>

void stackArrayExample() {
    int stackArray[10]; // 栈上分配的数组
    for (int i = 0; i < 10; ++i) {
        stackArray[i] = i;
    }
    for (int i = 0; i < 10; ++i) {
        std::cout << "Stack array element at index " << i << " : " << stackArray[i] << std::endl;
    }
}

void heapArrayExample() {
    int* heapArray = new int[10]; // 堆上分配的数组
    for (int i = 0; i < 10; ++i) {
        heapArray[i] = i;
    }
    for (int i = 0; i < 10; ++i) {
        std::cout << "Heap array element at index " << i << " : " << heapArray[i] << std::endl;
    }
    delete[] heapArray;
}

int main() {
    stackArrayExample();
    heapArrayExample();
    return 0;
}

在上述代码中,stackArrayExample 使用栈上分配的数组,而 heapArrayExample 使用堆上分配的数组。栈上分配的数组在函数结束时自动释放,无需手动管理内存,且分配和释放的开销较小。

内存池技术

内存池是一种预先分配一定数量内存块的技术,程序在需要时从内存池中获取内存块,使用完毕后再归还到内存池,而不是频繁地调用系统的内存分配和释放函数。这样可以减少内存碎片,提高内存分配的效率。

下面是一个简单的内存池示例代码:

#include <iostream>
#include <vector>

class MemoryPool {
public:
    MemoryPool(size_t blockSize, size_t initialBlocks)
        : blockSize(blockSize), freeList() {
        for (size_t i = 0; i < initialBlocks; ++i) {
            char* block = new char[blockSize];
            freeList.push_back(block);
        }
    }

    ~MemoryPool() {
        for (char* block : freeList) {
            delete[] block;
        }
    }

    void* allocate() {
        if (freeList.empty()) {
            char* newBlock = new char[blockSize];
            return newBlock;
        }
        char* block = freeList.back();
        freeList.pop_back();
        return block;
    }

    void deallocate(void* block) {
        freeList.push_back(static_cast<char*>(block));
    }

private:
    size_t blockSize;
    std::vector<char*> freeList;
};

int main() {
    MemoryPool pool(1024, 10); // 创建一个内存池,每个块大小为 1024 字节,初始有 10 个块
    void* mem1 = pool.allocate();
    void* mem2 = pool.allocate();
    pool.deallocate(mem1);
    pool.deallocate(mem2);
    return 0;
}

在上述代码中,MemoryPool 类实现了一个简单的内存池。构造函数预先分配了一定数量的内存块,并将它们放入 freeList 中。allocate 函数从 freeList 中获取内存块,如果 freeList 为空,则分配新的内存块。deallocate 函数将使用完毕的内存块归还到 freeList 中。

智能指针

C++ 引入了智能指针来帮助管理堆内存,以避免内存泄漏。智能指针是一种类模板,它能够自动释放所指向的内存。

  1. std::unique_ptr std::unique_ptr 持有对对象的唯一所有权,当 std::unique_ptr 被销毁时,它所指向的对象也会被自动销毁。
#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> uniquePtr(new int(30));
    std::cout << "Value pointed by unique_ptr: " << *uniquePtr << std::endl;
    return 0;
}

在上述代码中,std::unique_ptr<int> 自动管理 new int(30) 分配的内存,当 uniquePtr 超出作用域时,所指向的 int 对象会被自动释放。

  1. std::shared_ptr std::shared_ptr 允许多个指针共享对同一对象的所有权。它使用引用计数来跟踪有多少个 std::shared_ptr 指向同一个对象,当引用计数为 0 时,对象会被自动销毁。
#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> sharedPtr1(new int(40));
    std::shared_ptr<int> sharedPtr2 = sharedPtr1;
    std::cout << "Use count of shared_ptr1: " << sharedPtr1.use_count() << std::endl;
    std::cout << "Use count of sharedPtr2: " << sharedPtr2.use_count() << std::endl;
    return 0;
}

在这个例子中,sharedPtr1sharedPtr2 共享对 new int(40) 分配的对象的所有权,通过 use_count 函数可以获取当前的引用计数。

  1. std::weak_ptr std::weak_ptr 是一种不控制对象生命周期的智能指针,它指向由 std::shared_ptr 管理的对象,但不会增加对象的引用计数。std::weak_ptr 主要用于解决 std::shared_ptr 之间的循环引用问题。
#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> ptrB;
    ~A() {
        std::cout << "A destroyed" << std::endl;
    }
};

class B {
public:
    std::weak_ptr<A> ptrA;
    ~B() {
        std::cout << "B destroyed" << std::endl;
    }
};

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

在上述代码中,如果 B 中的 ptrA 也是 std::shared_ptr,就会形成循环引用,导致 AB 对象无法被正确释放。使用 std::weak_ptr 可以避免这种情况,使得 AB 对象在不再被其他对象引用时能够正常销毁。

结构体和类的内存布局

在 C++ 中,结构体和类的内存布局也遵循一定的规则。理解这些规则有助于优化内存使用和提高程序性能。

结构体和类的基本内存布局

结构体和类本质上都是用户自定义的数据类型,它们的成员变量在内存中按声明顺序依次排列。例如:

#include <iostream>

struct SimpleStruct {
    int a;
    char b;
    short c;
};

int main() {
    std::cout << "Size of SimpleStruct: " << sizeof(SimpleStruct) << std::endl;
    return 0;
}

在上述代码中,SimpleStruct 包含一个 int、一个 char 和一个 short。理论上,int 通常占用 4 字节,char 占用 1 字节,short 占用 2 字节,总共应该是 7 字节。但实际上,运行程序会发现 sizeof(SimpleStruct) 的结果可能大于 7 字节,这是因为内存对齐的原因。

内存对齐

内存对齐是为了提高内存访问效率,现代计算机硬件通常要求数据存储在特定的内存地址边界上。例如,有些硬件要求 int 类型数据的地址必须是 4 的倍数。

C++ 编译器会自动进行内存对齐,在结构体和类中,每个成员变量的偏移量必须是其自身大小的倍数。对于上述 SimpleStruct,编译器可能会在 char b 后面填充 3 个字节,使得 short c 的地址是 2 的倍数,这样 SimpleStruct 的总大小就变为 8 字节。

可以通过 #pragma pack 指令来改变默认的内存对齐方式。例如:

#include <iostream>

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

int main() {
    std::cout << "Size of PackedStruct: " << sizeof(PackedStruct) << std::endl;
    return 0;
}

在上述代码中,#pragma pack(push, 1) 将内存对齐方式设置为 1 字节对齐,#pragma pack(pop) 恢复默认的对齐方式。此时,PackedStruct 的大小为 7 字节,因为不再进行额外的填充。

继承体系下的内存布局

当存在继承关系时,派生类对象的内存布局会包含基类对象的内存布局。例如:

#include <iostream>

class Base {
public:
    int baseVar;
};

class Derived : public Base {
public:
    int derivedVar;
};

int main() {
    Derived d;
    d.baseVar = 10;
    d.derivedVar = 20;
    std::cout << "Base var in Derived object: " << d.baseVar << std::endl;
    std::cout << "Derived var in Derived object: " << d.derivedVar << std::endl;
    std::cout << "Size of Derived object: " << sizeof(Derived) << std::endl;
    return 0;
}

在上述代码中,Derived 类继承自 Base 类,Derived 对象的内存布局中,首先是 Base 类的成员 baseVar,然后是 Derived 类自身的成员 derivedVarsizeof(Derived) 的大小是 Base 类和 Derived 类成员变量大小之和(考虑内存对齐)。

如果存在虚函数,情况会更加复杂。当类中包含虚函数时,编译器会为该类添加一个虚函数表指针(vptr),该指针指向一个虚函数表(vtable),虚函数表中存储了虚函数的地址。在继承体系中,派生类会继承基类的虚函数表,并根据自身的情况进行修改。例如:

#include <iostream>

class Base {
public:
    virtual void virtualFunction() {
        std::cout << "Base virtual function" << std::endl;
    }
};

class Derived : public Base {
public:
    void virtualFunction() override {
        std::cout << "Derived virtual function" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived();
    basePtr->virtualFunction();
    delete basePtr;
    return 0;
}

在上述代码中,Base 类和 Derived 类都有虚函数 virtualFunctionBase 类对象和 Derived 类对象的内存布局中都会包含一个 vptr,通过 vptr 来实现动态绑定,使得 basePtr->virtualFunction() 能够正确调用 Derived 类的 virtualFunction 函数。

动态内存分配的异常处理

在使用 new 运算符进行动态内存分配时,可能会由于内存不足等原因导致分配失败。C++ 提供了异常处理机制来处理这种情况。

new 抛出异常

默认情况下,当 new 无法分配足够的内存时,会抛出 std::bad_alloc 异常。例如:

#include <iostream>
#include <new>

int main() {
    try {
        int* largeArray = new int[1000000000]; // 可能会导致内存分配失败
    } catch (const std::bad_alloc& e) {
        std::cerr << "Memory allocation failed: " << e.what() << std::endl;
    }
    return 0;
}

在上述代码中,尝试分配一个非常大的数组,如果内存不足,new 会抛出 std::bad_alloc 异常,通过 try - catch 块可以捕获并处理该异常。

不抛出异常的 new

C++ 还提供了一种不抛出异常的 new 形式,即 std::nothrow。当使用 std::nothrow 时,如果内存分配失败,new 不会抛出异常,而是返回 nullptr。例如:

#include <iostream>
#include <new>

int main() {
    int* largeArray = new (std::nothrow) int[1000000000];
    if (largeArray == nullptr) {
        std::cerr << "Memory allocation failed" << std::endl;
    } else {
        // 使用 largeArray
        delete[] largeArray;
    }
    return 0;
}

在这个例子中,使用 new (std::nothrow) int[1000000000] 进行内存分配,如果分配失败,largeArray 会被赋值为 nullptr,程序可以通过检查 nullptr 来处理内存分配失败的情况。

选择使用抛出异常的 new 还是不抛出异常的 new 取决于具体的应用场景。抛出异常的 new 更适合在程序可以方便地处理异常的情况下使用,而不抛出异常的 new 则更适合在不希望程序因异常而中断,需要自行处理错误的情况下使用。

总结内存分配与布局相关要点

  1. 栈与堆内存分配
    • 栈内存分配主要用于局部变量,效率高且自动管理,随着函数调用和返回自动分配和释放。
    • 堆内存分配通过 newdelete 手动控制,用于动态内存需求,但需要注意避免内存泄漏。
  2. 内存布局
    • 程序内存分为代码段、数据段、BSS 段、堆和栈等区域,每个区域有其特定的用途和生命周期。
    • 理解内存布局有助于优化程序性能和资源管理,例如减少不必要的堆内存分配,利用栈上分配的优势。
  3. 内存分配策略与优化
    • 减少堆内存分配可以提高性能,如使用栈上数组代替堆上动态数组。
    • 内存池技术可以减少内存碎片和提高分配效率。
    • 智能指针(std::unique_ptrstd::shared_ptrstd::weak_ptr)有助于自动管理堆内存,避免内存泄漏。
  4. 结构体和类的内存布局
    • 结构体和类的成员变量按声明顺序排列,会进行内存对齐以提高访问效率。
    • 继承体系下,派生类对象包含基类对象的内存布局,虚函数会引入虚函数表指针和虚函数表。
  5. 动态内存分配的异常处理
    • new 运算符默认在内存分配失败时抛出 std::bad_alloc 异常,可以通过 try - catch 块捕获处理。
    • std::nothrow 形式的 new 不抛出异常,分配失败时返回 nullptr,适用于需要自行处理错误的场景。

深入理解 C++ 的内存分配与布局,能够帮助程序员编写出更高效、稳定和健壮的程序。在实际编程中,应根据具体需求合理选择内存分配方式,并注意内存管理的各个方面,以避免内存相关的错误和性能问题。