C/C++ 内存管理深度解析

前言

内存管理是 C/C++ 程序员绕不过去的一道坎。你写 Java 或 Python 的时候,垃圾回收器帮你把脏活累活都干了;但到了 C/C++,每一块内存从哪里来、到哪里去,都得你自己操心。这种"手动挡"的体验,既是 C/C++ 性能优势的来源,也是无数 bug 的温床。

这篇文章会把 C/C++ 内存管理的核心知识体系梳理一遍——从进程的内存布局,到 malloc/free 和 new/delete 的用法与原理,再到定位 new 和内存池的实际应用。不是简单的 API 教程,而是从"为什么这样设计"的角度去理解每一层机制。读完之后,你对内存管理的理解应该能从"会用"提升到"明白底层在干什么"。

C/C++ 程序的内存布局

写 C/C++ 代码之前,先搞清楚程序运行时内存长什么样。一个典型的 C/C++ 进程,其虚拟地址空间大致被划分为以下几个区域:

高地址
┌──────────────────────┐
│     内核空间          │
├──────────────────────┤
│     栈 (Stack)        │  ← 向下增长
├──────────────────────┤
│                      │
│     共享库/内存映射段  │
│                      │
├──────────────────────┤
│                      │
│     堆 (Heap)         │  ← 向上增长
├──────────────────────┤
│     BSS 段            │  ← 未初始化的全局/静态变量
├──────────────────────┤
│     数据段 (Data)      │  ← 已初始化的全局/静态变量
├──────────────────────┤
│     代码段 (Text)      │  ← 可执行代码 + 只读常量
└──────────────────────┘
低地址

光看这个图可能还不够直观,来一段代码,把每个变量"归位":

int globalVar = 1;              // 数据段(已初始化全局变量)
static int staticGlobalVar = 1; // 数据段(已初始化静态全局变量)

void Test()
{
    static int staticVar = 1;    // 数据段(已初始化局部静态变量)
    int localVar = 1;            // 栈(局部变量)
    int num1[10] = {1, 2, 3, 4}; // 栈(局部数组)
    char char2[] = "abcd";       // 栈(数组本身),数据段("abcd"字符串常量被拷贝到栈上)
    const char* pChar3 = "abcd"; // 栈(指针变量 pChar3),代码段("abcd"字符串常量)
    int* ptr1 = (int*)malloc(sizeof(int) * 4); // 栈(ptr1),堆(*ptr1)
    int* ptr2 = (int*)calloc(4, sizeof(int));  // 栈(ptr2),堆(*ptr2)
    int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4); // 栈(ptr3),堆(*ptr3)
    free(ptr1);
    free(ptr3);
}

这里有几个容易搞混的点值得单独拎出来:

指针变量 vs 指针指向的内容pChar3 这个指针本身在栈上,但它指向的字符串 "abcd" 存放在代码段(常量区)。同理,ptr1 在栈上,*ptr1(即 malloc 出来的那块内存)在堆上。面试中经常拿这个来挖坑,一定要分清"变量在哪"和"变量指向的内容在哪"。

栈向下增长,堆向上增长。这是两个区域最显著的生长方向差异。栈的增长方向意味着越早压栈的变量地址越高,而堆则相反。理解这一点对调试栈溢出和堆碎片问题很有帮助。

内存映射段。这个区域主要用于加载共享动态链接库(.so/.dll),也可以通过 mmap 系统调用进行文件映射或进程间共享内存。日常开发中不太会直接操作,但了解它的存在有助于理解动态链接和进程间通信的底层机制。

C 语言的动态内存管理:malloc/calloc/realloc/free

C 语言提供了四个函数来处理动态内存,其中 malloccallocrealloc 负责申请,free 负责释放。

malloc、calloc、realloc 的区别

// malloc:申请 size 字节的内存,内容不初始化(值是随机的)
int* p1 = (int*)malloc(sizeof(int) * 4);

// calloc:申请 n 个 size 字节的内存,并且每一位都初始化为 0
int* p2 = (int*)calloc(4, sizeof(int));

// realloc:调整已申请内存的大小,可以扩大也可以缩小
int* p3 = (int*)realloc(p2, sizeof(int) * 10);

callocmalloc 多了一步"清零"操作。如果你申请的内存打算存放数值型数据且需要初始值为 0,用 calloc 可以省去手动 memset。但如果存放的是指针或者浮点数,“全零"不一定等于"有效的零值”(比如浮点数的 0 在 IEEE 754 中确实是全零位,但某些平台上可能有特殊处理),这点要注意。

