C/C++ 内存管理:从 malloc 到 new——掌控每一块内存

内存泄漏不是 bug,是失控。而失控,是绝不能容忍的。

为什么需要 C++ 的内存管理方式

C 程序员对 mallocfree 再熟悉不过——申请一块堆内存,用完释放,干净利落。这套机制在处理 intcharstruct 时完全够用。

直到遇到类。

malloc 只管划出一块原始内存。它不知道什么叫构造函数,也不会在释放时帮你调用析构函数。对 class 类型的对象,这意味着什么?用 malloc 创建的对象内部状态完全未初始化,指针成员指向垃圾;用 free 释放时,它持有的资源悄无声息地泄漏了。

C++ 引入 newdelete 就是为了解决这个缺口:把内存分配和对象生命周期绑定在一起new 不仅分配空间,还执行构造函数;delete 不仅释放空间,还先调用析构函数清理资源。这不是"更方便"的问题——这是确定性的问题。

在深入 new/delete 之前,先看清楚程序内存的全景图。

C/C++ 内存分布全景

一个运行中的 C/C++ 程序,其虚拟地址空间大致分为以下几个区域:

内存区域 存储内容 特点
栈(Stack) 非静态局部变量、函数参数、返回值 向下增长,编译器自动管理,生命周期随作用域
堆(Heap) 动态分配的内存(malloc/new) 向上增长,程序员手动管理
数据段(Data Segment) 全局变量、静态变量 编译期确定,程序整个生命周期存活
代码段(Code Segment) 可执行代码、只读常量(如字符串字面量) 只读,固定大小

💡 背景补充:C 语言中 static 修饰的局部变量不随函数返回而销毁——因为它实际存放在数据段,而非栈上。它的初始化只发生一次,之后的值跨越多次函数调用保持。

看一段经典的内存分布判断题:

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";            // 栈上的数组,内容从代码段拷贝而来
    const char* pChar3 = "abcd";      // 指针在栈上,指向代码段的字面量
    int* ptr1 = (int*)malloc(sizeof(int) * 4);
    int* ptr2 = (int*)calloc(4, sizeof(int));
    int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);
    free(ptr1);
    free(ptr3);
}

各变量归属逐一定位:

变量 所在区域 说明
globalVar 数据段 全局变量
staticGlobalVar 数据段 静态全局变量
staticVar 数据段 静态局部变量——虽在函数内,生命周期却是全局的
localVar 普通局部变量
num1 数组整体分配在栈上
char2 数组名是栈上的地址
*char2(首元素) 数组内容在栈上
pChar3 指针变量本身
*pChar3(指向内容) 代码段 字符串字面量 "abcd" 是只读常量
ptr1 指针变量本身在栈上
*ptr1(指向内容) malloc 分配的内存在堆上

一个容易混淆的点:char2 是数组,其内容(含 \0)全部在栈上;pChar3 指向的 "abcd" 在代码段中,是只读的。试图通过 pChar3[0] = 'X' 修改它——未定义行为。不要这样做。

C 语言动态内存管理回顾

malloccallocreallocfree 是 C 标准库提供的四个内存管理函数:

函数 行为 初始化
malloc(size) 分配 size 字节连续空间 不初始化(残留垃圾值)
calloc(n, size) 分配 n * size 字节,即 n 个元素 初始化为 0
realloc(ptr, new_size) 调整已分配空间大小 保留原有内容,新增部分未初始化
free(ptr) 释放之前分配的空间

一个 realloc 面试题:

int* p2 = (int*)calloc(4, sizeof(int));
int* p3 = (int*)realloc(p2, sizeof(int) * 10);
// 这里需要 free(p2) 吗?
free(p3);

答案:不需要realloc 扩容成功时会自动回收原内存(即使原地扩展,逻辑上旧内存也已失效),p2 此时是悬垂指针。只需 free(p3)。若 realloc 失败返回 NULL,原内存仍有效,此时仍需 free(p2)——但这是另一个分支了。

C 内存管理最大的问题不在函数本身,而在使用者。以下是野指针的三大成因:

// 1. 指针未初始化——指向随机地址
char* p;              // 野指针!值不可预测
char* p = nullptr;    // 正确做法

