10_C++内存分区与动态内存管理

C++内存分区与动态内存管理

一、程序的内存布局

程序要运行,必须先加载进内存。一个 C++ 程序在操作系统上运行时,它的虚拟地址空间(Virtual Address Space)会被划分为若干个逻辑区域,每个区域承载不同种类的数据,拥有不同的生命周期和访问权限。

理解这些区域,是写出健壮、高效 C++ 代码的前提。

1.1 分区概览

C++ 程序在运行时的内存布局大致划分为四个区域:

区域 存放内容 管理方式
代码区(Code Segment / Text) 函数体的二进制机器指令 操作系统负责,只读共享
全局区(Data Segment + BSS) 全局变量、静态变量、常量 程序启动时分配,结束时回收
栈区(Stack) 局部变量、函数参数、返回地址 编译器自动分配与释放
堆区(Heap) 程序员按需分配的数据 程序员手动分配与释放

很多人也会把代码区和全局区统称为"静态存储区",但实际在操作系统中它们是两个不同的段(.text 和 .data/.bss)。

1.2 为什么需要对内存进行分区?

把内存划分为不同的区域,核心驱动力是生命周期管理权限控制

  • 有些数据需要在程序的整个运行期间存在(如全局变量);
  • 有些数据仅在函数调用期间有效(如局部变量);
  • 有些数据需要在运行时动态决定大小(如可变长度的数组);
  • 代码指令不允许被程序自身修改(防意外篡改)。

通过分区,操作系统和编译器可以用最经济的方式管理每种数据的生命周期、访问权限和释放时机。


二、程序运行前的内存布局

一个 .exe 或可执行文件在磁盘上就已经被编译链接好了。程序被加载进内存但尚未执行之前,内存中已经存在两个区域:代码区全局区

2.1 代码区(Code Area)

代码区存放的是 CPU 要执行的机器指令------也就是所有函数被编译后生成的二进制代码。

两个关键特征:

  • 只读(Read-Only):代码区的数据不允许被程序修改。如果某个程序试图在运行时修改自己的指令,操作系统会向该进程发送 SIGSEGV(段错误)信号。这是为了安全性:防止程序因 bug 或恶意攻击改写指令流。
  • 共享(Shared):如果你同时运行同一个程序的两个实例(比如开了两个一样的计算器),操作系统会让这两个进程共享内存中同一份代码区的物理页面。这样可以显著节省物理内存。

从编写 C++ 代码的角度,所有函数(包括 main 函数)最终都在代码区占据一席之地。但你应该知道:内联函数(inline functions) 在经过编译器优化后,其指令可能被直接插入到调用处,从而不一定在代码区占据独立的函数体。

2.2 全局区(Global Area)

全局区存放的数据在程序的整个生命周期中一直存在,直到程序退出时由操作系统统一释放。它内部又可分为两个子区:

已初始化数据段(.data):存放已经初始化的全局变量和静态变量。例如:

cpp 复制代码
int global_x = 100;          // 已初始化的全局变量 → .data
static int file_scope = 200; // 已初始化的静态变量 → .data

未初始化数据段(.bss):存放未初始化或初始化为 0 的全局变量和静态变量。操作系统在加载程序时会把这个区域全部置为 0。

cpp 复制代码
int global_y;                // 未初始化的全局变量 → .bss(自动置 0)
static int file_scope_z;    // 未初始化的静态变量 → .bss(自动置 0)

常量区(.rodata,属于全局区的一部分) :存放字符串常量和其他 const 修饰的全局常量。注意区分------全局 const 在常量区,而局部 const 在栈上。

cpp 复制代码
const int GLOBAL_CONST = 5;  // 全局 const → .rodata

2.3 内存地址对比实验

下面我们通过一个代码示例,直观地观察不同类型变量的内存分布:

cpp 复制代码
#include <iostream>
using namespace std;

// 全局变量(已初始化)
int g_a = 10;
int g_b = 10;

// 全局常量(const 修饰)
const int c_g_a = 10;

// 全局静态变量
static int s_global = 10;

