本task是关于C++ 程序的编译过程、内存以及头文件的一些知识点,重点在内存方面进行展开,包括内存的分区、内存对齐、内存泄漏、内存泄漏的防止方法、现有的检测内存泄漏的工具等等。

由于问题之间的关联性,可能有些问题并非是本章相关的知识点,例如一些问题涉及到了类中的虚函数、创建类的对象的底层原理等等,但为了保持问题上下的连贯性,也放在了这里。

文章目录

1. C++ 程序编译过程

编译预处理:处理以 # 开头的指令;

编译、优化:将源码 .cpp 文件翻译成 .s 汇编代码;

汇编:将汇编代码 .s 翻译成机器指令 .o 文件;

链接:因为​​.cpp​​​文件中的函数可能会引用了另一个​​.cpp​​​文件中定义的符号或者调用某个库文件的函数,即汇编程序生成目标文件(​​.o​​​文件)后不会立刻执行,而是通过【链接】将对应目标文件连成整体,生成​​.exe​​可执行文件。

这里说的可执行的程序.exe文件。注意exe是Windows平台的二进制文件,在Linux中并不存在,Linux中并不是以文件后缀来区分文件类型的,所以.o文件也有可能是Linux中的二进制文件。

【C++面试】编译内存相关_c++

  • 动态链接和静态链接:
  • 静态链接:程序运行前,将各个目标模块及其库函数链接成一个完整的可执行程序。
  • 动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等信息,在程序执行时,动态链接库的全部内容会被映射到运行时对应的虚拟地址空间。
  • 静态链接:
  • 优点:既然可执行程序具备了程序运行的所有内容,所以优点就是运行时速度快。
  • 缺点:如果目标文件进行更新操作(或有些库更新了),就需要重新编译链接生成可执行程序,即更新会有一丢丢困难;而且每个可执行程序都会有目标文件的一个副本,链接时可能同一个库链接了好几次,有点浪费空间。

【C++面试】编译内存相关_全局变量_02

  • 动态链接(程序执行时才载入引用的库):
  • 优点:节省内存、更新方便;
  • 缺点:每次执行都需要链接,相比静态链接有一定的性能损失。

【C++面试】编译内存相关_内存泄漏_03

2. C++ 内存管理

C++ 内存分区:栈、堆、全局/静态存储区、常量存储区、代码区。

//存储在栈
int x=0;
int *p=NULL;

//存储在堆区,注意这里的数组名为p,而不是int(关键字int)
int *p=new int[20];

//全局区
存储全局变量和静态变量

//常量区
string str="hello";

//代码区
  • 栈:存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放。
  • 堆:动态申请的内存空间,就是由​​malloc​​​ 或者​​new​​分配的内存块,由程序员控制它的分配和释放,如果程序执行结束还没有释放,操作系统会自动回收。
  • 全局区 / 静态存储区(​​.bss​​​ 段和​​.data​​ 段):存放全局变量和静态变量,程序运行结束操作系统自动释放,在 C 语言中,未初始化的放在 .bss 段中,初始化的放在 .data 段中,C++ 中不再区分了。
  • 常量存储区(​​.data​​ 段):存放的是常量,不允许修改,程序运行结束自动释放。
  • 代码区(​​.text​​ 段):存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。
#include <iostream>
using namespace std;

/*
说明:C++ 中不再区分初始化和未初始化的全局变量、静态变量的存储区,如果非要区分下述程序标注在了括号中
*/

int g_var = 0; // g_var 在全局区(.data 段)
char *gp_var; // gp_var 在全局区(.bss 段)

int main()
{
// var 在栈区
int var;
// p_var 在栈区
char *p_var;
// arr 为数组变量,存储在栈区;"abc"为字符串常量,存储在常量区
char arr[] = "abc";
// p_var1 在栈区;"123456"为字符串常量,存储在常量区
char *p_var1 = "123456";
// s_var 为静态变量,存在静态存储区(.data 段)
static int s_var = 0;
// 分配得来的 10 个字节的区域在堆区
p_var = (char *)malloc(10);
free(p_var);
return 0;
}

来看CSAPP中的图(如下),Linux虚拟内存系统地址空间分配,图中缺少了用户空间顶端的 env 区,以及 .text上的 rodata段,但其实 .text和 .rodata都只是 ro(read only,只读) 的,算是归为一类吧。

【C++面试】编译内存相关_面试_04

3. 栈和堆的区别



申请方式

系统自动分配

程序员主动申请

