05 C/C++内存管理

C/C++内存管理

C/C++内存分布

问题引入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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);
}
1. 选择题:
选项 : A.栈 B.堆 C.数据段(静态区) D.代码段(常量区)
globalVar在哪里?____ staticGlobalVar在哪里?____
staticVar在哪里?____ localVar在哪里?____
num1 在哪里?____
char2在哪里?____ * char2在哪里?___
pChar3在哪里?____ * pChar3在哪里?____
ptr1在哪里?____ * ptr1在哪里?____
2. 填空题:
sizeof(num1) = ____;
sizeof(char2) = ____; strlen(char2) = ____;
sizeof(pChar3) = ____; strlen(pChar3) = ____;
sizeof(ptr1) = ____;
3. sizeof 和 strlen 区别?

变量存储位置分析

变量名存储位置解释
globalVar数据段(静态区)globalVar 是全局变量,它存储在数据段中。数据段存储了全局变量和静态变量。
staticGlobalVar数据段(静态区)staticGlobalVar 是一个静态全局变量,存储在数据段,它的生命周期是整个程序执行期间。
staticVar数据段(静态区)staticVar 是一个局部静态变量,使用 static 修饰,存储在静态区而不是栈中,生命周期为整个程序执行期间。
localVarlocalVar 是普通局部变量,存储在栈上,生命周期是函数调用期间。
num1num1 是一个局部数组,存储在栈上,数组元素按顺序存储。
char2char2 是局部字符数组,存储在栈上,包含字符串 "abcd" 及其结束符 '\0'
char2[0] (即 char2)char2[0] 是字符数组的第一个元素,存储在栈上。
pChar3pChar3 是一个指针,指向常量字符串 "abcd",指针本身存储在栈上。
pChar3[0] (即 pChar3)常量区pChar3[0] 指向的字符 "a" 存储在常量区,常量区用于存储不可修改的数据。
ptr1ptr1 是一个指针,指向堆上动态分配的内存,但指针本身存储在栈上。
ptr1[0] (即 ptr1)ptr1[0] 是堆上动态分配的内存中的数据。

详解

1. globalVar 和 staticGlobalVar

这两个变量是全局变量,并且具有静态存储期。它们存储在 数据段(静态区),这是 C 语言中用于存储静态和全局变量的区域。全局变量在程序开始时分配内存,并且在程序结束时才释放。

2. staticVar

staticVar 是一个局部静态变量,尽管它是在函数 Test() 内声明的,但由于 static 关键字的作用,它的生命周期贯穿整个程序的运行,而不是函数调用期间。因此,它被存储在 数据段(静态区) 中,而非栈中。其作用范围仍然局限于函数内。

3. localVar

localVar 是一个普通局部变量,声明在 Test() 函数内部。局部变量会分配在 上,每次函数调用时,栈为局部变量分配内存,函数返回后内存释放。

4. num1 和 char2

这两个变量分别是局部数组和字符数组,它们存储在 上。由于它们是局部变量,所以栈内存会在函数调用时分配,并在函数返回时释放。

5. char2 [0] 和 *char2

char2[0] 是字符数组的第一个元素,直接存储在栈上。而 *char2 是通过指针访问的第一个元素,也存储在栈上的 char2 数组内。

6. pChar3

pChar3 是一个指向常量字符串的指针,它本身存储在 上,指向的字符串 "abcd" 存储在 常量区(常量区用于存储程序中的常量字符串)。指针本身存储在栈上,但它指向的内存位置位于常量区。

7. ptr1 和 ptr1 [0]

ptr1 是一个指针,它是局部变量,存储在 上。ptr1 通过 malloc 分配了堆上的内存,指针指向堆上分配的内存区域,而 ptr1[0] 是堆上分配内存中的数据。

填空题答案表格