int main() {
    // 局部变量(栈区)
    int l_a = 10;
    int l_b = 10;

    // 局部常量(栈区)
    const int c_l_a = 10;

    // 静态局部变量(全局区,而不是栈区)
    static int s_local = 10;

    cout << "===== 地址对比(以十六进制显示)=====" << endl;
    cout << "局部变量 l_a:    " << &l_a << "  <- 栈区" << endl;
    cout << "局部变量 l_b:    " << &l_b << "  <- 栈区" << endl;
    cout << "局部常量 c_l_a:  " << &c_l_a << "  <- 栈区" << endl;

    cout << "全局变量 g_a:    " << &g_a << "  <- 全局区" << endl;
    cout << "全局变量 g_b:    " << &g_b << "  <- 全局区" << endl;
    cout << "全局常量 c_g_a:  " << &c_g_a << "  <- 常量区(只读)" << endl;
    cout << "静态全局 s_global:" << &s_global << "  <- 全局区" << endl;
    cout << "静态局部 s_local: " << &s_local << "  <- 全局区" << endl;

    cout << "字符串常量:      " << &"hello world" << "  <- 常量区" << endl;

    return 0;
}

运行后你会发现:所有全局区变量的地址相对集中在一个较"低"的地址范围,而栈区变量则聚集在另一个范围。这是因为在虚拟地址空间中,全局区与栈区位于两个不同的段。

注意:实际输出地址因平台和编译器而异,但同一类变量的地址相对聚集这一规律是通用的。

2.4 关键区分:作用域 vs 生命周期

初学阶段最容易混淆的概念是作用域生命周期

  • 作用域(Scope) :变量在代码中可以被访问的范围。例如 {} 包围的区域。
  • 生命周期(Lifetime):变量在内存中实际存在的时长。

全局作用域中的变量,生命周期是整个程序运行期,这很容易理解。但值得注意的特例是 static 局部变量:

cpp 复制代码
void func() {
    static int count = 0;  // 虽然作用域仅限于 func() 内部
    count++;               // 但它在内存(全局区)中一直存活
    cout << count << endl;
}

int main() {
    func();  // 输出 1
    func();  // 输出 2
    func();  // 输出 3
    return 0;
}

count 的作用域只在函数 func() 内部,但它的生命周期贯穿整个程序。这是因为 static 关键字将它的存储位置从栈迁移到了全局区,只初始化一次,之后每次都沿用上次的值。


三、程序运行后的内存变化

当程序开始执行 main 函数后,栈区和堆区被激活。这两个区域的管理方式和生命周期截然不同,是 C++ 内存管理中最需要小心的地方。

3.1 栈区(Stack Area)

栈由编译器自动管理。每次函数被调用,系统会为这个函数在栈上分配一块连续的内存区域(称为"栈帧"或"栈空间"),用于存放函数的局部变量、参数和返回地址。函数调用结束时,这块空间自动释放。

栈的特点:

  • 自动分配和释放:不需要程序员干预,也不建议干预。
  • 连续地址,朝低地址方向增长:新调用的函数栈帧地址比调用它的函数"更低"。
  • 容量有限:栈的大小在程序启动时就已经确定。在 Windows 上默认约为 1MB,Linux 默认约为 8MB(可通过编译器选项修改)。
  • 高效:分配和释放只是一个栈顶指针的移动,代价极低。

绝对不能做的事:返回局部变量的地址

cpp 复制代码
#include <iostream>
using namespace std;

// 危险操作:返回局部变量的地址
int* dangerous_func() {
    int local_val = 42;        // local_val 在栈上
    return &local_val;         // 函数返回后,栈空间被回收
}                              // local_val 已经"不复存在"

int main() {
    int* p = dangerous_func();
    // p 指向的是一块已经被释放的栈内存(悬空指针/dangling pointer)
    cout << *p << endl;        // 未定义行为:可能打印 42,也可能打印垃圾值
    cout << *p << endl;        // 两行结果甚至可能不同
    return 0;
}

第一次调用 cout << *p 时,栈空间可能还没有被新数据覆盖,侥幸能读到原来的 42;第二次调用时可能因为 cout 内部函数调用"覆盖"了那个位置,结果就变成了垃圾值。这就是典型的未定义行为(Undefined Behavior)