内存中的空间

栈在内存中是连续的一块空间(向低地址扩展)最大容量是系统预定好的

堆在内存中的空间(向高地址扩展)是不连续的

申请效率

申请效率高,但程序员无法控制;

由程序员主动申请,效率低,使用起来方便但是容易产生碎片

存放的内容

栈中存放的是局部变量,函数的参数

堆中存放的内容由程序员控制。

4. 全局变量、局部变量、静态全局变量、静态局部变量

C++ 变量根据定义的位置的不同的生命周期,具有不同的作用域,作用域可分为 6 种:全局作用域,局部作用域,语句作用域,类作用域,命名空间作用域和文件作用域。

  • 从作用域看:
  • 全局变量:具有全局作用域。全局变量只需在一个源文件中定义,就可以作用于所有的源文件。当然,其他不包含全局变量定义的源文件需要用​​extern​​ 关键字再次声明这个全局变量。
  • 静态全局变量:具有文件作用域。它与全局变量的区别在于如果程序包含多个文件的话,它作用于定义它的文件里,不能作用到其它文件里,即被​​static​​ 关键字修饰过的变量具有文件作用域。这样即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。
  • 局部变量:具有局部作用域。它是自动对象(​​auto​​),在程序运行期间不是一直存在,而是只在函数执行期间存在,函数的一次调用执行结束后,变量被撤销,其所占用的内存也被收回。
  • 静态局部变量:具有局部作用域。它只被初始化一次,自从第一次被初始化直到程序运行结束都一直存在,它和全局变量的区别在于全局变量对所有的函数都是可见的,而静态局部变量只对定义自己的函数体始终可见。
  • 从分配内存空间看:
  • 静态存储区:全局变量,静态局部变量,静态全局变量。
  • 栈:局部变量。
  • 几个说明:
  • 静态变量和栈变量(存储在栈中的变量)、堆变量(存储在堆中的变量)的区别:
    静态变量会被放在程序的静态数据存储区(​​​.data​​ 段)中(静态变量会自动初始化),这样可以在下一次调用的时候还可以保持原来的赋值。而栈变量或堆变量不能保证在下一次调用的时候依然保持原来的值。
  • 静态变量和全局变量的区别:
    静态变量用​​​static​​ 告知编译器,自己仅仅在变量的作用范围内可见。

5. 全局变量定义在头文件中有什么问题?

如果在头文件中定义全局变量,当该头文件被多个文件 ​​include​​ 时,该头文件中的全局变量就会被定义多次,导致重复定义,因此不能再头文件中定义全局变量。

6. 对象创建限制在堆或栈

后期补充。

7. 内存对齐

在64位机器中,double(8B) int(4B) short(2B)char(1B)。
【存储对齐的重要条件】
(1)每个成员按其类型的方式对齐,char的对齐值为1,short为2,int为4(单位均为B字节);
存放起始地址%该成员长度=0
(2)struct长度必须是成员中最大的对齐值的整数倍(不够就补空字节),以便在处理数组时保证每一项都边界对齐。
【前提】以下栗子均是按字节编址。
【分析】若N为对齐值,则该成员的“存放起始地址%N=0”,而结构体中的成员都是按定义的先后顺序排放的。
【实例1】设B结构体从地址0x0000开始,第一个成员b的对齐值是1(char是1B),所以其存放地址0x0000符合0x0000%1=0;第二个成员a的对齐值是4(int是4B),如果放在0x0002,2不能被4整除(注意不是看2能否被4整除,而是看2H),不行(不能保证边界对齐),
只能存放在0x0004到0x0007这4个连续的字节中,满足0x0004%4=0且紧邻第一个成员;
第三个成员c的对齐值是2,可以存放在0x0008到0x0009这2个字节中,满足0x0008%2=0且紧邻第二个成员。
结构体长度必须是最大对齐值(此处为4)的整数倍,故0x000A到0x000B也为B所占用,共12B。

struct A{
int a;
char b;
short c;
}
struct B{
char b;
int a;
short c;
}

【实例2】设A结构体从地址0x0000开始,第一个成员a的对齐值是4(int是4B),所以其存放在0x0000到0x0003这4个连续字节;第二个成员b的对齐值是1(char是1B),存放在0x0004中,满足0x0004%4=0且紧邻第一个成员;第三个成员c的对齐值是2,可以存放在0x0006到0x0007这2个字节中,满足0x0006%2=0且紧邻第二个成员。
结构体长度必须是最大对齐值(此处为4)的整数倍,故占用0x0000到0x0007,共8B。
【结果】sizeof(A)=8;sizeof(B)=12。