// 2. free/delete 后未置空——仍指向已释放内存
char* p = (char*)malloc(100);
free(p);
// p 仍存着那个地址,但那里已是垃圾
p = nullptr;          // 必须置空——这是我的规则

// 3. 指针超出变量作用域——指向已销毁对象
int* p;
{
    int a = 42;
    p = &a;           // a 的生命期仅在这个大括号内
}
// *p 是未定义行为——a 已经不存在了

⚠️ 野指针不是 NULL 指针。if (p != NULL) 对它毫无意义——它指向某个地址,只是那个地址的内容已经失控。这种隐藏的失败,是内存 bug 中最难追踪的一类。

C++ new/delete 机制

内置类型

对内置类型,new/deletemalloc/free 行为几乎一致——失败处理方式和语法除外:

#include <iostream>

int main()
{
    // 申请单个 int
    int* ptr4 = new int;           // 未初始化

    // 申请单个 int 并初始化为 10
    int* ptr5 = new int(10);       // 初始化为 10

    // 申请 10 个 int 的连续空间
    int* ptr6 = new int[3];        // 未初始化

    delete ptr4;
    delete ptr5;
    delete[] ptr6;                 // 数组释放必须用 delete[]
}

⚠️ newdeletenew[]delete[]——必须严格匹配。混用是未定义行为。这条规则没有例外。

自定义类型——这才是关键

对自定义类型,new/deletemalloc/free 的差异是本质性的:

#include <iostream>

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

private:
    int _a;
};

int main()
{
    // malloc/free —— 不开辟对象,只开辟内存
    A* p1 = (A*)malloc(sizeof(A));   // 没有输出 A()
    free(p1);                         // 没有输出 ~A()

    // new/delete —— 开辟内存 + 构造对象 + 析构对象
    A* p2 = new A(1);                 // 输出 A():地址
    delete p2;                        // 输出 ~A():地址

    // 数组版本——差异更明显
    A* p3 = (A*)malloc(sizeof(A) * 10);  // 静默——10 个未初始化的内存块
    free(p3);                             // 静默——资源泄漏,如果有的话

    A* p4 = new A[10];                    // 输出 10 行 A():地址
    delete[] p4;                           // 输出 10 行 ~A():地址

    return 0;
}

mallocfree 对构造/析构函数一无所知。它们只关心字节数。用 malloc 创建了一个持有文件句柄、网络连接、动态内存的对象——内部状态未定义,free 直接丢弃它,所有资源就此泄漏。它跑着跑着就会崩。

newdelete 做的是完整的生命周期管理:构造 → 使用 → 析构 → 释放。四步,缺一不可。

operator new 与 operator delete——底层机制

newdelete 是操作符,不是函数。但它们底层依赖两个系统提供的全局函数:operator newoperator delete

// operator new —— 底层是 malloc,但增加了异常处理
void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
    void *p;
    while ((p = malloc(size)) == 0)
        if (_callnewh(size) == 0)
        {
            // 申请失败 → 抛出 bad_alloc 异常,而非返回 NULL
            static const std::bad_alloc nomem;
            _RAISE(nomem);
        }
    return p;
}

// operator delete —— 底层是 free
void operator delete(void *pUserData)
{
    if (pUserData == NULL)
        return;
    _mlock(_HEAP_LOCK);              // 线程安全——加锁
    _free_dbg(pUserData, pHead->nBlockUse);
    _munlock(_HEAP_LOCK);            // 解锁
}

关键信息很清楚:operator new 内部调用 mallocoperator delete 内部调用 free。也就是说:

  • new = operator new(分配内存) + 构造函数(初始化对象)
  • delete = 析构函数(清理资源) + operator delete(释放内存)

💡 背景补充:malloc 失败时返回 NULL,调用者必须判空;operator new 失败时抛出 std::bad_alloc 异常。返回码 vs 异常——两种不同的错误处理哲学。C++ 选择了异常,因为构造失败而返回 NULL 意味着拿到一个"不存在的对象",后续任何操作都是灾难。

new/delete 实现原理——完整调用链

