C++ 内存管理:分区、自定义分配器、常见问题与检测工具

都说 C++ 内存管理难,难在哪?

Java 程序员:我有 GC。

Go 程序员:我也 GC,而且并发快。

C++ 程序员看了一眼代码,默默写了个 delete,然后忘了写 new。

对,这就是 C++ 内存管理最朴素的悲剧:我们亲手 new 出来的小可爱,最后没人 delete。

所以今天我想聊聊 C++ 中内存管理的问题。

内存基础

我们先看看 C++ 的内存分区以及栈和堆这两玩意。

1. 内存分区

C++ 程序运行时的内存,就像一套'豪华大别野'(`д´):

  • 代码区:只读,放 CPU 要执行的机器指令。谁敢乱改?程序直接崩溃。
  • 数据区:放全局变量、静态变量,这些家伙从程序启动活到程序结束。
  • 栈区:函数调用时,局部变量、函数参数放上去用,用完自动清理。非常快,但地方小,放太多就容易栈溢出。
  • 堆区:我们需要大块内存、或者对象要活过函数返回,就去堆上申请(new / malloc)。自己申请的,用完必须亲手 delete / free,否则就会内存泄漏。

另外还有常量区(只读数据段),比如字符串字面量 "hello" 放那儿。

你敢改它?未定义行为,大概率崩溃。

放一张流程图:

2. 栈 vs 堆

对比项
管理方式 编译器自动压栈/弹栈,我们只管声明变量 手动申请(new)、手动释放(delete)
分配速度 极快------移动一下栈指针,比眨眼还快 慢------需要找空闲内存块,可能触发系统调用
大小限制 很小(看编译器/平台) 大(比如 2 GB 在 32 位下,更大在 64 位)
生命周期 随作用域自动结束 从 new 到 delete,跨函数存活
碎片问题 无碎片(LIFO 完美整齐) 有外部碎片(申请释放顺序乱,产生空洞)
出错后果 栈溢出(递归忘终止、超大局部数组) 内存泄漏、野指针、double free

举个栗子:

c++ 复制代码
void fun() 
{
    int a = 21; // 栈上:a 在函数结束时自动消失
    int* p = new int(100); // 堆上:分配一个 int,p 本身在栈上
    // ... 忘了 delete p;
} // 这里 p 被销毁,但堆上的 int(100) 永远没人管,然后内存泄漏

3. 什么时候用堆?

  • 对象体积很大
  • 对象需要比创建它的函数活得更久(比如工厂函数返回一个对象的指针)。
  • 需要动态大小(比如输入决定数组长度,栈上不能放变长数组,堆上可以 new int[n])。
  • 需要多态(基类指针指向派生类对象,通常放堆上,配合智能指针)。

但记住:能用栈就别用堆。栈又快又安全,自动析构,不会泄漏。

堆每次使用都需要亲手释放。

4. 现代 C++ 的救赎:智能指针

能不写 new / delete 就别写。

用 std::unique_ptr 和 std::shared_ptr,它们遵循 RAII(资源获取即初始化)。

c++ 复制代码
void good() 
{
    auto p = std::make_unique<int>(21);
    // 不用 delete,函数结束自动释放堆内存
}

堆还是那个堆,但我们再也不用自己扫地了。

自定义内存管理

默认的 new/delete 虽然大多时候够用了,但我们要是遇到高频创建小对象、实时系统、或者需要把对象放在共享内存里,那就得自己动手搓了。

1. 重载 operator new / operator delete

注意一下:我们重载的是 分配/释放内存 的底层函数(operator new),而不是 new 表达式(它先调 operator new 再调构造函数)。

全局重载(不推荐,容易冲突)

c++ 复制代码
void* operator new(size_t size) 
{
    std::cout << "Global new: " << size << " bytes" << std::endl;
    void* p = malloc(size);
    if (!p) throw std::bad_alloc();
    return p;
}

void operator delete(void* p) noexcept 
{
    std::cout << "Global delete" << std::endl;
    free(p);
}

所有地方(包括标准库)都会用我们这个版本,容易出诡异 bug。

一般只在特定调试或内存追踪工具里用。

类专属重载(常见做法)

c++ 复制代码
class Widget 
{
public:
    static void* operator new(size_t size) 
    {
        std::cout << "Widget new" << std::endl;
        return ::operator new(size); // 仍用全局分配
    }
    
    static void operator delete(void* ptr) 
    {
        std::cout << "Widget delete" << std::endl;
        ::operator delete(ptr);
    }
};

我们想统计某个类的分配次数、对齐到特殊边界、或者从内存池里分配,都可以用这种方式。

当然,别以为重载了 new 就万事大吉,new[] / delete[] 也要重载,否则可能不对称。

而且 size 参数有时候比 sizeof(Widget) 大(因为数组需要额外记录元素个数),我们忘了处理就会崩。

2. 定位 new

假如我们已经有一块内存(栈上、堆上、共享内存、内存池里),想在上面构造对象。

这时候普通 new 不行,它总是自己分配内存。定位 new 就是答案。

c++ 复制代码
#include <new>

int main()
{
    char buffer[sizeof(Widget)]; // 栈上原始内存
    Widget* pw = new (buffer) Widget(); // 在 buffer 上构造 Widget

    /* 使用 pw... */

    pw->~Widget(); // 必须显式析构,否则资源泄漏
}

buffer 不需要 delete,因为是栈上的。

经典使用场景:

  • 内存池:从池子里拿一块空闲内存,用 placement new 构造对象。
  • 共享内存:进程间通信,把对象构造在映射的共享内存区域上。
  • 避免重复分配:比如容器预留容量后,在已分配的内存上构造新元素。

三大铁律:

  1. 定位 new 不分配内存,只调用构造函数。
  2. 必须手动调用析构函数(pw->~Widget()),否则对象内部可能泄漏资源(比如它自己又 new 了堆内存)。
  3. 不能直接用 delete pw,因为 delete 会尝试释放那块内存,而那块内存可能不在堆上。
    正确做法:先析构,再根据原始内存的来源释放(如果是堆上 malloc 的,就 free)。

3. 显式析构:和定位 new 是黄金搭档

显式调用析构函数是 C++ 为数不多的"允许我们手动调用像 . 操作符一样的东西"。

语法很简单:ptr->~T()。

为什么需要:普通栈对象离开作用域自动析构;堆对象 delete 时先析构再释放内存。

但定位 new 构造的对象,编译器不会自动调用析构,必须我们亲自来。

示例(结合内存池简单雏形):

c++ 复制代码
int main()
{
    int i = 10;
    char* pool = static_cast<char*>(malloc(1000 * sizeof(Widget)));
    Widget* pw = new (pool + i * sizeof(Widget)) Widget();
    
    /* ... 使用 */
    
    pw->~Widget(); // 析构
    // 最后别忘了释放整个 pool: free(pool);
}

显式析构之后,那块内存上不再有活动对象,但内存本身仍然有效(可以再次用构造新对象)。

这叫内存重用

4. 自定义分配器

因为标准库容器支持分配器参数。

所以我们可以写一个分配器类,提供 allocate、deallocate、construct、destroy 等接口。

然后 std::vector<int, MyAllocator<int>> v; 就会用我们的策略分配内存。

一个极简固定大小内存池分配器(示意,只讲思路):

c++ 复制代码
template<typename T>
class PoolAllocator 
{
    // 内部维护一个链表:每个空闲块指向下一个
    // allocate: 从链表头部取一个块,返回指针
    // deallocate: 把块插回链表头部
};

为什么要自己写分配器:

  • 避免频繁调用系统 malloc。
  • 减少内存碎片。
  • 满足特定对齐要求。

我们要是想写一个符合标准库要求的分配器非常繁琐。

C++17 之后可以用 std::pmr::memory_resource 作为更现代的接口,但底层原理还是类似。

感兴趣的可以了解一下,绝不是因为我懒。

5. 内存池

核心思想:一次性向系统申请一大块内存(比如 1 MB),然后自己用小尺子切成等长或不等长的块,快速分配/释放。

释放时不是还给系统,而是放回池子的空闲链表。

实现思路

  • 固定大小池:适合同一类对象(如游戏中的子弹、粒子)。每个块大小相同,用单向链表串起空闲块。分配:取头结点;释放:头插。
  • 变长池:复杂,需要处理合并相邻空闲块,一般直接用现成的库(如 tcmalloc、jemalloc)或者 boost::pool。

它的优点:极快(O(1) 分配/释放),无外碎片(固定大小池),减少系统调用。

缺点:内碎片(如果对象小于块大小,浪费空间);自己管理内存生命周期,容易踩坑。

简单实现(固定大小对象池):

c++ 复制代码
class ObjectPool 
{
    struct Block { Block* next; };
    Block* freeList = nullptr;
    char* pool = nullptr;
    size_t blockSize, capacity;

public:
    ObjectPool(size_t size, size_t count)
        : blockSize(size), capacity(count) 
    {
        pool = static_cast<char*>(malloc(size * count));
        // 初始化空闲链表
        for (size_t i = 0; i < count; ++i) 
        {
            Block* b = reinterpret_cast<Block*>(pool + i * size);
            b->next = freeList;
            freeList = b;
        }
    }

    void* allocate() 
    {
        if (!freeList) return nullptr;
        void* p = freeList;
        freeList = freeList->next;
        return p;
    }

    void deallocate(void* p) 
    {
        Block* b = static_cast<Block*>(p);
        b->next = freeList;
        freeList = b;
    }

    ~ObjectPool() { free(pool); }
};

我们可以在 allocate 返回的内存上构造对象,用完先析构再 deallocate。

6. 总结一下

技术 场景
重载 operator new 调试统计、对齐控制等
定位 new + 显式析构 内存池、共享内存、避免默认构造函数
自定义分配器 高性能容器、特殊内存区域(如 GPU 内存)
内存池 游戏/高频小对象分配,且测量证明 malloc 是瓶颈

我们还是先写清晰、安全的代码,用 std::vector、智能指针、RAII。

不要一上来就自定义内存管理。

等我们的 operator new 占了 30% 的时间,或者我们需要在共享内存里放一个哈希表,再来自己实现这些玩意。

现代 C++ 是给了我们这些工具,但也给了我们足够的绳索把自己吊起来。

所以用好 RAII 和智能指针,比什么都强。

常见内存问题与检测工具

有时候我们写的 C++ 程序跑起来像喝醉了一样:偶尔崩溃、偶尔输出乱码、偶尔吃掉所有内存。

导致我们一脸懵,想知道到底哪儿出事了。

所以我把常见的内存问题分成五大恶人,每个都有自己的作案手法和痕迹。

然后推荐几件工具,帮你快速定位。

1. 内存泄漏

症状:程序运行越久越卡,内存占用持续上升,最后被系统 OOM Killer(Out Of Memory killer) 干掉。

或者我们写了个服务器,跑了三天后 new 抛出 std::bad_alloc。

典型示例:

c++ 复制代码
void leaky() 
{
    int* p = new int[1000];
    // 忘了 delete[] p;
} // p 离开作用域,但堆上那点东西永远没人管

检测工具:

  1. Valgrind (memcheck):经典工具,运行我们的程序,退出时报告哪些内存没释放。慢(程序慢 20 倍),但可靠。

假设我们有一个程序叫 test,使用 Valgrind 时可以这么用:

bash 复制代码
valgrind --leak-check=yes test

--leak-check选项会开启详细的内存泄漏检测。

我就作简单介绍,其它的命令自己探索吧(・ε・)。

  1. AddressSanitizer(ASan):编译器插桩(Clang/GCC 加 -fsanitize=address),运行时检测,更快(慢 2 倍),而且能同时抓很多其他问题。

使用 Clang 示例:

bash 复制代码
clang -fsanitize=address -g test.c -o test

使用 GCC 示例:

bash 复制代码
gcc -fsanitize=address -g test.c -o test
  • -fsanitize=address:启用ASan检测
  • -g:生成调试符号

2. 悬垂指针

症状:程序偶发崩溃,崩溃的地方看起来不可能(比如访问一个已经释放的对象)。

有时能运行,有时崩,多线程下尤其邪门。

典型示例:

c++ 复制代码
int* dangling() 
{
    int x = 21;
    return &x; // 返回局部变量地址
} // x 已销毁,外部拿到一个指向过去的指针

void use() 
{
    int* p = dangling();
    *p = 100; // 未定义行为:可能崩,可能改掉别的变量,可能没反应
}

另一个更隐蔽的:

c++ 复制代码
int* p = new int(10);
int* q = p;
delete p;
*q = 20; // q 现在是悬垂指针

检测工具:

  • ASan:释放后将内存标记为已释放,下次访问立即报错(use-after-free)。
  • Valgrind:同样能检测对已释放内存的读写。

我们释放指针后应该立刻置为 nullptr(虽然不能完全解决问题,但能避免二次释放)。

或者用智能指针,让生命周期自动管理。

3. 缓冲区溢出

症状:程序崩溃,崩溃点离写入代码很远,因为溢出破坏的是相邻内存的控制信息(比如堆块头、栈上的返回地址)。

有时候表现为奇怪的逻辑错误或安全漏洞。

典型示例:

c++ 复制代码
// 栈溢出
void stack_overflow() 
{
    char buf[4];
    strcpy(buf, "hello"); // 写了 6 个字符(包括结尾 '\0'),buf 只有 4
    // 破坏栈上相邻的变量或返回地址
}

// 堆溢出
void heap_overflow() 
{
    int* arr = new int[10];
    arr[10] = 21; // 越界,破坏堆元数据
    delete[] arr;
}

检测工具:

  • ASan:在栈和堆的缓冲区周围放红区(poisoned memory),访问到就报错。
  • Valgrind:也能检测越界,但比 ASan 慢。
  • Compiler 选项:-D_FORTIFY_SOURCE=2(GCC)在编译时对一些 strcpy 等函数加边界检查。

4. 重复释放

症状:程序崩溃在 delete 或 free 内部,报错类似 "double free or corruption"。

典型示例:

c++ 复制代码
int* p = new int(21);
delete p;
delete p; // 第二次释放同一块内存

或者两个指针指向同一块堆内存,各自释放:

c++ 复制代码
int* p = new int(21);
int* q = p;
delete p;
delete q; // double free

检测工具:

  • ASan:分配内存时记录,释放后标记,第二次释放立即报错。
  • Valgrind:同样能检测到。
  • Debug 堆(Windows):会触发断点。

5. 内存碎片

症状:程序运行一段时间后,new 一个大对象失败(抛出 bad_alloc),但 mallinfo() 或任务管理器显示还有大量空闲内存。

或者性能逐渐下降。

我们分成两类:

  • 外部碎片:空闲内存被分割成许多小片,没有一块足够大的连续空间。
  • 内部碎片:分配的内存块比实际需要的大(比如固定大小内存池,对象只有 8 字节,块大小 16 字节,浪费 8 字节)。

典型示例:

c++ 复制代码
// 不断交错分配不同大小的对象
for (int i = 0; i < 100000; ++i) 
{
    int* p1 = new int[1]; // 4 字节
    int* p2 = new int[100]; // 400 字节
    delete p1; // 留下 4 字节空洞
}
// 多次后,空闲内存全是 4 字节的洞,没法分配 400 字节连续块

检测工具:

  • 没有直接'碎片检测'的现成工具,但可以用自定义分配器记录分配大小分布,或者用 malloc_info / mallinfo(Linux)看空闲块数量。
  • Heapprof(Google 工具)能可视化堆布局。
  • Valgrind 的 massif 可以生成堆使用时间线图,看出碎片趋势。

缓解方法:

  • 使用内存池(固定大小)消除外部碎片。
  • 避免频繁分配不同大小的小对象,用 std::vector 代替大量独立 new。

6. 最后想说的

内存问题排查起来很痛苦,但请记住:

  1. RAII + 智能指针:让析构自动发生,消灭大多数泄漏和悬垂。
  2. 用容器代替裸数组:std::vector、std::array、std::string 自带边界检查和安全生命周期。
  3. 善用 ASan 能让我们多点摸鱼时间。

结尾

今天不想写结尾,完~

相关推荐
-许平安-2 小时前
MCP项目笔记九(插件 bacio-quote)
c++·笔记·ai·plugin·mcp
沉鱼.442 小时前
第十三届题目
c语言·c++·算法
liulilittle3 小时前
C++ 无锁编程:单停多发送场景高性能方案
服务器·开发语言·c++·高性能·无锁·原子
无限进步_3 小时前
【C++】巧用静态变量与构造函数:一种非常规的求和实现
开发语言·c++·git·算法·leetcode·github·visual studio
小超超爱学习99373 小时前
大数乘法,超级简单模板
开发语言·c++·算法
xyx-3v5 小时前
qt创建新工程
开发语言·c++·qt
样例过了就是过了5 小时前
LeetCode热题100 爬楼梯
c++·算法·leetcode·动态规划
少司府5 小时前
C++基础入门:类和对象(中)
c语言·开发语言·c++·类和对象·运算符重载·默认成员函数
王老师青少年编程5 小时前
csp信奥赛c++之状压枚举
数据结构·c++·算法·csp·信奥赛·csp-s·状压枚举