05 C/C++内存管理

05 C/C++内存管理
小米里的大麦C/C++内存管理
C/C++内存分布
问题引入:
1 | int globalVar = 1; |
变量存储位置分析
变量名 | 存储位置 | 解释 |
---|---|---|
globalVar |
数据段(静态区) | globalVar 是全局变量,它存储在数据段中。数据段存储了全局变量和静态变量。 |
staticGlobalVar |
数据段(静态区) | staticGlobalVar 是一个静态全局变量,存储在数据段,它的生命周期是整个程序执行期间。 |
staticVar |
数据段(静态区) | staticVar 是一个局部静态变量,使用 static 修饰,存储在静态区而不是栈中,生命周期为整个程序执行期间。 |
localVar |
栈 | localVar 是普通局部变量,存储在栈上,生命周期是函数调用期间。 |
num1 |
栈 | num1 是一个局部数组,存储在栈上,数组元素按顺序存储。 |
char2 |
栈 | char2 是局部字符数组,存储在栈上,包含字符串 "abcd" 及其结束符 '\0' 。 |
char2[0] (即 char2) |
栈 | char2[0] 是字符数组的第一个元素,存储在栈上。 |
pChar3 |
栈 | pChar3 是一个指针,指向常量字符串 "abcd" ,指针本身存储在栈上。 |
pChar3[0] (即 pChar3) |
常量区 | pChar3[0] 指向的字符 "a" 存储在常量区,常量区用于存储不可修改的数据。 |
ptr1 |
栈 | ptr1 是一个指针,指向堆上动态分配的内存,但指针本身存储在栈上。 |
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) |
40 |
num1 是 int[10] 类型数组,包含 10 个 int ,每个占 4 字节,总大小为 10*4=40 。 |
sizeof(char2) |
5 |
char2 是字符数组,初始化为 "abcd" ,包含 'a','b','c','d','\0' ,共 5 个字符。 |
strlen(char2) |
4 |
strlen 计算字符串长度时,不包含终止符 '\0' ,因此为 4 。 |
sizeof(pChar3) |
4 /8 |
pChar3 是指针,32 位系统占 4 字节,64 位系统占 8 字节(题目未明确系统位数)。 |
strlen(pChar3) |
4 |
pChar3 指向常量字符串 "abcd" ,strlen 计算结果为 4 。 |
sizeof(ptr1) |
4 /8 |
ptr1 是指针,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);程序运行期间长时间存在,由系统管理。 |
堆 | 用于动态内存分配 | 程序员手动管理,生命周期灵活;需要注意内存泄漏问题。 |
栈 | 存储局部变量和函数调用信息 | 自动分配和释放,速度快,空间有限,可能发生栈溢出。 |
内存映射区 | 用于文件映射、共享内存等资源 | 直接内存操作,提高效率;支持进程间通信和动态库加载。 |
补充说明
- 栈与堆的增长方向:栈通常向下增长,堆通常向上增长,但这取决于系统实现。
- BSS 段零初始化:未初始化的全局和静态变量在程序启动时会被自动初始化为零,这一行为由语言标准规定。
C++ 动态内存管理:new、delete 与 malloc、free 的比较
C++ 提供了一套独特的内存管理机制,相较于 C 语言的 malloc
和 free
更加灵活和强大。通过 new
和 delete
,C++ 可以在进行动态内存分配的同时,还能自动调用构造函数和析构函数,这使得 C++ 在管理对象生命周期时更加安全和高效。operator new
和 operator delete
是底层实现内存分配与释放的关键函数,而 new[]
和 delete[]
使得我们可以管理数组类型的对象。定位 new
则提供了在已分配内存中初始化对象的能力,常常与内存池一起使用。
C 语言与 C++ 中内存管理的区别
在 C 语言中,内存管理通常依赖于 malloc
和 free
函数。malloc
用于申请动态内存,而 free
用于释放这些内存。然而,C 语言的内存管理不适合处理 C++ 中的对象,因为它不考虑对象的构造和析构。因此,C++ 提出了新的内存管理方式:
- new:用于动态分配内存并调用构造函数。(开空间 –>
operator new
–>malloc
+ 构造函数) - delete:用于释放内存并调用析构函数。(析构函数 + 释放空间 –>
operator delete
–>free
) - new []:用于分配数组内存并调用多个构造函数。
- delete []:用于释放数组内存并调用多个析构函数。
注意:
- 使用
new
和delete
进行单个对象的内存管理。 - 使用
new[]
和delete[]
进行数组的内存管理。 - 确保配对使用:
new
配对delete
,new[]
配对delete[]
。
C++ 的内存管理机制
new 和 delete
C++ 中的 new
和 delete
操作符不仅仅负责内存的分配和释放,它们还会自动调用对象的构造函数和析构函数。与 C 语言的 malloc/free 对比:
- malloc/free 仅负责内存的分配和释放,不考虑对象的构造和析构。
- new/delete 除了分配和释放内存,还会自动调用构造函数和析构函数,适用于 C++ 中的类和对象。
new 和 delete 对于自定义类型的特殊性
对于 C++ 中的自定义类型,new
和 delete
在底层会执行构造和析构操作,这与 malloc
和 free
的区别十分明显。例如,new
会在分配内存后调用对象的构造函数,delete
会在释放内存之前调用析构函数。
示例:
1 |
|
在上述代码中,new
分配了内存并调用了 MyClass
的构造函数,而 delete
释放内存并调用了析构函数。
operator new
和 operator delete
new
和 delete
操作符是用户用于内存管理的工具,而 operator new
和 operator delete
是系统提供的全局函数,它们实现了内存分配和释放的底层逻辑。具体来说:
- operator new:负责申请内存空间,实际是通过
malloc
来分配空间。 - operator delete:负责释放内存空间,实际是通过
free
来释放空间。
operator new
的工作原理
- 调用
malloc
来申请内存空间。 - 如果
malloc
成功返回内存地址,operator new
返回该地址;如果失败,operator new
会通过用户定义的应对措施继续尝试分配内存,否则抛出std::bad_alloc
异常。
operator delete
的工作原理
- 调用
free
来释放内存空间。 - 如果需要,执行额外的清理工作,如释放与内存块相关的资源。
new [] 和 delete []:处理数组
C++ 还提供了 new[]
和 delete[]
用于分配和释放数组。这些操作符与 new
和 delete
类似,但会针对数组元素执行额外的构造和析构操作。
new[]
和 delete[]
的原理
- new []:申请连续的内存空间,并对每个元素调用构造函数。
- delete []:释放连续的内存空间,并对每个元素调用析构函数。
示例:
1 | int* arr = new int[10]; // 使用 new [] 申请数组 |
初始化:
1 |
|
定位 new (Placement new)
C++ 提供了定位 new
表达式,允许我们在已经分配的内存空间上调用构造函数。这通常用于内存池中,因为内存池管理的内存块不会自动初始化,所以需要显式调用构造函数来初始化对象。
使用格式:
1 | new (place_address) type; // 在指定地址 place_address 上构造对象 |
定位 new 的常见场景
- 内存池管理:内存池为对象分配原始内存,但并不初始化对象。定位
new
可用于在这些原始内存块上调用构造函数,从而初始化对象。
面试题
1. malloc/calloc/realloc 的区别?
malloc
用于分配一块指定大小的内存,但 不会初始化这块内存,因此分配的内存中可能包含随机值(内存的旧数据)。
返回一个
void*
类型的指针,需要显式地转换为所需的指针类型。示例:
1 int* ptr = (int*)malloc(10 * sizeof(int)); // 分配 10 个 int 大小的内存
calloc
- 与
malloc
功能相似,但有两个主要区别:
calloc
会将分配的内存初始化为零。calloc
需要两个参数:第一个参数是分配的元素数量,第二个参数是每个元素的大小。
- 示例:
1 int* ptr = (int*)calloc(10, sizeof(int)); // 分配 10 个 int 大小的内存,并初始化为 0
realloc
用于重新调整由
malloc
或calloc
分配的内存大小。如果新大小比原来的小,则多余的内存会被释放;如果新大小比原来的大,系统会尝试扩展内存,如果无法扩展,会分配新的内存块并复制旧内存的数据。
注意:
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 实现原理)
核心机制
- 堆区内存管理:
- 程序运行时,操作系统会为进程分配一个堆区,堆区的大小通常是动态调整的。
malloc
通过管理堆区来实现动态内存分配。- 操作系统的系统调用(如
brk
或mmap
)是malloc
的底层实现工具。
- 内存分配算法:
- 首次适配 (First Fit):寻找第一个大小合适的空闲块分配内存。
- 最佳适配 (Best Fit):寻找最接近所需大小的空闲块分配内存。
- 最差适配 (Worst Fit):寻找最大的空闲块分配内存。
- 内存碎片:
- 长时间运行的程序中,频繁的内存分配和释放可能会造成 内存碎片(许多小的、无法使用的空闲块)。
- 为减少碎片,
malloc
通常会合并相邻的空闲块(内存回收时)。实现注意点
- 对齐:分配的内存通常按照特定字节对齐(如 8 字节或 16 字节对齐),以提高内存访问效率。
- 元数据:
malloc
会在分配的内存块中存储一些元数据(如块大小、状态等),以便管理分配和释放。
3. malloc/free 和 new/delete 的区别?
malloc/free
malloc
和free
是标准库中的函数,属于 C 语言内存分配机制。malloc
只分配内存,不会调用构造函数或初始化对象。free
只释放内存,不会调用析构函数。malloc
的返回值是void*
,需要显式类型转换。new/delete
new
和delete
是 C++ 中的运算符,用于分配和释放内存。new
会为对象分配内存并调用构造函数进行初始化。delete
会释放对象的内存并调用析构函数。new
返回的是指定类型的指针,无需类型转换。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/free new/delete 类型 函数 运算符 初始化 不初始化内存 初始化内存(调用构造函数) 类型转换 返回 void*
,需强制转换类型返回指定类型指针,无需强制转换 失败处理 返回 NULL
,需手动检查抛出 std::bad_alloc
异常(或返回nullptr
)构造和析构函数 不调用构造或析构函数 自动调用构造函数和析构函数 数组内存管理 需要手动计算大小 自动计算大小(支持 new[]
/delete[]
)注意事项
- 混用问题:
不要混用
malloc
和delete
,或new
和free
,否则可能导致未定义行为。示例(错误用法):
1
2 int* ptr = (int*)malloc(sizeof(int));
delete ptr; // 错误:malloc 分配的内存不能用 delete 释放
new[]
和delete[]
的配对:
- 如果通过
new[]
分配数组,必须使用delete[]
释放;否则可能导致析构函数未被正确调用或内存泄漏。
C/C++的内存泄漏
C/C++中的内存泄漏是指程序在运行时动态分配的内存没有正确释放,导致无法再使用这些内存,从而浪费了宝贵的系统资源。这类问题尤其在使用堆内存分配时比较常见。
堆内存泄漏(Heap Leak)
堆内存是程序在运行时通过动态内存分配(如 malloc
, calloc
, realloc
, new
等)从操作系统的堆区分配的内存。当程序使用完这些内存后,必须显式地通过 free
或 delete
来释放内存。如果程序没有正确释放这些内存,它们将无法再被使用,就会导致堆内存泄漏。
例如:
1 | int* ptr = new int[10]; // 分配堆内存 |
在这个例子中,如果程序员忘记调用 delete[] ptr
来释放分配的内存,程序会导致堆内存泄漏。每次这样的错误都会导致系统中未被使用的内存空间增加,从而影响程序的性能,甚至可能导致程序崩溃。
系统资源泄漏
除了内存泄漏外,还有一种资源泄漏的情况,即程序未能释放操作系统分配的资源。比如文件描述符、套接字、管道等系统资源,在使用后应通过适当的关闭操作释放。否则,这些资源也会被“泄漏”,占用有限的系统资源,可能导致系统性能降低或崩溃。
例如:
1 | FILE* file = fopen("somefile.txt", "r"); |
在上面的例子中,如果忘记关闭文件,文件描述符将会被泄漏,可能最终耗尽可用的文件句柄,导致程序无法打开更多的文件。
普通程序与长期运行程序中的内存泄漏影响
- 普通程序:对于普通程序,内存泄漏的影响通常比较小。如果程序运行完毕后正常退出,操作系统会回收所有分配的资源。虽然内存泄漏会导致程序占用多余的内存,但由于程序在结束时释放了所有资源,通常不会造成太大影响。此类程序通常在一次运行中分配和释放内存,相对较短生命周期,内存泄漏的问题不容易显现。
- 长期运行的程序:对于长期运行的程序,内存泄漏的影响则非常严重。例如,游戏服务、电商服务、金融系统 等,这些程序需要长时间稳定运行。如果发生内存泄漏,随着时间的推移,未释放的内存会不断积累,导致程序占用的内存越来越大,从而可能导致系统资源耗尽,程序崩溃,甚至系统崩溃。长期运行的服务需要特别关注内存泄漏问题,因为它们的稳定性和高效性至关重要。
如何检测内存泄漏
1. 使用 _CrtDumpMemoryLeaks() (在 Visual Studio 中)
在 Visual Studio 中,可以使用 _CrtDumpMemoryLeaks()
函数来简单检测内存泄漏。它会在程序退出时输出所有泄漏的内存块大小,但不会提供泄漏的详细位置信息,因此仅能检测到大概的泄漏情况,无法精确找到具体发生泄漏的代码行。
例如:
1 |
|
2. 使用第三方工具
对于复杂的项目,可能会有多个地方发生内存泄漏,手动追踪泄漏点非常困难。这时,可以借助一些内存泄漏检测工具,如 Valgrind、AddressSanitizer 等,来帮助定位内存泄漏。这些工具能提供更准确的泄漏位置,帮助开发人员快速定位问题。
如何避免内存泄漏
1. 良好的设计规范
在程序设计初期,养成良好的内存管理规范至关重要。所有分配的内存都应当在合适的时机释放。如果可能,避免手动内存管理,使用智能指针来自动管理内存。
2. 使用 RAII(资源获取即初始化)思想
RAII 是一种编程思想,在这种思想中,资源的获取与对象的生命周期绑定,资源在对象创建时分配,在对象销毁时释放。C++中的智能指针(如 std::unique_ptr
, std::shared_ptr
)可以自动管理内存,避免内存泄漏。
例如,使用 std::unique_ptr
:
1 |
|
3. 内存管理库
一些公司或项目会实现私有的内存管理库,这些库通常带有内存泄漏检测的功能,可以在运行时监控内存分配和释放,及时发现和报告内存泄漏。
4. 使用内存泄漏工具
如前所述,当内存泄漏发生时,使用工具来检测和分析程序是一个有效的方式。这些工具可以帮助开发人员准确找出泄漏的内存块,并进行修复。
内存泄漏是 C/C++ 编程中常见的问题,它会导致程序占用越来越多的内存,影响系统性能,甚至导致系统崩溃。解决内存泄漏的方法可以分为两类:
- 事前预防型:通过良好的设计规范,使用智能指针等资源管理技术避免内存泄漏。
- 事后查错型:使用内存泄漏检测工具来发现和修复内存泄漏。