/*
说明:程序是在 64 位编译器下测试的
*/
#include <iostream>

using namespace std;

struct A
{
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;
}

8. 类的大小

说明:类的大小是指类的实例化对象的大小,用 sizeof 对类型名操作时,结果是该类型的对象的大小。

  • 计算原则:
  • 遵循结构体的对齐原则。
  • 与普通成员变量有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响。因为静态数据成员被类的对象共享,并不属于哪个具体的对象。
  • 虚函数对类的大小有影响,是因为虚函数表指针的影响。
  • 虚继承对类的大小有影响,是因为虚基表指针带来的影响。
  • 空类的大小是一个特殊情况,空类的大小为 1,当用​​new​​ 来创建一个空类的对象时,为了保证不同对象的地址不同,空类也占用存储空间。

栗子:

/*
说明:程序是在 64 位编译器下测试的
*/
#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() {} // 不影响类的大小
};

class B
{
};
int main()
{
A ex1(4);
B ex2;
cout << sizeof(ex1) << endl; // 12 字节
cout << sizeof(ex2) << endl; // 1 字节
return 0;
}
  • 带有虚函数的情况如下:
  • 虚函数表指针即virtual table pointer,简称vptr;而虚函数表为virtual table,简称vtbl。
/*
说明:程序是在 64 位编译器下测试的
*/
#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;
}
  • 分析上面的结果:
  • 第一行指针长度为8:虚函数的个数并不影响所占内存的大小,因为类对象的内存中只保存了指向虚函数表的指针。这个指针的大小在x86编译下占用4个字节,在x64下占用8个字节,上面的栗子中指向虚函数表的指针就是4B,这里是VS2012环境)。
  • 第二行大小为24:12 + 8(指向虚函数的指针) + 4= 24。

9. 内存泄露

内存泄漏:由于疏忽或错误导致的程序未能释放已经不再使用的内存。

  • 内存泄漏常指 堆内存泄漏,因为堆是动态分配的,由用户来控制,如果使用不当,则会产生内存泄漏。比如使用​​malloc​​​、​​calloc​​​、​​realloc​​​、​​new​​​ 等分配内存时,使用完后要调用相应的​​free​​​ 或​​delete​​ 释放内存。
  • 3类内存泄漏:
  • 堆内存泄漏:new/mallc分配内存,未使用对应的delete/free回收
  • 系统资源泄漏, Bitmap, handle,socket等资源未释放
  • 没有将基类析构函数定义称为虚函数,(使用基类指针或者引用指向派生类对象时)派生类对象释放时将不能正确释放派生对象部分。

举个简单栗子:指针重新赋值

char * p = (char *)malloc(10);
char * np = (char *)malloc(10);

其中,指针变量 p 和 np 分别被分配了 10 个字节的内存。

【C++面试】编译内存相关_内存泄漏_05


如果执行​​p=np;​​后,指针变量 p 被 np 指针重新赋值,其结果是 p 以前所指向的内存位置变成了孤立的内存。它无法释放,因为没有指向该位置的引用,从而导致 10 字节的内存泄漏。

【C++面试】编译内存相关_面试_06

10. 怎么防止内存泄漏?内存泄漏检测工具的原理?

内部封装:将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存。

#include <iostream>
#include <cstring>

using namespace std;

class A
{
private:
char *p;
unsigned int p_size;

public:
A(unsigned int n = 1) // 构造函数中分配内存空间
{
p = new char[n];
p_size = n;
};
~A() // 析构函数中释放内存空间
{
if (p != NULL)
{
delete[] p; // 删除字符数组
p = NULL; // 防止出现野指针
}
};
char *GetPointer()
{
return p;
};
};
void fun()
{
A ex(100);
char *p = ex.GetPointer();
strcpy(p, "Test");
cout << p << endl;
}
int main()
{
fun();
return 0;
}

但这样做并不是最佳的做法,在类的对象复制时,程序会出现同一块内存空间释放两次的情况,如下程序:

void fun1()
{
A ex(100);
A ex1 = ex;
char *p = ex.GetPointer();
strcpy(p, "Test");
cout << p << endl;
}

对于 ​​fun1​​​ 这个函数中定义的两个类的对象而言,在离开该函数的作用域时,会两次调用析构函数来释放空间,但是这两个对象指向的是同一块内存空间,所以导致同一块内存空间被释放两次(在VS中是报错​​block_type_is_valid​​),可以通过增加计数机制来避免这种情况,或者使用智能指针 or 内存泄漏检测工具valgrind:

