C++常见面试题总结5---内存
C++ 内存分区
栈、堆、全局/静态存储区、常量存储区、代码区。从操作系统的本身来讲,以上存储区在内存中的分布是如下形式(从低到高地址):.text 段 --> .data 段 --> .bss 段 --> 堆 --> unused --> 栈 --> env。如下图所示。
栈:存放函数的局部变量、函数参数、返回地址,由编译器自动分配和释放。
堆:动态申请的内存空间,就是由 malloc 分配的内存块,由程序员控制它的分配和释放,如果程序执行结束还没有释放,操作系统会自动回收。
全局区/静态存储区(.bss 段和 .data 段):存放全局变量和静态变量,程序运行结束操作系统自动释放。在 C 语言中,未初始化的放在 .bss 段中,初始化的放在 .data 段中,C++ 中不再区分了。
常量存储区(.data 段):存放常量,不允许修改,程序运行结束自动释放。
代码区(.text 段):存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。
#include <iostream>
using namespace std;
int g_var = 0; // g_var 在全局区(.data 段)
char *gp_var; // gp_var 在全局区(.bss 段)
int main()
{
int var; // var 在栈区
char *p_var; // p_var 在栈区
char arr[] = "abc"; // arr 为数组变量,存储在栈区;"abc"为字符串常量,存储在常量区
char *p_var1 = "123456"; // p_var1 在栈区;"123456"为字符串常量,存储在常量区
static int s_var = 0; // s_var 为静态变量,存在静态存储区(.data 段)
p_var = (char *)malloc(10); // 分配得来的 10 个字节的区域在堆区
free(p_var);
return 0;
}
堆和栈
- 管理方式不同:栈是由编译器在需要时自动分配,不需要时自动清除的变量存储区,程序员无法控制。通常存放局部变量、函数参数等。堆是由new分配的内存块,由程序员释放(编译器不管),一般一个new与一个delete对应,一个new[]与一个delete[]对应。如果程序员没有释放掉,资源将由操作系统在程序结束后自动回收。
- 空间大小不同:栈是一块连续的内存的区域,且空间有限(windows下栈大小是2M);堆是不连续的内存区域,内存可以达到4G(32位机)。
- 能否产生碎片不同:栈不会产生碎片,因为栈是种先进后出的队列。堆则容易产生碎片,多次的new/delete会造成内存的不连续,从而造成大量的碎片。
- 生长方向不同:堆的生长方式是向上的,向高地址方向增长。栈是向下的,向低地址方向增长。
- 分配方式不同:堆是动态分配的。栈可以是静态分配(由编译器完成(如函数局部变量))和动态分配(动由alloca函数分配)两种,但是栈的动态分配由编译器释放。
- 分配效率不同:栈是机器系统提供的数据结构,计算机底层提供支持;堆则是由C/C++函数库提供, 机制复杂,没有专门的系统支持,因此堆的效率比栈低得多。由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。
- 存放的内容:栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制。
全局变量、局部变量、静态全局变量、静态局部变量的区别
C++ 变量根据定义的位置的不同的生命周期,具有不同的作用域,作用域可分为 6 种:全局作用域,局部作用域,语句作用域,类作用域,命名空间作用域和文件作用域。
从作用域看:
- 全局变量:具有全局作用域。全局变量只需在一个源文件中定义,就可以作用于所有的源文件。当然,其他不包含全局变量定义的源文件需要用 extern 关键字再次声明这个全局变量。
- 静态全局变量:具有文件作用域。它与全局变量的区别在于如果程序包含多个文件的话,它作用于定义它的文件里,不能作用到其它文件里,即被 static 关键字修饰过的变量具有文件作用域。这样即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。
- 局部变量:具有局部作用域。它是自动对象(auto),在程序运行期间不是一直存在,而是只在函数执行期间存在,函数的一次调用执行结束后,变量被撤销,其所占用的内存也被收回。
- 静态局部变量:具有局部作用域。它只被初始化一次,自从第一次被初始化直到程序运行结束都一直存在,它和全局变量的区别在于全局变量对所有的函数都是可见的,而静态局部变量只对定义自己的函数体始终可见。
从分配内存空间看:
静态存储区:全局变量,静态局部变量,静态全局变量。
栈:局部变量。
静态变量和栈变量(存储在栈中的变量)、堆变量(存储在堆中的变量)的区别: 静态变量会被放在程序的静态数据存储区(.data 段)中(静态变量会自动初始化),这样可以在下一次调用的时候还可以保持原来的赋值。而栈变量或堆变量不能保证在下一次调用的时候依然保持原来的值。
静态变量和全局变量的区别: 静态变量用 static 告知编译器,自己仅仅在变量的作用范围内可见。
全局变量定义在头文件中有什么问题?怎么解决?
如果在头文件中定义全局变量,当该头文件被多个文件 include 时,该头文件中的全局变量就会被定义多次,导致重复定义,因此不能再头文件中定义全局变量。
解决这个问题有三种方法: 原文地址点这里
(1)全局变量作为内部变量
创建一个头文件来保留这些常数;
在此头文件中,定义一个namespace;
在namespace中添加所有常数;
#include头文件。
存在的问题:
更改单个常量值需要重新编译包含常量头的每个文件,这可能导致大型项目的重建时间过长。
如果常量很大并且无法优化,这可能会使用大量内存。
(2)全局变量作为外部变量
避免这些问题的一种方法是将这些常数变成外部变量,因为我们可以在所有文件中共享一个变量(初始化一次)。在此方法中,定义 .cpp文件中的常数(确保定义仅存在于一个地方),并在头文件中声明(其他文件将包括该声明)。
我们在这个方法中使用 const 而不是 constexpr。因为 constexpr 变量不能被前向声明,即使它们有外部链接。这是因为编译器在编译时需要知道变量的值,而前向声明不提供此信息。
此方法的缺点:
首先,这些常量现在仅在它们实际在 () 中定义的文件中才被视为编译时常量。在其他文件中,编译器只会看到前向声明,它没有定义常量值(必须由链接器解析)。这意味着在其他文件中,这些被视为运行时常量值,而不是编译时常量。因此,这些变量不能在需要编译时常量的任何地方使用。
其次,因为编译时常量通常比运行时常量更优,编译器可能无法优化这些。
(3)全局变量作为内联变量
C++17引入了一个新的概念,叫做内联。在C++,这个词已经演变为"允许多个定义"。因此,内联变量允许在不违反一个定义规则的情况下在多个文件中定义。默认情况下,内联全局变量具有外部联系。
内联变量有两个主要限制必须遵守:
1)内联变量的所有定义必须相同(否则,将导致未定义的行为)。
2)内联变量定义(不是前向声明)必须存在于任何使用该变量的文件中。
链接器将整合变量的所有内联定义为单个可变定义(从而满足一个定义规则)。这允许我们在头文件中定义变量,并将其视为.cpp文件中仅有一个定义。这些变量在包含它们的所有文件中也保留其收缩性。
如何限制类的对象只能在堆上或栈上创建?
C++ 中的类的对象的建立分为两种:静态建立、动态建立。
- 静态建立:由编译器为对象在栈空间上分配内存,直接调用类的构造函数创建对象。例如:A a;
- 动态建立:使用 new 关键字在堆空间上创建对象,底层首先调用 operator new() 函数,在堆空间上寻找合适的内存并分配;然后,调用类的构造函数创建对象。例如:A *p = new A();
限制对象只能建立在堆上
(1)将析构函数设置为私有。 原因:静态对象建立在栈上,是由编译器分配和释放内存空间,编译器为对象分配内存空间时,会对类的非静态函数进行检查,即编译器会检查析构函数的访问性。当析构函数设为私有时,编译器创建的对象就无法通过访问析构函数来释放对象的内存空间,因此,编译器不会在栈上为对象分配内存。该方法存在的问题:
- 用 new 创建的对象,通常会使用 delete 释放该对象的内存空间,但此时类的外部无法调用析构函数,因此类内必须定义一个 destory() 函数,用来释放 new 创建的对象。
- 无法解决继承问题,因为如果这个类作为基类,析构函数要设置成 virtual,然后在派生类中重写该函数,来实现多态。但此时,析构函数是私有的,派生类中无法访问。
(2)构造函数设置为 protected,并提供一个 public 的静态函数来完成构造,而不是在类的外部使用 new 构造;将析构函数设置为 protected。 原因:类似于单例模式,也保证了在派生类中能够访问析构函数。通过调用 create() 函数在堆上创建对象。
限制对象只能建立在栈上
解决方法:将 operator new() 设置为私有。原因:当对象建立在堆上时,是采用 new 的方式进行建立,其底层会调用 operator new() 函数,因此只要对该函数加以限制,就能够防止对象建立在堆上。
内存对齐
内存对齐的原则
内存对齐: 编译器将程序中的每个“数据单元”安排在字的整数倍的地址指向的内存之中。内存对齐的原则:
- 结构体变量的首地址能够被其最宽基本类型成员大小与对齐基数中的较小者所整除;
- 结构体每个成员相对于结构体首地址的偏移量 (offset) 都是该成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在成员之间加上填充字节 (internal padding);
- 结构体的总大小为结构体最宽基本类型成员大小与对齐基数中的较小者的整数倍,如有需要编译器会在最末一个成员之后加上填充字节 (trailing padding)。
struct A //64 位编译器
{
short var; // 2 字节
int var1; // 8 字节 (内存对齐原则:填充 2 个字节) 2 (short) + 2 (填充) + 4 (int)= 8
long var2; // 12 字节 8 + 4 (long) = 12
char var3; // 16 字节 (内存对齐原则:填充 3 个字节)12 + 1 (char) + 3 (填充) = 16
string s; // 48 字节 16 + 32 (string) = 48
};
int main()
{
short var;
int var1;
long var2;
char var3;
string s;
A ex1;
cout << sizeof(var) << endl; // 2 short
cout << sizeof(var1) << endl; // 4 int
cout << sizeof(var2) << endl; // 4 long
cout << sizeof(var3) << endl; // 1 char
cout << sizeof(s) << endl; // 32 string
cout << sizeof(ex1) << endl; // 48 struct
return 0;
}
32位于64位系统下不同数据类型的大小:
数据类型 | 32位 | 64位 |
---|---|---|
char | 1 | 1 |
short | 2 | 2 |
int | 4 | 4 |
int* | 4 | 8 |
long | 4 | 4 |
long long | 8 | 8 |
float | 4 | 4 |
double | 8 | 8 |
long double | 12 | 16 |
如何判断结构体是否相等?能否用 memcmp 函数判断结构体相等?
需要重载操作符 == 判断两个结构体是否相等;不能用函数 memcmp 来判断两个结构体是否相等。因为 memcmp 函数是逐个字节进行比较的,而结构体存在内存空间中保存时存在字节对齐,字节对齐时补的字节内容是随机的,会产生垃圾值,所以无法比较。
类的大小计算
类的大小是指类的实例化对象的大小,用 sizeof 对类型名操作时,结果是该类型的对象的大小。计算原则:
- 遵循结构体的对齐原则。
- 与普通成员变量有关,与成员函数和静态成员无关(占用内存:0)。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响。因为静态数据成员被类的对象共享,并不属于哪个具体的对象。
- 虚函数对类的大小有影响(占用内存:1),是因为虚函数表指针的影响。
- 虚继承对类的大小有影响,是因为虚基表指针带来的影响。
- 空类的大小是一个特殊情况,空类的大小为 1,当用 new 来创建一个空类的对象时,为了保证不同对象的地址不同,空类也占用存储空间。
#include <iostream>
using namespace std;
class A
{
private:
static int s_var; // 不影响类的大小
const int c_var; // 4 字节
int var; // 8 字节 4 + 4 (int) = 8
char var1; // 12 字节 8 + 1 (char) + 3 (填充) = 12
public:
A(int temp) : c_var(temp) {} // 不影响类的大小
~A() {} // 不影响类的大小
virtual void f() { cout << "A::f" << endl; }
virtual void g() { cout << "A::g" << endl; }
virtual void h() { cout << "A::h" << endl; } // 24 字节 12 + 4 (填充) + 8 (指向虚函数的指针) = 24
};
int main()
{
A ex1(4);
A *p;
cout << sizeof(p) << endl; // 8 字节 注意:指针所占的空间和指针指向的数据类型无关
cout << sizeof(ex1) << endl; // 24 字节
return 0;
}
类内存分布
子类和父类数据存储方式
子类对象开头部分的4个字节是虚函数表,接着就是基类的数据成员,然后是子类的数据成员。有多个父类时,内存中先存储第一个父类的成员变量,其次是第二个,一直到第N个,最后才保存子类的成员变量。成员函数不占字节。
构造时先构造父函数,再是子函数。析构时先析构子函数,再是父函数。
内存结构图分成了两个部分,上面是内存分布,下面是虚表。
VS所带编译器是把虚表指针放在了内存的开始处(0地址偏移),然后再是成员变量;下面生成了虚表,紧跟在&Base_meta后面的0表示,这张虚表对应的虚指针在内存中的分布,下面列出了虚函数,左侧的0是这个虚函数的序号,这里只有一个虚函数,所以只有一项,如果有多个虚函数,会有序号为1,为2的虚函数列出来。
下面加上子类,并在子类中添加虚函数,可以看到子类内存的排布如下:
上半部是内存分布,可以看到,虚表指针被继承了,且仍位于内存排布的起始处,下面是父类的成员变量a和b,最后是子类的成员变量c,注意虚表指针只有一个,子类并没有再生成虚表指针了;下半部的虚表情况与父类是一样的。
如果重声明了一个新的子类虚方法,还是只有一个虚表指针,但是下方虚表的内容变化了,虚表的0号是父类的VirtualFunction,而1号放的是子类的VirtualFunction2。
总结:
- 每个类都有虚指针和虚表;
- 如果不是虚继承,那么子类将父类的虚指针继承下来,并指向自身的虚表(发生在对象构造时)。有多少个虚函数,虚表里面的项就会有多少。多重继承时,可能存在多个的基类虚表与虚指针;
- 如果是虚继承,那么子类会有两份虚指针,一份指向自己的虚表,另一份指向虚基表,多重继承时虚基表与虚基表指针有且只有一份。
C++中的类对象实例化后存在内存中的什么位置?
既可能在栈又可能在堆。如果类对象实例作为局部变量出现,放在栈中,作用域结束,类实例所占内存释放。如果是通过指针的形式new class就在堆中创建,在堆中的要手动删除。
内存泄露
内存泄漏: 由于疏忽或错误导致的程序未能释放已经不再使用的内存。并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。
常指堆内存泄漏,因为堆是动态分配的,而且是用户来控制的,如果使用不当,会产生内存泄漏。使用 malloc、calloc、realloc、new 等分配内存时,使用完后要调用相应的 free 或 delete 释放内存,否则这块内存就会造成内存泄漏。指针重新赋值,指针p指向一块内存空间,但p被重新赋值后会导致p初始时指向的那块内存空间无法找到,发生内存泄漏。
如何检测内存泄漏?
在申请内存时记录下该内存的地址和在代码中申请内存的位置,在内存销毁时删除该地址对应的记录,程序最后统计下还有哪条记录没有被删除,如果还有没被删除的记录就代表有内存泄漏。
- 定义一个容器类,保存申请内存的状态信息(通过链表)。函数Add为增加一条信息,申请内存时调用;函数Remove移除一条信息,内存释放时调用。
- 重载类的new 和 delete,重载类的new 和 delete,统计各个类的分配次数和释放次数,并定义宏DEBUG_NEW指向重载的操作。
- 通过宏定义把 new 操作转向我们重载的带参数的操作。
- 内存信息输出。
怎么防止内存泄漏?
- 内部封装:将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存。但这样做并不是最佳的做法,在类的对象复制时,程序会出现同一块内存空间释放两次的情况,可以通过增加计数机制来避免这种情况。
- 智能指针:C++中已经对内存泄漏封装好了一个工具,可以直接拿来使用,将在下一个问题中对智能指针进行详细的解释。
内存泄漏检测工具
valgrind 是一套 Linux 下,开放源代码(GPL V2)的仿真调试工具的集合,包括以下工具:
- Memcheck:内存检查器(valgrind 应用最广泛的工具),能够发现开发中绝大多数内存错误的使用情况,比如:使用未初始化的内存,使用已经释放了的内存,内存访问越界等。
- Callgrind:检查程序中函数调用过程中出现的问题。
- Cachegrind:检查程序中缓存使用出现的问题。
- Helgrind:检查多线程程序中出现的竞争问题。
- Massif:检查程序中堆栈使用中出现的问题。
- Extension:可以利用core提供的功能,自己编写特定的内存调试工具。
Memcheck 能够检测出内存问题,关键在于其建立了两个全局表:
- Valid-Value表:对于进程的整个地址空间中的每一个字节(byte),都有与之对应的8个bits;对于CPU的每个寄存器,也有一个与之对应的bit向量。这些 bits 负责记录该字节或者寄存器值是否具有有效的、已初始化的值。
- Valid-Address表:对于进程整个地址空间中的每一个字节(byte),还有与之对应1个bit,负责记录该地址是否能够被读写。
检测原理:
- 当要读写内存中某个字节时,首先检查这个字节对应的Valid-Address表中对应的bit。如果该bit显示该位置是无效位置,Memcheck则报告读写错误。
- 内核(core)类似于一个虚拟的CPU环境,这样当内存中的某个字节被加载到真实的CPU中时,该字节在Valid-Value表对应的bits也被加载到虚拟的CPU环境中。一旦寄存器中的值,被用来产生内存地址,或者该值能够影响程序输出,则Memcheck会检查Valid-Value表对应的bits,如果该值尚未初始化,则会报告使用未初始化内存错误。
溢出、泄漏、越界
栈溢出
如果程序使用的栈内存超出最大值,就会发生栈溢出错误。栈是一种数据结构,它按照先进后出的原则存储数据,大小一般为1 ~ 2M。
栈溢出产生场景:最常见的就是递归(需要进行代码优化);定义变量过多。
避免方式:使用堆内存;用vector替换数组;增加栈内存:在项目->属性->链接->系统中增大栈大小。
缓冲区溢出
程序为了临时存取数据的需要,一般会分配一些内存空间称为缓冲区。如果向缓冲区中写入缓冲区无法容纳的数据,机会造成缓冲区以外的存储单元被改写,称为缓冲区溢出。而栈溢出是缓冲区溢出的一种。
内存溢出
内存溢出就是要求分配的内存超出了系统本身的内存,系统不能满足需求,于是产生内存溢出。
原因可能如下:内存中加载的数据过于庞大;代码中存在死循环;递归调用太深,导致堆栈溢出等;内存泄漏最终导致内存溢出。
内存泄漏
指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况(如只new不delete)。它并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存越界
是指向系统申请一块内存后,使用时却超出申请范围。比如一些操作内存的函数:sprintf、strcpy、strcat、vsprintf、memcpy、memset、memmove。当造成内存泄漏的代码运行时,所带来的错误是无法避免的,通常会造成:破坏了堆中内存的分配信息数据,特别是动态分配的内存块的内存信息数据;破坏了程序其他对象的内存空间,会影响程序执行的不正确性;破坏了空闲内存块。
C++数组下标越界
C++中数组下标越界程序并不报错是因为编译器不会对数组下标作越界检查造成的。数组只是一个指针,访问数组的时候,只是根据首地址向后连续推算,只要有内存就行。如arr[5]等效就是 *(arr+5),实际上访问了arr后5位的内存。
C语言非常重视运行时的效率,所以没有进行数组越界检查,而C++继承了C的效率要求,自然也不做数组越界检查。(检查数据越界,编译器就必须在生成的目标代码中加入额外的代码用于程序运行时检测下标是否越界,这就会导致程序的运行速度下降)。
如果数组下标越界了,那么它会自动接着那块内存往后写。以前说不允许数组下标越界,并不是因为界外没有存储空间,而是因为界外的内容是未知的。也就是说如果界外的空间暂时没有被利用,那么我们可以占用那块内存。但是如果之前界外的内存已经存放了东西,那么我们越界过去就会覆盖那块内存,则有可能会导致错误的产生或是程序最终的运行结果出错。
C printf 中的 i++ 和 ++i
int i = 1;
printf("%d,%d", ++i, ++i); //3,3
printf("%d,%d", i++, i++); //4,3
printf("%d,%d", ++i, i++); //7,5
printf("%d,%d", i++, ++i); //8,9
参数自右向左运算。
第三行,计算i++,将i = 5放入缓存;给i + 1,i = 9;计算++i,再给i加1,i = 10;入栈操作,将缓存8入栈,然后将i = 10入栈;
第四行,计算++i,i = 9;将i的值放入缓存,即存9;计算i++,i = 10;入栈操作,i = 10入栈,缓存i = 9入栈;出栈操作,9出栈,10出栈。