realloc 是三个里面行为最复杂的。它做的事情是:尝试把原来那块内存扩展到新的大小。这里有两种情况:

  • 原地扩容:如果原内存块后面有足够的空闲空间,直接在原位置扩展,返回的指针和原来一样。
  • 异地搬迁:如果原位置后面空间不够,realloc 会在堆的另一处找到一块足够大的新内存,把旧数据拷贝过去,然后自动释放旧内存,返回新地址。
int* p = (int*)malloc(sizeof(int) * 4);
// ... 使用 p ...

// 扩容到 10 个 int
int* newP = (int*)realloc(p, sizeof(int) * 10);
if (newP == NULL) {
    // realloc 失败,p 仍然有效,需要手动处理
} else {
    p = newP; // realloc 成功,用新指针替换旧指针
}

这里有个常见的坑:不要用原来的指针直接接收 realloc 的返回值。如果 realloc 失败返回 NULL,你就把原来的指针也丢了,导致内存泄漏。正确做法是用一个临时指针接收,判断非 NULL 后再赋值。

另外,realloc 的第一个参数传 NULL 时,行为等同于 malloc;第二个参数传 0 时(C 标准),行为等同于 free。但这些"奇技淫巧"可读性太差,实际项目中不建议这么用。

malloc 的底层实现原理

面试中经常被问到"malloc 是怎么实现的"。简单来说,glibc 中的 malloc 实现基于 ptmalloc(pthreads malloc),核心思路是:

  1. 维护多个"空闲链表"(bin),按大小分类管理回收的内存块
  2. 申请小内存时,从对应的 bin 中查找合适的块
  3. 申请大内存时,通过 brkmmap 系统调用向操作系统扩展堆空间
  4. 分配时会在内存块头部维护元数据(大小、是否空闲等),这就是为什么会有"内存对齐"的开销

brk 用于调整堆顶指针,适合小规模内存分配;mmap 用于大块内存分配(默认阈值 128KB),分配的是独立的虚拟内存区域,释放时可以直接归还给操作系统。这也是为什么大块 malloc 出来的内存 free 之后能立即被 OS 回收,而小块的 free 之后可能还在进程的堆空间里"躺"着。

C++ 的 new 和 delete:不只是语法糖

C 语言的那套 malloc/free 在 C++ 里照样能用,但面对自定义类型就显得力不从心了——malloc 只管分配内存,不会调用构造函数;free 只管释放内存,不会调用析构函数。C++ 引入了 newdelete 操作符来解决这个问题。

操作内置类型

// 动态申请一个 int,不初始化(值未定义)
int* ptr4 = new int;

// 动态申请一个 int,初始化为 10
int* ptr5 = new int(10);

// 动态申请 3 个 int(连续空间)
int* ptr6 = new int[3];

// 释放
delete ptr4;
delete ptr5;
delete[] ptr6;

对于内置类型,new/delete 和 malloc/free 的行为基本一致,区别在于:

  • new 可以在申请时直接初始化
  • new 申请失败抛 std::bad_alloc 异常,malloc 返回 NULL
  • 申请单个元素用 new/delete,申请连续空间用 new[]/delete[]必须匹配使用

操作自定义类型

这才是 new/delete 真正发挥威力的地方:

class A {
public:
    A(int a = 0) : _a(a) {
        cout << "A():" << this << endl;
    }
    ~A() {
        cout << "~A():" << this << endl;
    }
private:
    int _a;
};

int main()
{
    // malloc 只分配内存,不调用构造函数
    A* p1 = (A*)malloc(sizeof(A));

    // new 分配内存 + 调用构造函数
    A* p2 = new A(1);

    free(p1);   // 不调用析构函数
    delete p2;  // 调用析构函数 + 释放内存

    // 数组版本同理
    A* p5 = (A*)malloc(sizeof(A) * 10);
    A* p6 = new A[10];

    free(p5);     // 10 个对象的析构函数都不会被调用!
    delete[] p6;  // 依次调用 10 次析构函数 + 释放内存

    return 0;
}

用 malloc 申请自定义类型空间是危险的——对象内部的资源(如动态分配的指针、打开的文件句柄等)不会被正确初始化和清理。如果你在项目中看到有人对自定义类型用 malloc/free,大概率是个 bug。