内置类型

  • new int → 调用 operator newmalloc 申请空间。失败则抛异常。
  • delete p → 调用 operator deletefree 释放空间。

基本就是 malloc/free 的封装,唯一区别是失败处理。

自定义类型

new T 的两步:

  1. 调用 operator new(sizeof(T)) 申请原始空间
  2. 在申请到的空间上执行构造函数

delete p 的两步:

  1. 在 p 指向的空间上执行析构函数,清理对象持有的资源
  2. 调用 operator delete(p) 释放原始空间

new T[N] 的两步:

  1. 调用 operator new[] 申请 N 个对象的连续空间(内部仍是调 operator new
  2. 在空间上依次执行 N 次构造函数

delete[] p 的两步:

  1. 在空间上依次执行 N 次析构函数
  2. 调用 operator delete[] 释放空间(内部调 operator delete

一条铁律:

A* p = new A[10];
delete[] p;    // ✅ 10 次析构 + 完整释放
delete p;      // ❌ 只析构 p[0],其余 9 个对象资源泄漏

编译器在 new A[10] 时可能额外记录数组长度(用于析构时确定调用次数)。delete 不读这个记录,只释放了第一个对象大小的空间。结果是未定义行为——而且是那种在简单测试中碰巧能跑、生产环境才爆的未定义行为。

placement new——在指定地址构造对象

有一种场景:已经有一块原始内存(malloc 出来的,或内存池分配的),想在它上面构造一个对象。

直接强转指针不行——强转只是类型系统层面的操作,构造函数没有被调用。这块内存还不是一个"对象"。

定位 new 表达式(placement new) 就是为此设计的:

#include <iostream>
#include <new>  // placement new 需要此头文件

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

private:
    int _a;
};

int main()
{
    // p1 只是一块大小够放 A 的内存,还不是一个对象
    A* p1 = (A*)malloc(sizeof(A));
    new(p1) A;          // placement new —— 在这块内存上调用构造函数
    p1->~A();           // 手动调用析构函数
    free(p1);           // 手动释放

    // operator new 版本(与 malloc 类似,但失败抛异常)
    A* p2 = (A*)operator new(sizeof(A));
    new(p2) A(10);      // placement new 带参数
    p2->~A();
    operator delete(p2);

    return 0;
}

语法:new (地址指针) 类型(初始化参数)。注意没有对应的 “placement delete”——析构函数需要手动调用,内存需要手动释放。

placement new 主要用在内配池场景:池子分配出未初始化的内存块,对象在块上就地构造,避免了 new 隐含的 malloc 开销。

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

维度 malloc/free new/delete
本质 C 库函数 C++ 操作符
返回类型 void*,需强制转型 类型安全,自动推导
空间大小 手动计算字节数 sizeof(T) 编译器根据类型自动计算
初始化 不初始化(calloc 除外) 支持原地初始化 new T(val)
构造/析构 不调用 自动调用
失败处理 返回 NULL,需手动判空 抛出 std::bad_alloc 异常
可否重载 不可 可重载 operator new/delete
配对释放 free(ptr) delete(单个) / delete[](数组)

最后一条再强调一遍:malloc/freenew/delete 不能混用。malloc 分配的内存绝不能用 delete 释放,new 分配的内存绝不能用 free 释放。交叉使用是未定义行为——它能编过,能跑,然后在某个不确定的时刻崩溃。

本节要点

  • C/C++ 内存分四区:栈(局部变量)、堆(动态分配)、数据段(全局/静态变量)、代码段(常量/指令)
  • new = 分配 + 构造,delete = 析构 + 释放——这是与 malloc/free 的本质区别
  • 自定义类型必须用 new/deletemalloc/free 不会调用构造和析构函数
  • new[]delete[] 必须配对,混用导致未定义行为
  • 野指针是内存 bug 的头号来源——free 后立即置 nullptr,不是建议,是规则
  • operator new 底层仍是 malloc,但失败时抛 bad_alloc 异常而非返回 NULL
  • placement new 在已有原始内存上构造对象,用于内存池等高性能场景

📖 参考:《高质量C++/C编程指南》第7章 —— 内存管理;《Effective C++》条款49-52 —— 定制 new 和 delete


Logo

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

更多推荐