正确的做法有两种:

  1. 通过返回值传值(而不是传地址):
cpp 复制代码
int safe_func() {
    int local_val = 42;
    return local_val;  // 返回值的副本,安全
}
  1. 如果一定要返回地址,使用堆区。

3.2 堆区(Heap Area)

堆是程序的一块自由存储区,所有动态分配的内存都来自这里。与栈不同,堆上没有自动管理------程序员用 new 申请内存,就必须用 delete 释放;用 malloc 申请,就必须用 free 释放。

堆的特点:

  • 手动管理:分配和释放完全由程序员控制。
  • 地址不连续:堆上的空闲块以某种数据结构(如空闲链表)组织,多次分配释放后可能产生碎片。
  • 容量远大于栈:取决于系统的物理内存和虚拟地址空间上限。
  • 效率低于栈:分配时需要搜索合适大小的空闲块,释放后可能需要合并相邻空闲块。

栈和堆的地址分布差异:

cpp 复制代码
#include <iostream>
using namespace std;

int main() {
    int stack_var = 0;
    int* heap_var = new int(0);

    cout << "栈上变量地址:" << &stack_var << endl;
    cout << "堆上变量地址:" << heap_var  << endl;

    delete heap_var;
    return 0;
}

通常你会发现栈变量地址在较高的虚拟地址区域,而堆变量地址在较低的区域------这是因为在大多数操作系统的虚拟地址空间布局中,栈从高地址向低地址增长,而堆从低地址向高地址增长,两者相向而行。

3.3 栈 vs 堆:全方位对比

对比维度 栈(Stack) 堆(Heap)
管理方式 编译器自动管理 程序员手动管理
分配效率 极快(移动栈顶指针) 较慢(查找可用块)
空间大小 很小(MB 级别) 很大(GB 级别)
生命周期 随函数调用结束而结束 一直存在直到被释放
碎片问题 无(连续分配、连续释放) 可能有内存碎片
典型错误 返回栈地址(悬空指针) 忘记释放(内存泄漏)
适合场景 小数据、临时数据 大数据、跨函数的数据

四、C 风格动态内存管理:malloc / free

在正式讲解 C++ 的 newdelete 之前,有必要了解 C 语言遗留下来的动态内存管理方式。虽然 C++ 中推荐使用 new/delete,但在很多遗留代码和底层库中仍然广泛使用 malloc/free

malloc 是 C 标准库 <cstdlib>(或 C 的 <stdlib.h>)中的函数,原型如下:

cpp 复制代码
void* malloc(size_t size);
  • 向堆申请 size 个字节的连续内存空间。
  • 返回 void* 类型指针,使用时需要强制转换为目标类型。
  • 如果分配失败,返回 NULL
  • malloc 只分配内存,不初始化内存。分配出的空间中内容是不确定的(垃圾值)。

对应的释放函数是 free

cpp 复制代码
void free(void* ptr);

使用示例:

cpp 复制代码
#include <iostream>
#include <cstdlib>  // 包含 malloc / free
using namespace std;

int main() {
    // 分配一个 int 大小的内存块
    int* p = (int*)malloc(sizeof(int));
    if (p == nullptr) {     // 检查分配是否成功
        cerr << "内存分配失败" << endl;
        return 1;
    }

    *p = 42;                // 写入数据
    cout << *p << endl;

    free(p);                // 释放内存
    // p = nullptr;         // 建议将指针置空,避免野指针
    return 0;
}

分配数组:

cpp 复制代码
#include <iostream>
#include <cstdlib>
using namespace std;

int main() {
    int n = 10;
    int* arr = (int*)malloc(n * sizeof(int));
    if (arr == nullptr) {
        cerr << "内存分配失败" << endl;
        return 1;
    }

    // malloc 分配的内存未被初始化,内容随机
    for (int i = 0; i < n; i++) {
        arr[i] = i * 2;     // 手动赋值
    }

    for (int i = 0; i < n; i++) {
        cout << arr[i] << " ";
    }
    cout << endl;

    free(arr);               // 释放数组
    // arr = nullptr;
    return 0;
}