还有一个细节:new[]delete[] 必须配对使用。如果你用 new A[10] 申请,却用 delete p(而不是 delete[] p)释放,结果未定义——可能只调用了第一个元素的析构函数,其余 9 个的资源就泄漏了。反过来,new Adelete[] p 也有问题。编译器在 new[] 时会在数组前面额外存储元素个数的信息,delete[] 靠这个信息知道要调用多少次析构函数。

深入 operator new 与 operator delete

很多人以为 new 是一个函数,其实不是。new 是一个操作符(operator/keyword),它在底层调用了两个独立的步骤:operator new(申请内存)和 构造函数(初始化对象)。delete 同理:先调析构函数,再调 operator delete(释放内存)。

operator new 的实现

来看 glibc 中 operator new 的源码(简化版):

void* __CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
    // 尝试用 malloc 分配 size 字节的内存
    void* p;
    while ((p = malloc(size)) == 0)
        if (_callnewh(size) == 0)
        {
            // malloc 失败,且用户没有设置内存不足的处理函数
            // 抛出 bad_alloc 异常
            static const std::bad_alloc nomem;
            _RAISE(nomem);
        }
    return p;
}

逻辑很清晰:

  1. 调用 malloc 申请内存
  2. 如果 malloc 成功,直接返回指针
  3. 如果 malloc 返回 NULL(申请失败),调用 _callnewh——这是用户可以通过 set_new_handler 设置的回调函数
  4. 如果用户没设置回调,或者回调也搞不定,就抛 bad_alloc 异常

所以 operator new 本质上就是对 malloc 的一层包装,加上了异常处理机制。

operator delete 的实现

void operator delete(void* pUserData)
{
    // ... 一些调试和线程安全处理 ...
    if (pUserData == NULL)
        return;
    // ... 获取内存块头部信息,验证块类型 ...
    _free_dbg(pUserData, pHead->nBlockUse);  // 最终调用 free
    // ... 释放锁 ...
}

// free 的宏定义
#define free(p) _free_dbg(p, _NORMAL_BLOCK)

operator delete 最终就是调 free。在 Debug 模式下,_free_dbg 会做一些额外的检查(比如验证内存块头部信息是否被篡改),Release 模式下基本就是直接 free。

new/delete 的完整执行流程

把上面这些串起来,newdelete 的完整流程是:

new A(10) 的执行过程:
  1. 调用 operator new(sizeof(A))  →  内部调 malloc 分配内存
  2. 在分配好的内存上调用 A 的构造函数 A(10)
  3. 返回指向该对象的指针

delete p 的执行过程(p 是 A* 类型):
  1. 调用 p 指向对象的析构函数 ~A()
  2. 调用 operator delete(p)  →  内部调 free 释放内存

new A[10] 的执行过程:
  1. 调用 operator new[](sizeof(A) * 10)  →  内部调 operator new 分配内存
  2. 在分配好的内存上调用 10 次 A 的构造函数
  3. 返回指向数组首元素的指针

delete[] p 的执行过程:
  1. 调用 10 次析构函数 ~A()
  2. 调用 operator delete[](p)  →  内部调 operator delete 释放内存

理解这个两层结构很重要。它意味着:你可以重载 operator new/delete 来定制内存分配策略,但构造函数和析构函数的调用是编译器保证的,你干预不了。很多高性能库(如游戏引擎、数据库)都会重载 operator new 来使用自己的内存池。

定位 new(Placement New):内存池的搭档

普通 new 做两件事:分配内存 + 构造对象。但有时候你已经有一块内存了,只想在上面构造对象,不需要再分配。这时候就需要定位 new(也叫 placement new)。

语法

new (place_address) type;
new (place_address) type(initializer-list);

place_address 必须是一个指针,指向一块已经分配好、大小足够的内存。

使用场景

定位 new 最典型的应用场景是配合内存池使用。内存池预先分配一大块内存,之后需要对象时直接从池中取,避免了频繁调用 malloc/free 的开销。

class A {
public:
    A(int a = 0) : _a(a) {
        cout << "A():" << this << endl;
    }
    ~A() {
        cout << "~A():" << this << endl;
    }
private:
    int _a;
};