【C++面试】编译内存相关_内存泄漏_07

11. 智能指针有哪几种?智能指针的实现原理?

智能指针的实现原理: 计数原理。

智能指针是为了解决动态内存分配时带来的内存泄漏以及多次释放同一块内存空间而提出的。C++11 中封装在了 ​​<memory>​​ 头文件中。

C++11 中智能指针包括以下三种:

  • 共享指针(​​shared_ptr​​):资源可以被多个指针共享,使用计数机制表明资源被几个指针共享。通过 use_count() 查看资源的所有者的个数,可以通过 unique_ptr、weak_ptr 来构造,调用 release() 释放资源的所有权,计数减一,当计数减为 0 时,会自动释放内存空间,从而避免了内存泄漏。
  • 独占指针(​​unique_ptr​​):独享所有权的智能指针,资源只能被一个指针占有,该指针不能拷贝构造和赋值。但可以进行移动构造和移动赋值构造(调用 move() 函数),即一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,可以通过该方法进行赋值。
  • 弱指针(​​weak_ptr​​):指向 share_ptr 指向的对象,能够解决由shared_ptr带来的循环引用问题。

参考阅读:
1、​​​javascript:void(0)​​​ 2、​​javascript:void(0)​​ 3、​​javascript:void(0)​​ 4、​​javascript:void(0)​​ 5、​​智能指针的视频​

12. 一个 unique_ptr 怎么赋值给另一个 unique_ptr 对象?

借助 ​​std::move()​​​ 可以实现将一个 ​​unique_ptr​​​ 对象赋值给另一个 ​​unique_ptr​​ 对象,其目的是实现所有权的转移。

// A 作为一个类 
std::unique_ptr<A> ptr1(new A());
std::unique_ptr<A> ptr2 = std::move(ptr1);

13. 使用智能指针会出现什么问题?怎么解决?

待补充。

14. C++和Python区别

C++

Python

语言

Python 为脚本语言,解释执行,不需要经过编译

C++ 是一种需要编译后才能运行的语言,在特定的机器上编译后运行

运行效率

C++ 运行效率高,安全稳定。

Python 代码和 C++ 最终都会变成 CPU 指令来跑,但一般情况下,比如反转和合并两个字符串,Python 最终转换出来的 CPU 指令会比 C++ 多很多。首先,Python 中涉及的内容比 C++ 多,经过了更多层,Python 中甚至连数字都是 object ;其次,Python 是解释执行的,和物理机 CPU 之间多了解释器这层,而 C++ 是编译执行的,直接就是机器码,编译的时候编译器又可以进行一些优化。

开发效率

Python 一两句代码就能实现的功能,C++ 往往需要更多的代码才能实现。

Python 开发效率高

书写格式和语法不同

Python 的语法格式不同于其 C++ 定义声明才能使用,而且极其灵活,完全面向更上层的开发者。

定义声明才能使用

15. C++和C的区别

C++

C

语言特性

面向对象,不是完成一个步骤,而是描述某个事务在解决整个问题步骤中的行为。将涉及到的数据和数据的操作封装在类中,通过类可以创建对象,以事件或消息来驱动对象执行处理。

面向过程。最重要的特点是函数。

应用领域

C++ 可以用于应用层开发,用户界面开发等与操作系统打交道的领域。

主要用于嵌入式领域,驱动开发等与硬件直接打交道的领域

操作特性

特性繁多,面向对象语言的多继承,对值传递与引用传递的区分以及 const 关键字等。


C++多了啥

类型检查更为严格。增加了面向对象的机制、泛型编程的机制(Template)、异常处理、运算符重载、标准模板库(STL)、命名空间(避免全局命名冲突)。


16. 继承、封装、多态

特性

具体

封装

将具体的实现过程和数据封装成一个函数,只能通过接口进行访问,降低耦合性。

继承

子类继承父类的特征和行为,子类有父类的非 private 方法或成员变量,子类可以对父类的方法进行重写,增强了类之间的耦合性,但是当父类中的成员变量、成员函数或者类本身被 ​​final​​ 关键字(新引入的关键字)修饰时,修饰的类不能继承,修饰的成员不能重写或修改。

多态

多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。

Reference

[1] 现代C++教程 :https://changkun.de/modern-cpp/zh-cn/01-intro/