题目答案原因
sizeof(num1)40num1int[10] 类型数组,包含 10 个 int,每个占 4 字节,总大小为 10*4=40
sizeof(char2)5char2 是字符数组,初始化为 "abcd",包含 'a','b','c','d','\0',共 5 个字符。
strlen(char2)4strlen 计算字符串长度时,不包含终止符 '\0',因此为 4
sizeof(pChar3)4/8pChar3 是指针,32 位系统占 4 字节,64 位系统占 8 字节(题目未明确系统位数)。
strlen(pChar3)4pChar3 指向常量字符串 "abcd"strlen 计算结果为 4
sizeof(ptr1)4/8ptr1 是指针,32 位系统占 4 字节,64 位系统占 8 字节(与 pChar3 一致)。

C/C++内存区域的划分

在 C/C++编程中,程序的运行是通过内存来实现的。操作系统在程序执行时会将程序加载到内存中,并且将程序划分成不同的内存区域,每个区域有其特定的用途和特点。这些内存区域不仅影响程序的效率,还直接关系到程序的运行过程和内存管理。

1. 代码段 (Code Segment)——可执行代码/只读常量

位置:代码段通常位于内存的高地址部分。

用途:代码段存储程序的机器代码,即经过编译后的可执行指令。程序执行时,CPU 将从代码段中取出指令并按顺序执行。

特点

  • 只读性:代码段是只读的,程序在执行过程中不能修改代码内容。这是为了防止程序错误地修改自己的执行代码。
  • 保护性:由于代码段的内容不能被修改,它也常常受到操作系统和硬件的保护,防止出现不可预期的行为。
  • 静态性:代码段在程序运行时不会发生变化,程序的机器代码在加载后是固定的(少数情况下,JIT 编译器或自修改代码除外)。
  • 扩展功能:支持动态生成代码,例如通过 JIT 编译生成和执行指令。

2. 数据段 (Data Segment)——全局数据/静态数据

位置:数据段位于代码段下方。

用途:数据段用于存储程序的全局变量、静态变量和常量数据。它被进一步划分为两个子区段:

  • 已初始化的数据区:存储已初始化的全局变量和静态变量。
  • 未初始化的数据区 (BSS 段):存储未初始化的全局变量和静态变量,程序启动时会将这些变量初始化为零。

特点

  • 生命周期:数据段在程序的整个运行过程中存在,直到程序退出时才释放。
  • 内存管理:数据段的内存分配由编译器或操作系统自动管理。程序员无需手动分配和释放。
  • 常量数据:一些编译器会将全局 const 常量数据放在一个只读数据区,该区域通常也属于代码段的一部分。

3. 堆 (Heap)

位置:堆位于数据段下方,通常随着程序的运行动态增长。

用途:堆用于动态内存分配。通过 malloc()calloc()realloc() 等函数,程序可以向堆申请内存空间,并在不再需要时通过 free() 函数释放内存。

特点

  • 动态性:堆内存是动态分配的,可以在程序运行时根据需要申请或释放,大小在运行期间可变化。
  • 程序员控制:堆内存的管理由程序员控制,程序员需要显式地申请和释放内存。如果忘记释放,会导致内存泄漏。
  • 灵活性:堆适合存储生命周期不固定的数据,例如大型数据结构或需要在函数间共享的资源。
  • 增长方向:通常向上增长(从低地址到高地址),但具体取决于操作系统和硬件架构。

4. 栈 (Stack)

位置:栈位于堆的上方,通常向下增长。

用途:栈用于存储局部变量、函数参数以及函数调用时的返回地址。每当函数被调用时,栈会为该函数分配一块内存区域,函数执行完后,这部分内存会被自动回收。

特点

  • 自动管理:栈的内存分配和释放是自动的,通常由编译器或操作系统管理。每次函数调用时,栈内存会自动分配,函数返回时会自动回收。
  • 生命周期短:栈内存的生命周期通常与函数的调用和返回紧密相关,内存的分配和回收速度非常快。
  • 空间限制:栈的空间是有限的,过多的递归调用或过大的局部变量可能导致栈溢出(stack overflow)错误。
  • 增长方向:通常向下增长(从高地址到低地址),具体取决于硬件和操作系统。

5. 内存映射区 (Memory Mapped Region)——文件映射、动态库、匿名映射

位置:内存映射区位于栈的下方。