int main()
{
    // 方式一:配合 malloc
    A* p1 = (A*)malloc(sizeof(A));  // 只分配了内存,还不是有效的 A 对象
    new(p1)A;                        // 在这块内存上调用构造函数,现在才是真正的 A 对象
    p1->~A();                        // 手动调用析构函数(清理资源)
    free(p1);                        // 释放内存

    // 方式二:配合 operator new
    A* p2 = (A*)operator new(sizeof(A));  // 分配内存
    new(p2)A(10);                          // 带参数的构造
    p2->~A();                              // 手动析构
    operator delete(p2);                   // 释放内存

    return 0;
}

这里有几个关键点:

  • mallocoperator new 出来的内存只是一块"原始内存",没有经过构造,不能当作对象来用
  • 定位 new 只调用构造函数,不分配内存
  • 定位 new 构造的对象必须手动调用析构函数,不能用 delete(因为 delete 会再次释放内存)
  • 析构函数可以显式调用 p->~A(),但构造函数不能显式调用,必须通过定位 new

实际项目中,STL 的 allocator、SGI STL 的内存池、以及各种高性能服务器框架(如 brpc、muduo)的内存管理,都大量使用了定位 new 的技术。

malloc/free vs new/delete:完整对比

最后用一张表把两者的区别总结清楚:

对比维度 malloc/free new/delete
本质 库函数 C++ 关键字/操作符
内存初始化 不初始化,内容随机 可以初始化,如 new int(10)
空间大小 手动计算:malloc(sizeof(int) * 10) 编译器自动推算:new int[10]
返回类型 void*,需要强转 对应类型的指针,无需强转
失败处理 返回 NULL,需要手动判空 抛出 std::bad_alloc 异常
自定义类型 只分配/释放内存,不调构造/析构 自动调用构造函数和析构函数
扩展性 无法重载 可以重载 operator new/delete 定制分配策略
数组处理 malloc/free 统一处理 new[]/delete[] 配对使用

一句话总结:对于内置类型,两者差别不大;对于自定义类型,new/delete 是必须的选择

实战中的内存管理建议

理解了原理,再聊聊实际开发中怎么用。

常见内存问题

内存泄漏。申请了忘了释放,或者指针被覆盖导致原来的内存找不到了。长期运行的服务端程序对这个问题特别敏感,一个小泄漏积累几天就能把进程搞崩。

野指针。free 之后没有把指针置 NULL,后续代码还继续用这个指针读写,结果完全不可预测。C++ 的智能指针能很大程度上解决这个问题,但 C 语言就得靠纪律了。

重复释放。同一块内存 free 了两次。在多线程环境下尤其容易触发,因为两个线程可能同时判断指针非 NULL 然后都去释放。

new/delete 不匹配newdelete[],或者 new[]delete,导致析构函数调用次数不对。

RAII 与智能指针

C++ 解决内存问题的终极武器是 RAII(Resource Acquisition Is Initialization)——把资源的生命周期绑定到对象的生命周期上。std::unique_ptrstd::shared_ptr 就是 RAII 思想的最佳实践:

#include <memory>

// unique_ptr:独占所有权,离开作用域自动释放
std::unique_ptr<A> p1(new A(10));
// 或者 C++14 起:
auto p2 = std::make_unique<A>(10);

// shared_ptr:共享所有权,引用计数归零时自动释放
auto p3 = std::make_shared<A>(20);

现代 C++ 的建议是:尽量用智能指针代替裸指针管理动态内存。但理解 new/delete 的底层机制依然重要——智能指针不是魔法,它底层调用的还是 operator new/delete,出了问题你还得能排查。

实际项目中的最佳实践

  1. 优先使用栈内存。栈上分配零开销,且自动管理生命周期。能用局部变量解决的,别用 new。
  2. new 和 delete 写在一起。如果必须手动管理,申请和释放的代码尽量靠近,减少忘记释放的概率。
  3. new(std::nothrow) 代替 try-catch。如果你不想处理异常,可以用 new(std::nothrow) 让 new 在失败时返回 NULL(和 malloc 行为一致),但这种方式不推荐作为常规做法。
  4. Valgrind 和 AddressSanitizer 是你的朋友。开发阶段定期用这些工具检查内存问题,别等线上出了问题再排查。
  5. 自定义 operator new 要谨慎。重载全局的 operator new/delete 会影响整个程序,建议只在特定类上重载成员版本的 operator new/delete。

内存管理是 C/C++ 程序员的内功。它不像设计模式那样"学了就能在代码里用上",但你对内存理解得越深,写出来的代码就越可靠、性能调优时就越有底气。希望这篇文章能帮你把内存管理的知识体系梳理清楚。有问题欢迎交流。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