c++学习笔记:内存管理

156 阅读8分钟

引言

内存管理是c++编程中最核心也最具挑战性的部分之一。与许多现代语言(Java、golang等)不同,c++给予了开发者对内存的直接控制权。C++并不会自动回收内存,开发者必须手动管理堆上的内存分配和释放,这往往会导致内存泄漏和内存溢出等问题。而且这些问题可能不会立即出现,而是运行一段时间后,才暴露出来,排查也十分困难。本文将深入探讨c++内存管理的各个方面,帮助开发者写出更安全高效的代码。

1、c++内存模型

c++程序的内存通常分为栈区、堆区、全局/静态存储区、代码区,总的来说可以分为动态存储区(堆区、栈区)和静态存储区(全局区、代码区)。

  • 栈区:存放函数中的局部变量、函数参数和返回值,栈区的内存由编译器自动管理,随着作用域自动创建和销毁。使用内存资源较小,通常只有几MB,访问速度快。
  • 堆区:需要开发者手动进行内存管理,用于动态分配内存。可以使用melloc/new申请,free/delete释放。受系统可用内存资源限制,访问速度相对较慢。
  • 全局/静态存储区:存放全局变量、静态变量和常量,程序的整个生命周期都存在。访问速度与栈区相当。
  • 代码区:用于存放函数体被编译后的机器码,通常是只读的,用于存放代码指令。程序运行期间都存在。

1.1、 栈区

栈区主要用于存放局部变量、函数的参数和返回地址等数据,内存的分配和释放都由编译器自动管理,遵循先进后出原则(FILO)。

1.1.1 栈区工作原理

1.1.1.1 栈指针

栈区通过两个指针来管理:

  • 栈指针(SP):指向当前栈顶位置;
  • 基址指针(BP):指向当前函数栈帧的基址;
1.1.1.2 函数调用时栈操作

函数调用时:

  • 参数从右到左依次入栈
  • 返回地址入栈
  • 基址针入栈
  • 栈指针移动到新的栈顶
  • 为局部变量分配新的内存空间

函数返回时:

  • 释放局部变量空间
  • 恢复基址指针
  • 根据返回地址跳转
  • 调整栈顶指针释放函数空间

1.1.2 栈上的数据存储

栈区主要存储 局部变量、函数参数、返回地址(函数调用完成后需要返回的指令地址)、寄存器保存(函数调用时需要保存的寄存器值)

1.1.3 栈帧(Stack Frame)

每个函数调用都会在栈上创建一个栈帧(也称为活动记录),包含:函数参数、返回地址、前一个栈帧的基址、局部变量、临时变量

1.1.4 栈区的优势

  • 快速分配/释放:只需要移动栈指针即可完成没存操作
  • 自动管理:不需要程序员手动释放
  • 缓存友好:连续的内存访问模式
  • 确定性:内存分配和释放时机明确

1.1.5 栈区的限制

  • 大小限制:栈空间通常较小(windows默认1MB,Linux默认8MB),可以通过编译器选项调整大小,Linux下可通过ulimit -s查看栈大小
  • 生命周期受限:栈上对象的生命周期仅限于所在作用域
  • 不适合大对象:大对象可能导致栈溢出
  • 灵活性差:不能在运行时动态调整大小

1.1.6 栈溢出(Stack Overflow)

当栈空间耗尽时会发生栈溢出,常见原因:

  • 过深的递归调用
void inifiniteRecursion() {
    infiniteRecursion(); // 无限递归导致栈溢出
}
  • 过大的局部变量
void hugeArray() {
    int massiveArray[1000000]; // 可能在栈上分配过大的空间
}
  • 过多的嵌套函数调用

1.2 堆区

由开发人员手动申请,手动释放,若不手动释放,程序结束后由系统回收,生命周期是整个程序运行期间。使用malloc或者new进行堆的申请,堆的总大小为机器的虚拟内存大小。

1.2.1 栈区工作原理

1.2.1.1 内存分配流程
  • 内存申请:通过new操作符调用底层malloc,系统在堆区查找可用内存块(可能触发缺页中断向操作系统申请物理内存)。
  • 内存初始化:调用构造函数(对于对象),返回分配的内存地址。
  • 内存释放:通过调用析构函数(对于对象),或通过delete调用底层free,最后内存块被标记为可用(不一定立即返还系统)。
1.2.1.2 对内存的底层管理

这里列举一个经典内存分配器dlmalloc内存块结构实现方式:

内存块结构:

