6、基础 | C++的内存管理一
@[toc]
1. C++内存分区
在C++岗位面试中,关于C++内存分区的问题是一个常见且重要的考察点。C++程序在执行时,其内存使用被划分为不同的区域,每个区域都有其特定的用途和管理方式。以下是对C++内存分区的详细解答:
C++内存分区概述
C++程序在执行时,其内存使用大致可以划分为几个不同的区域,尽管不同的资料可能会给出略有不同的划分方式,但核心思想是一致的。一般来说,可以归纳为以下几个主要区域:
-
代码区(Code Segment/Text Segment)
- 用途:存放函数体的二进制代码,包括程序的代码、静态数据和常量等信息。
- 管理方式:由操作系统进行管理,内容在程序启动时加载,通常为只读(Read-Only),以保证代码的安全性和一致性。
- 特点:大小固定,不会随着程序运行的过程而发生变化。
-
全局区(Global/Static Segment)
- 用途:存储全局变量、静态变量和常量等数据的内存区域。
- 管理方式:在程序运行时,系统会为这些数据分配内存,并在整个程序的生命周期中一直存在。
- 细分:
- 全局存储区域:用于存放全局变量。
- 静态存储区域:用于存放静态变量和常量。
- 特点:这些数据具有静态生命周期和可见性,能够在整个程序执行期间保持不变,并且可以在程序中的多个位置访问。
-
栈区(Stack Segment)
- 用途:存放函数的参数值、局部变量以及函数调用过程中的临时变量等数据。
- 管理方式:由编译器自动分配和释放,无需程序员手动管理。
- 特点:
- 栈的大小在编译时确定,通常是有限的。
- 栈的分配和释放非常高效,支持函数的递归调用。
- 栈区的数据具有短暂的生命周期,函数执行完毕后,其栈帧及其中的数据会被销毁。
-
堆区(Heap Segment)
- 用途:动态内存分配区域,程序员通过
new
和delete
操作符(或C++11中的智能指针)手动申请和释放内存。 - 管理方式:由程序员控制内存的分配和释放,若程序员不释放,程序结束时由操作系统回收。
- 特点:
- 堆是一个大内存池,可以动态地请求和释放内存。
- 堆内存的分配和释放相对较慢,且需要程序员谨慎管理,以避免内存泄漏和内存碎片等问题。
- 用途:动态内存分配区域,程序员通过
总结
C++内存分区是程序设计和性能优化的重要基础。了解不同内存区域的特点和管理方式,有助于编写更高效、更安全的C++程序。在面试中,能够清晰地阐述C++内存分区的概念、各区域的用途和管理方式,将展现出应聘者对C++内存管理的深入理解和扎实基础。
2. new 和 malloc 的区别?
在C++岗位面试中,关于new
和malloc
的区别是一个常见问题,因为这两个操作符/函数都用于动态内存分配,但它们在用法、特性、安全性和类型支持上存在显著差异。以下是针对您列出的几个方面的详细解答:
1. 函数与运算符
-
malloc
:是一个标准的C库函数,用于动态分配内存。其原型定义在<stdlib.h>
(C++中为<cstdlib>
)头文件中。它接收一个表示所需字节数的size_t
类型参数,并返回一个指向分配的内存块的void*
指针。 -
new
:是C++中的一个运算符,用于动态分配内存并同时构造对象。它既可以分配原始内存(如new char[100]
),也可以与构造函数一起使用来创建对象(如new int(10)
)。
2. 类型安全
-
malloc
:不提供类型安全。它仅返回void*
类型的指针,这意呀着在将内存用于特定类型之前,必须进行类型转换。这可能导致类型不匹配的错误,从而引发运行时问题。 -
new
:是类型安全的。当使用new
运算符时,可以自动进行类型转换,因为编译器知道分配给什么类型的对象。如果分配失败,new
还可以抛出异常(默认情况下,当new
操作符无法分配内存时,会抛出std::bad_alloc
异常),这比malloc
仅返回NULL
(在C++11之前)或nullptr
(在C++11及以后)提供了更好的错误处理机制。
3. 计算空间
-
malloc
:仅分配请求的字节数。如果对象需要额外的空间(如对齐要求或管理信息),则程序员需要手动处理这些额外需求。 -
new
:可能会分配比请求更多的内存以满足对象的特定需求(如对齐和可能的内存管理开销)。这种额外的处理是自动完成的,程序员无需担心。
4. 步骤
-
malloc
:- 分配内存。
- 手动类型转换(如果需要)。
- (可选)调用构造函数(对于对象类型,这通常需要显式调用)。
- 使用内存。
- 释放内存(使用
free
)。
-
new
:- 分配内存。
- 自动调用构造函数(如果是对象类型)。
- 使用内存。
- 释放内存(使用
delete
或delete[]
)。
5. operator new
的实现
operator new
是C++中用于内存分配的全局或类特定的函数,通常与new
运算符一起使用。operator new
本身只负责分配内存,并不负责对象的构造。它接受一个size_t
类型的参数(表示请求的字节数),并返回一个void*
类型的指针,指向分配的内存。
void* operator new(std::size_t size) {
if (void* p = std::malloc(size)) {
return p;
} else {
// 在实际实现中,通常会抛出 std::bad_alloc 异常
throw std::bad_alloc();
}
}
然而,标准库提供的operator new
可能还包括其他步骤,如处理内存不足的情况、日志记录、调试检查等。用户可以通过重载operator new
来提供自定义的内存分配策略。
总之,new
和malloc
在C++中都用于动态内存分配,但它们在用法、类型安全、错误处理、内存计算等方面存在显著差异。在C++中,通常推荐使用new
和delete
(或new[]
和delete[]
)进行对象的动态分配和释放,因为它们提供了更好的类型安全和错误处理机制。
3. new[] 与 delete[]?
在C++中,new[]
和 delete[]
是用于动态分配和释放对象数组的特定操作符。它们与单个对象的 new
和 delete
操作符相似,但专门用于处理对象数组。下面将详细解释这些操作符的各个方面:
1. 如何分配内存
-
new[]
:当你使用new[]
操作符时,它首先为数组中的每个元素调用operator new
(或全局的::operator new[]
,如果为数组特化)来分配足够的连续内存空间以存储指定数量的对象。分配的内存大小是元素大小乘以元素数量,加上可能的额外空间(如用于记录元素数量的空间,但这不是C++标准强制要求的)。 -
内存分配失败:如果内存分配失败,
new[]
会抛出std::bad_alloc
异常。
2. 构建对象
- 在内存分配成功后,
new[]
会对数组中的每个元素调用相应的构造函数(默认构造函数或用户指定的构造函数),以在分配的内存中初始化对象。对于非POD(Plain Old Data)类型的对象,这是必要的步骤。
3. 如何析构与释放内存
-
delete[]
:当你使用delete[]
操作符时,它会首先调用数组中每个对象的析构函数(如果有的话),以按逆序(与构造函数调用的顺序相反)销毁对象。然后,它调用operator delete[]
(或全局的::operator delete[]
)来释放之前通过new[]
分配的内存空间。 -
内存释放失败:理论上,
delete[]
不应失败,因为它只是释放内存并调用析构函数。如果operator delete[]
抛出异常,这将是C++程序中的一个严重错误,因为标准的operator delete[]
实现不应抛出异常。
4. 构造与析构的注意事项
-
异常安全:在使用
new[]
分配内存并构造对象时,如果构造过程中抛出异常,则已经构造的对象会被自动析构(这是通过异常处理机制中的栈展开(stack unwinding)实现的),但内存不会被自动释放。这可能导致内存泄漏。为了避免这种情况,可以使用智能指针(如std::unique_ptr<T[]>
或std::vector<T>
)来自动管理内存和对象的生命周期。 -
类型匹配:
delete[]
必须与new[]
配对使用,且类型必须完全匹配。使用delete
而不是delete[]
来释放由new[]
分配的内存,或者反之,都是未定义行为,可能导致运行时错误或程序崩溃。 -
数组大小:与
malloc
和free
不同,new[]
和delete[]
不需要(也不支持)显式地传递数组的大小。数组的大小信息通常存储在分配的内存的某个地方(尽管这不是C++标准的要求),但delete[]
运算符知道如何找到并使用这个信息来正确地调用析构函数和释放内存。
4. new 带括号和不带的区别?
在C++中,new
操作符用于动态分配内存并(可选地)构造对象。关于 new
带括号和不带的区别,实际上主要涉及到对象的初始化行为,而不是简单地分配内存与否的差别。不过,你的描述中关于“不带括号的 new 只分配内存,带括号的 new 会初始化为 0”并不完全准确,尤其是在考虑不同类型的对象时。下面我将详细解释这个区别。
不带括号的 new
当你使用不带括号的 new
时,你实际上是在请求为对象分配足够的内存空间,但并不会调用任何构造函数(对于非POD类型)来初始化该对象。然而,这个描述在C++中并不完全准确,因为对于类类型的对象,如果不使用括号,编译器仍然会尝试调用该类型的默认构造函数(如果存在)来初始化对象。如果类没有定义默认构造函数,且没有提供其他构造函数,或者构造函数是私有的(且没有友元函数或类成员函数调用它),则编译器会报错。
带括号的 new
带括号的 new
允许你指定初始化器,这个初始化器可以是任何合法的C++表达式,用于初始化新分配的对象。这意味着你可以直接调用构造函数(对于类类型的对象)或进行值初始化(对于基本数据类型)。
- 对于类类型:你可以直接调用构造函数,传递所需的参数。
- 对于内置类型:如果你使用括号但不提供任何值(如
int* p = new int();
),对象会被值初始化,对于基本数据类型这通常意味着它们会被初始化为零(对于整数和浮点类型是0,对于指针类型是nullptr
,对于类类型是调用默认构造函数,如果类类型是POD且没有构造函数,则行为未定义)。如果你提供了具体的值(如int* p = new int(5);
),则对象会被初始化为该值。
关于“初始化为 0”的误解
你的描述中提到“带括号的 new 会初始化为 0”,这实际上是对值初始化的一种简化理解。对于内置类型(如 int
、float
等),如果不带括号且类型有默认构造函数(对于内置类型来说,这实际上不适用,因为内置类型没有构造函数),或者带括号但不提供值,则对象会被值初始化,对于数值类型这通常意味着它们被初始化为零。但是,对于类类型,除非类定义了将其成员初始化为零的默认构造函数,否则对象不会被自动初始化为零。
结论
- 不带括号的
new
对于类类型对象会调用默认构造函数(如果存在),对于内置类型则分配未初始化的内存。 - 带括号的
new
允许你指定初始化器,可以是调用构造函数(对于类类型)或进行值初始化(对于内置类型)。 - “初始化为 0”的表述主要适用于内置类型的值初始化,而不是所有情况下带括号的
new
都会导致这种结果。
5. new 时内存不足?
- 《Effective C++:条款 49》 (new-handler)
在C++中,当使用new
操作符动态分配内存时,如果系统无法满足内存请求(即内存不足),默认情况下,new
会抛出一个std::bad_alloc
异常。然而,C++标准库提供了一种机制,允许程序员在安装自定义的“new-handler”函数来响应内存分配失败的情况,这在《Effective C++》的条款49中有详细讨论。
new-handler 是什么?
new-handler 是一个可以设置的函数指针,指向一个函数,这个函数在内存分配失败时被调用。默认情况下,这个指针指向一个标准的错误处理函数,该函数通常只是抛出std::bad_alloc
异常。但是,你可以通过调用std::set_new_handler
函数来更改这个指针,使其指向你自己的new-handler函数。
如何设置和使用 new-handler?
-
定义 new-handler 函数: 这个函数需要接受
void*
类型的参数(尽管在实际调用时,这个参数不会被使用),并且没有返回值(即返回类型为void
)。void myNewHandler() { // 处理内存不足的情况,例如记录日志、释放一些资源、尝试增加内存等 // 也可以抛出异常,或者终止程序 std::cerr << "Memory allocation failed. Attempting recovery..." << std::endl; // 可以尝试释放一些内存 // ... // 如果无法恢复,可以再次抛出异常或终止程序 throw std::bad_alloc(); // 或者 std::abort(); }
-
设置 new-handler: 在程序开始或适当的位置,通过调用
std::set_new_handler
来设置你的new-handler函数。std::set_new_handler(myNewHandler);
-
使用 new: 现在,当
new
操作符因为内存不足而失败时,它会调用你设置的myNewHandler
函数。
注意事项
-
递归调用:new-handler函数需要特别小心处理递归调用的问题。如果new-handler函数尝试再次分配内存(直接或间接地),这可能会再次触发new-handler的调用,从而导致无限递归。因此,在new-handler函数中分配内存时要格外小心,或者确保有某种机制来防止这种情况。
-
恢复能力:不是所有的内存不足情况都可以通过new-handler来恢复。在某些情况下,程序可能需要优雅地关闭或执行一些清理操作,而不是试图继续执行。
-
性能考虑:设置自定义的new-handler可能会对性能产生影响,因为它在每次内存分配失败时都会被调用。确保你的new-handler函数尽可能高效。
通过这种方式,C++提供了一种灵活的方式来处理内存分配失败的情况,使得程序员可以根据具体的应用需求来定制错误处理策略。
6. 分别说说malloc、calloc、realloc、alloca的实现?
1. malloc
通用实现细节:
- 内存请求:
malloc
接受一个size_t
类型的参数,表示要分配的内存大小(以字节为单位)。 - 内存搜索:
malloc
会搜索一个足够大的空闲内存块来满足请求。这通常是通过维护一个或多个空闲内存块的链表或树来完成的。 - 内存分割:如果找到足够大的内存块,但比请求的大小大得多,
malloc
可能会将该块分割成两部分:一部分用于满足当前请求,另一部分保留为未来的空闲块。 - 内存返回:如果成功找到并分配了内存,
malloc
会返回一个指向该内存的指针。 - 内存不足:如果无法分配内存,
malloc
会返回NULL
。
底层机制:
- 在Unix-like系统中,
malloc
可能会使用brk()
系统调用来扩展堆区,或者使用mmap()
来映射新的内存区域。 malloc
还负责处理内存碎片,这通常涉及合并相邻的空闲块或分割大块以满足小请求。
2. calloc
实现细节:
calloc
首先调用malloc
来分配请求的内存大小。- 然后,它将分配的内存区域清零,确保每个字节都被初始化为0。
- 最后,它返回指向已分配并清零的内存的指针。
3. realloc
实现细节:
realloc
接受两个参数:一个指向已分配内存的指针和一个新的大小。- 如果新大小小于或等于当前大小,
realloc
可能会简单地返回原始指针,而不做任何其他操作(或可能执行一些清理操作以释放多余的空间,但这取决于实现)。 - 如果新大小大于当前大小,
realloc
会尝试在原地扩展内存块。这通常是不可能的,因为堆中的内存块可能不是连续的,或者没有足够的连续空间来满足请求。 - 如果原地扩展失败,
realloc
会分配一个新的内存块,将旧数据复制到新块中(如果需要,则只复制部分数据以适应新大小),然后释放旧块。 realloc
返回指向新内存块的指针(可能与原指针相同,也可能不同)。
4. alloca
注意:alloca
不是标准C或C++的一部分,但它在许多编译器中作为扩展提供。
实现细节:
alloca
在栈上分配内存,这意味着它不需要调用操作系统来管理内存。- 分配的内存大小通常在编译时确定,或者至少是在函数调用时确定的,因为栈的大小在函数调用时是固定的。
- 分配的内存会在包含它的函数返回时自动释放。
- 由于栈的大小有限,使用
alloca
时需要小心以避免栈溢出。 alloca
通常通过编译器内建的代码来实现,而不是通过库函数。
注意:由于alloca
的这些特性和潜在的风险,现代C和C++程序通常建议使用malloc
、calloc
或realloc
进行动态内存分配,并在不再需要时显式释放内存。对于需要在栈上分配小量临时内存的情况,考虑使用局部变量或自动存储期限的对象。
7. 调用 malloc 函数之后,OS 会马上分配内存空间吗?
- 不会,只会返回一个虚拟地址,待用户要使用内存时,OS 会发出一个缺页中断,此时,内存管理模块才会为程序分配真正内存
答案:
调用malloc
函数之后,操作系统(OS)并不会立即分配物理内存空间给进程。实际上,malloc
函数是C标准库中的一个函数,用于动态分配内存。它并不是系统调用,而是通过一系列复杂的机制来管理内存分配,这些机制可能涉及系统调用,但并非直接分配物理内存。
具体来说,malloc
函数的工作流程大致如下:
-
内存请求处理:当调用
malloc
函数请求一定大小的内存时,malloc
首先会在其管理的内存池中查找是否有足够的空闲内存块可以满足请求。这里的内存池可能是一个或多个内存区域,它们已经被malloc
提前从操作系统处获得,但尚未分配给具体的内存请求。 -
系统调用(如果需要):
- 如果内存池中没有足够的空闲内存,
malloc
可能会通过系统调用来请求更多的内存。这通常涉及两种主要方式:- brk系统调用:对于较小的内存请求(具体阈值取决于C库的实现,如glibc中可能默认是128KB),
malloc
可能会通过brk
系统调用来调整进程的堆顶指针,从而在堆上分配内存。brk
会将堆顶指针(也称为brk
指针)向高地址移动,从而扩展堆空间。 - mmap系统调用:对于较大的内存请求,
malloc
可能会选择使用mmap
系统调用来在进程的地址空间中创建一个新的内存映射区域。mmap
通过“私有匿名映射”的方式,在文件映射区分配一块内存,这实际上是从虚拟内存空间中“借用”了一块空间,而不是直接从物理内存中分配。
- brk系统调用:对于较小的内存请求(具体阈值取决于C库的实现,如glibc中可能默认是128KB),
- 如果内存池中没有足够的空闲内存,
-
虚拟内存与物理内存的映射:需要注意的是,无论是通过
brk
还是mmap
获得的内存,最初都是虚拟内存。只有当进程实际访问这些虚拟内存地址时,操作系统才会通过页表等机制将它们映射到物理内存上。这种机制称为“延迟分配”或“按需分页”,它有助于减少物理内存的浪费,因为未使用的虚拟内存不会占用物理资源。 -
内存分配与返回:一旦
malloc
找到足够的内存块(无论是从内存池中还是通过系统调用获得),它就会将该内存块标记为已分配,并返回指向该内存块起始地址的指针给调用者。此时,调用者就可以通过该指针来访问和操作分配的内存空间了。
综上所述,调用malloc
函数之后,操作系统并不会立即分配物理内存空间。相反,它会通过一系列复杂的机制来管理内存分配,包括在需要时通过系统调用来请求更多的虚拟内存空间,并在进程实际访问这些内存时再将它们映射到物理内存上。
8. delete
在C++岗位面试中,关于delete
操作符的问题是一个常见的考察点,因为它涉及到内存管理、对象生命周期和资源释放等多个重要方面。下面是对您提到的几个方面的详细解答:
1. delete的步骤
当在C++中使用delete
操作符来释放一个动态分配的对象时,大致会经历以下几个步骤:
-
检查指针是否非空:虽然
本身并不直接检查指针是否为空(这是程序员的责任),但安全编程实践建议总是先检查指针是否为,以避免未定义行为。
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
【C/C++面试必考必会】专栏,直击面试核心,精选C/C++及相关技术栈中面试官最爱的必考点!从基础语法到高级特性,从内存管理到多线程编程,再到算法与数据结构深度剖析,一网打尽。助你快速构建知识体系,轻松应对技术挑战。希望专栏能让你在面试中脱颖而出,成为技术岗的抢手人才。