用途:内存映射区用于映射文件、共享内存等资源。此区域存储的是映射到内存的文件或程序中需要访问的共享资源。例如,动态链接库(DLL)或共享库(so)就是通过内存映射来实现的。

特点

  • 文件映射:程序可以通过内存映射区直接对映射文件进行读写操作,而无需通过标准的文件 I/O 操作,这通常能提高访问效率。
  • 共享资源:内存映射区可以映射各种资源,包括文件、设备、共享内存等,这对于实现进程间通信(IPC)和文件缓存等操作非常重要。
  • 匿名映射:可以分配不对应实际文件的匿名内存区域,常用于动态分配额外的内存。

各个区域的总结

区域用途特点
代码段存储程序的执行代码只读、静态,程序无法修改代码;支持动态生成代码。
数据段存储全局变量、静态变量等静态数据包括已初始化数据区和未初始化数据区 (BSS);程序运行期间长时间存在,由系统管理。
用于动态内存分配程序员手动管理,生命周期灵活;需要注意内存泄漏问题。
存储局部变量和函数调用信息自动分配和释放,速度快,空间有限,可能发生栈溢出。
内存映射区用于文件映射、共享内存等资源直接内存操作,提高效率;支持进程间通信和动态库加载。

补充说明

  1. 栈与堆的增长方向:栈通常向下增长,堆通常向上增长,但这取决于系统实现。
  2. BSS 段零初始化:未初始化的全局和静态变量在程序启动时会被自动初始化为零,这一行为由语言标准规定。

C++ 动态内存管理:new、delete 与 malloc、free 的比较

C++ 提供了一套独特的内存管理机制,相较于 C 语言的 mallocfree 更加灵活和强大。通过 newdelete,C++ 可以在进行动态内存分配的同时,还能自动调用构造函数和析构函数,这使得 C++ 在管理对象生命周期时更加安全和高效。operator newoperator delete 是底层实现内存分配与释放的关键函数,而 new[]delete[] 使得我们可以管理数组类型的对象。定位 new 则提供了在已分配内存中初始化对象的能力,常常与内存池一起使用。

C 语言与 C++ 中内存管理的区别