callocmalloc 的变种------它会把分配的内存全部置零:

cpp 复制代码
int* arr = (int*)calloc(n, sizeof(int));  // 等同于 malloc + 清零

realloc 用于调整已分配内存的大小:

cpp 复制代码
arr = (int*)realloc(arr, new_size * sizeof(int));
// 注意:realloc 可能移动内存块,从而改变地址

使用 malloc/free 时的注意事项:

  1. free 只能释放由 malloc/calloc/realloc 分配的内存。释放栈上的指针或同一地址重复释放,都会导致未定义行为。
  2. 释放后,指针仍然保留原来的地址值(即野指针)。释放后 立即 将指针置为 nullptr 是一个好习惯。
  3. malloc(0) 的行为是实现定义的,可能返回 NULL,也可能返回一个非 NULL 却不能解引用的指针;尽量不要这样写。
  4. malloc 分配的是未初始化的内存。这意味着如果你读取一个 intdouble,读到的值是任意的。所以务必在使用前初始化。

五、C++ 动态内存管理:new / delete

C++ 在保留 malloc/free 的同时提供了自己的动态内存管理操作符:newdelete

5.1 new / delete 的基本用法

分配单个对象:

cpp 复制代码
int* p = new int;       // 分配内存,但未初始化(值不确定)
int* q = new int();     // 分配内存,并初始化为 0
int* r = new int(42);   // 分配内存,并初始化为 42

// 使用
cout << *r << endl;     // 42

// 释放
delete r;
delete q;
delete p;

分配对象数组:

cpp 复制代码
int* arr = new int[10];       // 分配 10 个 int 的连续空间,值不确定
int* arr2 = new int[10]();    // 分配并全部初始化为 0
int* arr3 = new int[10]{1, 2, 3, 4, 5};  // 支持初始化列表
// 剩余元素自动初始化为 0

// 释放数组:注意 delete[](中括号不能省略)
delete[] arr;
delete[] arr2;
delete[] arr3;

5.2 new 和 malloc 的本质差异

newmalloc 看起来都是在堆上分配内存,但它们的差异远不止语法风格:

对比项 malloc new
语言属性 C 标准库函数 C++ 运算符(关键字)
内存分配 分配原始字节 分配内存 + 调用构造函数
初始化 不初始化(垃圾值) 可以初始化为指定值
失败行为 返回 NULL 抛出 std::bad_alloc 异常
大小计算 手动计算 sizeof(T) * n 编译器自动推导
类型安全 需要手动类型转换(void*T* 类型安全,返回正确类型的指针
重载 不可重载 可重载(了解即可)
释放 free delete / delete[]

最关键的区别 在于 new 会调用构造函数------这是 C++ 面向对象编程的基础。

关于分配失败的情况: new 在默认情况下如果分配失败,不会返回 NULL,而是抛出 std::bad_alloc 异常:

cpp 复制代码
#include <iostream>
using namespace std;

int main() {
    try {
        // 尝试分配超大量的内存,导致失败
        int* huge = new int[1000000000000];
    } catch (const bad_alloc& e) {
        cerr << "内存分配失败:" << e.what() << endl;
    }
    return 0;
}

如果你希望 newmalloc 那样返回 NULL 而不是抛异常,可以使用 nothrow 版本:

cpp 复制代码
int* p = new (nothrow) int[1000000000000];
if (p == nullptr) {
    cerr << "内存分配失败" << endl;
}

5.3 申请和释放必须匹配

一个核心规则:newdeletenew[]delete[]mallocfree 不要混用。

cpp 复制代码
int* a = new int;
delete a;        // 正确

int* b = new int[10];
delete b;        // 错误!未定义行为
delete[] b;      // 正确

int* c = (int*)malloc(sizeof(int) * 10);
free(c);         // 正确
delete[] c;      // 错误!未定义行为

为什么混用会出问题?因为 new[] 分配数组时,编译器通常会在数组头部额外记录数组的长度信息,以便对每个元素正确调用析构函数。用 delete(而不是 delete[])释放数组时,编译器不知道这是数组,不会读取这条长度信息,可能会导致析构次数错误或少释放内存。

5.4 new / delete 完整示例

cpp 复制代码
#include <iostream>
using namespace std;

struct Point {
    int x;
    int y;
};

int main() {
    // 1. 分配单一变量
    int* score = new int(95);
    cout << "score = " << *score << endl;
    delete score;

    // 2. 分配数组
    int* arr = new int[5]{10, 20, 30, 40, 50};
    for (int i = 0; i < 5; i++) {
        cout << arr[i] << " ";
    }
    cout << endl;
    delete[] arr;

    // 3. 分配结构体对象
    Point* p = new Point{100, 200};
    cout << "Point: (" << p->x << ", " << p->y << ")" << endl;
    delete p;

    return 0;
}

六、动态内存管理中的常见陷阱

理解"怎么用"只是第一步,更关键的是理解"哪里容易出问题"。

6.1 内存泄漏(Memory Leak)

内存泄漏是指动态分配的内存一直未被释放,导致程序占用的内存不断增长,最终可能耗尽系统资源。

cpp 复制代码
void leak_example() {
    int* p = new int(10);
    // 忘了 delete p;
    // 函数返回后,指针 p 被销毁,但我们再也找不到这块内存了
    // 这块内存直到程序结束才会被操作系统回收
}

int main() {
    for (int i = 0; i < 1000000; i++) {
        leak_example();  // 每次调用都泄漏一个 int 大小的内存
    }
    // 程序内存持续增长
    return 0;
}

在服务器程序、GUI 程序等长时间运行的程序中,内存泄漏是致命的。每泄漏一点,日积月累就会导致程序崩溃。

如何避免:

  • 养成 newdelete 成对书写的习惯。
  • 在复杂逻辑中,使用 RAII 思想------将资源绑定到栈上对象的生命周期上。
  • 可以在编写时先写 delete,再在 newdelete 之间写业务逻辑。

6.2 悬空指针(Dangling Pointer)

悬空指针是指向已释放内存的指针。解引用悬空指针是未定义行为。

cpp 复制代码
int main() {
    int* p = new int(100);
    delete p;          // 释放 p 指向的内存

    // p 现在是悬空指针------它仍然保存着原来的地址,但该地址已不属于我们
    *p = 200;          // 未定义行为:访问已释放的内存
                       // 可能正常写入,可能崩溃,可能损坏其他数据

    // 更危险的是:
    int* q = new int(300);
    // 如果 p 的地址恰好被重新分配给 q...那么 *p = 200 就悄悄篡改了 q 的数据
    return 0;
}

如何避免:

  • 释放后立即将指针置为 nullptr
cpp 复制代码
delete p;
p = nullptr;  // 这样再使用 p 时会立即崩溃(解引用空指针),便于调试

6.3 重复释放(Double Free)

对同一块内存调用两次 deletefree 也是未定义行为。

cpp 复制代码
int main() {
    int* p = new int(42);
    delete p;
    delete p;   // 错误!双重释放,运行时可能崩溃或损坏堆管理器的数据结构
    return 0;
}

如何避免: 同样,释放后置空指针可以防止这种情况,因为 delete nullptr 是安全的------C++ 标准保证对空指针执行 delete 是无操作的。

cpp 复制代码
int main() {
    int* p = new int(42);
    delete p;
    p = nullptr;
    delete p;   // 安全:delete 空指针什么都不做
    return 0;
}

6.4 越界访问(Buffer Overflow)

访问数组边界之外的内存是 C/C++ 中最常见也最危险的问题之一。

cpp 复制代码
int main() {
    int* arr = new int[5];           // 只有 5 个元素
    for (int i = 0; i <= 5; i++) {   // 错误:i <= 5 会多一次
        arr[i] = i * 10;             // 第 6 次写入越界
    }
    delete[] arr;
    return 0;
}

越界写入可能会破坏堆管理器的内部元数据(这些数据通常紧邻分配块前后),导致后续的释放操作崩溃,或者悄无声息地损坏其他变量的值。

6.5 野指针(Wild Pointer)

野指针是指向"随机"地址的未初始化指针,与悬空指针不同------悬空指针至少曾经指向有效内存。

cpp 复制代码
int main() {
    int* p;       // 未初始化------p 是野指针
    *p = 10;      // 危险!p 指向一个随机的地址
    return 0;
}

如何避免: 声明指针时立即初始化。

cpp 复制代码
int* p = nullptr;   // 明确初始化为空
// 或者
int* p = new int(0);
// 使用后释放置空
delete p;
p = nullptr;

七、综合应用:字符串处理中的动态内存管理

下面实现一个简单的字符串拼接函数。这个例子综合展示了动态内存分配、释放以及避免内存泄漏的完整流程。

cpp 复制代码
#include <iostream>
#include <cstring>  // for strlen, strcpy
using namespace std;

// 拼接两个 C 风格字符串,返回新字符串的指针
// 调用者负责释放返回的内存
char* concat_strings(const char* s1, const char* s2) {
    // 计算所需总长度(含结尾 '\0')
    size_t len1 = strlen(s1);
    size_t len2 = strlen(s2);
    size_t total_len = len1 + len2 + 1;

    // 在堆上分配足够的内存
    char* result = new char[total_len];

    // 拷贝字符串
    strcpy(result, s1);        // 先复制 s1
    strcat(result, s2);        // 再拼接 s2

    return result;  // 返回堆上的地址
}

int main() {
    const char* hello = "Hello, ";
    const char* world = "World!";

    char* msg = concat_strings(hello, world);
    cout << msg << endl;

    // 使用完后释放内存(调用者的责任)
    delete[] msg;
    msg = nullptr;

    // 再试一次
    msg = concat_strings("C++ ", "Memory Management");
    cout << msg << endl;
    delete[] msg;
    msg = nullptr;

    return 0;
}

八、总结与建议

内存分区的本质:

  • 程序按不同数据的生命周期和访问需求,将逻辑地址空间划分为代码区、全局区、栈区和堆区。
  • 代码区:只读、共享,存放指令。
  • 全局区:存放全局变量、静态变量和常量,程序周期内一直存活。
  • 栈区:自动管理、高效但空间小,存放局部变量。
  • 堆区:手动管理、空间大,适合动态数据结构。

动态内存管理的核心原则:

  1. 谁分配,谁释放。 确保每个 new 都有对应的 delete,每个 malloc 都有对应的 free
  2. 释放后立即置空。 防止悬空指针和双重释放。
  3. 不要将 new[] / malloc / new 混用释放。 申请和释放必须成对且匹配。
  4. 不要在栈上分配超大对象。 栈空间有限,大数组或大结构体应放在堆上。
  5. 避免越界访问。 分配了多少空间,就只使用多少。
  6. 始终检查分配结果(在使用 nothrow 版本或 malloc 时)。

在 C++ 的后续演进中,智能指针(unique_ptrshared_ptr)的出现大大降低了手动管理内存的负担。但在学习基础 C++ 的阶段,亲手通过 new / delete 管理内存的经历是不可替代的------它能让你对程序运行时内存的真实状态形成直觉,在后续学习更高级的内存管理技术时也能知其所以然。

相关推荐
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践<二>:图像噪声与图像处理
c++·图像处理·opencv·计算机视觉
键盘会跳舞2 小时前
C++:函数对象与 std::function 源码级深度拆解——泛型算法的策略内核与可调用对象统一封装
c++·算法·仿函数
程序员天天困2 小时前
Spring Boot i18n 国际化实战:从资源文件到多语言接口完整指南
spring boot·后端·编程语言
Augustzero2 小时前
为什么高性能调度器都在“偷任务”?从无锁队列看懂工作窃取
c++·后端
(Charon)2 小时前
【C++】线程安全队列(三):MPSC无锁队列、atomic与链表节点实现
c++·安全·链表
(Charon)2 小时前
【C++】:使用 mutex + deque 实现一个简单的 LockedQueue
开发语言·c++
星轨初途2 小时前
LeetCode 热题 100——day10 和为 K 的子数组
开发语言·c++·算法·leetcode
有点。2 小时前
C++广度优先搜索(二)-练习题
c++·算法·宽度优先
Q741_1473 小时前
TcpDump 使用笔记
网络·c++·笔记·测试工具·tcpdump