struct malloc_chunk {
    size_t prev_size;        // 前一个块大小(若空闲)
    size_t size;             // 当前块大小 + 状态标志
    struct malloc_chunk* fd; // 空闲链表前向指针
    struct malloc_chunk* bk; // 空闲链表向后指针
};

管理策略:

  • 分箱(Binning)机制:将空闲块按大小分类到不同链表中,其中小内存(<512B)使用精确大小分箱,中等内存使用二分查找树,大内存(>256KB)直接使用mmap分配
  • 分配流程:根据请求大小选择合适的分箱,查找最佳匹配块(首次适应策略),分割大块(如果剩余部分足够大),更新空闲链表

再列举一个现代内存分配器jemalloc(Facebook/FreeBSD)架构实现方式:

  • 三级内存管理:
  1. Arena:全局内存池(默认每个CPU核心对应4个arena);
  2. Chunk:2MB或4MB的大块内存;
  3. Run:特定大小类的内存区域;
  • 大小分类:
  1. 小对象(<16KB):按2^n对齐分级(8B,16B,……,16KB)
  2. 大对象(16KB-4MB):按页对齐(4KB单位)
  3. 超大对象(>4MB):直接mmap
1.2.1.3 内存碎片处理策略

内存碎片分为内部碎片外部碎片:内部碎片为已分配内存块中未被使用的部分,如申请17B但分配32B对齐的块;外部碎片是指空闲内存被分割成多个小块,无法满足大请求,产生原因是频繁不同大小的内存分配、释放,或未及时合并相邻空闲块,如总空闲1MB但最大连续块只有128KB。

核心处理策略:

  • 伙伴系统(Buddy System)

    实现原理:将内存按2的幂次方分割(如1024KB->512KB+512KB),仅允许合并相邻且大小相同的空闲块 操作流程:

// 分配200KB内存:
1、找到最小满足的块大小 256KB(2^18=262144B2、若256KB块不可用,分割512KB->256KB+256KB
3、分配其中一个256KB块

// 释放内存时:
1、检查相邻块是否为同大小且空闲
2、合并形成更大的块

优点是可以实现快速合并/分割操作(位运算实现),有效减少外部碎片;缺点是内部碎片严重(最多浪费50%内存),仅适合固定大小分配。

  • Slab分配器

    Slab分配器为特定对象类型分配内存(如std:string),每个Slab包含多个相同大小的对象槽位,分为三种管理状态:FULL、PARTIAL、EMPTY 工作流程:

class ObjectCache {
private:
    std::list<Slab*> partial_slabs; // 部分空闲的Slab列表
    size_t obj_size;                // 对象大小
public:
    void* alloc() {
        if (partial_slabs.empty()) create_new_slab();
        return partial_slabs.front()->allocate();
    }
}

Slab分配器具有零内部碎片(精确分配对象大小),高缓存布局性(同类型对象集中存储),快速分配/释放(无需复杂查找)的优点。

1.2.2 堆区的经典使用场景

  • 动态数据结构

场景:需要运行时动态调整大小的数据结构,如动态数组扩容

// 动态数组
int* dynamicArray = new int[100];
delete[] dynamicArray;

// 链表节点
struct Node {
    int data;
    Node* next;
};
Node* head = new Node();
  • 大内存需求处理

场景:处理大型数据集,如图像/视频/科学计算数据

// 存储大型数据(如高清图像)
const size_t IMAGE_SIZE = 1024*1024*100; // 100MB
unsigned char* imageData = new unsigned char[IMAGE_SIZE];
  • 多态对象

场景:面向对象编程中的运行时多态

class Animal { /*...*/ };
class Dog : public Animal { /*...*/ };

Animal* pet = new Dog(); // 堆上创建派生类对象
  • 跨作用域数据传递

场景:需要在函数间传递所有权或保持数据持久性

int* createArray(int size) {
    return new int[size]; // 堆内存可安全返回
}
  • 资源管理类

场景:管理需要精准控制生命周期的资源(文件句柄、网络连接等)

class ManualFileHandler {
public:
    FILE* m_file;
    ManualFileHandler(const char* filename) {
        m_file = std::fopen(filename, "w");
        if (!m_file) throw std::runtime_error("文件打开失败");
    }
    void close() { // 需手动调用
        if (m_file) {
            std::fclose(m_file);
            m_file = nullptr;
        }
    }
    ~ManualFileHandler() {
        if (m_file) { // 忘记调用close()时仍可能泄漏
            std::fclose(m_file);
        }
    }
};
  • 复杂数据结构实现

场景:实现链表、树等动态结构