在 C 语言中,内存管理通常依赖于 mallocfree 函数。malloc 用于申请动态内存,而 free 用于释放这些内存。然而,C 语言的内存管理不适合处理 C++ 中的对象,因为它不考虑对象的构造和析构。因此,C++ 提出了新的内存管理方式:

  • new:用于动态分配内存并调用构造函数。(开空间 –> operator new –> malloc + 构造函数)
  • delete:用于释放内存并调用析构函数。(析构函数 + 释放空间 –> operator delete –> free
  • new []:用于分配数组内存并调用多个构造函数。
  • delete []:用于释放数组内存并调用多个析构函数。

注意:

  • 使用 newdelete 进行单个对象的内存管理。
  • 使用 new[]delete[] 进行数组的内存管理。
  • 确保配对使用:new 配对 deletenew[] 配对 delete[]

C++ 的内存管理机制

new 和 delete

C++ 中的 newdelete 操作符不仅仅负责内存的分配和释放,它们还会自动调用对象的构造函数和析构函数。与 C 语言的 malloc/free 对比:

  • malloc/free 仅负责内存的分配和释放,不考虑对象的构造和析构。
  • new/delete 除了分配和释放内存,还会自动调用构造函数和析构函数,适用于 C++ 中的类和对象。

new 和 delete 对于自定义类型的特殊性

对于 C++ 中的自定义类型,newdelete 在底层会执行构造和析构操作,这与 mallocfree 的区别十分明显。例如,new 会在分配内存后调用对象的构造函数,delete 会在释放内存之前调用析构函数。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;

class MyClass
{
public:
MyClass() { std::cout << "构造函数已调用!" << std::endl; }
~MyClass() { std::cout << "析构函数已调用!" << std::endl; }
};

int main()
{
MyClass* obj = new MyClass(); // 调用构造函数
delete obj; // 调用析构函数
return 0;
}

在上述代码中,new 分配了内存并调用了 MyClass 的构造函数,而 delete 释放内存并调用了析构函数。


operator newoperator delete

newdelete 操作符是用户用于内存管理的工具,而 operator newoperator delete 是系统提供的全局函数,它们实现了内存分配和释放的底层逻辑。具体来说:

  • operator new:负责申请内存空间,实际是通过 malloc 来分配空间。
  • operator delete:负责释放内存空间,实际是通过 free 来释放空间。

operator new 的工作原理

  1. 调用 malloc 来申请内存空间。
  2. 如果 malloc 成功返回内存地址,operator new 返回该地址;如果失败,operator new 会通过用户定义的应对措施继续尝试分配内存,否则抛出 std::bad_alloc 异常。

operator delete 的工作原理

  1. 调用 free 来释放内存空间。
  2. 如果需要,执行额外的清理工作,如释放与内存块相关的资源。

new [] 和 delete []:处理数组

C++ 还提供了 new[]delete[] 用于分配和释放数组。这些操作符与 newdelete 类似,但会针对数组元素执行额外的构造和析构操作。

new[]delete[] 的原理

  • new []:申请连续的内存空间,并对每个元素调用构造函数。
  • delete []:释放连续的内存空间,并对每个元素调用析构函数。

示例:

1
2
int* arr = new int[10];  // 使用 new [] 申请数组
delete[] arr; // 使用 delete [] 释放数组

初始化:

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;

int main()
{
int* arr = new int[10] {1, 3, 2, 4, 5, 6, 7, 8, 9, 10};// 申请并初始化
delete[] arr;//释放
return 0;
}

定位 new (Placement new)

C++ 提供了定位 new 表达式,允许我们在已经分配的内存空间上调用构造函数。这通常用于内存池中,因为内存池管理的内存块不会自动初始化,所以需要显式调用构造函数来初始化对象。

使用格式:

1
2
new (place_address) type;  // 在指定地址 place_address 上构造对象
new (place_address) type(initializer-list); // 在指定地址并用初始化列表构造对象

定位 new 的常见场景

  • 内存池管理:内存池为对象分配原始内存,但并不初始化对象。定位 new 可用于在这些原始内存块上调用构造函数,从而初始化对象。

面试题

1. malloc/calloc/realloc 的区别?

malloc

  • 用于分配一块指定大小的内存,但 不会初始化这块内存,因此分配的内存中可能包含随机值(内存的旧数据)。

  • 返回一个 void* 类型的指针,需要显式地转换为所需的指针类型。

  • 示例:

1
int* ptr = (int*)malloc(10 * sizeof(int)); // 分配 10 个 int 大小的内存

calloc

  • malloc 功能相似,但有两个主要区别:
  1. calloc 会将分配的内存初始化为零。
  2. calloc 需要两个参数:第一个参数是分配的元素数量,第二个参数是每个元素的大小。
  • 示例:
1
int* ptr = (int*)calloc(10, sizeof(int)); // 分配 10 个 int 大小的内存,并初始化为 0

realloc

  • 用于重新调整由 malloccalloc 分配的内存大小。

  • 如果新大小比原来的小,则多余的内存会被释放;如果新大小比原来的大,系统会尝试扩展内存,如果无法扩展,会分配新的内存块并复制旧内存的数据。

  • 注意:realloc 可能返回一个新的指针地址,因此需要重新赋值,并小心处理内存泄漏。

  • 示例:

1
2
int* ptr = (int*)malloc(5 * sizeof(int));
ptr = (int*)realloc(ptr, 10 * sizeof(int)); // 调整大小为 10 个 int

小结

  • malloc:分配内存但不初始化。
  • calloc:分配内存并初始化为零。
  • realloc:调整已分配内存的大小。

2. malloc 的实现原理?(glibc 中 malloc 实现原理

核心机制

  1. 堆区内存管理:
  • 程序运行时,操作系统会为进程分配一个堆区,堆区的大小通常是动态调整的。malloc 通过管理堆区来实现动态内存分配。
  • 操作系统的系统调用(如 brkmmap)是 malloc 的底层实现工具。
  1. 内存分配算法:
  • 首次适配 (First Fit):寻找第一个大小合适的空闲块分配内存。
  • 最佳适配 (Best Fit):寻找最接近所需大小的空闲块分配内存。
  • 最差适配 (Worst Fit):寻找最大的空闲块分配内存。
  1. 内存碎片:
  • 长时间运行的程序中,频繁的内存分配和释放可能会造成 内存碎片(许多小的、无法使用的空闲块)。
  • 为减少碎片,malloc 通常会合并相邻的空闲块(内存回收时)。

实现注意点

  • 对齐:分配的内存通常按照特定字节对齐(如 8 字节或 16 字节对齐),以提高内存访问效率。
  • 元数据malloc 会在分配的内存块中存储一些元数据(如块大小、状态等),以便管理分配和释放。

3. malloc/free 和 new/delete 的区别?

malloc/free

  1. mallocfree 是标准库中的函数,属于 C 语言内存分配机制。
  2. malloc 只分配内存,不会调用构造函数或初始化对象。
  3. free 只释放内存,不会调用析构函数。
  4. malloc 的返回值是 void*,需要显式类型转换。

new/delete

  1. newdelete 是 C++ 中的运算符,用于分配和释放内存。
  2. new 会为对象分配内存并调用构造函数进行初始化。
  3. delete 会释放对象的内存并调用析构函数。
  4. new 返回的是指定类型的指针,无需类型转换。
  5. new 失败时会抛出 std::bad_alloc 异常(可以用 std::nothrow 避免抛出异常,改为返回 nullptr)。

示例代码:

1
2
3
4
5
6
7
>// malloc/free 示例
>int* arr = (int*)malloc(10 * sizeof(int)); // 分配 10 个 int 的内存
>free(arr); // 释放内存

>// new/delete 示例
>int* arr_new = new int[10]; // 分配 10 个 int 并初始化为默认值(未定义行为)
>delete[] arr_new; // 释放数组内存

4. malloc/free 和 new/delete 的共同点?

  • 都从 堆区 分配内存。
  • 都需要程序员手动释放内存,避免内存泄漏。

主要区别总结

特性malloc/freenew/delete
类型函数运算符
初始化不初始化内存初始化内存(调用构造函数)
类型转换返回 void*,需强制转换类型返回指定类型指针,无需强制转换
失败处理返回 NULL,需手动检查抛出 std::bad_alloc 异常(或返回 nullptr
构造和析构函数不调用构造或析构函数自动调用构造函数和析构函数
数组内存管理需要手动计算大小自动计算大小(支持 new[]/delete[]

注意事项

  1. 混用问题
  • 不要混用 mallocdelete,或 newfree,否则可能导致未定义行为。

  • 示例(错误用法):

    1
    2
    int* ptr = (int*)malloc(sizeof(int));
    delete ptr; // 错误:malloc 分配的内存不能用 delete 释放
  1. new[]delete[] 的配对
  • 如果通过 new[] 分配数组,必须使用 delete[] 释放;否则可能导致析构函数未被正确调用或内存泄漏。

C/C++的内存泄漏

C/C++中的内存泄漏是指程序在运行时动态分配的内存没有正确释放,导致无法再使用这些内存,从而浪费了宝贵的系统资源。这类问题尤其在使用堆内存分配时比较常见。

堆内存泄漏(Heap Leak)

堆内存是程序在运行时通过动态内存分配(如 malloc, calloc, realloc, new 等)从操作系统的堆区分配的内存。当程序使用完这些内存后,必须显式地通过 freedelete 来释放内存。如果程序没有正确释放这些内存,它们将无法再被使用,就会导致堆内存泄漏。

例如:

1
2
3
int* ptr = new int[10]; // 分配堆内存
// ... 使用 ptr
// 忘记调用 delete [] ptr;

在这个例子中,如果程序员忘记调用 delete[] ptr 来释放分配的内存,程序会导致堆内存泄漏。每次这样的错误都会导致系统中未被使用的内存空间增加,从而影响程序的性能,甚至可能导致程序崩溃。

系统资源泄漏

除了内存泄漏外,还有一种资源泄漏的情况,即程序未能释放操作系统分配的资源。比如文件描述符、套接字、管道等系统资源,在使用后应通过适当的关闭操作释放。否则,这些资源也会被“泄漏”,占用有限的系统资源,可能导致系统性能降低或崩溃。

例如:

1
2
3
4
FILE* file = fopen("somefile.txt", "r");
// ... 操作文件
// 忘记关闭文件
// fclose(file);

在上面的例子中,如果忘记关闭文件,文件描述符将会被泄漏,可能最终耗尽可用的文件句柄,导致程序无法打开更多的文件。

普通程序与长期运行程序中的内存泄漏影响

  • 普通程序:对于普通程序,内存泄漏的影响通常比较小。如果程序运行完毕后正常退出,操作系统会回收所有分配的资源。虽然内存泄漏会导致程序占用多余的内存,但由于程序在结束时释放了所有资源,通常不会造成太大影响。此类程序通常在一次运行中分配和释放内存,相对较短生命周期,内存泄漏的问题不容易显现。
  • 长期运行的程序:对于长期运行的程序,内存泄漏的影响则非常严重。例如,游戏服务电商服务金融系统 等,这些程序需要长时间稳定运行。如果发生内存泄漏,随着时间的推移,未释放的内存会不断积累,导致程序占用的内存越来越大,从而可能导致系统资源耗尽,程序崩溃,甚至系统崩溃。长期运行的服务需要特别关注内存泄漏问题,因为它们的稳定性和高效性至关重要。

如何检测内存泄漏

1. 使用 _CrtDumpMemoryLeaks() (在 Visual Studio 中)

在 Visual Studio 中,可以使用 _CrtDumpMemoryLeaks() 函数来简单检测内存泄漏。它会在程序退出时输出所有泄漏的内存块大小,但不会提供泄漏的详细位置信息,因此仅能检测到大概的泄漏情况,无法精确找到具体发生泄漏的代码行。

例如:

1
2
3
4
5
6
7
8
9
10
#include <crtdbg.h>

int main()
{
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

int* p = new int[10]; // 动态分配内存
// 不释放内存,程序退出时会报告内存泄漏
return 0;
}

2. 使用第三方工具

对于复杂的项目,可能会有多个地方发生内存泄漏,手动追踪泄漏点非常困难。这时,可以借助一些内存泄漏检测工具,如 Valgrind、AddressSanitizer 等,来帮助定位内存泄漏。这些工具能提供更准确的泄漏位置,帮助开发人员快速定位问题。


如何避免内存泄漏

1. 良好的设计规范

在程序设计初期,养成良好的内存管理规范至关重要。所有分配的内存都应当在合适的时机释放。如果可能,避免手动内存管理,使用智能指针来自动管理内存。

2. 使用 RAII(资源获取即初始化)思想

RAII 是一种编程思想,在这种思想中,资源的获取与对象的生命周期绑定,资源在对象创建时分配,在对象销毁时释放。C++中的智能指针(如 std::unique_ptr, std::shared_ptr)可以自动管理内存,避免内存泄漏。

例如,使用 std::unique_ptr

1
2
3
4
5
6
7
#include <memory>

void foo()
{
std::unique_ptr<int[]> arr(new int[10]); // 内存由 unique_ptr 自动管理
// 不需要手动释放内存,智能指针会在超出作用域时自动释放
}

3. 内存管理库

一些公司或项目会实现私有的内存管理库,这些库通常带有内存泄漏检测的功能,可以在运行时监控内存分配和释放,及时发现和报告内存泄漏。

4. 使用内存泄漏工具

如前所述,当内存泄漏发生时,使用工具来检测和分析程序是一个有效的方式。这些工具可以帮助开发人员准确找出泄漏的内存块,并进行修复。


内存泄漏是 C/C++ 编程中常见的问题,它会导致程序占用越来越多的内存,影响系统性能,甚至导致系统崩溃。解决内存泄漏的方法可以分为两类:

  1. 事前预防型:通过良好的设计规范,使用智能指针等资源管理技术避免内存泄漏。
  2. 事后查错型:使用内存泄漏检测工具来发现和修复内存泄漏。