C++面试宝典【配文档,全方面学习】

原word文档[链接: https://pan.baidu.com/s/1CKnm7vHDmHSDskAgxgZgKA?pwd=r4wv 提取码: r4wv 复制这段内容后打开百度网盘手机App,操作更方便哦 
--来自百度网盘超级会员v5的分享]

一、C / C++基础

1、简述C++的内存分区?

一个C、C++程序的内存分区主要有5个,分别是堆区、栈区、全局区、文字常量区和程序代码区。可以将全局区和文字常量区理解为静态存储区,在编译阶段就已经确定。

:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

:就是那些由 new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。

自由存储区:如果说堆是操作系统维护的一块内存,那么自由存储区就是C++中通过new和delete动态分配和释放对象的抽象概念。需要注意的是,自由存储区和堆比较像,但不等价。

全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量和静态变量又分为初始化的和未初始化的,在C++里面没有这个区分了,它们共同占用同一块内存区,在该区定义的变量若没有初始化,则会被自动初始化,例如int型变量自动初始为0。

常量存储区:这是一块比较特殊的存储区,这里面存放的是常量,不允许修改。

代码区:存放函数体的二进制代码。

2、简述malloc/free和new/delete内存分配和释放函数?

malloc和free函数是搭配使用的。malloc函数负责向内存申请一块连续可用的空间;这块空间开辟成功返回一个指向这个空间的void * 类型的指针,所以在接收的时候需要强制类型转换成指定的具体类型的指针。开辟空间失败返回NULL,所以一定要对malloc的返回进行检查,防止程序使用了未成功开辟的指针。free函数负责释放动态开辟出来的内存,并且这个内存只能释放一次,多次释放会出现错误;如果释放的是空指针相当于什么也没做,并且可以多次释放。并且也不要去释放未被初始化过的指针。

new和delete是操作符并不是函数。new分配内存的步骤是先调用operator new函数;然后调用对应类的构造函数构造对象初始化相对应的数据;最后返回一个指向该对象的指针。delete释放内存的步骤是先调用析构函数;然后调用operator delete函数释放内存空间即可。同free,delete也不能多次释放被释放过的对象,但是可以多次delete空指针。

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

  1. 开辟位置

严格来说,malloc动态开辟的内存在堆区,new开辟的在自由存储区;如果不重载new操作符,C++编译器默认在堆上实现自由存储,此时等价于堆区。

  1. 重载

new、delete是操作符,可以重载,只能在C++中使用。malloc、free是函数,可以覆盖,C和C++都可以使用。

  1. 是否调用析构函数和构造函数

new可以调用对象的构造函数,对应的delete调用相应的析构函数。malloc仅仅分配内存,free仅仅回收内存,并不执行构造和析构函数。

  1. 是否需要指定内存大小

malloc需要显示指出开辟内存的大小,new无需指定,编译器可以根据对应的类自动计算。

  1. 返回值类型

new返回的是某种数据类型的指针,malloc返回的是void指针,new比malloc更加安全。new内存分配失败时,会抛出bac_alloc异常,不会返回NULL;malloc开辟内存失败会返回NULL指针,所以需要判断。

4、在C++中,使用malloc申请的内存能否通过delete释放?使用new申请的内存能否用free释放?

不能,malloc/free主要为了兼容C,new和 delete 完全可以取代malloc/free的。malloc/free 的操作对象都是必须明确大小的。而且不能用在动态类上。new 和 delete会自动进行类型检查和大小 ,malloc/free不能执行构造函数与析构函数 ,所以动态对象它是不行的。

从理论上说使用malloc 申请的内存是可以通过delete释放的 。不过一般不这样写的。而且也不能保证每个C++的运行时都能正常。

5、预编译中头文件<>和””的区别?

预处理器发现#include指令后,就会寻找后面跟的文件名并把这个文件中的内容包含到当前文件中,被包含文件中的文本将替换源代码文件中的#include指令,就像是把包含的文件中的所有内容拷贝到了源文件中的这个位置。

#include < >:只搜索系统目录或者配置了的第三方库目录,不会搜索本地目录

#include " ":首先搜索本地目录,若找不到才会搜索系统目录。

#include<>相较于#include" " 快一些。

6、define和const的区别?

编译阶段:define是在编译的预处理阶段起作用;而const是在编译、运行的时候起作用。

安全性:define只做字符的替换,不做类型检查和计算,也不求解,容易产生错误,一般加上一个括号包括住其全部内容,否则容易出错;const常量有数据类型,编译器可以对其进行类型安全检查。

内存占用:define只是将宏名称进行替换,在内存中会产生多份相同的备份。const在程序运行中只有一份备份,且可以执行常量折叠,能将复杂的的表达式计算出结果放入常量表;   宏定义的数据没有分配内存空间,只是插入到代码中替换掉,const定义的变量只是值不能改变,但要分配内存空间。

7、sizeof和strlen的区别?

sizeof负责计算某一个类型或者变量在内存中所占用的字节数;strlen负责计算字符串的字节长度。

sizeof是一个操作符,strlen是库函数,在string.h头文件中。

sizeof的参数可以是数据的类型,也可以是变量。strlen只能用字符串作为参数。

数组做sizeof的参数不退化,可以计算出整个数组所占用的内存;做strlen的参数会退化为指针。

编译器在编译时就计算出了sizeof的结果,所以可以用sizeof的表达式作为定义数组时的长度;而strlen的结果必须在运行时才能计算。

8、关键字const的作用?

const 用来定义一个只读的变量或对象。主要优点:便于类型检查、同宏定义一样可以方便地进行参数 的修改和调整、节省空间,避免不必要的内存分配、可为函数重载提供参考。

  1. 阻止一个变量被改变。通常需要对它进行初始化,因为以后就没有机会再去改变它了。
  2. 与指针类型并用。可以指定本身为const(指针常量),让该指针的指向不可改变;也可以指定指针指向的数据为const(常量指针),将无法使用这个指针修改指针指向的数据。亦可以两者同时使用。
  3. 在函数声明中,const可以修饰形参,表明它是一个输入参数,在函数内部不能改变其值。
  4. 当 const 修饰函数返回值时,表示函数的返回值为只读,不能被修改。这样做可以使函数返回的值更加安全,避免被误修改。
  5. 对于类的成员函数,若是指定为const类型,则表明他是一个常函数,不能修改类的成员变量。类的常对象只能访问类的常成员函数。常函数无法调用未被const修饰的类成员函数。
  6. 当const修饰类的成员变量时,它将不能修改,因此必须在类构造函数的初始化列表中对该成员初始化。
  7. 一个没有明确声明为const的成员函数被看作是将要修改对象中数据成员的函数,而且编译器不允许它 为一个const对象所调用。因此const对象只能调用const成员函数。
  8. const类型变量可以通过类型转换符const_cast将const类型转换为非const类型。
  9. const修饰引用(常量引用),使得无法通过这个引用修改变量的值。

9、mutable关键字的作用?

如果需要在const成员函数中修改一个成员变量的值,那么需要将这个成员变量修饰为mutabe。也就是,用mutable修饰的成员变量不受const成员方法的限制。

还有就是在lambda函数表示中使用这个关键字,可以让函数体内部能够修改被捕获的外部变量,主要是针对按值捕获的变量。

10、extern的用法?

  1. extern修饰变量和函数的声明。如果文件a.c需要引用b.c中的变量int v,就可以在a.c中声明extern int v;然后就可以引用变量v。
  2. extern修饰符可用于指示C++代码调用其他的C语言代码。 比如在C++中调用C库函数,就需要在C++程序中用extern “C”声明要引用的函数。这是给链接器用 的,告诉链接器在链接的时候用C函数规范来链接。主要原因是C++和C程序编译完成后在目标代码中命名规则不同。

10.1、extern的作用?

1. extern 可以置于变量声明或者函数声明前,以表示变量或者函数的定义在别的文件中,提示编译器遇到此变量和函数时在其它文件中寻找其定义。

2. extern 变量表示声明一个变量,表示该变量是一个外部变量,也就是全局变量,所以 extern 修饰的变量保存在静态存储区(全局区),全局变量如果没有显示初始化,会默认初始化为 0,或者显示初始化为 0 ,则保存在程序的 BSS 段,如果初始化不为 0 则保存在程序的 DATA 段。

3. extern "C" 的作用是为了能够正确的实现 C++ 代码调用 C 语言代码。加上 extern "C" 后,会指示编译器这部分代码按照 C 语言(而不是 C++)的方式进行编译。由于 C++ 支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译 C 语言代码的函数时不会带上函数的参数类型,一般只包括函数名。

11、const的作用(优点)?

  1. 可以定义const常量。
  2. 便于类型检查。const常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全,而对后者只是进行字符替换,没有类型安全检查,并且在字符替换时可能产生意想不到的错误。
  3. 可以保护被修饰的东西,防止意外的修改,增强程序的健壮性。
  4. 为函数重载提供了一个参考。
  5. 可以节省空间,避免不必要的内存分配。const定义常量从汇编的角度来看,只是给出了对应的内存地址,而不是像#define一样给出的是立即数,所以,const定义的常量在程序运行过程中只有一份拷贝,而#define定义的常量在内存中有若干个拷贝。
  6. 提高了效率。编译器通常不为普通const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。

12、typedef和define的区别?

  1. 用法不同:typedef 用来定义一种数据类型的别名,增强程序的可读性。define 主要用来定义 常量,以及书写复杂使用频繁的宏。
  2. 执行时间不同:typedef 是编译过程的一部分,有类型检查的功能。define 是宏定义,是预编译的部分,其发生在编译之前,只是简单的进行字符串的替换,不进行类型的检查。
  3. 作用域不同:typedef 有作用域限定。define 不受作用域约束,只要是在define 声明后的引用都是正确的。
  4. 对指针的操作不同:typedef 和define 定义的指针时有很大的区别。

注意:typedef 定义是语句, 因为句尾要加上分号。 而define不是语句,千万不能在句尾加分号。

13、C++的内联函数?

内联函数inline的目的是为了解决程序中函数调用的效率问题。程序在编译器编译的时候,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体进行替换,而对于其他的函数,都是在运行时候对函数入栈执行。这其实就是空间换时间。所以内联函数一般都是行数很少的小函数。

使用的要求:在内联函数内不允许使用循环语句和开关语句;内联函数的定义必须出现在内联函数第一次调用之前;类结构中所在的类说明内部定义的函数是内联函数。

为什么不能把所有函数写成内联函数?

内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间。

14、宏定义(define)和内联函数(inline)的区别?

宏定义(#define)和内联函数(inline)都可以减少函数调用开销和提高代码运行效率而引入的机制,但是它们的实现方式和作用机制略有不同。

define主要有两种用途:定义常量和创建宏函数。无论哪种都是用于在编译时替换文本,也就是define实际上只是做文本的替换。

内联函数的定义和普通函数类似,只需要在函数声明前加上inline即可。编译器不一定会将所有声明为内联函数的函数进行内联,是否内联取决于编译器的实现和优化策略。内联函数的优点是类型安全、可调试、可优化,但是也存在一些问题。由于函数体会被复制多次,会占用更多的代码段空间,而且在某些情况下可能会导致代码膨胀。

区别:

  1. 语义不同:宏定义使用预处理器指令 #define 定义。它在预处理期间将宏展开,并替换宏定义中的代码。预处理器只进行简单的文本替换,不涉及类型检查。内联函数使用 inline 关键字定义,它是一个真正的函数。编译器会尝试将内联函数的调用处用函数体进行替换,从而避免函数调用的开销。
  2. 类型检查:宏定义就是单纯的字符替换,不涉及类型检查,容易导致错误;内联函数会进行类型检查,更加安全。
  3. 内联函数可以进行调试,宏定义的函数无法调试。
  4. 宏可能导致不合理的计算。在内联函数传递参数值计算一次,而使用宏的情况下,每次在程序中使用宏时都会传递表达式参数,因此宏会对表达式参数计算多次。因为宏只做替换,可能会把同样的表达式替换到其他地方。

15、指针常量和常量指针的区别?

指针常量(int * const vptr)是指定义了一个指针,这个指针只能在定义时初始化,其他地方不能改变,不能再改变这个指针的指向。

常量指针是指定义了一个指针,指向了一个只读对象,不能通过常量指针来改变这个对象的值。

指针常量强调的是指针的不可变性,常量指针强调的是指针对其所指对象的不可变性。

16、函数指针和指针函数的区别?

指针函数(类型说明符 * 函数名(参数))是一个函数,返回一个指针,实际上就是返回一个地址给调用函数;在调用指针时,需要一个同类型的指针来接收其函数的返回值;也可以将其返回值设置为void * 类型,调用时强制转换返回值为需要的类型。

函数指针(类型说明符 (* 函数名)(参数))是一个指针,指向函数的指针,包含了函数的地址,可以用它来调用函数,本质是一个指针变量,该指针指向了这个函数。

17、指针数组和数组指针?

指针数组是一个数组,每个元素都是指针。int * a[3]:数组中存放了3个int *指针变量。

数组指针是一个指针,指向了整个数组。比如int (*a)[10],是指向了一个元素个数为10的数组。

定义

说明

int a

一个整型数

int * a

一个指向整型的指针

int **a

一个指向指针的指针,指向的指针是一个整数类型

int a[10]

一个有10个整型的数组

int *a[10]

指针数组:一个有10个指针的数组,指针指向整型

int (*a)[10]

数组指针:一个指向有10个整型数数组的指针

int (*a)(int)

函数指针:一个指向函数的指针,该函数有一个整型参数,并返回一个整型

int (*a[10])(int)

一个有10个指针的数组,该指针指向一个函数,该函数有一个整型参数并返回一个整型

18、野指针和悬空指针的区别?

野指针和悬空指针都是指向无效内存的指针,他们的成因和表现有所不同。

野指针是一个指向不明确的指针,主要是指未被初始化过的指针。所以它的值是不确定的,可能指向任意内存地址;访问野指针可能导致未定义行为,如程序崩溃和数据损坏等。

悬空指针是指向已经被释放内存的指针。这种指针仍然具有以前分配的内存地址,但是这块内存可能已经被其他对象或数据占用。访问空悬指针同样会导致未定义行为。

19、如何避免野指针?

(1) 指针变量声明时没有被初始化。解决办法:指针声明时初始化,可以是具体的地址值,也可让它指向 NULL。

(2) 指针 p 被 free 或者 delete 之后,没有置为 NULL。解决办法:指针指向的内存空间被释放后指针应该指向 NULL。

(3) 指针操作超越了变量的作用范围。解决办法:在变量的作用域结束前释放掉变量的地址空间并且让指针指向 NULL。

20、定义和声明的区别?

声明是通知编译器变量的类型和名字,不会为变量分配空间。

定义需要分配空间,同一个变量可以可以被声明多次,但是只能被定义一次。

21、C语言关键字static和C++的static有什么么区别?

在C语言中static用来修饰局部静态变量和外部静态变量、函数。而C++中除了上述功能外还用来定义类的成员变量和函数,即静态成员和静态成员函数。

static的记忆性和全局性特点可以让在不同时期调用的函数进行通信,传递信息,而C++的静态成员可以在多个对象实例间进行通信,传递信息。

static用途:静态变量(局部 / 全局)、静态函数、类的静态数据成员、类的静态成员函数。

22、static的用法和作用?

(1)隐藏(静态全局变量和全局函数)。当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性,只要加上static就会被限制在当前文件。

(2)保持变量内容的持久性(静态全局和局部变量)。static变量存储在静态数据区,在程序开始就已经完成了初始化,在程序结束才会销毁这个变量。

(3)static的第三个作用时默认初始化为0。存储在静态数据区的变量都有这个特点,这个区域默认值都是0x00。

(4)类成员函数体内static变量的作用范围是在该函数体,该变量的内存只被分配一次,因此其值在下次调用这个函数的时候仍然维持上次的值。

(5)在类中的static成员变量属于整个类所有,对类的所有对象只有一份拷贝。

(6)在类中的static成员函数属于整个类所有,这些函数不接收this指针,因而只能访问类的static成员变量。

(7)static成员函数不能被virtual修饰,否则编译无法通过。static成员不属于任何对象或者实例,所以加上virtual没有任何实际意义;静态成员函数没有this指针,虚函数的实现是为了每一个对象分配一个vptr指针,而vptr是通过指针调用的,所以不能为virtual。虚函数的调用关系:this->vptr->ctable->virtual function。

22.1、说说静态变量什么时候初始化?

1、静态变量在程序启动时就会被初始化,而且只会初始化一次。针对全局作用域内的静态变量,在类内声明内外初始化的静态成员变量。

2、静态变量在函数内部则在函数第一次执行时进行初始化。

23、引用是什么?常引用的作用?

引用(Reference)是一种别名,用于为已经存在的变量起一个新的名称。引用提供了对变量的间接访问方式,允许使用引用来操作原始变量。

常引用的引入主要是为了避免使用变量的引用时,在不知情的情况下改变变量的值。常引用主要用于定义一个普通变量的只读属性的别名、作为函数的传入形参,避免实参在调用函数中被意外的改变。说明:很多情况下,需要用常引用做形参,被引用对象等效于常对象,不能在函数中改变实参的值,这样的好处是有较高的易读性和较小的出错率。

24、指针和引用的区别?

指针和引用在 C++ 中都用于间接访问变量,但它们有一些区别:

(1)指针是一个变量,它保存了另一个变量的内存地址;引用是另一个变量的别名,与原变量共享内存地址。

(2)指针可以被重新赋值,指向不同的变量;引用在定义的时候必须初始化,且之后不能更改,始终指向同一个变量。

(3)指针可以为 nullptr,表示不指向任何变量;引用必须绑定到一个变量,不能为 nullptr。

(4)使用指针需要对其进行解引用以获取或修改其指向的变量的值;引用可以直接使用,无需解引用。

(5)指针本身和其他变量一样,对于直接对指针变量的操作都是针对指针本身而不是指向的变量,比如sizeof、自增自减运算符等。而引用则是直接作用于原变量。

在什么时候使用指针或者引用?

  1. 如果需要返回局部变量就要使用指针,但是要求这个局部变量是动态开辟的。
  2. 如果对栈空间大小比较敏感,比如递归的时候使用引用。使用引用传递不需要创建临时对象,开销相对更小。
  3. 类对象作为传输传递时使用引用,这是C++类对象传递的标准方式。

25、C语言的struct和C++的struct的区别?

  1. C语言中:struct是用户自定义数据类型(UDT);C++中struct是抽象数据类型(ADT),支持成员函数的定义,(C++中的struct能继承,能实现多态,用类能实现的功能,结构体基本上都能实现)。
  2. C中struct是没有权限的设置的,且struct中只能是一些变量的集合体,可以封装数据却不可以隐藏数据,而且成员不可以是函数。
  3. C++中,struct的成员默认访问说明符为public(为了与C兼容),而C语言中的struct的成员没有访问权限的概念。
  4. struct作为类的一种特例是用来自定义数据结构的。一个结构标记声明后,在C中必须在结构标记前加上struct,才能做结构类型名。

26、C++ 中 class 和 struct 区别

C++ 中为了兼容 C 语言而保留了 C 语言的 struct 关键字,并且加以扩充了含义。

在 C 语言中,struct 只能包含成员变量,不能包含成员函数。

而在 C++ 中,struct 类似于 class,既可以包含成员变量,又可以包含成员函数。用类能实现的功能,结构体基本上都能实现

区别?

class 中类中的成员默认都是 private 属性的;而在 struct 中结构体中的成员默认都是 public 属性的。

class 继承默认是 private 继承,而 struct 继承默认是 public 继承。

27、volatile的作用?能够和const同时使用吗?

1、volatile是 C 语言中的一个关键字,用于修饰变量,表示该变量的值可能在任何时候被外部因素更改,例如硬件设备、操作系统或其他线程。当一个变量被声明为volatile时,编译器会禁止对该变量进行优化,以确保每次访问变量时都会从内存中读取其值,而不是从寄存器或缓存中读取。避免因为编译器优化而导致出现不符合预期的结果。

2、volatile限定符是告诉计算机,所修饰的变量随时都可能被外部因素修改,比如操作系统,其他线程等,不要对这个变量进行优化,每次取用的时候都要直接从内存中读取,而不是从寄存器中读取数据。const和volatile可以一起使用,volatile是防止编译器对代码进行优化,这个值是可以变的。而const的含义是在代码中不能对变量进行修改。因此,两者不矛盾。

28、内存字节对齐?

在C/C++中,字节对齐是内存分配的一种策略。

当分配内存时,编译器会自动调整数据结构的内存布局,使得数据成员的起始地址与其自然对齐边界(一般为自己大小的倍数)相匹配。

理论上,任何类型的变量都可以从任意地址开始存放。然而实际上,访问特定类型的变量通常需要从特定对齐的内存地址开始。因为如果不对数据存储进行适当的对齐,可能会导致存取效率降低。所以,各种数据类型需要按照一定的规则在内存中排列(起始地址),而不是顺序地一个接一个排放,这种排列就是字节对齐。

29、字节序?

大端模式:是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址端。

小端模式,是指数据的高字节保存在内存的高地址中,而低位字节保存在内存的低地址端。

在网络传输中,通常使用大端字节序(网络字节序)。在具体的操作系统中,字节序取决于底层硬件架构。例如,Linux和Windows操作系统主要运行在x86和x86_64(interl和AMD处理器)架构上,这些处理器使用小端字节序。

检测字节序方式一:

int i = 1;

    if (*((char *)&i) == 1) {

        cout << "小端" << endl;

    }

    else if (*((char *)&i) == 0) {

        cout << "小端" << endl;

    }

检测字节序方式二:

union Endian

{

    char a;

    int b;

};

Endian endi;

endi.b = 1;

(endi.a == 1) ? cout << "小端" << endl : cout << "大端" << endl;

31、什么是内存泄漏?内存泄漏有哪几种情况?

1、堆内存泄漏。在程序运行中根据需要分配一块内存,在完成相关操作后必须通过调用对应的free或者delete删掉。如果及时释放掉,之后将无法引用这块无用的内存,就会产生堆内存泄漏。

2、系统资源泄露。系统分配给程序的资源没有使用相应的函数释放掉(比如socket等),导致系统资源浪费,严重可导致系统效能降低,系统运行不稳定。

3、没有将基类的析构定义为虚析构。在这种情况下,如果使用多态,并且子类存在动态分配的成员变量,将无法调用子类析构释放资源,从而造成内存泄漏。

4、在释放对象数组时没有使用delete[]而是使用了delete。当一个数组中的多个元素均为对象时,在使用delete释放该数组是必须加上方括号,否则只是调用一次析构函数释放数组的第一个对象,而剩下的数组元素没有被析构掉,从而造成了内存泄漏。

5、缺少拷贝构造函数。如果类中没有手动编写拷贝构造函数,用该类对象进行拷贝赋值时,会使用默认的拷贝构造函数,即浅拷贝。

32、如何判断内存泄漏?

内存泄露只发生一次小的可能不会有太大的影响,但是大量泄漏内存的程序将会出现内存逐渐用完,程序性能下降。甚至导致其他程序运行失败。

1、Windows平台下的Vs,在主函数后面加上_CrtDumpMemoryLeaks();函数就可以在debug运行之后在输出窗口显示内存泄漏的情况

2、在Linux中可以使用valgrind工具

valgrind –leak-check=full ./app

33、如何解决内存泄漏?

内存泄漏解决方案:

1、使用智能指针辅助帮助内存的维护。

2、注意手动内存管理,在动态开辟空间后,需要及时使用delete或者free释放空间。

3、RAII资源获取即初始化原则,通过在对象的构造函数中分配资源,然后再析构函数中释放资源,确保在对象生命周期结束时被正确释放。

4、使用内存分析工具,比如Valgrind,检测和诊断内存泄漏问题。

5、编码规范和代码审查,准许良好的编码规范和代码审查,可以帮助发现潜在的内存泄漏问题。

34、什么是内存溢出(越界)?如何解决?

内存溢出是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个int类型大小的内存,但是存了long类型才能存下的数,这就是内存溢出。(动态内存分配过多,导致堆内存耗尽,引发内存溢出;递归深度过大导致栈空间被耗尽)。

内存溢出解决方案

1、检查内存泄漏,内存泄漏会间接导致内存溢出。

2、限制递归深度。

35、C和C++的区别?

1. C 语言是面向过程的语言,而 C++ 支持面向对象,所以 C 语言自然没有面向对象的封装、继承、多态等特性,也不支持面向对象的一些语法;

2. C++ 支持函数重载,C 语言不支持;

3. C 程序中如果函数没有任何参数需要将参数定义为 void 以此来限定函数不可传递任何参数,如果不进行限定让参数表默认为空其意义是可以传递任何参数,在 C++ 中,不带参数的函数表示函数不能传递任何参数;

4. C 语言 struct 中不能有函数,而 C++ 语言 struct 中可以有函数;

5. C 语言函数参数不支持默认值,而 C++ 语言支持参数默认值;

6. C++ 语言支持内联函数,而 C 语言不支持;

7. C++ 语言支持引用,而 C 语言不支持;

8. C 语言采用 malloc 和 free 函数动态申请和释放内存,而 C++ 使用 new 和 delete 运算符;

9. C 语言中只有局部和全局两个作用域,而 C++ 中有局部、全局、类、名称空间作用域。

36、C++中的堆和栈的区别?

1、管理方式不同:堆中的资源由程序员控制,容易产生内存泄漏;栈资源由编译器自动管理,无需手工控制。

2、空间大小不同:堆是不连续的内存区域(内部采用链表来存储空闲内存地址),堆大小受限于计算机系统中有效的虚拟内存,所以堆的空间比较灵活,内存比较大。栈是一块连续的内存区域,大小是操作系统预定好的。

3、碎片问题:在堆中频繁分配和释放空间会产生大量碎片,使程序效率降低。对于栈,是一个先进后出的队列,进出一一对应,不会产生碎片。

4、增长方向:堆是向高地址方向增长;栈是向低地址方向增长。

5、分配方式:堆是动态分配,栈有静态分配和动态分配,静态分配由编译器完成;动态分配由alloca函数分配,但栈的动态分配的资源由编译器进行释放,无需程序员实现。

6、堆由C/C++函数库提供,机制很复杂,所以堆的效率比栈低很多。

栈和堆哪个更快,原因?

栈更快,因为操作系统提供了对栈的硬件资源的支持,在底层会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也是简单,有专门的指令执行,所以栈的效率比较高。

而堆的操作是由C/C++库函数提供的,在分配堆内存时需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。

被free回收的内存会立即返还给操作系统吗?

不会立即返还。free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。

37、如何防止头文件被重复包含?

1、使用宏定义避免重复引入

#ifndef _NAME_H

#define _NAME_H​

#endif​

2、使用#pragma once指令避免重复引入

38、智能指针和指针的区别?

智能指针可以自动释放,使用了RAII资源分配即初始化原则,所以当一个对象的生命周期到了以后就会调用析构函数完成指针指向内存的释放,而指针需要程序员手动释放。

智能指针是类模板,而指针是一种数据类型;智能指针是在普通指针加了一层封装机制。

39、数组名和指针的区别?

数组名是数组中第一个元素的地址,二者均可以通过偏移量来访问数组中的元素。数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作

当数组名当作形参传递给调用函数后,就会失去原有特性,退化为一般指针,可以进行自增自减操作,因此,sizeof运算符不能再得到原数组的大小。

40、char str[]与char * str的区别?

1、概念不同:C语言中没有特定的字符串类型,常用以下两种方式定义字符串:一种是字符数组,另一种是指向字符串的指针。

char *str 声明的是一个指针,这个指针可以指向任何字符串常量。

char str[] 声明的是一个字符数组,数组的内容可以是任何任何字符,严格意义上说,末尾加上’\0’ 之后才能算是字符串。

2、变量不同

char *str里的str是指针变量,str的值未初始化(局部变量的话。全局则自动初始化为NULL)。

char str[]里str是地址常量,str的值是str[ ]的地址。

3、内存分配方式不同

字符串指针指向的内容是不可修改的(不可单个字符修改),字符数组是可以修改的,即char * str定义的字符串保存在常量区,是不可更改的,char str[ ]定义的字符串保存在全局数据区或栈区,是可修改的。

41、C++中新增了string,他与C语言中的char * 有什么区别?是如何实现的?

string继承自basic_string,其实是对char进行了封装,封装的string包含了char数组,容量,长度等等属性。string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2^n),然后将原字符串拷贝过去,并加上新增的内容。

42、C++程序是如何一步步生成的?

1、预处理

(1) 将所有的#define删除,并且展开所有的宏定义

(2) 处理所有的条件预编译指令,如#if、#ifdef

(3) 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。

(4) 过滤所有的注释

(5) 添加行号和文件名标识。

2、编译

(1) 词法分析:将源代码的字符序列分割成一系列的记号。

(2) 语法分析:对记号进行语法分析,产生语法树。

(3) 语义分析:判断表达式是否有意义。

(4) 代码优化:比如内联函数、合并代码分支、公共子表达式消除等。

(5) 目标代码生成:生成汇编代码并且进行优化。

3、汇编

这个过程主要是将汇编代码转变成机器可以执行的指令。

4、链接

将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。

链接分为静态链接和动态链接。

静态链接,是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。

动态链接,是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。

43、nullptr调用成员函数可以吗?为什么?

可以调用成员函数。因为在编译时对象就绑定了函数地址,和指针空不空没关系。调用某个函数时会把nullptr传递给这个函数,只要在这个函数中没有需要对这个空指针进行解引用的地方,那么就不会出错。

44、i++和++i的区别?

1、赋值顺序不同,++i是先加值后赋值;i++是赋值后加值。他们都分为两步进行。

2、效率不同,后置++执行速度比前置的慢。因为前置++不会产生临时对象,后置++必须产生临时对象,从而导致效率降低。

3、后置加加不能作为左值,而++i可以(所以++i可以取地址,但是i++不可以)。因为前置++返回一个引用,后置++返回一个对象。

4、两者都不是原子操作。

45、在main执行之前和之后执行的代码可能是什么?

main函数执行之前:

设置栈指针、初始化静态static变量和全局变量,即.data段的内容;

将未初始化的部分全局变量赋初值为0,即.bss段的内容;

全局对象初始化,调用对应的构造函数;

传递main函数的参数。

main函数执行之后:

全局对象的析构函数调用;

如果用atexit注册了一个函数,则会在main函数之后执行。

46、宏定义和typedef区别?

宏主要用于定义常量以及书写频繁使用且复杂的内容;typedef主要用于定义类型的别名。

宏替换实在预处理阶段之前执行的,属于文本插入替换;tpyedef属于编译阶段。

宏不检查类型,typedef会检查数据类型。

宏不是语句,不需要加分号结尾;typedef是语句,需要用分号结尾。

宏没有作用域的限制,定义宏之后都可以使用;而typedef有作用域的限制。

47、C++中有几种new?

主要有三种典型的new是使用方法:plain new、nothrow new和placement new。

  1. plain new就是普通的new,直接new A(); 在分配空间失败的情况下,抛出异常std::bad_alloc而不是返回NULL,因此通过返回值无法判断是否开辟成功。
  2. nothrow new在空间分配失败时不会抛出异常而是返回NULL(new(nothrow) A())。
  3. placement new 允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。

48、形参和实参的区别?

1、形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元。因此,形参只有在函数内部有效。 函数调用结束返回主调函数后则不能再使用该形参变量。

2、实参可以是常量、变量、表达式、函数等, 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。 因此应预先用赋值,输入等办法使实参获得确定值,会产生一个临时变量。

3、实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。

4、函数调用中发生的数据传送是单向的。 即只能把实参的值传送给形参,而不能把形参的值反向地传送给实参。 因此在函数调用过程中,形参的值发生改变,而实参中的值不会变化。

5、当形参和实参不是指针类型和引用类型时,在该函数运行时,形参和实参是不同的变量,他们在内存中位于不同的位置,形参将实参的内容复制一份,在该函数运行结束的时候形参被释放,而实参内容不会改变。

49、值传递、指针传递、引用传递的区别和效率?

值传递:有一个形参向函数所属的栈拷贝数据的过程,如果值传递的对象是类对象 或是大的结构体对象,将耗费一定的时间和空间。(传值)

指针传递:同样有一个形参向函数所属的栈拷贝数据的过程,但拷贝的数据是一个固定为4字节的地址。(传值,传递的是地址值)

引用传递:同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。(传地址)

效率上讲,指针传递和引用传递比值传递效率高。一般主张使用引用传递,代码逻辑上更加紧凑、清晰。

50、静态变量什么时候初始化?

初始化只有一次,赋值可以有多次。对于静态全局变量,在主程序执行之前,编译器已经为其分配了内存,但在C和C++中静态局部变量的初始化节点不一样。

在C语言中,初始化发生代码执行之前,编译阶段分配好内存之后,就会进行初始化。

在C++中,静态局部变量的初始化发生在相关代码执行的时候。

51、malloc、calloc、realloc函数的区别及用法?

malloc函数:void * malloc(size_t size)。这个函数分配指定字节大小的内存并且返回一个指针指向这块分配的内存。这块内存中的数据是没有被初始化的,如果获取都是一些随机值。

calloc函数:void * calloc(size_t nmemb, size_t size)。这个函数有两个参数,第一个参数是需要给多少个对象分配内存,第二个参数是每个对象所占用的字节大小。这块内存会被初始化为0。可以用作于开辟数组

realloc函数:void * realloc(void * ptr, size_t newSize)。用于对已有的空间进行扩容,返回扩容后地址的首地址。

52、C++中新增了string,他与C语言中的char *有什么区别?如何实现的?

string继承自basic_string,其实是对char*进行了封装,封装的string包含了char*数组,容量,长度等等属性。

string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2*n),然后将原字符串拷贝过去,并加上新增的内容。

53、对象复用的了解?零拷贝?

对象复用其本质是一种设计模式:Flyweight享元模式。通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。

零拷贝是一种计算机数据传输技术,旨在减少数据在不同内存区域之间的复制次数,从而提高数据传输的效率和性能。传统的数据传输方式通常涉及多次数据复制,而零拷贝技术可以最大程度地减少或避免这些复制操作,从而减少CPU和内存的开销。。零拷贝技术可以减少数据拷贝和共享总线操作的次数。C++中的vector容器的push_back函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入元素原地构造,不需要出发拷贝构造和移动构造,效率更高。减少了中间的拷贝环节。此外,在Linux中的sendfile系统调用可以将一个文件的内容直接传输到另一个文件或者网络套接字中,不需要经过拷贝到用户空间。

54、动态绑定和静态绑定的区别?

静态绑定发生在编译期,动态绑定发生在运行期;

对象的动态类型可以更改,但是静态类型无法更改;

要想实现动态,必须使用动态绑定;

在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定。

55、引用是否能实现动态绑定,为什么可以实现?

可以。

引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数才是动态绑定。

56、函数指针是什么?

函数指针是指向函数的指针变量。因此“函数指针”本身首先应是指针变量,只不过该指针变量指向函数。指向的是特殊的数据类型,函数的类型尤其返回的数据类型和其参数列表共同决定,而函数的名称则不是其类型的一部分。

57、为什么有函数指针?

函数与数据项相似,函数也有地址。使用函数指针可以在同一个函数中通过使用相同的函数指针形参在不同的时间使用产生不同的效果。

58、你知道strcpy和memcpy的区别是什么吗?

char *stpcpy(char *restrict dst, const char *restrict src);

void *memcpy(void dest[restrict .n], const void src[restrict .n], size_t n);

1、复制的内容不同。strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。

2、复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。

3、用途不同。通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy。

4、效率不同。memcpy效率比strcpy更高。

60、介绍一下几种典型的锁?

读写锁

1、多个读者可以同时进行读

2、写者必须互斥(只允许一个写者写,也不能读者写者同时进行)

3、写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

互斥锁

一次只能一个线程拥有互斥锁,其他线程只有等待。

互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁。

条件变量

互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

自旋锁

如果线程无法取得锁,线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

61、delete和delete[]的区别?

delete用于销毁单个对象,只会调用一次析构函数。delete [ ]用于销毁数组对象,销毁数组中的每个对象,会多次调用析构函数。

62、什么是内存池,如何实现?

内存池(Memory Pool) 是一种内存分配方式。通常我们习惯直接使用new、malloc 等申请内存,这样做的缺点在于:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块, 若内存块不够再继续申请新的内存。这样做的一个显著优点是尽量避免了内存碎片,使得内存分配效率得到提升

63、各种基本数据类型的字节大小(64位机器)?

64、什么是回调函数?有什么缺点?(面试题)

回调函数(Callback Function)是一种将一个函数作为参数传递给另一个函数,以便在需要时执行传递的函数的机制。回调函数常用于实现事件处理、异步操作、自定义排序和过滤等场景。通常,回调函数用于在某个特定事件发生时执行特定的逻辑,从而实现代码的模块化和灵活性。

优点:1、模块化和可重用性: 使用回调函数可以将代码逻辑分为模块,使得这些模块可以在多个地方重复使用,从而提高了代码的可维护性和可重用性。

2、灵活性: 回调函数允许在运行时动态地指定要执行的操作,从而在不修改核心代码的情况下改变程序的行为。

缺点:可读性较差: 如果不适当使用,回调函数可能会导致代码变得难以理解和维护。特别是当回调函数较复杂时,代码的流程可能会变得混乱。

上下文传递: 在一些情况下,回调函数可能需要访问调用它的函数的上下文信息。这可能需要额外的参数传递或者使用全局变量,导致代码的耦合度增加。

错误处理: 回调函数中的错误处理可能会变得复杂,因为错误的传递和处理需要更多的考虑。

异步操作: 在涉及异步操作的场景中,回调函数可能会导致代码嵌套过深,使代码变得难以理解和调试。

缺点解决:现代C++中提供了更多高级的方式来处理回调,如使用函数对象、Lambda 表达式、标准库中的函数指针容器(如 std::function)、异步编程库等。这些工具可以在一定程度上减轻回调带来的问题,提供更好的代码结构和可读性。

二、面向对象

1、什么是面向对象?面向对象的三大特性(基本特征)?

面向对象思想:面向对象的思想是尽可能模拟人类的思维方式,使得软件的开发方法与过程尽可能接近人类认识世界、解决现实问题的方法和过程,把客观世界中的实体抽象为问题域中的对象。面向对象以对象为核心,该思想认为程序由一系列对象组成。 面向对象思想的特点:一种更符合人类思维习惯的思,可以将复杂的问题简单化,将我们从执行者变成了指挥者。

C++ 面向对象编程 (OOP) 的三大特性包括:封装、继承和多态。

封装是将数据(属性)和操作这些数据的函数(方法)组合在一个类(Class)中的过程封装的主要目的是隐藏类的内部实现细节,仅暴露必要的接口给外部。通过封装,我们可以控制类成员的访问级别(例如:public、protected 和 private),限制对类内部数据的直接访问,确保数据的完整性和安全性。

继承是一个类(派生类,Derived Class)从另一个类(基类,Base Class)那里获得其属性和方法的过程。继承允许我们创建具有共享代码的类层次结构,减少重复代码,提高代码复用性和可维护性。在 C++ 中,访问修饰符(如 public、protected、private)控制了派生类对基类成员的访问权限。

多态是允许不同类的对象使用相同的接口名字,但具有不同实现的特性。在 C++ 中,多态主要通过虚函数(Virtual Function)和抽象基类(Abstract Base Class)来实现。虚函数允许在派生类中重写基类的方法,而抽象基类包含至少一个纯虚函数(Pure Virtual Function),不能被实例化,只能作为其他派生类的基类。通过多态,我们可以编写更加通用、可扩展的代码,提高代码的灵活性。

总结:封装、继承和多态是面向对象编程的三大核心特性,能够帮助我们编写更加模块化、可重用和可维护的代码。

2、C++类成员访问权限

在 C++ 中,类成员的访问权限是通过访问修饰符来控制的。有三种访问修饰符:public、private 和 protected,分别定义了类成员的访问级别,控制类成员的可见性和可访问性。

(1)在类成员中使用访问权限:

  1. public:公共成员在任何地方都是可以访问的,类内部外部都可以。调用方可以直接访问和修改公共成员,公共访问修饰符通常用于类的外部接口。类成员不建议使用public修饰,这不符合封装原则。
  2. protected:受保护成员类似于私有成员,但它们可以被派生类访问。受保护成员通常用于继承和多态等场景,这样子类也可以访问父类的成员变量。
  3. private:私有成员只能在类的内部访问,即仅在类的成员函数中可以访问。私有成员用于实现类的内部实现细节,这些细节对于类的用户来说是隐藏的。

(2)在继承时使用访问权限:

当子类继承父类时,类成员的访问权限会被修改,规则是继承时的权限替代父类大于等于继承的变量的权限。

3、重载、重写和隐藏的区别?

补充:重载是指相同作用域内拥有相同的方法名,但是有不同的参数类型和/或参数数量的方法。重载允许根据所提供的参数不同来调用不同的函数。(方法具有相同的名称、方法具有不同的参数类型或者参数数量、返回类型可以相同也可以不相同,同一作用域)。

重写是指在派生类中重新定义基类中的方法。当派生类需要改变或者扩展基类方法的功能时,就需要用到重写。(方法具有相同的名称、方法具有相同的参数类型和数量、方法具有相同的返回类型、重写的基类中被重写的函数必须有virtual修饰,发生在继承关系的类之间)。

隐藏是指派生类的函数屏蔽了与其同名的基类函数。 当参数不同时,无论基类中的函数是否被virtual修饰,基类函数都是被隐藏的,而不是被重写。

重载和重写的区别:

  1. 范围不同:重写和被重写的函数在不同的类中,重载和被重载的函数在同一个类中,也就是在同一个作用域中。
  2. 参数不同:重写和被重写的参数列表一定要相同,重载和被重载的函数列表一定不同。
  3. virtual不同:重写的基类的函数必须要有virtual修饰;重载函数和被重载函数可以被virtual修饰,也可以没有。

隐藏和重写、重载的区别?

  1. 与重载返回不同:隐藏函数和被隐藏函数在不同的类中。
  2. 参数的区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不相同,但是函数名一定相同;当参数不同时,无论基类中的函数是否被virtual修饰,基类函数都是被隐藏的,而不是被重写。

4、谈谈菱形继承(补充)?

菱形继承是在继承关系中,有一个基类,并且这个基类直接或者间接派生出了2个或者多个派生类,这些派生类又被一个共同的类继承了。比如iostream继承自istream和ostream,而这两个类由ios类派生。

菱形继承会引发一些问题,主要有数据冗余和二义性。如果最初的基类有一个字段,那么它的派生类都会有这个字段,最后,继承了多个类的派生类将会有多个这个字段,当用子类对象调用这个字段时将会出现错误,编译出实现不明确的问题。

为了解决菱形继承问题,C++引入了虚继承的概念,通过在派生类对共同基类的声明中使用virtual关键字,可以确保只有一个共享的基类实例,从而避免二义性和数据冗余的问题。如下图,最顶层的基类成为虚基类。

5、类的构造顺序和析构顺序?

一、构造顺序

  1. 如果当前类继承了一个或者多个基类,他们将按照声明顺序进行初始化,但是如果有虚继承,优先虚继承。
  2. 类的成员变量按照它们在类定义中的声明顺序进行初始化(成员变量的初始化顺序只与声明的顺序有关)。
  3. 执行本身的构造函数。

二、类的析构顺序与构造顺序完全相反。

类成员初始化方式?为什么用成员初始化列表会快一点?

初始化方式:赋值初始化(通过在函数体内进行赋值初始化);列表初始化(在构造函数之后使用初始化列表进行初始化)。

两者区别:

       对于在函数体内初始化是在所有数据都被分配内存空间后才进行的;

       列表初始化是给数据成员分配内存空间时就进行初始化,相比于赋值初始化,他减少了中间状态(临时对象),此外编译器也能够对其优化。用初始化列表会快一些的原因是,对于类类型,它少了一次调用构造函数的过程,而在函数体中赋值则会多一次调用。

哪些情况必须用到成员列表初始化?作用是什么?

① 当初始化一个引用成员时;

② 当初始化一个常量成员时;

③ 当调用一个基类的构造函数,而它拥有一组参数时;

④ 当调用一个成员类的构造函数,而它拥有一组参数时。

6、析构函数可以抛出异常吗?

首先,从语法层面并没有禁止析构函数抛出异常,但在实践中不要这样做。

由于析构函数常常被自动调用,在析构函数中抛出的异常往往会难以捕获,引发程序非正常退出或未定义行为。另外,我们都知道在容器析构时,会逐个调用容器中的对象析构函数,而某个对象析构时抛出异常还会引起后续的对象无法被析构,导致资源泄漏。资源可以是内存,也可以是数据库连接,或者其他类型的计算机资源。析构函数是由C++来调用的,源代码中不包含对它的调用,因此它抛出的异常不可被捕获。

如果析构函数中真的可能存在异常,需要直接在析构函数中捕获,而不能向外抛出。

7、C++中的深拷贝和浅拷贝

浅拷贝是一种简单的拷贝方式,仅仅是复制对象的基本类型成员和指针成员的值,而不复制指针所指向的内存。这可能会导致两个对象共享相同的资源,从而引发潜在的问题,如内存泄漏、意外修改共享资源等。一般来说编译器默认帮我们实现的拷贝构造函数就是一种浅拷贝。POD类型的数据就适合浅拷贝。

深拷贝不仅复制对象的基本类型成员,还复制指针所指向的内存。因此,两个对象不会共享相同的资源,避免了潜在问题。深拷贝通常需要显式实现拷贝构造函数和赋值运算符重载。

写出深拷贝和浅拷贝的代码。(面试题)

#include <iostream>

#include <cstring>

class DataArray {

private:

    double* data;

    size_t size;

public:

    // 构造函数

    DataArray(size_t _size, double* _data) : size(_size) {

        data = new double[size];

        std::memcpy(data, _data, size * sizeof(double));

    }

    // 拷贝构造函数 - 深拷贝

    DataArray(const DataArray& other) : size(other.size) {

        data = new double[size];

        std::memcpy(data, other.data, size * sizeof(double));

    }

    // 拷贝构造函数 - 浅拷贝

    // 该版本拷贝构造函数只是复制指针,没有创建新的内存副本

    DataArray(const DataArray& other) : size(other.size), data(other.data) {}

    // 析构函数

    ~DataArray() {

        delete[] data;

    }

    // 输出数组内容

    void printArray() {

        for (size_t i = 0; i < size; ++i) {

            std::cout << data[i] << " ";

        }

        std::cout << std::endl;

    }

};

8、this指针?delete this会发生什么?

this指针是一个指向当前对象的指针。在类的成员函数中访问类的成员变量或者调用成员函数时,编译器会隐式地将当前对象的地址为this指针传递给成员函数。因此,this 指针可以用来访问类的成员变量和成员函数,以及在成员函数中引用当前对象。在常量成员函数(const member function)中,this 指针的类型是指向常量对象的常量指针(const pointer to const object),因此不能用来修改成员变量的值。

在C++中,static 函数是一种静态成员函数,它与类本身相关,而不是与类的对象相关。大家可以将 static 函数视为在类作用域下的全局函数,而非成员函数。因为静态函数没有 this 指针,所以它不能访问任何非静态成员变量。如果在静态函数中尝试访问非静态成员变量,编译器会报错。

在析构函数中调用delete this会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。

在成员函数中调用delete this会将类对象的内存空间被释放。在delete this之后进行的任何函数调用,只要不涉及到this指针的内容,都能正常运行。一旦涉及到this指针,比如操作成员函数、调用虚函数等。就会出现不可预期的问题。

9、C++多态的实现方式?

多态是面向对象的三大特性之一。是指同一个函数或者操作在不同的对象上有不同的表现形式。C++实现多态的方式主要有函数重载和虚函数重写

前者是静态多态,是指在同一个作用域(多个重名的全局函数或者多个同名的类成员函数)中有1个以上函数名称相同但是函数参数的类型或者个数不一致的函数,在调用函数的地方,通过传递的实参类型或者个数就能在程序编译期间就能确定具体调用哪个一个函数。此外,模板函数也是一种静态多态的实现方式,因为模板也是在编译器期间根据具体的调用确定了具体的类型。

后者是动态多态,是指在类继承的关系中,基类存在虚函数或者纯虚函数,而派生类重写了基类的虚函数或者纯虚函数,当使用基类的指针或者引用去指向派生类对象时,可以调用到子类重写的函数。所以,动态多态必须满足两个条件,第一就是基类的指针或者引用调用虚函数;第二就是被调用的是虚函数,且派生类完成了对基类虚函数的重写。

10、动态多态的实现原理

C++的动态多态是通过虚函数实现的。当基类指针或者引用指向一个派生类对象时,调用虚函数时,实际上会调用派生类中的虚函数,而不是基类中的虚函数。

在底层,当一个类声明一个虚函数时,编译器会为该类创建一个虚函数表,并且会给类插入一个vptr虚函数表指针的字段。虚函数表存储该类的虚函数指针,这个指针指向实际实现该虚函数的代码地址。每个对象都包含一个指向该类的虚函数表的虚函数表指针(vptr),这个指针在对象构造时与该类的虚函数表绑定,通常是作为对象的第一个成员变量。

当调用一个虚函数时,编译器会通过对象的虚函数指针查找到该对象所属的类的虚函数表,并根据函数的索引值(通常是函数在表中的位置,编译时就能确定)来找到对应的虚函数地址。然后将控制转移到该地址,实际执行该函数的代码。

对于派生类,其虚函数表也是继承自基类的虚函数表,然后根据派生类自身虚函数重写的情况来更新继承的这张虚函数表(派生类的表继承之后也独立与基类),将重写之后的虚函数的地址更新继承的虚函数表中对应的项。

11、(补充)C++对象模型

虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的,简称为V-Table。在这个表中,存放的是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。

这样,这个类的实例内存中都有一个虚函数表的指针,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

在上面的示例中,意思就是一个对象在内存中一般由成员变量(非静态)、虚函数表指针(vptr)构成

虚函数表指针指向一个数组,数组的元素就是各个虚函数的地址,通过函数的索引,我们就能直接访问对应的虚函数。

12、纯虚函数是什么?能被实例化吗?为什么?

纯虚函数是一种在基类中声明但没有实现的虚函数。它的作用是定义了一种接口,这个接口需要由派生类来实现。(PS: C++ 中没有接口,纯虚函数可以提供类似的功能。包含纯虚函数的类称为抽象类(Abstract Class)。抽象类仅仅提供了一些接口,但是没有实现具体的功能。作用就是制定各种接口,通过派生类来实现不同的功能,从而实现代码的复用和可扩展性。另外,抽象类无法实例化,也就是无法创建对象。纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间。

带有纯虚函数的基类被称为抽象类或者接口,它要求继承它的子类实现对应纯虚函数。纯虚函数没有函数体,如果用它直接实例化对象,将无法确定如何执行这些函数,所以不能直接创建对象。

13、构造函数不能是虚函数?

从语法层面上:

  1. 虚函数的主要目的是实现多态,即允许在派生类中覆盖基类的成员函数。
  2. 但是,构造函数负责初始化类的对象,每个类都应该有自己的构造函数。
  3. 在派生类中,基类的构造函数会被自动调用,用于初始化基类的成员。因此,构造函数没有被覆盖的必要,不需要使用虚函数来实现多态

从虚函数表机制上:

  1. 虚函数使用了一种称为虚函数表(vtable)的机制。然而,在调用构造函数时,对象还没有完全创建和初始化,所以虚函数表可能尚未设置。
  2. 这意味着在构造函数中使用虚函数表会导致未定义的行为。
  3. 只有执行完了对象的构造,虚函数表才会被正确的初始化。

总之:将构造函数设置为虚函数编译器就会报错。

14、为什么C++基类析构函数需要是虚函数?

析构函数的作用:析构函数是进行类的清理工作,比如释放内存、关闭DB链接、关闭Socket等等。

为什么:当使用动态多态时,如果派生类中增加了新的字段,并且这个字段是指针类型,那么在析构函数中需要释放这个字段的内存,如果基类不声明析构函数为虚析构函数,那么在调用析构函数时就会和成员函数一样,会直接调用父类的构造函数,而不会调用子类自身的,所以无法释放这个资源。为此,需要将基类析构定义为虚析构。在释放对象时会先调用子类的析构然后调用父类的析构。

15、友元函数和友元类

友元提供了不同类的成员函数之间,类的成员函数和一般函数之间进行数据共享的机制。

通过友元,另一个类中的成员函数可以访问类中的私有成员和保护成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。

1)友元函数

友元函数是可以访问类的私有成员的非成员函数。它是定义在类外的普通函数,不属于任何类,但是需 要在类的定义中加以声明。 friend 类型 函数名(形式参数);一个函数可以是多个类的友元函数,只需要在各个类中分别声明。

2)友元类

友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和 保护成员)。友元类声明:friend class 类名; 使用友元类时,继承关系不能被继承;友元关系是单向的,不具有交换性。如果类B是类A的友元,类A不一定是类B的友元,需要看具体的友元声明情况;友元关系不具有传递性。若是类B是类A的友元,类C是B的友元,类C不一定是类A的友元。

16、为什么友元函数必须在类内部声明?

因为编译器必须能够读取这个结构的声明以理解这个数据类型的行为等方面的所有规则。有一条 规则在任何关系中都很重要,那就是谁可以访问我的私有部分。

17、explicit关键字的作用?

在C++中,explicit通常用于构造函数的声明中,用于防止隐式转换。当将一个参数传递给构造函数时,如果构造函数声明中使用了explicit关键字,则只能使用显示转换进行转换,而不能使用隐式转换。这种机制可以防止编译器自动执行预期外的类型转换,提高代码安全性

结论(Google编码规范):

在类型定义中,类型转换运算符和单参数构造函数都应用explicit进行标记,一个例外是,拷贝和移动构造函数不应当被标记,因为他们并不执行类型转换。对于涉及目的就是用于对其他类型进行透明包装的类来说,隐式类型转换有时是必要且合适的。不能以一个参数进行调用构造函数不应当加上explicit。接受一个std::initializer_list作为参数的构造函数也应当省略explicit,以便支持拷贝初始化(例如:MyTpye m = {1, 2})。

18、final和override关键字?

override修饰的函数是指子类重写了父类的虚函数,可以有效防止开发者在子类中写错了重写的函数签名。如果写错了,编译期间就能够提示出来。

final可以修饰类和虚函数。如果希望某个类不被其他类继承,可以使用final对类进行修饰,class A final {};其次,如果希望某个虚函数不被子类重写,可以使用final修饰。

19、初始化和赋值的区别?

对于简单数据类型,初始化和赋值没什么区别。对于类和复杂类型有很大的区别。赋值运算符会将传入的对象的成员数据给被赋值的对象。

20、什么时候会调用拷贝构造函数?

1、用一个类对象去创建另一个对象(A a; A b(a));

2、用一个类对象初始化类一个对象 (A a; A b = a);

3、函数参数是类(void t(A a)),在调用函数时,调用的拷贝构造。

21、组合知道吗?与继承相比有什么优缺点?

1、继承。继承的优点是子类可以重写父类的方法来方便实现对父类的扩展。但是有以下几个缺点:父类的内部细节对子类是可见的;子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为;如果对父类的方法做了修改的话,则子类的方法必须做出相对应的修改。所以子类与父类是一种高耦合。

2、组合。组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。组合的优点:①:当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。②:当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。③:当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。组合的缺点:①:容易产生过多的对象。②:为了能组合多个对象,必须仔细对接口进行定义。

22、成员函数里memset(this, 0, sizeof(*this))会发生什么?

1、有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。对于这种情形可以很好的工作,但是下面几种情形是不可以这么使用的;

2、类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;

3、类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存。

23、类的对象存储空间?

类对象的大小包括非静态成员的数据类型大小之和编译器加入的额外成员变量(比如指向虚函数表的指针);为了字节对齐而加入的新的字节

空类:大小为1个字节,保证创建的每个对象都有不同的地址;当作为基类是大小为0。

24、C++中类的数据成员和成员函数内存分布情况?

一个类对象的地址就是类所包含的这一片内存空间的首地址,这个首地址也就对应具体某一个成员变量的地址。对象的大小和对象中的数据成员的大小是一致的,也就是说,成员函数不占用对象的内存,静态成员也不占用对象的内存。所有的函数都放在代码区,不管是全局函数还是成员函数,还有静态成员函数也放在代码区。

静态成员函数与一般成员函数的唯一区别是没有this指针,因此不能访问非静态数据成员。

25、关于this指针你知道什么?

1、说明

this指针是类的指针,指向对象的首地址。

this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。

this指针只有在成员函数中才有定义,且存储位置会因为编译器不同有不同的存储位置。

2、this指针的用处

一个对象的this指针并不是对象本身的一部分,不会影响 sizeof(对象) 的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。

3、this指针的使用?

一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this;

另外一种情况是当形参数与成员变量名相同时用于区分,如this->n = n (不能写成n = n)。

4、this指针的特点?

第一、this只能在成员函数中使用,全局函数,静态函数都不能使用this。实际上,传入参数为当前对象地址,成员函数第一个参数为 T * const this。比如成员函数 int func(int p){ },从编译器的角度来看应该是:int func(A * const this, int p);。

第二、this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。A a; a.fun(1);  A::func(&a, 10)。;

26、几个this指针的易混问题?

A:this指针是什么时候创建的?

this指针在成员函数的开始执行前构造,在成员的执行结束后清除。

B:this指针存放在何处?堆栈、全局还是其他?

this指针会因为编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,设置全局变量。在汇编级别里面,一个值只会以3种形式出现:立即数、寄存器值和内存变量值。不是存放在寄存器就是存放在内存中,他们并不是和高级语言变量对应的。

C:每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?

普通的类函数(不论是成员函数,还是静态函数)都不会创建一个函数表来保存函数指针。只有虚函数才会被放到函数表中。但是,即使是虚函数,如果编译期就能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。正是由于this指针的存在,用来指向不同的对象,从而确保不同对象之间调用相同的函数可以互不干扰。

27、this指针调用成员函数时,堆栈会发生什么变化?

当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。

即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this的。

例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。

28、基类的虚函数表存放在内存的什么区,虚函数表指针vptr的初始化时间?

虚函数表的特征:

  1. 虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成。
  2. 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段。
  3. 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时就确定了,即虚函数表的大小可以在编译时期确定,不必动态分配内存空间存储虚函数表,所以不在堆中。

由于虚函数表指针vptr和虚函数密不可分,对于有虚函数或者继承于用于虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚函数表指针进行初始化,并且存在内存布局的前面(也就是vptr这个隐含成员在其他成员变量之前)。

C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

29、模板函数和模板类的特例化?

引入原因:编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化。

1、函数模板特例化

必须为原函数模板的每个模板参数都提供实参,且使用关键字template后跟一个空尖括号对<>,表明将原模板的所有模板参数提供实参。

本质:特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。例如,此处如果是compare(3,5),则调用普通的模板,若为compare(“hi”,”haha”)则调用特例化版本(因为这个cosnt char*相对于T,更匹配实参类型),注意二者函数体的语句不一样了,实现不同功能。

2、类模板特例化

类模板可以全部特例化也可以部分特例化。

30、构造函数、析构函数、虚函数可否声明为内联函数?

将构造函数和析构函数声明为内联函数是没有意义的,编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请、释放内存等),致使构造函数和析构函数没有表面看上去那么精简。其次,类中的函数默认是inline内联的,编译器也只是有选择性的内联,所以,将构造函数和析构函数声明为内联函数没什么意义。

如果虚函数在编译期间就能决定将要调用哪个函数时,就能够内联。也就是不具备多态性的时候,如果虚函数比较简短,那么就能让内联生效。

31、C++模板是什么?

在C++中,模板(Templates)是一种通用编程工具允许编写通用的代码,以适应多种不同的数据类型或数据结构。模板使得可以编写不特定于特定数据类型的代码,从而提高代码的重用性和灵活性。模板在STL(标准模板库)中广泛使用,例如容器(如向量、列表、映射等)和算法(如排序、查找等)。

编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。

32、构造函数和析构函数可以调用虚函数吗?为什么?

1、在C++中,提倡不在构造函数和析构函数中调用虚函数;

2、构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;

3、因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;

4、析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。

33、构造函数一般不定义为虚函数的原因?

(1)创建一个对象时需要确定对象的类型,而虚函数是在运行时动态确定其类型的。在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型。

(2)虚函数的调用需要虚函数表指针vptr,而该指针存放在对象的内存空间中,若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表vtable地址用来调用虚构造函数了。

(3)虚函数的作用在于通过父类的指针或者引用调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函数。

34、构造函数的几种关键字?

default关键字可以显式要求编译器生成构造函数,防止在调用时相关构造函数类型没有而报错。

delete关键字可以删除构造函数、赋值运算符函数等。

35、构造函数、拷贝构造函数和赋值运算符的区别?

构造函数:对象不存在,没有用别的对象初始化,在创建一个新的对象时调用构造函数。

拷贝构造函数:对象不存在,但是使用别的已经存在的对象进行初始化。

赋值运算符:对象存在,用别的对象给它赋值,这属于重载“=”号运算符的范畴,“=”号两侧的对象都是已存在的。

36、虚函数的代价是什么?

1、带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大了类;

2、带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小;

3、不能再是内联的函数,因为内联函数在编译阶段进行替代,而虚函数表示等待,在运行阶段才能确定到低是采用哪种函数,虚函数不能是内联函数。

三、STL

1、STL的介绍?说说STL的基本组成部分?

STL是标准模板库,是C++的标准库之一,一套基于模板的容器类库,还包括许多常用的算法,提高了程序开发的效率和复用性。STL包含6大部件:容器、迭代器、算法、仿函数、适配器和空间配置器。

容器

是一种数据结构, 如list, vector, 和deques,以模板类的方法提供。为了访问容器中的数据,可以使用由容器类输出的迭代器。

算法

  是用来操作容器中的数据的模板函数。例如,STL用sort()来对一 个vector中的数据进行排序,用find()来搜索一个list中的对象, 函数本身与他们操作的数据的结构和类型无关,因此他们可以用于从简单数组到高度复杂容器的任何数据结构上。

迭代器

提供了访问容器中对象的方法。例如,可以使用一对迭代器指定list或vector中的一定范围的对象。 迭代器就如同一个指针。事实上,C++ 的指针也是一种迭代器。 但是,迭代器也可以是那些定义了operator*()以及其他类似于指针的操作符方法的类对象;

仿函数

仿函数又称之为函数对象, 其实就是重载了操作符的 ( ) ,没有什么特别的地方。

适配器

    简单的说就是一种接口类,专门用来修改现有类的接口,提供一中新的接口;或调用现有的函数来实现所需要的功能。主要包括3中适配器Container Adaptor、Iterator Adaptor、Function Adaptor。

空间配置器

  为STL提供空间配置的系统。其中主要工作包括两部分:

(1)对象的创建与销毁;

(2)内存的获取与释放。

2、vector容器的底层原理?

vector底层是一个动态数组,包含三个迭代器,start和finish之间是已经被使用的空间范围,end_of_storage是整块连续空间包括备用空间的尾部。

当空间不够装下数据(vec.push_back(val))时,会自动申请另一片更大的空间(1.5倍Windows或者2倍Linux),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间【vector内存增长机制】。当释放或者删除(vec.clear())里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。

对vector的任何操作一旦引起了空间的重新配置,指向原vector的所有迭代器都会失效。

3、vector中的reseve和resize的区别?

reserve是直接扩充容器到确定的大小,可以减少多次开辟、释放空间的问题,可以有效提高效率。reserve只是保证了vector容器中的空间大小,也就是容量最少达到参数所指定的大小,reserve只有一个参数,是新的容量大小。(如果目前容器中已经有了3个数据,此时,用reserve设置为2将是没有效果的)。

resize不仅仅是改变容量,还会给扩充的位置赋初始值。也就是容量和大小都会被改变。所以当设置之后,可以通过size和capacity函数获取容量和大小,两者都是一样的。

4、vector中的size和capacity的区别?

size表示当前vector中有多少个元素(start-finish),而capacity函数则表示他已经分配的内存中可以容纳多少个元素。

5、vector容器中能存放引用吗?

不能。引用不支持一般意义上的赋值操作,而vector中的元素有两个要求:元素必须能赋值;元素必须能赋值。

6、vector迭代器失效的情况?

  1. 插入或者删除某个元素时,会导致该元素后面的所有元素向前或者向后移动一个位置。erase和insert方法会返回下一个有效的迭代器和当前插入位置的迭代器,以解决迭代器失效的问题。
  2. 需要注意的是,如果size < capcity的情况下插入,迭代器并不会全部失效,通过原来的迭代器还是可以实现相对应位置数据的获取;如果出现重新分配内存的情况,迭代器会全部失效。

7、vector内存相关的函数clear、swap、shrink_to_fit()

vec.clear() : 清空内容,但是不释放内存。也就是size=0,capacity不变。

vector<int>().swap(vec):清空内容,且释放内存,得到新的vector。即size=capacity=0

vec.shrink_to_fit() ://请求降低size和capacity的匹配。

vec.clear();vec.shrink_to_fit();:清空内容,且释放内存。

8、list的底层原理?

list的底层是一个双向链表,以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间。list不支持随机存取,如果需要大量的插入和删除,而不关系随机存取,则比较适合这种数据结构。

在这里插入图片描述

9、deque的底层原理

deque是一个双向开口的连续线性空间(双端队列),在头尾两端进行元素的插入跟删除操作都有理想的时间复杂度。

在这里插入图片描述

10、什么情况下用vector,list和deque?

vector可以随机存储元素(即可以通过公式直接计算出元素地址,而不需要挨个查找),但在非尾部插入删除数据时,效率很低,适合对象简单,对象数量变化不大,随机访问频繁。除非必要,尽可能选择使用vector而非deque,因为deque的迭代器比vector迭代器复杂很多。

list不支持随机存储,适用于对象大,对象数量变化频繁,插入和删除频繁,比如写多读少的场景。

需要从首尾两端进行插入或删除操作的时候需要选择deque。

11、priority_queue的底层原理

priority_queue:优先队列,其底层是用堆来实现的。在优先队列中,队首元素一定是当前队列中优先级最高的那一个。优先队列具备队列的所有特性,包括基本操作,只是在这个基础上添加了内部的一个排序。

12、map、set、multiset和multimap的底层原理?

map 、set、multiset、multimap的底层实现都是红黑树。

13、map、set、multiset和multimap的特点?

set和multiset会根据特定的排序准则自动将元素排序,set中元素不允许重复,multiset可以重复。

map和multimap将key和value组成的pair作为元素,根据key的排序准则自动将元素排序(因为红黑树也是二叉搜索树,所以map默认是按key排序的),map中元素的key不允许重复,multimap可以重复。

map和set的增删改查速度为都是logn,是比较高效的。

14、为何map和set的插入删除效率比其他序列容器高,而且每次insert之后,以前保存的iterator不会失效?

因为存储的是结点,不需要内存拷贝和内存移动。

因为插入操作只是结点指针换来换去,结点内存没有改变。而iterator就像指向结点的指针,内存没变,指向内存的指针也不会变。

15、为何map和set不能像vector一样有个reserve函数来预分配数据?

因为在map和set内部存储的已经不是元素本身了,而是包含元素的结点。也就是说map内部使用的Alloc并不是map<Key, Data, Compare, Alloc>声明的时候从参数中传入的Alloc。

16、unordered_map、unordered_set的底层原理?

这两个容器是使用哈希表作为底层实现的,提供了高效的查找、插入和删除操作。底层哈希表是一个数组,每隔元素称为桶(每个元素都是不同的,插入相同元素无效)。每个桶可以存储一个或者多个元素,其中每个元素由键值对组成(unordered_map是键值对key-value;unordered_set是只有键值)。通过将键的哈希值映射到对应的桶,可以快速定位元素。在桶中,使用开放地址发和拉链法解决计算出来的哈希值的冲突问题。

在这里插入图片描述

17、unordered_map和map的区别?使用场景?

区别?

  1. 内部实现是不同的,unordered_map使用哈希表作为底层实现,而map使用红黑树作为底层实现。哈希表具有平均0(1)的查找插入和删除操作,红黑树则是0(logn)。
  2. 元素顺序是不同的,unordered_map中的键值对没有特定的顺序,而map中的键值对按照键的比较顺序进行排序。
  3. 效率:由于哈希表的特性,unordered_map的平均情况下提供了更快的查找、插入和删除操作。但是在最坏的情况下(冲突很多),性能可能下降;而红黑树比较稳定。

场景?

  1. 如果需要高效的查找操作,而不关心元素的顺序,可以选择unordered_map;
  2. 如果需要元素有序,并且对性能要求不严格,可以选择map;
  3. 如果对性能要求非常严格,并且不关心元素顺序,首选unordered_map。

unordered_map什么时候扩容?

当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值---即当前数组的长度乘以加载因子的值的时候,就要自动扩容。扩容需要重新计算容量。

18、迭代器种类

1、输入迭代器:是只读迭代器,在每个被遍历的位置上只能读取一次。

2、输出迭代器:是只写迭代器,在每个被遍历的位置上只能被写一次。•

3、前向迭代器:兼具输入和输出迭代器的能力,但是它可以对同一个位置重复进行读和写。但它不支持operator–,所以只能向前移动。

4、双向迭代器:很像前向迭代器,只是它向后移动和向前移动同样容易。

5、随机访问迭代器:有双向迭代器的所有功能。而且,它还提供了“迭代器算术”,即在一步内可以向前或向后跳跃任意位置, 包含指针的所有操作,可进行随机访问,随意移动指定的步数。支持前面四种Iterator的所有操作,并另外支持it + n、it - n、it += n、 it -= n、it1 - it2和it[n]等操作。

19、说说push_back和emplace_back的区别?

如果要将一个临时变量push到容器的末尾,push_back()需要先构造临时对象,再将这个对象拷贝到容器的末尾;而emplace_back()则直接在容器的末尾构造对象,这样就省去了拷贝的过程。

20、vector与list的区别和应用?怎么找到vector或者list的倒数第二个元素?

1、vector的随机访问效率高,但在插入和删除时(不包括尾部)需要挪动数据,不易操作。

2、list的访问要遍历整个链表,它的随机访问效率低。但对数据的插入和删除操作等都比较方便,改变指针的指向即可。

3、从遍历上来说,list是单向的,vector是双向的。

4、vector中的迭代器在使用后就失效了,而list的迭代器在使用之后还可以继续使用。

list不提供随机访问,所以不能用下标直接访问到某个元素,要访问list里的元素只能遍历,可以用反向迭代器遍历。

21、vector越界访问下标?map越界访问下标?vector删除元素时会不会释放空间?

1、通过下标访问vector中的元素时会做边界检查,如果超出,很大可能导致程序崩溃。

2、map的下标运算符[ ]的作用是将键作为下标去执行查找,并返回相应的值;如果不存在这个键,就将一个具有该key和value的值插入到这个map中。

erase删除某一个元素只会删除内容,不会改变容器的容量。

22、map中的 [ ] 和find的区别?

1、map的下标运算符[ ]的作用是:将关键码作为下标去执行查找,并返回对应的值;如果不存在这个关键码,就将一个具有该关键码和值类型的默认值项插入到这个map。

2、map的find函数:用关键码执行查找,找到了返回该位置的迭代器;如果不存在这个关键码,就返回尾迭代器。

四、C++新特性

1、说一下C++的左值引用和右值引用?

1、什么是左值,什么是右值?

左值:指在内存中给有明确存储地址的数据,可以用&运算符取地址。

右值:指在内存中可以提供的,不可以取地址的字面量或者临时对象。右值可以分为将亡值和纯右值。将亡值是与右值引用相关的表达式,比如右值引用类型函数的返回值、move移动函数的返回值。纯右值是非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等等。

2、左值引用与右值引用?

左值引用是对左值的引用,要求初始化时右边的值是可以取地址的,如果无法取地址,就必须使用常引用。

右值引用是用来绑定到右值的,绑定右值以后,本来被销毁的右值的生存周期会延长到绑定到它的右值引用的生存期。

3、右值引用作用?

右值引用的存在并不是为了取代左值引用,而是充分利用右值,特别是临时对象,来减少对象构造和析构的操作次数以达到提高效率的目的。

带右值引用参数的拷贝构造和赋值函数,叫做移动构造函数和移动赋值函数,这里的移动指的是临时量资源转移给当前对象,临时对象将不再持有这个资源。

在 C++ 中,并不是所有情况下 && 都代表是一个右值引用,具体的场景体现在模板和自动类型推导中。auto&&或者函数参数类型自动推导的T&&是一个未定的引用类型,它可能是左值引用也可能是右值引用类型,这取决于初始化的值类型。通过右值推导 T&& 或者 auto&& 得到的是一个右值引用类型,其余都是左值引用类型。

4、完美转发是什么?什么场景下用到完美转发?

完美转发指的是函数模板可以将自己的参数完美转发给内部调用的其他函数。所谓完美,是指不仅能够准确转发参数的值,还能保证被转发参数的左右值属性不变。

forward<T>(t) : 当T为左值引用类型时,t将被转换为T类型的左值。当T不是左值引用类型时,t将被转换为T类型的右值。

2、说说C++11的新特性有哪些?

C++新特性主要包括包含语法改进和标准库扩充两个方面,主要包括以下11点:

语法的改进

(1)统一的初始化方法。增大了初始化列表的适用性,可以用于任何类型的对象。

(2)成员变量默认初始化。构造一个类的对象不需要用构造函数初始化成员变量。

(3)auto关键字。用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)

(4)decltype求表达式的类型,在编译期间自动类型推导。

(5)智能指针 shared_ptr。使用RAII机制封装的一个类模板帮助管理指针类型。

(6)空指针 nullptr(原来NULL)。nullptr专门用于初始化空类型指针,可以避免NULL的弊端。

(7)基于范围的for循环。

(8)右值引用和move语义。让程序员有意识减少进行深拷贝操作。

标准库扩充(往STL里新加进一些模板类,比较好用)

(9)无序容器(哈希表)    用法和功能同map一模一样,区别在于哈希表的效率更高。

(10)正则表达式。可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串。

(11)Lambda表达式。lambda 表达式定义了一个匿名函数,并且可以捕获一定范围内的外部变量。可以在需要的时间和地点实现功能的就地闭包,使得程序更加灵活。

3、说说C++中智能指针和指针的区别是什么?

所有权管理: 普通指针不会自动释放内存,需要手动给调用delete或delete [ ] 来释放。而智能指针会自动管理所指向的对象的内存,当智能指针超出作用域或被显式释放时,会自动调用delete或者delete [ ] 来释放内存。

多线程安全:普通指针不提供多线程安全的保证,如果多个线程同时访问同一个指针,可能会导致竞态条件。而智能指针可以通过引用计数或其他机制来保证多线程安全。

拷贝和赋值:普通指针可以随意拷贝和赋值,这可能会导致多个指针指向同一个内存地址,造成内存泄漏或悬空指针。而智能指针可以通过禁止拷贝和赋值或使用引用计数等机制来避免这种问题。

智能指针和普通指针的区别在于智能指针实际上是对普通指针加了一层封装机制,区别是它负责自动释放所指的对象,这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期。智能指针相比普通指针更加安全和方便,可以避免内存泄漏、悬空指针和竞态条件等问题。但是,智能指针也有一些缺点,例如可能会增加程序的开销和复杂度,需要谨慎使用。

4、说说C++中的智能指针有哪些?分别解决的问题以及区别?

C++中的智能指针有4种,分别为:shared_ptr、unique_ptr、weak_ptr、auto_ptr,其中auto_ptr被C++11弃用。

使用智能指针的原因:申请的空间(即new出来的空间),在使用结束时,需要delete掉,否则会内存泄漏。在程序运行期间,new出来的对象,在析构函数中delete掉,但是这种方法不能解决所有问题,因为有时候new发生在某个全局函数里面,该方法会给程序员造成精神负担。此时,智能指针就派上了用场。使用智能指针可以很大程度上避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源。所以,智能指针的作用原理就是在函数结束时自动释放内存空间,避免了手动释放内存空间

1、为什么使用智能指针?

主要目的是为了更安全的管理内存,防止内存泄漏。

智能指针是一个类模板,用于管理普通的指针,它采用了资源获取即初始化的原则,当智能指针对象超出作用域的时候就会自动析构,释放管理的指针或者减少引用计数。

2、auto_ptr智能指针

是C++98引入的,他是一个独占式的智能指针,只能有一个智能指针对某个普通指针进行管理,如果同时交给多个智能指针,那么在超出作用域释放的时候会释放多次导致程序运行时崩溃;此外,如果将一个智能指针对象赋值给另一个,就会触发所有权的转移,被转移的智能指针在当前作用域就失效了,如果再次引用获取数据就会导致程序崩溃。所以这个智能指针存在安全问题,容易导致程序运行崩溃。

3、unique_ptr独占智能指针

类似于auto_ptr智能指针,是一个独占的智能指针,同一时间只能有一个智能指针管理普通的指针对象,它比auto智能指针更加安全,因为它是禁止拷贝操作的,以此来保证独占。

4、share_ptr共享智能指针

共享智能指针是一种共享所有权的智能指针,它允许多个智能指针指向同一个对象,并使用引用计数的方式来管理指向对象的指针,这个引用计数在多个智能指针对象之间也是一个共享数据。当某个智能指针对象创建出来了引用计数+1,销毁就-1。并判断-1之后引用计数是否为0,如果是就需要销毁被管理的普通指针,并且将引用计数这个指针指向的空间也销毁。

5、weak_ptr弱指针

弱指针是一种不控制对象生命周期的智能指针,它指向一个share_ptr管理的对象,进行该对象的内存管理的是共享智能指针,所以弱指针不会改变引用计数,只是提供了一种访问其管理对象的手段。用于防止share_ptr出现的循环引用导致内存泄漏的情况。

5、说说智能指针的特点?

智能指针的作用是管理一个指针,因为存在申请的空间在函数结束时忘记释放,造成内存泄漏的情况。使用智能指针可以很大程度上避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,自动释放资源。

(1)auto_ptr

 auto指针存在的问题是,两个智能指针同时指向一块内存,就会两次释放同一块资源,自然报错。

(2)unique_ptr

 unique指针规定一个智能指针独占一块内存资源。当两个智能指针同时指向一块内存,编译报错。

 实现原理:将拷贝构造函数和赋值拷贝构造函数申明为private或delete。不允许拷贝构造函数和赋值操作符,但是支持移动构造函数,通过std:move把一个对象指针变成右值之后可以移动给另一个unique_ptr

(3)shared_ptr

共享指针可以实现多个智能指针指向相同对象,该对象和其相关资源会在引用为0时被销毁释放。

 实现原理:有一个引用计数的指针类型变量,专门用于引用计数,使用拷贝构造函数和赋值拷贝构造函数时,引用计数加1,当引用计数为0时,释放资源。

注意:weak_ptr、shared_ptr存在一个问题,当两个shared_ptr指针相互引用时,那么这两个指针的引用计数不会下降为0,资源得不到释放。因此引入weak_ptr,weak_ptr是弱引用,weak_ptr的构造和析构不会引起引用计数的增加或减少。

6、weak_ptr能不能知道对象计数为0,为什么?

不能,它获取的引用计数是shared_ptr的引用计数。

weak_ptr是一种不控制对象生命周期的智能指针,它指向一个shared_ptr管理的对象。进行该对象管理的是那个引用的shared_ptr。weak_ptr只是提供了对管理 对象的一个访问手段。weak_ptr设计的目的只是为了配合shared_ptr而引入的一种智能指针,配合shared_ptr工作,它只可以从一个shared_ptr或者另一个weak_ptr对象构造,它的构造和析构不会引起计数的增加或减少。

7、weak_ptr如何解决shared_ptr的循环引用问题?

为了解决循环引用导致的内存泄漏,引入了弱指针weak_ptr,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存。它可以检测到所管理的对象是否已经被释放,从而避免非法访问。

8、shared_ptr、unique_ptr和weak_ptr的自定义实现?

9、shared_ptr怎么知道跟它共享对象的指针释放了?

多个shared_ptr对象可以同时托管一个指针,系统会维护一个托管计数。当无shared_ptr托管该指针时,delete该指针。

10、智能指针有没有内存泄漏的情况?

智能指针有内存泄露的情况发生。

智能指针发生内存泄露的情况 à 当两个对象同时使用一个shared_ptr成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄露。

智能指针的内存泄漏如何解决? à 为了解决循环引用导致的内存泄漏,引入了弱指针weak_ptr,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问。

11、说说C++11中四种类型转换?

C++中四种类型转换分别为const_cast、static_cast、dynamic_cast、reinterpret_cast,四种转换功能分别如下:

  1. const_cast : 将const变量转为非const变量。
  2. static_cast :最常用,可以用于各种隐式转换,比如非const转const,基本数据类型之间的转换,类向上转换;但是向下类型转换不安全。
  3. dynamic_cast : 用于含有虚函数的类层次之间的转换,类向上和向下转换。

向上转换:子类向基类转换;

向下转换:基类向子类转换。当父类转子类时可能出现非法内存访问,是不安全的。

当 dynamic_cast 转换失败时,返回一个空指针(nullptr)或者在指针类型的情况下返回一个空指针指针(nullptr)。如果转换失败并且是引用类型,会抛出一个 std::bad_cast 异常

  1. reinterpret_cast : 主要用于在不同类型之间进行低级别的转换。它仅仅是重新解释底层比特(也就是对指针所指向的那片比特位换个类型解释),而不进行任何类型检查。type-id必须是指针、引用、算术类型、函数指针或者成员指针。因此,reinterpret_cast可能导致未定义的行为。

12、简述auto的具体用法?

auto用于定义变量,编译器可以自动判断变量的类型。auto 仅仅是一个占位符,在编译器期间它会被真正的类型所替代。或者说,C++ 中的变量必须是有明确类型的,只是这个类型是由编译器自己推导出来的。

auto主要有以下几种用法:

  1. 使用auto定义迭代器。迭代器类型比较复杂,可以用auto替代。
  2. 用于泛型编程(不知道变量是什么类型,或者不希望指明具体类型的时候)。

 

五、C++新特性(学习使用)

1、原始字面量

原始字面量可以解决在字符串中出现的转义字符等特殊字符,但实际不想做转移的问题。语法格式:R”xxx(原始字符串)xxx”。其中括号两边的xxx可以省略,主要起到备注的作用,相当于注释的作用,但是不可以省略一边,要么都不要,要么都一样存在。

2、final和override?

final关键字用来限制某个类不能被继承,或者某个虚函数不能被重写,和Java的final关键字功能类似。如果使用final修饰函数,只能修饰虚函数,并且要把final关键字放到类或者函数的后面。

override关键字可以确保在派生类中声明的重写函数与基类的虚函数具有相同的签名,同时也明确表明会重写基类的虚函数,这样可以确保重写虚函数的正确性,也提高了代码的可读性。如果意外出现错误,可以及时提示。注意,要求重写的是虚函数,如果父类的不是虚函数,这就不是重写了,所以这个关键字是会报错的。

3、数值与字符串之间的转换?

1、数值转为字符串

使用to_string()方法可以方便的将各种数值类型转为字符串类型,这是一个重载函数,函数声明位于头文件<string>中。

inline string to_string(int _Val)

inline string to_string(unsigned int _Val)

inline string to_string(long _Val)

inline string to_string(unsigned long _Val)

inline string to_string(long long _Val)

inline string to_string(unsigned long _Val)

inline string to_string(float _Val)

inline string to_string(double _Val)

inline string to_string(long double _Val)

2、字符串转数值

int stoi(const std::string& str, std::size_t * pos = 0, int base = 10);

long stol(const std::string& str, std::size_t * pos = 0, int base = 10);

long stoll(const std::string& str, std::size_t * pos = 0, int base = 10);

unsigned long stoul(const std::string& str, std::size_t * pos = 0, int base = 10);

unsigned long stoull(const std::string& str, std::size_t * pos = 0, int base = 10);

float stof(const std::string& str, std::size_t * pos = 0);

double stod(const std::string& str, std::size_t * pos = 0);

long double stold(const std::string& str, std::size_t * pos = 0);

str: 源字符串

pos:表示出现问题的位置,是一个输出参数。比如123a456,那么pos就是3,因为在索引为3的地方出现无法转换的问题。

base:表示将字符串str中的数字当作哪种进制转换,返回的都是10进制数。

        如果base0,那么会根据字符串的数字格式进行合理的转换,如果是0x开头就是按照16进制转,返回十进制;如果是0开头,按照8进制转换,返回十进制。

   如果直接指定字符串中数字的进制,即使没有0x或者0开头亦可。

注意:

如果字符串中所有字符都是数值类型,整个字符串会被转换为对应的数值,并返回。

如果字符串前部分是数值类型后部分不是,那么前半部分会被转为对应的数值,并返回。

如果字符的第一个就不是数值类型,那么转换失败,抛出异常。

4、静态断言static_cast?

断言(assertion)是一种常用的手段,在通常情况下,断言就是将一个返回值总是需要为真的判断表示放在语句中,用于排除在设计逻辑上不应该产生的情况。比如输入一个用户的年龄,在函数体内就可以对这个年龄变量进行断言,让其在0<= age <= 100之间,如果出了这个返回就会发生异常,程序退出,从而避免程序陷入逻辑的混乱。

从某种意义上讲,断言并不是正常程序所必需的,因为不能因为某些不合理的输入就让程序停止。不过对于调试程序可以很有效的定位某些前提条件的错误。

使用断言时,需要在程序中包含头文件<cassert>或者<assert.h>,头文件中提供了assert宏,用于运行时断言。断言中的表达式返回true才能继续执行,否则直接终止程序报错。

assert是一个运行时断言,只有在程序运行时才能起作用。在某些情况下,无法满足程序设计的需求,比如想要知道当前是32位还是64位平台,此时C++11引入的静态断言就可以达到这个功能。

静态断言static_assert,所谓静态就是在编译时就能够进行检查的断言,使用时不需要引用头文件。此外,可以自定义违反断言时的提示信息。静态断言比断言多一个参数也就是警告信息,通常是一段字符串,在违反断言时提示该信息。(静态断言的表达式是在编译阶段进行检测的,所以表达式中不能出现变量)。

5、noexcept?

异常通常用于处理逻辑上可能发生的错误,在C++98中提供了一套完整的异常处理机制,可以直接在程序中将各种类型的异常抛出,从而强制终止程序的运行。

为了加强程序的可读性,可以在函数声明中列出可能抛出的所有异常类型,通常有以下三种书写方式:

1、显式指定可以抛出的异常类型。如果抛出了未指定类型,将无法抛出。

2、如果在函数后面不显式指定抛出的类型,表示可以抛出任意类型的异常。

3、如果在函数后面显式声明throw(),不指定任何类型,那么将不能抛出任何异常。

noexcept说明:

上面的第一种指定抛出哪几种类型的异常在C++11中被弃用了,而第三种不抛出任何异常throw()也被新的noexcept异常声明所取代。noexcept表示其修饰的函数不会抛出异常,不过与throw()动态异常声明不同,如果用noexcept修饰的函数抛出了异常,编译器会直接调用std::terminate()函数来终止程序的运行,这比基于异常机制的throw()在效率上会高一些。这是因为异常机制会带来一些额外开销,比如函数抛出异常,会导致函数栈被依次展开,并自动调用析构函数释放栈上的所有对象。

从语法上讲,noexcept修饰符有两种形式:

第一:简单地在函数声明后加上noexcept关键字。

第二:可以接受一个常量表达式作为参数。

      

常量表达式地结果会被转换成一个bool类型的值:值为true,表示函数不会抛出异常;值为false,表示有可能抛出异常。不带常量表达式相当于常量表达式为true。

6、自动类型推导?

1、auto。在 C++11 之前 auto 和 static 是对应的,表示变量是自动存储的,但是非 static 的局部变量默认都是自动存储的,因此这个关键字变得非常鸡肋,在 C++11 中他们赋予了新的含义,使用这个关键字能够像别的语言一样自动推导出变量的实际类型。

auto推导类型规则:C++11 中 auto 并不代表一种实际的数据类型,只是一个类型声明的 “占位符”,auto 并不是万能的在任意场景下都能够推导出变量的实际类型,使用auto声明的变量必须要进行初始化,以让编译器推导出它的实际类型,在编译时将auto占位符替换为真正的类型。语法:auto 变量名 = 变量值;

auto 还可以和指针、引用结合起来使用也可以带上 const、volatile 限定符,在不同的场景下有对应的推导规则,规则内容如下:

1、当变量不是指针或者引用类型时,推导的结果中不会保留 const、volatile 关键字

2、当变量是指针或者引用类型时,推导的结果中会保留 const、volatile 关键字

    int temp = 110;

    auto *a = &temp; //auto被推导为int

    auto b = &temp;  //auto被推导为int *类型

    auto &c = temp;  //auto被推导为int类型

    auto d = temp;       //auto被推导为int

    int tmp = 250;

    const auto a1 = tmp;  //a1的数据类型为const int,因此auto关键字被推导为int类型

    auto a2 = a1;  //a2的数据类型为int,但是a2没有声明为指针或者引用,因此const属性被去掉,auto被推导为int

    const auto &a3 = tmp; //a3的数据类型为const int &

auto &a4 = a3; //a4的数据类型为const int &,a4被声明为引用因此const属性被保留,auto关键字被推导为const int

auto的限制:

auto 关键字并不是万能的,在以下这些场景中是不能完成类型推导的:

1、不能作为函数参数使用。因为只有在函数调用的时候才会给函数参数传递实参,auto 要求必须要给修饰的变量赋值,因此二者矛盾。

2、不能用于类的非静态成员变量的初始化

3、不能使用 auto 关键字定义数组

4、无法使用 auto 推导出模板参数。

auto应用?

1、用于STL容器遍历

2、用于泛型编程

2、decltype。

7、增强for循环(范围遍历)?

在遍历的过程中需要给出容器的两端:开头(begin)和结尾(end),因为这种遍历方式不是基于范围来设计的。在基于范围的for循环中,不需要再传递容器的两端,循环会自动以容器为范围展开,并且循环中也屏蔽掉了迭代器的遍历细节,直接抽取容器中的元素进行运算,使用这种方式进行循环遍历会让编码和维护变得更加简便。

语法格式:

declaration 表示遍历声明,在遍历过程中,当前被遍历到的元素会被存储到声明的变量中。expression 是要遍历的对象,它可以是表达式、容器、数组、初始化列表等。

将容器中遍历的当前元素拷贝到了声明的变量 value 中,因此无法对容器中的元素进行写操作,如果需要在遍历过程中修改元素的值,需要使用引用。

对容器的遍历过程中,如果只是读数据,不允许修改元素的值,可以使用 const 定义保存元素数据的变量,在定义的时候建议使用 const auto &,这样相对于 const auto 效率要更高一些。

使用细节:

1、关系型容器

使用基于范围的 for 循环有一些需要注意的细节,比如关系型容器 map 的遍历:

2、元素只读

在 for 循环内部声明一个变量的引用就可以修改遍历的表达式中的元素的值,但是这并不适用于所有的情况,对应 set 容器来说,内部元素都是只读的,这是由容器的特性决定的,因此在 for 循环中 auto & 会被视为 const auto & 。

在遍历关联型容器时也会出现同样的问题,基于范围的for循环中,虽然可以得到一个std::pair引用,但是我们是不能修改里边的first值的,也就是key值。

3、访问次数

对于基于范围的 for 循环来说,冒号后边的表达式只会被执行一次。在得到遍历对象之后会先确定好迭代的范围,基于这个范围直接进行遍历。如果是普通的 for 循环,在每次迭代的时候都需要判断是否已经到了结束边界。

8、nullptr指针空值类型?

在 C++ 程序开发中,为了提高程序的健壮性,一般会在定义指针的同时完成初始化操作,或者在指针的指向尚未明确的情况下,都会给指针初始化为 NULL,避免产生野指针(没有明确指向的指针,操作也这种指针极可能导致程序发生异常)。C++98/03 标准中,将一个指针初始化为空指针的方式有 2 种:

char *ptr = 0;

char *ptr = NULL;

在底层源码中 NULL 这个宏是这样定义的:

#ifndef NULL

    #ifdef __cplusplus

        #define NULL 0

    #else

        #define NULL ((void *)0)

    #endif

#endif

如果是 C++ 程序 NULL 就是 0,如果是 C 程序 NULL 表示 (void*)0。

由于 C++ 中,void * 类型无法隐式转换为其他类型的指针,此时使用 0 代替 ((void *)0),用于解决空指针的问题。这个 0(0x0000 0000)表示的就是虚拟地址空间中的 0 地址,这块地址是只读的。

C++ 中将 NULL 定义为字面常量 0,并不能保证在所有场景下都能很好的工作,比如,函数重载时,NULL 和 0 无法区分:

虽然调用 func(NULL); 最终链接到的还是 void func(int p) 和预期是不一样的,其实这个原因已经很明白了,在 C++ 中 NULL 和 0 是等价的。

出于兼容性的考虑,C++11 标准并没有对 NULL 的宏定义做任何修改,而是另其炉灶,引入了一个新的关键字 nullptr。nullptr 专用于初始化空类型指针,不同类型的指针变量都可以使用 nullptr 来初始化。nullptr 无法隐式转换为整形,但是可以隐式匹配指针类型。在 C++11 标准下,相比 NULL 和 0,使用 nullptr 初始化空指针可以令我们编写的程序更加健壮。

9、Lambda表达式?

1、基本用法

lambda表达式是C++11最重要也是最常用的特性之一。具备以下优点:

  1. 声明式的编程风格:就地匿名定义目标函数,不需要额外写一个命名函数。
  2. 简洁:避免了代码膨胀和功能分散,让开发更加高效
  3. 需要的时间和地点实现功能闭包,使程序更加灵活

lambda 表达式定义了一个匿名函数,并且可以捕获一定范围内的外部变量。lambda 表达式的语法形式简单归纳如下:

[capture](params) opt -> ret {body;};

其中,capture 是捕获列表,params 是参数列表,opt 是函数选项,ret 是返回值类型,body 是函数体。

2、捕获列表

3、返回值

一般情况下,不指定 lambda 表达式的返回值,编译器会根据 return 语句自动推导返回值的类型,但需要注意的是 labmda表达式不能通过列表初始化({1, 2, 3})自动推导出返回值类型。

4、什么通过值拷贝的方式捕获的外部变量是只读的?

lambda表达式的类型在C++11中会被看做是一个带operator()的类,即仿函数。

按照C++标准,lambda表达式的operator()默认是const的,一个const成员函数是无法修改成员变量值的。

对于没有捕获任何变量的 lambda 表达式,还可以转换成一个普通的函数指针:

10、常量表达式修饰符constexpr?

1、const说明

在C++11之前只有const关键字,从功能上,这个关键字有双重语义:变量只读,修饰常量(变量只读不等于常量)。

2、constexpr

这个关键字是用来修饰常量表达式的。常量表达式指的是由多个(>=1)常量组成并且在编译过程中就得到计算结果的表达式。(常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算结果,但是非常量表达式的计算往往发生在程序的编译阶段,这可以极大提高程序的执行效率,因为表达式只需要在编译阶段计算一次,节省了每次程序运行时都需要计算一次的时间)

编译器如何识别表达式是不是常量表达式:constexpr关键字可以在程序中用来修饰常量表达式,用来提高程序的执行效率。在使用中建议将const和constexpr的功能区分开,即凡是表达只读语义的场景都是用const,表达常量的语义的场景都用constexpr。

3、常量表达式函数

为了提高程序的执行效率,可以将程序中值不需要发生变化的变量定义为常量,也可以使用constexpr修饰函数的返回值,这种函数被称为常量表达式函数。这些函数主要有:普通函数、类成员函数、类构造函数和模板函数。

3.1修饰函数

constexpr并不能修改任意函数的返回值,使这些函数称为常量表达式函数,必须要满足几个条件(同时也对类的成员函数适用):

第一、函数必须要有返回值,并且return返回的表达式必须是常量表达式。(C++11是无法编译通过的,但是高版本放宽了限制)

第二、在函数体中,不能出现非常量表达式之外的语句(using指令、tpyedef语句以及static_assert断言、return语句除外)。(注意:C++11中在constexpr的函数体中不能定义constexpr的局部变量,只能用于函数和对象的声明,C++14以上则可以,如下,在C++11中无法通过编译)

3.2修饰模板函数

constexpr可以修饰函数模板,但是由于模板中类型的不确定性,因此函数模板实例化后的模板函数是否符合常量表达式函数的要求也是不确定的。如果constexpr修饰的模板函数实例化后的结果不满足常量表达式函数的要求,则constexpr会被自动忽略,这就相当于一个普通函数。

3.3修饰构造函数

如果想要直接得到一个常量对象,也可以使用constexpr修饰一个构造函数,常量构造函数有一个要求:构造函数的函数体必须为空,并且必须采用初始化列表的方式为各个成员赋值。

11、using关键字?

using关键字通常用于声明命名空间。此外,C++11赋予了其新功能。

1、定义别名

using关键字作为别名声明的开始,其后紧跟别名和等号,其作用是把等号左侧的名字规定成等号右侧类型的别名。类型别名和类型的名字等价,只要是类型的名字能出现的地方,就能使用类型别名。使用typedef定义的别名和使用using是等效的。(using newType=oldType)。在定义函数指针时,using关键字的优势更能凸显。

//使用typedef定义函数指针

typedef int(*func_ptr)(int, double);

//使用using定义函数指针

using func_ptr = int(*)(int, double)

2、模板的别名

使用typedef重定义很方便,但是他有一点限制,比如无法重定义一个模板,而using关键可以支持这个功能。

12、列表初始化?

关于 C++ 中的变量,数组,对象等都有不同的初始化方法,在这些繁琐的初始化方法中没有任何一种方式适用于所有的情况。为了统一初始化方式,并且让初始化行为具有确定的效果,在 C++11 中提出了列表初始化的概念。

1、统一的初始化

2、列表初始化细节

对象 a 是对一个自定义的聚合类型进行初始化,它将以拷贝的形式使用初始化列表中的数据来初始化 T1 结构体中的成员。

在结构体 T2 中自定义了一个构造函数,因此实际的初始化是通过这个构造函数完成的。

如果使用列表初始化对对象初始化时,还需要判断这个对象对应的类型是不是一个聚合体,如果是初始化列表中的数据就会拷贝到对象中。

聚合体:普通数组本身就是一种聚合类型;{ 无用户自定义构造函数、无私有或者保护的非静态数据成员、无基类、无虚函数以及类中不能有使用 {} 和 = 直接初始化的非静态数据成员(从 c++14 开始就支持了)}

非聚合体:对于聚合类型的类可以直接使用列表初始化进行对象的初始化,如果不满足聚合条件还想使用列表初始化其实也是可以的,需要在类的内部自定义一个构造函数, 在构造函数中使用初始化列表对类成员变量进行初始化:

综上,对于一个聚合类型,使用列表初始化相当于对其中的每个元素分别赋值,而对于非聚合类型,则需要先自定义一个合适的构造函数,此时使用列表初始化将会调用它对应的构造函数。

3、std::initializer_list

在 C++ 的 STL 容器中,可以进行任意长度的数据的初始化,使用初始化列表也只能进行固定参数的初始化,如果想要做到和 STL 一样有任意长度初始化的能力,可以使用 std::initializer_list 这个轻量级的类模板来实现(可变长参数)。

特点:

1、它是一个轻量级的容器类型,内部定义了迭代器 iterator 等容器必须的概念,遍历时得到的迭代器是只读的。

2、对于 std::initializer_list<T> 而言,它可以接收任意长度的初始化列表,但是要求元素必须是同种类型 T

3、在 std::initializer_list 内部有三个成员接口:size(), begin(), end()。std::initializer_list 对象只能被整体初始化或者赋值。

场景1:作为普通函数参数

自定义一个函数并且接收任意个数的参数(变参函数),只需要将函数参数指定为 std::initializer_list,使用初始化列表 { } 作为实参进行数据传递即可。

场景2:作为构造函数参数

自定义的类如果在构造对象的时候想要接收任意个数的实参,可以给构造函数指定为 std::initializer_list 类型,在自定义类的内部还是使用容器来存储接收的多个实参。

13、可调用对象包装器、绑定器

1、可调用对象

可调用对象就是类似于可以像函数调用一样执行的对象。

函数调用主要有以下几种定义方式:

第一、函数指针

第二、仿函数(具有operator()运算符的类对象)

第三、可被转换为函数指针的类对象

第四、类成员函数指针或者类成员指针

由这几种方式可知,可调用方式形式多样,如果需要做统一的方式保存,或者传递一个可调用对象时会是什么繁琐。为此,C++11引入了std::function和std::bind统一可调用对象的各种操作。

2、可调用对象包装器function

std::function是可调用对象的包装器。它是一个类模板,可以容纳除了类成员(函数)指针之外的所有可调用对象。通过指定它的模板参数,它可以用统一的方式处理函数、函数对象、函数指针,并允许保存和延迟执行它们。

第一、基本用法

总结:std::function 可以将可调用对象进行包装,得到一个统一的格式,包装完成得到的对象相当于一个函数指针,和函数指针的使用方式相同,通过包装器对象就可以完成对包装的函数的调用了。

第二、作为回调函数使用

使用对象包装器 std::function 可以非常方便的将仿函数转换为一个函数指针,通过进行函数指针的传递,在其他函数的合适的位置就可以调用这个包装好的仿函数了。另外,使用 std::function 作为函数的传入参数,可以将定义方式不相同的可调用对象进行统一的传递,这样大大增加了程序的灵活性。

3、可调用对象绑定器bind

std::bind用来将可调用对象与其参数一起进行绑定。绑定后的结果可以使用std::function进行保存,并延迟调用到任何我们需要的时候。通俗来讲,它主要有两大作用:

  1. 将可调用对象与其参数一起绑定成一个仿函数。
  2. 将多元(参数个数为n,n>1)可调用对象转换为一元或者(n-1)元可调用对象,即只绑定部分参数。

第一、绑定非类成员函数/变量(包括静态成员变量和函数)

std::bind绑定器返回的是一个仿函数类型,得到的返回值可以直接赋值给一个std::function,在使用的时候我们并不需要关心绑定器的返回值类型,使用auto进行自动类型推导就可以了。

placeholders::_1 是一个占位符,代表这个位置将在函数调用时被传入的第一个参数所替代。同样还有其他的占位符 placeholders::_2、placeholders::_3、placeholders::_4、placeholders::_5 等……

占位符细节说明:

有了占位符的概念之后,使用std::bind的使用会很灵活。

第二、绑定成员变量和成员函数

可调用对象包装器 std::function 是不能实现对类成员函数指针或者类成员指针的包装的,但是通过绑定器 std::bind 的配合之后,就可以完美的解决这个问题了。

14、默认函数控制=default和=delete

在 C++ 中声明自定义的类,编译器会默认帮助程序员生成一些他们未自定义的成员函数。这样的函数版本被称为” 默认函数”。这样的函数一共有六个:

在C++11 标准中称 = default 修饰的函数为显式默认【缺省】函数,而称 =delete 修饰的函数为删除(deleted)函数或者显示删除函数。


C++11 引入显式默认和显式删除是为了增强对类默认函数的控制,让程序员能够更加精细地控制默认版本的函数。

可以在类内部修饰满足条件的类函数为显示默认函数,也可以在类定义之外修饰成员函数为默认函数。不能使用 =default 修饰这六个函数以外的函数。

=delete 表示显示删除,`显式删除可以避免用户使用一些不应该使用的类的成员函数`,使用这种方式可以有效的防止某些类型之间自动进行隐式类型转换产生的错误。

15、智能指针(auto_ptr、shared_ptr、unique_ptr和weak_ptr)

1、为什么使用智能指针

智能指针是存储指向动态分配(堆)对象指针的类,用于生存期的控制,能够确保在离开指针所在作用域时,自动地销毁动态分配的对象,防止内存泄露。智能指针的核心实现技术是引用计数,每使用它一次,内部引用计数加1,每析构一次内部的引用计数减1,减为0时,删除所指向的堆内存。

C++11 中提供了三种智能指针,使用这些智能指针时需要引用头文件 <memory>:

std::shared_ptr:共享的智能指针

std::unique_ptr:独占的智能指针

std::weak_ptr:弱引用的智能指针,它不共享指针,不能操作资源,是用来监视 shared_ptr 的。

2、shared_ptr

共享智能指针是指多个智能指针可以同时管理同一块有效的内存,共享智能指针 shared_ptr 是一个模板类,如果要进行初始化有三种方式:通过构造函数、std::make_shared 辅助函数以及 reset 方法。共享智能指针对象初始化完毕之后就指向了要管理的那块堆内存,如果想要查看当前有多少个智能指针同时管理着这块内存可以使用共享智能指针提供的一个成员函数 use_count,函数原型如下:

2.1 通过构造函数初始化

2.2 通过拷贝和移动构造函数初始化

如果使用拷贝的方式初始化共享智能指针对象,这两个对象会同时管理同一块堆内存,堆内存对应的引用计数也会增加;如果使用移动的方式初始智能指针对象,只是转让了内存的所有权,管理内存的对象并不会增加,因此内存的引用计数不会变化。

2.3 通过std::make_shared初始化

使用 std::make_shared() 模板函数可以完成内存地址的创建,并将最终得到的内存地址传递给共享智能指针对象管理。如果申请的内存是普通类型,通过函数的()可完成地址的初始化,如果要创建一个类对象,函数的()内部需要指定构造对象需要的参数,也就是类构造函数的参数。

2.4 通过reset方法初始化

共享智能指针类提供的 std::shared_ptr::reset 方法函数原型如下:

对于一个未初始化的共享智能指针,可以通过 reset 方法来初始化,当智能指针中有值的时候,调用 reset 会使引用计数减 1。

2.5 获取原始指针

通过智能指针可以管理一个普通变量或者对象的地址,此时原始地址就不可见了。当我们想要修改变量或者对象中的值的时候,就需要从智能指针对象中先取出数据的原始内存的地址再操作,解决方案是调用共享智能指针类提供的 get() 方法,其函数原型如下:

2.6 指定删除器

当智能指针管理的内存对应的引用计数变为 0 的时候,这块内存就会被智能指针析构掉了。另外,我们在初始化智能指针的时候也可以自己指定删除动作,这个删除操作对应的函数被称之为删除器,这个删除器函数本质是一个回调函数,我们只需要进行实现,其调用是由智能指针完成的。

在 C++11 中使用 shared_ptr 管理动态数组时,需要指定删除器,因为 std::shared_ptr的默认删除器不支持数组对象,具体的处理代码如下:

在删除数组内存时,除了自己编写删除器,也可以使用 C++ 提供的 std::default_delete<T>() 函数作为删除器,这个函数内部的删除功能也是通过调用 delete 来实现的,要释放什么类型的内存就将模板类型 T 指定为什么类型即可。具体处理代码如下:

模拟一个shared_ptr代码(面试题)

3、独占智能指针unique_ptr

3.1 初始化

std::unique_ptr 是一个独占型的智能指针,它不允许其他的智能指针共享其内部的指针,可以通过它的构造函数初始化一个独占智能指针对象,但是不允许通过赋值将一个 unique_ptr 赋值给另一个 unique_ptr。

std::unique_ptr 不允许复制,但是可以通过函数返回给其他的 std::unique_ptr,还可以通过 std::move 来转移给其他的 std::unique_ptr,这样原始指针的所有权就被转移了,这个原始指针还是被独占的。

unique_ptr 独占智能指针类也有一个 reset 方法,函数原型如下:

使用 reset 方法可以让 unique_ptr 解除对原始内存的管理,也可以用来初始化一个独占的智能指针。

如果想要获取独占智能指针管理的原始地址,可以调用 get () 方法,函数原型如下:

3.2 删除器

4、弱引用的智能指针(weak_ptr

弱引用智能指针 std::weak_ptr 可以看做是 shared_ptr 的助手,它不管理 shared_ptr 内部的指针。std::weak_ptr 没有重载操作符 * 和 ->,因为它不共享指针,不能操作资源,所以它的构造不会增加引用计数,析构也不会减少引用计数,它的主要作用就是作为一个旁观者监视 shared_ptr 中管理的资源是否存在。

4.1 初始化

4.2 常用方法

4.3 返回管理this的shared_ptr

通过输出的结果可以看到一个对象被析构了两次,其原因是这样的:在这个例子中使用同一个指针 this 构造了两个智能指针对象 sp1 和 sp2,这二者之间是没有任何关系的,因为 sp2 并不是通过 sp1 初始化得到的实例对象。在离开作用域之后 this 将被构造的两个智能指针各自析构,导致重复析构的错误。

上面的问题可以通过 weak_ptr 来解决,通过 wek_ptr 返回管理 this 资源的共享智能指针对象 shared_ptr。C++11 中为我们提供了一个模板类叫做 std::enable_shared_from_this<T>,这个类中有一个方法叫做 shared_from_this(),通过这个方法可以返回一个共享智能指针,在函数的内部就是使用 weak_ptr 来监测 this 对象,并通过调用 weak_ptr 的 lock() 方法返回一个 shared_ptr 对象。

16、为什么要使用智能指针?

为了更容易(同时也更安全的)地使用动态内存,新的标准库提供了两种智能指针,来管理动态对象。智能指针的行为类似于常规指针,重要的区别是它负责自动释放所指向的对象。

shared_ptr允许多个指针指向同一个对象,unique_ptr是“独占”所指向的对象。标准库还定义了一个名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象。这三种类型都定义在memeory头文件中。

它的原理是将动态分配的内存都交给有生命周期的对象来处理,当对象过期时,让他的析构函数删除指向的内存。C++98提供了auto_ptr模板的解决方案;C++11增加了shared_ptr、unique_ptr和weak_ptr三种。其实就是一个模板类,里面有析构函数能自动释放这个对象开辟的内存。

17、右值与右值引用

1.1、右值

C++增加了新的类型称为右值引用,标记为&&。

左值:指存储在内存中、有明确存储地址(可取地址)的数据;

右值:指可以提供数据值的数据(不可取地址)。

通过描述可以看出,区分左值与右值的便捷方法是:可以对表达式取地址(&)就是左值,否则为右值 。所有有名字的变量或对象都是左值,而右值是匿名的。

C++11 中右值可以分为两种:一个是将亡值( xvalue, expiring value),另一个则是纯右值( prvalue, PureRvalue):

纯右值:非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和 lambda 表达式等

将亡值:与右值引用相关的表达式,比如,T&& 类型函数的返回值、 std::move 的返回值等。

1.2、右值引用

右值引用就是对一个右值进行引用的类型。因为右值是匿名的,所以我们只能通过引用的方式找到它。无论声明左值引用还是右值引用都必须立即进行初始化,因为引用类型本身并不拥有所绑定对象的内存,只是该对象的一个别名。通过右值引用的声明,该右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样,只要该变量还活着,该右值临时量将会一直存活下去。

1.2、性能优化

在 C++ 中在进行对象赋值操作的时候,很多情况下会发生对象之间的深拷贝,如果堆内存很大,这个拷贝的代价也就非常大,在某些情况下,如果想要避免对象的深拷贝,就可以使用右值引用进行性能的优化。

通过输出的结果可以看到调用 Test t = getObj(); 的时候调用拷贝构造函数对返回的临时对象进行了深拷贝得到了对象 t,在 getObj() 函数中创建的对象虽然进行了内存的申请操作,但是没有使用就释放掉了。如果能够使用临时对象已经申请的资源,既能节省资源,还能节省资源申请和释放的时间,如果要执行这样的操作就需要使用右值引用了,右值引用具有移动语义,移动语义可以将资源(堆、系统对象等)通过浅拷贝从一个对象转移到另一个对象这样就能减少不必要的临时对象的创建、拷贝以及销毁,可以大幅提高 C++ 应用程序的性能。

在上面的代码给 Test 类添加了移动构造函数(参数为右值引用类型),这样在进行 Test t = getObj(); 操作的时候并没有调用拷贝构造函数进行深拷贝,而是调用了移动构造函数,在这个函数中只是进行了浅拷贝,没有对临时对象进行深拷贝,提高了性能。

在测试程序中 getObj() 的返回值就是一个将亡值,也就是说是一个右值,在进行赋值操作的时候如果 = 右边是一个右值,那么移动构造函数就会被调用。移动构造中使用了右值引用,会将临时对象中的堆内存地址的所有权转移给对象t,这块内存被成功续命,因此在t对象中还可以继续使用这块内存。

注意:对于需要动态申请大量资源的类,应该设计移动构造函数,以提高程序效率。需要注意的是,一般在提供移动构造函数的同时,也会提供常量左值引用的拷贝构造函数,以保证移动不成还可以使用拷贝构造函数。

1.4、&&特性

在 C++ 中,并不是所有情况下 && 都代表是一个右值引用,具体的场景体现在模板和自动类型推导中,如果是模板参数需要指定为 T&&,如果是自动类型推导需要指定为 auto &&,在这两种场景下 && 被称作未定的引用类型。另外还有一点需要额外注意 const T&& 表示一个右值引用,不是未定引用类型。

通过右值推导 T&& 或者 auto&& 得到的是一个右值引用类型

通过非右值(右值引用、左值、左值引用、常量右值引用、常量左值引用)推导 T&& 或者 auto&& 得到的是一个左值引用类型。

总结:

1、左值和右值是独立于他们的类型的,右值引用类型可能是左值也可能是右值。

2、编译器会将已命名的右值引用视为左值,将未命名的右值引用视为右值。

3、auto&&或者函数参数类型自动推导的T&&是一个未定的引用类型,它可能是左值引用也可能是右值引用类型,这取决于初始化的值类型。

4、通过右值推导 T&& 或者 auto&& 得到的是一个右值引用类型,其余都是左值引用类型

18、转移move

在C++11添加了右值引用,并且不能使用左值初始化右值引用,如果想要使用左值初始化一个右值引用需要借助std::move()函数,使用std::move方法可以将左值转换为右值。使用这个函数并不能移动任何东西,而是和移动构造函数一样都具有移动语义,将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存拷贝。

从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);,函数原型如下:

场景:假设一个临时容器很大,并且要将这个容器赋值给另一个容器,就可以执行如下操作:

如果不使用std::move,拷贝的代价很大,性能较低。使用move几乎没有任何代价,只是转换了资源的所有权。如果一个对象内部有较大的堆内存或者动态数组时,使用move()就可以非常方便的进行数据所有权的转移。另外,我们也可以给类编写相应的移动构造函数(T::T(T&& another))和和具有移动语义的赋值函数(T&& T::operator=(T&& rhs)),在构造对象和赋值的时候尽可能的进行资源的重复利用,因为它们都是接收一个右值引用参数。

19、完美转发forward

六、C++多线程(C++11多线程)

C++11 之前,C++ 语言没有对并发编程提供语言级别的支持,这使得我们在编写可移植的并发程序时,存在诸多的不便。现在 C++11 中增加了线程以及线程相关的类,很方便地支持了并发编程,使得编写的多线程程序的可移植性得到了很大的提高。

C++11 中提供的线程类叫做 std::thread,基于这个类创建一个新的线程非常的简单,只需要提供线程函数或者函数对象即可,并且可以同时指定线程函数的参数。

1、C++线程的使用

2、公共成员函数

1、get_id()

应用程序启动之后默认只有一个线程,这个线程一般称之为主线程或父线程,通过线程类创建出的线程一般称之为子线程,每个被创建出的线程实例都对应一个线程 ID,这个 ID 是唯一的,可以通过这个 ID 来区分和识别各个已经存在的线程实例,这个获取线程 ID 的函数叫做 get_id(),函数原型如下:

当启动了一个线程(创建了一个 thread 对象)之后,在这个线程结束的时候(std::terminate ()),如何去回收线程所使用的资源,thread 库给我们两种选择且必须选择其中一个,否则会报错:

2、join()

join() 字面意思是连接一个线程,意味着主动地等待线程的终止(线程阻塞)。在某个线程中通过子线程对象调用 join() 函数,调用这个函数的线程被阻塞,但是子线程对象中的任务函数会继续执行,当任务执行完毕之后 join() 会清理当前子线程中的相关资源然后返回,同时,调用该函数的线程解除阻塞继续向下执行(函数在哪个线程中被执行,那么函数就阻塞哪个线程)。函数原型是:void join();

除了等待子线程结束回收资源外,还有其他场景,比如:有3个线程,2个子线程,2个主线程,2个子线程负责分段下载一个大文件,然后主线程在等待下载完成之后做其他后续工作。

3、detach()

detach() 函数的作用是进行线程分离,分离主线程和创建出的子线程。在线程分离之后,主线程退出也会一并销毁创建出的所有子线程,在主线程退出之前,它可以脱离主线程继续独立的运行,任务执行完毕之后,这个子线程会自动释放自己占用的系统资源。函数原型:void detach()

线程分离函数 detach () 不会阻塞线程,子线程和主线程分离之后,在主线程中就不能再对这个子线程做任何控制了,比如:通过 join () 阻塞主线程等待子线程中的任务执行完毕,或者调用 get_id () 获取子线程的线程 ID。有利就有弊,鱼和熊掌不可兼得,建议使用 join ()。

4、joinable()

joinable() 函数用于判断主线程和子线程是否处理关联(连接)状态,一般情况下,二者之间的关系处于关联状态,该函数返回一个布尔类型:返回值为 true:主线程和子线程之间有关联(连接)关系;返回值为 false:主线程和子线程之间没有关联(连接)关系。

5、operator=

线程中的资源是不能被复制的,因此通过 = 操作符进行赋值操作最终并不会得到两个完全相同的对象。

3、静态函数

thread 线程类还提供了一个静态方法,用于获取当前计算机的 CPU 核心数,根据这个结果在程序中创建出数量相等的线程,每个线程独自占有一个CPU核心,这些线程就不用分时复用CPU时间片,此时程序的并发效率是最高的。

4、命名空间this_thread

在 C++11 中不仅添加了线程类,还添加了一个关于线程的命名空间 std::this_thread,在这个命名空间中提供了四个公共的成员函数,通过这些成员函数就可以对当前线程进行相关的操作了。

1、get_id()

调用命名空间 std::this_thread 中的 get_id() 方法可以得到当前线程的线程 ID。

2、sleep_for()

线程创建后一共有五种状态:创建态,就绪态,运行态,阻塞态(挂起态),退出态(终止态) 。

线程和进程的执行有很多相似之处,在计算机中启动的多个线程都需要占用 CPU 资源,但是 CPU 的个数是有限的并且每个 CPU 在同一时间点不能同时处理多个任务。为了能够实现并发处理,多个线程都是分时复用CPU时间片,快速的交替处理各个线程中的任务。因此多个线程之间需要争抢CPU时间片,抢到了就执行,抢不到则无法执行(因为默认所有的线程优先级都相同,内核也会从中调度,不会出现某个线程永远抢不到 CPU 时间片的情况)。

命名空间 this_thread 中提供了一个休眠函数 sleep_for(),调用这个函数的线程会马上从运行态变成阻塞态并在这种状态下休眠一定的时长,因为阻塞态的线程已经让出了 CPU 资源,代码也不会被执行,所以线程休眠过程中对 CPU 来说没有任何负担。这个函数是函数原型如下,参数需要指定一个休眠时长,是一个时间段:

程序休眠完成之后,会从阻塞态重新变成就绪态,就绪态的线程需要再次争抢 CPU 时间片,抢到之后才会变成运行态,这时候程序才会继续向下运行。

3、sleep_until()

命名空间 this_thread 中提供了另一个休眠函数 sleep_until(),和 sleep_for() 不同的是它的参数类型不一样:

函数原型如下:

4、yield()

命名空间 this_thread 中提供了一个非常绅士的函数 yield(),在线程中调用这个函数之后,处于运行态的线程会主动让出自己已经抢到的 CPU 时间片,最终变为就绪态,这样其它的线程就有更大的概率能够抢到 CPU 时间片了。使用这个函数的时候需要注意一点,线程调用了 yield () 之后会主动放弃 CPU 资源,但是这个变为就绪态的线程会马上参与到下一轮 CPU 的抢夺战中,不排除它能继续抢到 CPU 时间片的情况,这是概率问题。

总结:

1、std::this_thread::yield() 的目的是避免一个线程长时间占用CPU资源,从而导致多线程处理性能下降

2、std::this_thread::yield() 是让当前线程主动放弃了当前自己抢到的CPU资源,但是在下一轮还会继续抢。

5、call_once函数

在某些特定情况下,某些函数只能在多线程环境下调用一次,比如:要初始化某个对象,而这个对象只能被初始化一次,就可以使用 std::call_once() 来保证函数在多线程环境下只能被调用一次。使用 call_once() 的时候,需要一个 once_flag 作为 call_once() 的传入参数,该函数的原型如下:

6、C++线程同步

解决多线程数据混乱的方案就是进行线程同步,最常用的就是互斥锁,在 C++11 中一共提供了四种互斥锁:

std::mutex:独占的互斥锁,不能递归使用

std::timed_mutex:带超时的独占互斥锁,不能递归使用

std::recursive_mutex:递归互斥锁,不带超时功能

std::recursive_timed_mutex:带超时的递归互斥锁

1、std::mutex

不论是在 C 还是 C++ 中,进行线程同步的处理流程基本上是一致的,C++ 的 mutex 类提供了相关的 API 函数:

lock() 函数:lock() 函数用于给临界区加锁,并且只能有一个线程获得锁的所有权,它有阻塞线程的作用,函数原型如下:

独占互斥锁对象有两种状态:锁定和未锁定。如果互斥锁是打开的,调用 lock() 函数的线程会得到互斥锁的所有权,并将其上锁,其它线程再调用该函数的时候由于得不到互斥锁的所有权,就会被 lock() 函数阻塞。当拥有互斥锁所有权的线程将互斥锁解锁,此时被 lock() 阻塞的线程解除阻塞,抢到互斥锁所有权的线程加锁并继续运行,没抢到互斥锁所有权的线程继续阻塞。

try_lock() 函数:也起到枷锁的作用,与lock函数的区别是,lock会阻塞线程而try_lock函数不会阻塞线程。如果互斥锁是未锁定状态,得到互斥锁所有权并枷锁成功,返回true;如果互斥锁是锁定状态,无法得到互斥锁所有权枷锁失败,返回false。

unlock函数:解锁。

通过上面三个函数,基本就能实现线程同步了,大致步骤如下:

1、找到多个线程操作的共享资源(全局变量、堆内存、类成员变量等),也可以称之为临界资源

2、找到和共享资源有关的上下文代码,也就是临界区(下图中的黄色代码部分)

3、在临界区的上边调用互斥锁类的 lock() 方法

4、在临界区的下边调用互斥锁的 unlock() 方法

线程同步的目的是让多线程按照顺序依次执行临界区代码,这样做线程对共享资源的访问就从并行访问变为了线性访问,访问效率降低了,但是保证了数据的正确性。

在所有线程的任务函数执行完毕之前,互斥锁对象是不能被析构的,一定要在程序中保证这个对象的可用性。

互斥锁的个数和共享资源的个数相等,也就是说每一个共享资源都应该对应一个互斥锁对象。互斥锁对象的个数和线程的个数没有关系。

2、std::lock_guard

lock_guard 是 C++11 新增的一个模板类,使用这个类,可以简化互斥锁 lock() 和 unlock() 的写法,同时也更安全。这个模板类的定义和常用的构造函数原型如下:

lock_guard 在使用上面提供的这个构造函数构造对象时,会自动锁定互斥量,而在退出作用域后进行析构时就会自动解锁,从而保证了互斥量的正确操作,避免忘记 unlock() 操作而导致线程死锁。lock_guard 使用了 RAII 技术,就是在类构造函数中分配资源,在析构函数中释放资源,保证资源出了作用域就释放。

这种方式也有弊端,在上面的示例程序中整个for循环的体都被当做了临界区,多个线程是线性的执行临界区代码的,因此临界区越大程序效率越低,还是需要根据实际情况选择最优的解决方案。

7、线程同步之条件变量

1、条件变量

条件变量是 C++11 提供的另外一种用于等待的同步机制,它能阻塞一个或多个线程,直到收到另外一个线程发出的通知或者超时时,才会唤醒当前阻塞的线程。条件变量需要和互斥量配合起来使用,C++11 提供了两种条件变量:

条件变量通常用于生产者和消费者模型,大致使用过程如下:

2、condition_variable

condition_variable 的成员函数主要分为两部分:线程等待(阻塞)函数 和线程通知(唤醒)函数,这些函数被定义于头文件 <condition_variable>。

  1. 等待函数

调用wait函数的线程会被阻塞,并且释放当前拿到的锁对象。

独占的互斥锁对象不能直接传递给 wait() 函数,需要通过模板类 unique_lock 进行二次处理,通过得到的对象仍然可以对独占的互斥锁对象做如下操作,使用起来更灵活。

如果线程被该函数阻塞,这个线程会释放占有的互斥锁的所有权,当阻塞解除之后这个线程会重新得到互斥锁的所有权,继续向下执行(这个过程是在函数内部完成的,其目的是为了避免线程的死锁)。

wait_for函数和wait_until函数:一个是有阻塞时长功能,一个是阻塞到达一个时间点的功能,只要阻塞时长到达,或者到达一个时间点都会自动解除阻塞,向下执行。

  1. 通知函数

  1. 生产者消费者模型

#include <iostream>

#include <thread>

#include <mutex>

#include <list>

#include <functional>

#include <condition_variable>

using namespace std;

class SyncQueue

{

private:

    list<int> m_queue;                   // 存储队列数据

    mutex m_mutex;                       // 互斥锁

    condition_variable m_notEmpty;    // 不为空的条件变量

    condition_variable m_notFull;     // 没有满的条件变量

    int m_maxSize;                       // 任务队列的最大任务个数

public:

    //构造函数

    SyncQueue(int maxSize) : m_maxSize(maxSize) {}

    //往队列中填入数据

    void put(const int& x)

    {

        //自动拿到锁,可以不用调用lock方法,超出作用域则会自动解锁

        unique_lock<mutex> locker(m_mutex);

       

        // 判断任务队列是不是已经满了

        //while (m_queue.size() == m_maxSize)

        //{

        //  cout << "任务队列已满, 请耐心等待..." << endl;

        //  // 阻塞线程,等待取出数据的线程的通知,表示不满了,可以继续填入数据

        //  m_notFull.wait(locker);

        //}

        //while循环简化

        m_notFull.wait(locker, [this]() ->bool {return m_queue.size() < m_maxSize; });

        // 将任务放入到任务队列中

        m_queue.push_back(x);

        cout << x << " 被生产" << endl;

        // 通知消费者去消费

        m_notEmpty.notify_one();

    }

    //往对类中取出数据

    int take()

    {

        unique_lock<mutex> locker(m_mutex);

        /*while (m_queue.empty())

        {

            cout << "任务队列已空,请耐心等待。。。" << endl;

            m_notEmpty.wait(locker);

        }*/

        //简化

        m_notEmpty.wait(locker, [this]()->bool {return !m_queue.empty(); });

        // 从任务队列中取出任务(消费)

        int x = m_queue.front();

        m_queue.pop_front();

        // 通知生产者去生产

        m_notFull.notify_one();

        cout << x << " 被消费" << endl;

        return x;

    }

    //判断是否为空

    bool empty()

    {

        lock_guard<mutex> locker(m_mutex);

        return m_queue.empty();

    }

    //判断是否队列是否满

    bool full()

    {

        lock_guard<mutex> locker(m_mutex);

        return m_queue.size() == m_maxSize;

    }

    //队列的大小

    int size()

    {

        lock_guard<mutex> locker(m_mutex);

        return m_queue.size();

    }

};

int main()

{

    SyncQueue taskQ(50);

    auto produce = bind(&SyncQueue::put, &taskQ, placeholders::_1);

    auto consume = bind(&SyncQueue::take, &taskQ);

    thread t1[3];

    thread t2[3];

    for (int i = 0; i < 3; ++i)

    {

        t1[i] = thread(produce, i + 100);

        t2[i] = thread(consume);

    }

    for (int i = 0; i < 3; ++i)

    {

        t1[i].join();

        t2[i].join();

    }

    return 0;

}

3、condition_variable_any

condition_variable_any 的成员函数也是分为两部分:线程等待(阻塞)函数 和线程通知(唤醒)函数,这些函数被定义于头文件 <condition_variable>。

  1. 等待函数

此外还有两个阻塞时长和时间点的。

  1. 通知函数

  1. 生产者和消费者模型

#include <iostream>

#include <thread>

#include <mutex>

#include <list>

#include <functional>

#include <condition_variable>

using namespace std;

class SyncQueue

{

public:

    SyncQueue(int maxSize) : m_maxSize(maxSize) {}

    void put(const int& x)

    {

        lock_guard<mutex> locker(m_mutex);

        // 根据条件阻塞线程

        m_notFull.wait(m_mutex, [this]() {

            return m_queue.size() != m_maxSize;

        });

        // 将任务放入到任务队列中

        m_queue.push_back(x);

        cout << x << " 被生产" << endl;

        // 通知消费者去消费

        m_notEmpty.notify_one();

    }

    int take()

    {

        lock_guard<mutex> locker(m_mutex);

        m_notEmpty.wait(m_mutex, [this]() {

            return !m_queue.empty();

        });

        // 从任务队列中取出任务(消费)

        int x = m_queue.front();

        m_queue.pop_front();

        // 通知生产者去生产

        m_notFull.notify_one();

        cout << x << " 被消费" << endl;

        return x;

    }

    bool empty()

    {

        lock_guard<mutex> locker(m_mutex);

        return m_queue.empty();

    }

    bool full()

    {

        lock_guard<mutex> locker(m_mutex);

        return m_queue.size() == m_maxSize;

    }

    int size()

    {

        lock_guard<mutex> locker(m_mutex);

        return m_queue.size();

    }

private:

    list<int> m_queue;     // 存储队列数据

    mutex m_mutex;         // 互斥锁

    condition_variable_any m_notEmpty;   // 不为空的条件变量

    condition_variable_any m_notFull;    // 没有满的条件变量

    int m_maxSize;         // 任务队列的最大任务个数

};

int main()

{

    SyncQueue taskQ(50);

    auto produce = bind(&SyncQueue::put, &taskQ, placeholders::_1);

    auto consume = bind(&SyncQueue::take, &taskQ);

    thread t1[3];

    thread t2[3];

    for (int i = 0; i < 3; ++i)

    {

        t1[i] = thread(produce, i + 100);

        t2[i] = thread(consume);

    }

    for (int i = 0; i < 3; ++i)

    {

        t1[i].join();

        t2[i].join();

    }

    return 0;

}

4、总结

总结:以上介绍的两种条件变量各自有各自的特点,condition_variable 配合 unique_lock 使用更灵活一些,可以在在任何时候自由地释放互斥锁,而 condition_variable_any 如果和 lock_guard 一起使用必须要等到其生命周期结束才能将互斥锁释放(亦可以手动调用lock和unlock加锁和解锁)。但是,condition_variable_any 可以和多种互斥锁配合使用,应用场景也更广,而 condition_variable 只能和独占的非递归互斥锁(mutex)配合使用,有一定的局限性。

8、原子变量

C++11 提供了一个原子类型 std::atomic<T>,通过这个原子类型管理的内部变量就可以称之为原子变量,我们可以给原子类型指定 bool、char、int、long、指针等类型作为模板参数(不支持浮点类型和复合类型)。

原子指的是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,当某个 CPU 核心开始运行原子操作时,会先暂停其它 CPU 内核对内存的操作,以保证原子操作不会被其它 CPU 内核所干扰。

由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改,读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。

可以看出原子类型是无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了 CAS 循环,当大量的冲突发生时,该等待还是得等待!但是总归比锁要好。

C++11 内置了整形的原子变量,这样就可以更方便的使用原子变量了。在多线程操作中,使用原子变量之后就不需要再使用互斥量来保护该变量了,用起来更简洁。因为对原子变量进行的操作只能是一个原子操作(atomic operation),原子操作指的是不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何的上下文切换。多线程同时访问共享资源造成数据混乱的原因就是因为 CPU 的上下文切换导致的,使用原子变量解决了这个问题,因此互斥锁的使用也就不再需要了。

CAS 全称是 Compare and swap, 它通过一条指令读取指定的内存地址,然后判断其中的值是否等于给定的前置值,如果相等,则将其修改为新的值

1、atomic类成员 – 构造函数

2、atomic类成员 – 公共成员函数

原子类型在类内部重载了 = 操作符,并且不允许在类的外部使用 = 进行对象的拷贝。

原子地 desired 替换当前值。按照 order 的值影响内存。

desired:       存储到原子变量中的值

order:         强制的内存顺序

原子地加载并返回原子变量的当前值。按照 order 的值影响内存。直接访问原子对象也可以得到原子变量的当前值。

4、atomic内存顺序约束

如上API,在调用 atomic 类提供的 API 函数的时候,需要指定原子顺序,在 C++11 给我们提供的 API 中使用枚举用作执行原子操作的函数的实参,以指定如何同步不同线程上的其他操作。

9、线程异步

C++11 中增加的线程类,使得我们能够非常方便的创建和使用线程,但有时会有些不方便,比如需要获取线程返回的结果,就不能通过 join() 得到结果,只能通过一些额外手段获得,比如:定义一个全局变量,在子线程中赋值,在主线程中读这个变量的值,整个过程比较繁琐。C++ 提供的线程库中提供了一些类用于访问异步操作的结果。

1、std::future

作用:是C++11中引入的一个模板类,用于表示异步任务的结果。通过std::future对象,可以获取异步任务的返回值或处理异步任务的状态。

  1. 类定义

future 是一个模板类,这个类可以存储任意指定类型的数据。

  1. 构造函数

  1. 常用成员函数(public)
  • 一般情况下使用 = 进行赋值操作就进行对象的拷贝,但是 future 对象不可用复制,因此会根据实际情况进行处理:

  • 取出 future 对象内部保存的数据,其中 void get() 是为 future<void> 准备的,此时对象内部类型就是 void,该函数是一个阻塞函数,当子线程的数据就绪后解除阻塞就能得到传出的数值了。

  • 因为 future 对象内部存储的是异步线程任务执行完毕后的结果,是在调用之后的将来得到的,因此可以通过调用 wait() 方法,阻塞当前线程,等待这个子线程的任务执行完毕,任务执行完毕当前线程的阻塞也就解除了。

  • 如果当前线程 wait() 方法就会死等,直到子线程任务执行完毕将返回值写入到 future 对象中,调用 wait_for() 只会让线程阻塞一定的时长,但是这样并不能保证对应的那个子线程中的任务已经执行完毕了。wait_until() 和 wait_for() 函数功能是差不多,前者是阻塞到某一指定的时间点,后者是阻塞一定的时长。

当 wait_until() 和 wait_for() 函数返回之后,并不能确定子线程当前的状态,因此我们需要判断函数的返回值,这样就能知道子线程当前的状态了:

2、std::async

async用于方便地启动异步任务并获取其结果。它位于<future>头文件中。

这函数可以直接启动一个子线程并在这个子线程中执行对应的任务函数,异步任务执行完成返回的结果也是存储到一个 future 对象中,当需要获取异步任务的结果时,只需要调用 future 类的get() 方法即可,如果不关注异步任务的结果,只是简单地等待任务完成的话,可以调用 future 类的wait()或者wait_for() 方法。

该函数的函数原型如下:

这是一个模板函数,在 C++11 中这个函数有两种调用方式:

函数①:直接调用传递到函数体内部的可调用对象,返回一个 future 对象

函数②:通过指定的策略调用传递到函数内部的可调用对象,返回一个 future 对象

  1. 两种策略的使用

七、Linux网络编程(IO多路复用)

1、什么是IO多路复用?

IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时就会阻塞应用程序,交出cpu的占用权。多路是指网络连接,复用指的是同一个线程。

2、为什么有IO多路复用机制?

没有IO多路复用时,有BIO(同步阻塞)和NIO(同步非阻塞)两种实现方式,但是都存在一些问题。

  1. 同步阻塞(BIO):服务器采用单线程,当accept一个请求后,在recv和send调用阻塞时,将无法accept其他请求(必须等上一个请求处理完),无法处理并发。

为此,服务器端采用多线程,当accept一个请求后,开启子线程进行recv,可以完成并发处理,但是随着请求数增加需要增加系统线程,大量的线程占用很大的内存空间,并且线程切换会带来很大的开销,10000个线程真正发生读写事件的线程数不会超过20%,每次accept一个连接后,开启新的线程也会带来非常大的资源消耗。

2、同步非阻塞(NIO):服务器端当accept一个请求后,加入fds文件句柄集合,每次轮询一遍fds文件句柄集合recv(非阻塞)数据,没有数据则立即返回错误,每次轮询所有fd文件句柄(包括没有发生读写事件的fd)会很浪费cpu资源。如果有10000个连接,可能只有10几个才有数据可读取。

3、IO多路复用:服务端采用单线程通过select/poll/epoll等系统调用获取socket文件句柄,遍历有事件的socket文件句柄进行accept/recv/send等操作,使其能够支持更多的并发连接请求。

3、select接口,select的原理?select优缺点?

原理:首先构造一个关于文件描述符的数组,将要监听的文件描述符添加到该数组中。调用select这个系统调用时,监听该数组中的文件描述符,直到这些描述符中的一个或者多个进行IO操作操作时,该函数才返回。默认情况下,select是阻塞的,函数对文件描述符的事件检测是由内核完成的。在返回时,他会告诉进程有多少文件描述符有事件发生。需要遍历select修改后的文件描述符数组,判断是否有哪种事件发生,才进行相对应的处理。

优点:可移植性好;连接数少并且连接都十分活跃的情况下,效率也不错。

缺点:1、每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。2、每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。3、select支持的文件描述符太小,默认是1024(可以用ulimit -n 2048命令进行修改)。4、文件描述符集合不能重用,每次都需要重置,重新传入。

4、poll接口?poll原理?poll优缺点?

poll原理:poll与select差不多,但是他没有文件描述符数量的限制,但是依然采用轮询遍历的方式检查是否有事件发生。所以poll和select的缺点都差不多。

5、epoll接口?

6、epoll原理?工作模式?

原理:epoll是一种更加高效的IO多路复用的方式,可以监听的文件描述符数量突破了1024的限制,同时不需要通过轮询遍历的方式去检查文件描述符是否有事件发生,因为epoll_wait返回的就是有事件发生的文件描述符。本质上是事件驱动的。

内部具体是通过红黑树和就绪链表实现的,红黑树存储所有的文件描述符,就绪链表存储有事件发生的文件描述符:

  1. epoll_ctl可以对文件描述符节点进行增删改查,并且告知内核注册回调函数(事件)。
  2. 一旦文件描述符上有事件发生,那么内核将该文件描述符节点插入到就绪链表里面。
  3. 这时候epoll_wait将会接收到消息,并且将数据拷贝到用户空间。

在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要更多回调函数。

工作模式:

  1. 首先,epoll_create函数会创建一个epoll实例,返回值是一个文件描述符指向内核的一块空间,这块空间是epoll的工作空间,主要由两块重要的内存,一块是红黑树类型的rbr,里面存放的是所有要监测的文件描述符;另一块是双链表类型的rdlist,里面存放的是被监测到有数据变动的文件描述符(来自于rbr
  2. 遇到一个新的文件描述符就将这个文件描述符通过 epoll_ctl 函数添加到上面的epoll实例中,也就是将其放到rbr空间中,作为待监测的文件描述符。同时,这个函数可以设置监测文件描述符发生的行为(注册事件),比如客户端发送到服务端数据。
  3. 如果内核监测到rbr中的文件描述符出现了 epoll_ctl 设置的要监听的的行为,那么就会将其拷贝的 rdlist 就绪链表中。 epoll_wait 函数则是可以获取到rdlist中的数据,通过传入传出参数返回,它的返回值就是数据变动的文件描述符的数量。
  4. 根据epoll_wait的传出参数,遍历之,这是一个结构体数组。获取每个元素中的文件描述符,判断它是监听文件描述符还是其他的, 如果是监听文件描述符,那么就有新的客户端连接,此时就要将其添加到rbr空间中(使用epoll_ctl),如果是其他文件描述符就说明有客户端发送了数据,此时可以根据文件描述符读取数据 。

7、epoll的LT和ET模式的区别?

LT模式(水平模式)

水平触发模式是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核会告诉一个文件描述符是否就绪了,然后可以对这个就绪的fd文件描述符进行IO操作。如果不做任何操作,或者只做部分操作,内核会继续通知,也就是在下一次epoll_wait时继续触发,直到处理完成。

假设委托内核检测读事件 -> 检测fd的读缓冲区

读缓冲区有数据 - > epoll检测到了会给用户(服务端)通知

a.用户不读数据,数据一直在缓冲区,epoll 会一直通知

b.用户只读了一部分数据,epoll会继续通知

c.缓冲区的数据读完了,不通知

ET 模式(边沿触发)

ET(edge - triggered)是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。

但是注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。

ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

ET模式需要主动开启,在添加事件的时候(epev.events = EPOLLIN | EPOLLET

假设委托内核检测读事件 -> 检测fd的读缓冲区

读缓冲区有数据 - > epoll检测到了会给用户通知

a.用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了

b.用户只读了一部分数据,epoll不通知

c.缓冲区的数据读完了,不通知。

为此需要循环读取,直到读取完成,否则会导致有数据停留在缓冲区,数据不完整。

8、epoll的LT和ET模式的区别?

1、epoll的水平触发模式是默认的模式,边沿触发模式是需要手动设置的。

2、水平触发模式下,只要这个文件描述符有数据可读,每次epoll_wait都会返回他的事件,提醒用户操作。而边沿触发模式下,他只会提示一次,直到下次在有新的数据流入。

3、水平触发模式支持文件描述符的阻塞和非阻塞,而边沿模式只支持非阻塞。

9、select /epoll之间的区别?

(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大;而epoll保证了每个fd在整个过程中只会拷贝一次。

(2)每次调用select都需要在内核遍历传递进来的所有fd;而epoll只需要轮询一次fd集合,同时查看就绪链表中有没有就绪的fd就可以了。

(3)select支持的文件描述符数量太小了,默认是1024;而epoll没有这个限制,它所支持的fd上限是最大可以打开文件的数目,这个数字一般远大于2048。

10、epoll为什么高效?

(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。

(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把当前进程往设备等待队列中挂一次,而epoll只要一次拷贝,而且把当前进程往等待队列上挂也只挂一次,这也能节省不少的开销。

11、说说多路IO复用计数有哪些?区别是什么?

select,poll,epoll都是IO多路复用的机制,I/O多路复用就是通过一种机制,可以监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知应用程序进行相应的读写操作。

区别:

(1)poll与select不同,通过一个pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd中的events字段和revents分别用于标示关注的事件和发生的事件,故pollfd数组只需要被初始化一次。

(2)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。

(3)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把当前进程往设备等待队列中挂一次,而epoll只要一次拷贝,而且把当前进程往等待队列上挂也只挂一次,这也能节省不少的开销。

12、端口和地址复用?

在默认情况下,如果一个网络应用程序的一个套接字绑定了一个端口,这时候,别的套接字就无法使用这个端口(8080)。

但是端口复用允许在一个应用程序可以把多个套接字绑在一个端口上而不出错。通过设置socket的SO_REUSEADDR选项,即可实现端口复用:

13、为什么要有端口复用?

因为在服务端结束后,也就是第三次挥手的时候会有个等待释放时间(time_wait),这个时间段大概是1-4分钟(2MSL), 在这个时间内,端口不会迅速的被释放,所以可通过端口复用的方法来解决这个问题。

  SO_REUSEADDR允许单个进程绑定相同的端口到多个socket上,但每个socket绑定的ip地址不同。

14、说说Reactor、Proactor模式。

Reactor模式用于同步I/O,而Proactor运用于异步I/O操作。

Reactor模式: Reactor模式应用于同步I/O的场景。Reactor中读操作的具体步骤如下:

读取操作:

(1)应用程序注册读就需事件和相关联的事件处理器。

(2)事件分离器等待事件的发生。

(3)当发生读就需事件的时候,事件分离器调用第一步注册的事件处理器。

(4)事件处理器首先执行实际的读取操作,然后根据读取到的内容进行进一步的处理。

Proactor模式:Proactor模式应用于异步I/O的场景。Proactor中读操作的具体步骤如下:

(1)应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件,这是区别于Reactor的关键。

(2)事件分离器等待读取操作完成事件。

(3)在事件分离器等待读取操作完成的时候,操作系统调用内核线程完成读取操作,并将读取的内容放入用户传递过来的缓存区中。这也是区别于Reactor的一点,Proactor中,应用程序需要传递缓存区。

(4)事件分离器捕获到读取完成事件后,激活应用程序注册的事件处理器,事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。

综上:Reactor中需要应用程序自己读取或者写入数据,而Proactor模式中,应用程序不需要用户再自己接收数据,直接使用就可以了,操作系统会将数据从内核拷贝到用户区。

              IO模型的类型

(1)阻塞IO:调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作。

(2)非阻塞IO:非阻塞等待,每隔一段时间就去检查IO事件是否就绪。没有就绪就可以做其他事情。

(3)信号驱动IO:Linux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO信号,然后处理IO事件。

(4)IO多路复用:Linux用select/poll函数实现IO复用模型,这两个函数也会使进程阻塞,但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检查。知道有数据可读或可写时,才真正调用IO操作函数。

(5)异步IO:Linux中,可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。用户可以直接去使用数据。

前四种模型--阻塞IO、非阻塞IO、多路复用IO和信号驱动IO都属于同步模式,因为其中真正的IO操作(函数)都将会阻塞进程,只有异步IO模型真正实现了IO操作的异步性。

17、介绍一下5中IO模型

1、阻塞IO:调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作。

2、非阻塞IO:非阻塞等待,每隔一段时间就去检查IO事件是否就绪。没有就绪就可以做其他事情。

3、信号驱动IO:Linux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO信号,然后处理IO事件。

4、IO多路复用:Linux用select/poll函数实现IO复用模型,这两个函数也会使进程阻塞,但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检查。知道有数据可读或可写时,才真正调用IO操作函数。

5、异步IO:Linux中,可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。用户可以直接去使用数据。

前四种模型--阻塞IO、非阻塞IO、多路复用IO和信号驱动IO都属于同步模式,因为其中真正的IO操作(函数)都将会阻塞进程,只有异步IO模型真正实现了IO操作的异步性。

异步和同步的区别就在于,异步是内核将数据拷贝到用户区,不需要用户再自己接收数据,直接使用就可以了,而同步是内核通知用户数据到了,然后用户自己调用相应函数去接收数据。

异步IO操作:从文件中读取,然后输出。

同步非阻塞:完成上述功能

18、说说socket网络编程中客户端和服务端用到哪些函数?

服务器端:

(1)socket创建一个套接字

(2)bind绑定ip和port

(3)listen使套接字变为可以被动链接

(4)accept等待客户端的链接

(5)write/read接收发送数据

(6)close关闭连接

客户端:

(1)创建一个socket,用函数socket( )

(2)bind绑定ip和port (可以不做bind)

(3)连接服务器,用函数connect()

(4)收发数据,用函数send()和recv(),或read()和write()

(5)close关闭连接

八、Linux系统编程

1、说说静态库和动态库怎么制作以及如何使用?区别是什么?

1、静态库的制作

生成对应项目文件的对象文件:

将生成的对象文件打包成静态库:

2、使用静态库

 -l(小写L):表示生成的静态库名称,除去前缀lib和后缀 .a

 -L : 表示静态库缩放的路径

 -I(大写i):表示静态库所需要的头文件所在目录。

1、动态库的制作

生成项目对象文件:

生成动态库文件:

-fpic : 表示生成和位置无关的代码

-shared : 表示生成共享库

2、使用动态库

同静态库,需要指定以来的动态库的名称、路径已经需要的头文件。

在执行app可执行文件时,会报错,无法找到对应的动态库。通过 ldd ./app命令可以查看这个可执行文件依赖的动态库是否能够都找到。

动态库是在程序加载时动态引入的,查找先后顺序:环境变量LD_LIBARY_PATH à /etc/ld.so.cache文件列表 à /lib/ 目录 à  /usr/lib目录。

静态库和动态库的区别?

  1. 静态库代码代码装载的速度快,执行速度略比动态库快。
  2. 动态库更加节省内存,可执行文件体积比静态库小很多。
  3. 静态库是在编译时加载,动态库是在运行时加载。
  4. 生成的静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀;生成的动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。

2、简述GDB常见的调试命令,什么是条件断点,多进程下如何调试?

GDB调试:GDB调试的是可执行文件,在gcc编译时加入-g参数,告诉gcc在编译时加入调试信息,这样gdb才能调试这个被编译的文件。此外还会加上-Wall参数尽量显示所有警告信息。

GDB命令格式

1、start:程序在第一行停止;run:遇到断点才停止。

2、continue:继续运行,到下一个断点停止;next:向下执行一行代码(不进入函数体);step:向下单步执行(遇到函数调用可以进入函数体, finish可以跳出函数体);util:可以跳出循环体。

3、print 变量名(打印变量的值);ptype 变量名(打印变量类型)。display 变量名(自动打印指定变量的值,之后每执行一步,都会自动打印这个变量);undisplay 编号(将自动打印的变量关掉);info display:查看当前正在自动打印的变量有什么。

4、list : 从头默认位置显示;list 行号(从指定的行显示,这个行在显示的中间);list 函数名(从指定的函数显示);list 文件名:行号(函数名)从指定文件名的行号或者函数名显示。show listsize :查看显示的行数(默认10行);set listsize 行数:设置显示的行数。

5、break 行号:在指定的行号位置打断点;break 函数名:在指定的函数位置打断点;break 文件名:行号/函数名(在指定的文件中的行号或者函数位置打断点)。info break : 显示所有的断点信息;delete 断点编号:删除断点;disable 断点编号:设置断点无效;enable 断点编号:设置断点生效。break 10 if a == 5 : 在指定行设置条件断点,a == 5时断点生效。

GDB多进程断点调试

  1. set follow-fork-mode [parent(默认) | child ] :设置调试父进程还是子进程,默认父进程。
  2. show follow-fork-mode : 查看调试父进程还是子进程。
  3. set detach-on-fork [ on | off ] : 设置调试模式。
  4. show detach-on-fork:查看调试模式。(默认为on,表示调试当前进程的时候,其他进程继续运行;如果为off,调试当前进程的时候,其他进程会被GDB挂起)。
  5. info inferiors : 查看调试的进程;inferior id :切换当前调试的进程;detach inferiors id :使进程脱离GDB调试。

3、说说什么是大端小端,如何判断大端小端?

大端:数据的高位数据存储在低的存储器地址,数据的低位数据存储在高的存储器地址。

小端:数据的低位数据存储在高的存储器地址,数据的高位数据存储在低的存储器地址。

可以通过联合体判断,因为联合体变量总是从低地址开始存储的。联合体第一个字段为字符类型,第二个字段是整数类型。用这个联合体的对象给第二个整型字段赋值为1,然后取第一个字符字段的值,如果这个值是1,那么系统是小端的;如果是0就是大端的。

4、说说进程调度算法有哪些?

1、先来先服务调度算法:每次调度都是从后备进程队列中选择一个或者多个最先进入该队列的进程,将他们调入内存,为他们分配资源、创建进程,然后放入就绪队列。

2、短作业(进程)优先调度算法:短作业优先(SJF)的调度算法是从后备队列中选择一个或若干个估计运行时间最短的作业(进程),将它们调入内存运行。

3、高优先级优先调度算法:当把该算法用于作业调度时,系统将从后备队列中选择若干个优先权最高的作业装入内存。当用于进程调度时,该算法是把处理机分配给就绪队列中优先权最高的进程。

4、时间片轮转法:每次调度时,把CPU 分配给队首进程,并令其执行一个时间片。时间片的大小从几ms 到几百ms。当执行的时间片用完时,由一个计时器发出时钟中断请求,调度程序便据此信号来停止该进程的执行,并将它送往就绪队列的末尾;然后,再把处理机分配给就绪队列中新的队首进程,同时也让它执行一个时间片。

5、多级反馈队列调度算法:综合前面多种调度算法。

5、简述操作系统如何申请以及管理内存的?

1、物理内存:物理内存有四个层次,分别是寄存器、高速缓存、主存和磁盘。操作系统会对物理内存进行管理,有一个部分称为内存管理器(memory manager),它的主要工作是有效的管理内存,记录哪些内存是正在使用的,在进程需要时分配内存以及在进程完成时回收内存。

2、虚拟内存:操作系统为每一个进程分配一个独立的地址空间,作为虚拟内存。虚拟内存与物理内存存在映射关系,通过页表寻址完成虚拟地址和物理地址的转换。

从操作系统角度来看,进程分配内存有两种方式,分别是brk和mmap。

6、简述Linux内核态和用户态,什么时候会进入内核态?

1、内核态与用户态:内核态(系统态)与用户态是操作系统的两种运行级别。内核态拥有最高权限,可以访问所有系统指令;用户态则只能访问一部分指令。

2、什么时候进入内核态:共有三种方式:a、系统调用。b、异常。c、设备中断。其中,系统调用是主动的,另外两种是被动的。

3、为什么区分内核态与用户态:在CPU的所有指令中,有一些指令是非常危险的,如果错用,将导致整个系统崩溃。比如:清内存、设置时钟等。所以区分内核态与用户态主要是出于安全的考虑。

7、简述LRU算法及其实现方法?

1、LRU(Least Recently Used)算法:LRU算法用于缓存淘汰。思路是将缓存中最近最少使用的对象删除掉。

2、实现方式:利用链表和hashmap。当需要插入新的数据项的时候,如果新数据项在链表中存在(一般称为命中),则把该节点移到链表头部,如果不存在,则新建一个节点,放到链表头部,若缓存满了,则把链表最后一个节点删除即可。

在访问数据的时候,如果数据项在链表中存在,则把该节点移到链表头部,否则返回-1.这样在链表尾部的节点就是最近最久未被访问的数据项。

8、一个线程占用多大内存?

大概占用8M内存。

9、什么是页表,为什么会有页表?

页表是虚拟内存的概念。操作系统虚拟内存到物理内存的映射表,就被称为页表

原因:不可能每一个虚拟内存的 Byte 都对应到物理内存的地址。这张表将大得真正的物理地址也放不下,于是操作系统引入了页(Page)的概念。进行分页,这样可以减小虚拟内存页对应物理内存页的映射表大小。

如果将每一个虚拟内存的 Byte 都对应到物理内存的地址,每个条目最少需要 8字节(32位虚拟地址->32位物理地址),在 4G 内存的情况下,就需要 32GB 的空间来存放对照表,那么这张表就大得真正的物理地址也放不下了,于是操作系统引入了页(Page)的概念。

在系统启动时,操作系统将整个物理内存以 4K 为单位,划分为各个页。之后进行内存分配时,都以页为单位,那么虚拟内存页对应物理内存页的映射表就大大减小了,4G 内存,只需要 8M 的映射表即可,一些进程没有使用到的虚拟内存,也并不需要保存映射关系,而且Linux 还为大内存设计了多级页表,可以进一页减少了内存消耗。

10、简述操作系统中的缺页中断?

缺页异常:malloc和mmap函数在分配内存时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常,引发缺页中断。

缺页中断:缺页异常后将产生一个缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。

缺页中断与一般中断的区别:缺页中断与一般中断一样,需要经历四个步骤:保护CPU现场、分析中断原因、转入缺页中断处理程序、恢复CPU现场,继续执行。 缺页中断与一般中断区别: (1)在指令执行期间产生和处理缺页中断信号 (2)一条指令在执行期间,可能产生多次缺页中断 (3)缺页中断返回的是执行产生中断的一条指令,而一般中断返回的是执行下一条指令。

11、说说虚拟内存分布,什么时候会由用户态陷入内核态?

(1)代码段.text:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。

(2)数据段.data:存放程序中已初始化的全局变量和静态变量的一块内存区域。

  (3)BSS 段.bss:存放程序中未初始化的全局变量和静态变量的一块内存区域。

  (4)可执行程序在运行时又会多出两个区域:堆区和栈区。

     堆区:动态申请内存用。堆从低地址向高地址增长。

     栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。

  (5)最后还有一个共享区,位于堆和栈之间。

共有三种方式进入内核态:系统调用、异常、设备中断。系统调用是主动的

12、简述一下虚拟内存和物理内存,为什么要用虚拟内存,好处是什么?

1、虚拟内存和物理内存:

物理内存:物理内存有四个层次,分别是寄存器、高速缓存、主存、磁盘。

寄存器:速度最快、量少、价格贵。

高速缓存:次之。

主存:再次之。

磁盘:速度最慢、量多、价格便宜。

操作系统会对物理内存进行管理,有一个部分称为内存管理器(memory manager),它的主要工作是有效的管理内存,记录哪些内存是正在使用的,在进程需要时分配内存以及在进程完成时回收内存。

虚拟内存:操作系统为每一个进程分配一个独立的地址空间,但是虚拟内存。虚拟内存与物理内存存在映射关系,通过页表寻址完成虚拟地址和物理地址的转换。

2、为什么要用虚拟内存?

(1)进程地址空间不隔离。会导致数据被随意修改。

(2)内存使用效率低。

(3)程序运行的地址不确定。操作系统随机为进程分配内存空间,所以程序运行的地址是不确定的。

3、使用虚拟内存的好处?

(1)扩大地址空间。每个进程独占一个4G空间,虽然真实物理内存没那么多。

(2)内存保护:防止不同进程对物理内存的争夺和践踏,可以对特定内存地址提供写保护,防止恶意篡改。

(3)可以实现内存共享,方便进程通信。

(4)可以避免内存碎片,虽然物理内存可能不连续,但映射到虚拟内存上可以连续。

4、使用虚拟内存的缺点?

(1)虚拟内存需要额外构建数据结构,占用空间。

(2)虚拟地址到物理地址的转换,增加了执行时间。

(3)页面换入换出耗时。

(4)一页如果只有一部分数据,浪费内存。

13、虚拟地址到物理地址是怎么映射的?

操作系统为每一个进程维护了一个从虚拟地址到物理地址的映射关系的数据结构,叫页表。页表中的每一项都记录了这个页的基地址

14、说说堆栈溢出是什么,会怎么样?

堆栈溢出就是不顾堆栈中分配的局部数据块大小,向该数据块写入了过多的数据,导致数据越界。常指调用堆栈溢出,本质上一种数据结构的满溢情况。堆栈溢出可以理解为两个方面:堆溢出和栈溢出

堆溢出:比如不断的new 一个对象,一直创建新的对象,而不进行释放,最终导致内存不足。将会报错:OutOfMemory Error。

栈溢出:一次函数调用中,栈中将被依次压入:参数,返回地址等,而方法如果递归比较深或进去死循环,就会导致栈溢出。将会报错:StackOverflow Error。

15、简述malloc的实现原理?

当开辟的空间小于 128K 时,调用 brk()函数;当开辟的空间大于 128K 时,调用mmap()。malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址。

16、说说并发和并行,及其区别?

并发:对于单个CPU,在一个时刻只有一个进程在运行,但是线程的切换时间则减少到纳秒数量级,多个任务不停来回快速切换。

并行:对于多个CPU,多个进程同时运行。

区别。通俗来讲,它们虽然都说是"多个进程同时运行",但是它们的"同时"不是一个概念。并行的"同时"是同一时刻可以多个任务在运行(处于running),并发的"同时"是经过不同线程快速切换,使得看上去多个任务同时都在运行的现象。

17、说说进程、线程、协程是什么,他们的区别是什么?

进程:操作系统提供的抽象概念,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体。程序本身是没有生命周期的,它只是存在磁盘上的一些指令,程序一旦运行就是进程。

线程:是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。

协程:协程(Coroutine,又称微线程)是一种比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制。

线程和进程的区别:

1、进程是资源的分配和调度的独立单元。进程拥有完整的虚拟地址空间,当发生进程切换时,不同的进程拥有不同的虚拟地址空间。而同一进程的多个线程共享同一地址空间(不同进程之间的线程无法共享)

2、线程是CPU调度的基本单元,一个进程包含若干线程(至少一个线程)。

3、线程比进程小,基本上不拥有系统资源。线程的创建和销毁所需要的时间比进程小很多

4、由于线程之间能够共享地址空间,因此,需要考虑同步和互斥操作

5、一个线程的意外终止会影响整个进程的正常运行,但是一个进程的意外终止不会影响其他的进程的运行。因此,多进程程序安全性更高。

线程与协程的区别:

(1)协程执行效率极高。协程直接操作栈基本没有内核切换的开销,所以上下文的切换非常快,切换开销比线程更小。

(2)协程不需要多线程的锁机制,因为多个协程从属于一个线程,不存在同时写变量冲突,效率比线程高。

(3)一个线程可以有多个协程。

18、说说Linux中的fork函数的作用?

fork函数用来创建一个子进程。对于父进程,fork函数返回新创建的子进程的PID;对于子进程,fork函数调用成功会返回0。如果出错,返回-1。

#include <unistd.h>  pid_t fork();

fork()函数创建一个新进程后,会为这个新进程分配进程空间,将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段。这时候,子进程和父进程一模一样,都接受系统的调度。(读时共享,写时复制)。

19、说说什么是孤儿进程,什么是僵尸进程?如何解决僵尸进程?

孤儿进程:是指一个父进程退出后,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并且由init进程对它们完整状态收集工作。

僵尸进程:是指一个进程使用fork函数创建子进程,如果子进程退出,而父进程并没有调用wait()或者waitpid()系统调用取得子进程的终止状态,那么子进程的进程描述符仍然保存在系统中,占用系统资源,这种进程称为僵尸进程。

如何解决僵尸进程?

(1)一般,为了防止产生僵尸进程,在fork子进程之后我们都要及时使用wait系统调用;同时,当子进程退出的时候,内核都会给父进程一个SIGCHLD信号,所以我们可以建立一个捕获SIGCHLD信号的信号处理函数,在函数体中调用wait(或waitpid),就可以清理退出的子进程以达到防止僵尸进程的目的。

(2)使用kill命令。打开终端并输入 ps aux | grep Z 命令,会输出所有的僵尸进程的详细内容,然后输入kill -s SIGCHLD pid(父进程pid)。

20、说说什么是守护进程?如何实现?

守护进程是运行在后台的一种生存期长的特殊进程。它独立于控制终端,处理一些系统级别任务。

如何实现:

(1)创建子进程,终止父进程。方法是调用fork() 产生一个子进程,然后使父进程退出。

(2)调用setsid() 创建一个新会话。

(3)将当前目录更改为根目录(修改工作目录)。使用fork() 创建的子进程也继承了父进程的当前工作目录。

(4)重设文件权限掩码。文件权限掩码是指屏蔽掉文件权限中的对应位。

(5)关闭不再需要的文件描述符(默认标准输入、标准输出、标准错误是打开的)。子进程从父进程继承打开的文件描述符。

21、进程通信的方式有哪些?

进程间通信主要包括管道、系统IPC(包括消息队列、信号量、信号、共享内存)、套接字socket,内存映射区

1、管道:

无名管道(PIPE内存文件):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程之间使用。进程的亲缘关系通常是指父子进程关系。

有名管道(FIFO文件,借助文件系统):有名管道也是半双工的通信方式,但是允许在没有亲缘关系的进程之间使用,管道是先进先出的通信方式。

2、共享内存:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的IPC方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与信号量,配合使用来实现进程间的同步和通信。

3、消息队列:消息队列是有消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

4、套接字:适用于不同机器间进程通信,在本地也可作为两个进程通信的方式。

5、信号:用于通知接收进程某个事件已经发生,比如按下ctrl + C就是信号。

6、信号量:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,实现进程、线程的对临界区的同步及互斥访问。

22、说说进程同步的方式?

信号量semaphore:是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步。P操作(递减操作)可以用于阻塞一个进程,V操作(增加操作)可以用于解除阻塞一个进程。

管道:一个进程通过调用管程的一个过程进入管程。在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。

消息队列:消息的链接表,放在内核中。消息队列独立于发送与接收进程,进程终止时,消息队列及其内容并不会被删除;消息队列可以实现消息的随机查询,可以按照消息的类型读取。

23、说说进程有多少种状态?

进程有五种状态:创建、就绪、执行、阻塞、终止。一个进程创建后,被放入队列处于就绪状态,等待操作系统调度执行,执行过程中可能切换到阻塞状态(并发),任务完成后,进程销毁终止。

1、创建状态 一个应用程序从系统上启动,首先就是进入创建状态,需要获取系统资源创建进程管理块(PCB:Process Control Block)完成资源分配。

2、就绪状态 在创建状态完成之后,进程已经准备好,处于就绪状态,但是还未获得处理器资源,无法运行。

3、运行状态 获取处理器资源,被系统调度,当具有时间片开始进入运行状态。如果进程的时间片用完了就进入就绪状态。

4、阻塞状态 在运行状态期间,如果进行了阻塞的操作,如耗时的I/O操作,此时进程暂时无法操作就进入到了阻塞状态,在这些操作完成后就进入就绪状态。等待再次获取处理器资源,被系统调度,当具有时间片就进入运行状态。

5、终止状态 进程结束或者被系统终止,进入终止状态

24、进程通信中的管道实现原理是什么?

操作系统在内核中开辟一块缓冲区(称为管道)用于通信。管道是一种两个进程间进行单向通信的机制。因为这种单向性,管道又称为半双工管道,所以其使用是有一定的局限性的。半双工是指数据只能由一个进程流向另一个进程(一个管道负责读,一个管道负责写);如果是全双工通信,需要建立两个管道。管道分为无名管道和命名管道,无名管道只能用于具有亲缘关系的进程直接的通信(父子进程或者兄弟进程),可以看作一种特殊的文件,管道本质是一种文件;命名管道可以允许无亲缘关系进程间的通信。

25、简述mmap内存映射的原理和使用场景?

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read, write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

使用场景:

对同一块区域频繁读写操作;

可用于实现用户空间和内核空间的高效交互

可提供进程间共享内存及相互通信

可实现高效的大规模数据传输。

26、协程是轻量级的线程,轻量级表现在哪里?

协程调用和切换比线程效率高:协程执行效率极高。协程不需要多线程的锁机制,可以不加锁的访问全局变量,所以上下文的切换非常快。

协程占用内存少:执行协程只需要极少的栈内存(大概是4~5KB),而默认情况下,线程栈的大小为1MB。

切换开销更少:协程直接操作栈基本没有内核切换的开销,所以切换开销比线程少。

27、说说常见的信号有哪些?表示什么含义?

1号信号SIGHUP:该信号让进程立即关闭.然后重新读取配置文件之后重启。

2号信号SIGINT:程序中止信号,用于中止前台进程。相当于输出 Ctrl+C 快捷键。

8号信号SIGFPE:在发生致命的算术运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为 0 等其他所有的算术运算错误。

9号信号SIGKILL:用来立即结束程序的运行。本信号不能被阻塞、处理和忽略。一般用于强制终止进程。

14号信号SIGALRM:时钟定时信号,计算的是实际的时间或者时钟时间。alarm函数使用该信号。

15号信号SIGTERM:正常结束进程的信号,kill 命令的默认信号。如果进程已经发生了问题,那么这 个信号是无法正常中止进程的,这时我们才会尝试 SIGKILL 信号,也就是信号 9。

17号信号SIGCHLD:子进程结束时,父进程会收到这个信号。

18号信号SIGCONT:该信号可以让暂停的进程恢复执行。本信号不能被阻断。

19号信号SIGSTOP:该信号可以暂停前台进程,相当于输入 Ctrl+Z 快捷键。本信号不能被阻断。该信号不能被阻塞、处理和忽略

信号的 5 中默认处理动作 1、 Term 终止进程 2、 Ign 当前进程忽略掉这个信号 3、 Core 终止进程,并生成一个Core文件 4、 Stop 暂停当前进程 5、 Cont 继续执行当前被暂停的进程

28、说说线程间通信的方式有哪些?

线程间的通信方式包括临界区、互斥量、信号量、条件变量、读写锁

临界区:每个线程中访问临界资源的那段代码称为临界区(Critical Section)(临界资源是一次仅允许一个线程使用的共享资源)。每次只准许一个线程进入临界区,进入后不允许其他线程进入。不论是硬件临界资源,还是软件临界资源,多个线程必须互斥地对它进行访问。

互斥量:采用互斥对象机制,只有拥有互斥对象的线程才可以访问。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。

信号量:计数器,允许多个线程同时访问同一个资源。

条件变量:通过条件变量通知操作的方式来保持多线程同步。

读写锁:读写锁与互斥量类似。但互斥量要么是锁住状态,要么就是不加锁状态。读写锁一次只允许一个线程写,但允许一次多个线程读,这样效率就比互斥锁要高。

29、说说线程同步方式有哪些?

线程同步的实现方式主要有6种:互斥锁、自旋锁、读写锁、条件变量、屏障、信号量

1、互斥锁。互斥锁在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量进行解锁。对互斥量加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞,直至当前线程释放该互斥量。

2、自旋锁。自旋锁与互斥量类似,但它不使线程进入阻塞态,而是在获取锁之前一直占用CPU,处于忙等自旋状态。自旋锁适用于锁被持有的时间短且线程不希望在重新调度上花费太多成本的情况。

3、读写锁。读写锁有三种状态:读模式加锁、写模式加锁和不加锁,一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。读写锁非常适合对数据结构读的次数远大于写的情况。

4、条件变量。条件变量允许线程睡眠,直到满足某种条件,当满足条件时,可以向该线程发送信号,通知并唤醒该线程。条件变量通常与互斥量配合一起使用。条件变量由互斥量保护,线程在改变条件状态之前必须首先锁住互斥量,其他线程在获得互斥量之前不会察觉到条件的改变,因为必须在锁住互斥量之后它才可以计算条件是否发生变化。

5、屏障。屏障是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行。

6、信号量。信号量本质上是一个计数器,用于为多个进程提供共享数据对象的访问。编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 0 时,则可以访问,否则将阻塞。PV 原语是对信号量的操作,一次 P 操作使信号量减1,一次 V 操作使信号量加1。

30、说说什么是死锁?

死锁: 是指多个进程在执行过程中,因争夺资源而造成了互相等待。此时系统产生了死锁。比如两只羊过独木桥,若两只羊互不相让,争着过桥,就产生死锁。

死锁发生的场景:1、没有释放锁。2、重复加锁。一个线程加了一个锁之后,在没有释放锁之前又加了锁。3、多线程多锁,抢占锁资源。每个共享资源都会有一个锁进行同步控制,当有多个线程且对多个不同的共享资源进行操作,此时多线程之间可能会抢占锁资源。比如一个锁被一个线程抢占了,继续执行时,有需要另一个锁,但是在此之前这个锁被其他线程占用了,并且这个线程刚好需要第一个锁,此时就会造成死锁。

产生条件(四个必要条件)

(1)互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问,只能等待,直到进程使用完成后释放该资源;

(2)请求保持条件:进程获得一定资源后,又对其他资源发出请求,但该资源被其他进程占有,此时请求阻塞,而且该进程不会释放自己已经占有的资源;

(3)不可剥夺条件:进程已获得的资源,只能自己释放,不可剥夺;

(4)环路等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

避免死锁

(1)破坏请求和保持条件:在系统中不允许进程在已获得某种资源的情况下,申请其他资源,即要想出一个办法,阻止进程在持有资源的同时申请其它资源。

(2)破坏不可抢占条件:允许对资源实行抢夺。

(3)破坏循环等待条件。

避免多次锁定, 多检查

对共享资源访问完毕之后, 一定要解锁,或者在加锁的使用 trylock

如果程序中有多把锁, 可以控制对锁的访问顺序(顺序访问共享资源,但在有些情况下是做不到的),另外也可以在对其他互斥锁做加锁操作之前,先释放当前线程拥有的互斥锁。

项目程序中可以引入一些专门用于死锁检测的模块

31、有了进程,为什么还要线程?

原因:

进程在早期的多任务操作系统中是基本的执行单元。每次进程切换,都要先保存进程资源然后再恢复,这称为上下文切换。但是进程频繁切换将引起额外开销,从而严重影响系统的性能。为了减少进程切换的开销,人们把两个任务放到一个进程中,每个任务用一个更小粒度的执行单元来实现并发执行,这就是线程。

线程与进程对比

(1)进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此必须采用一些进程间通信方式,在进程间进行信息交换。

但多个线程共享进程的内存,如代码段、数据段、扩展段,线程间进行信息交换十分方便。

(2)调用 fork() 来创建进程的代价相对较高,即便利用写时复制技术,仍然需要复制诸如内存页表和文件描述符表之类的多种进程属性,这意味着 fork() 调用在时间上的开销依然不菲。

但创建线程比创建进程通常要快 10 倍甚至更多。线程间是共享虚拟地址空间的,无需采用写时复制来复制内存,也无需复制页表。

32、在单核机器上写多线程程序,是否需要考虑加锁,为什么?

需要加锁。

原因:因为线程锁通常用来实现线程的同步和通信。在单核机器上的多线程程序,仍然存在线程同步的问题。因为在抢占式操作系统中,通常为每个线程分配一个时间片,当某个线程时间片耗尽时,操作系统会将其挂起,然后运行另一个线程。如果这两个线程共享某些数据,不使用线程锁的前提下,可能会导致共享数据修改引起冲突。

33、简述互斥锁的机制,互斥锁与读写锁的区别?

互斥锁机制:mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒。

互斥锁和读写锁

(1) 读写锁区分读者和写者,而互斥锁不区分。

(2)互斥锁同一时间只允许一个线程访问该对象,无论读写;读写锁同一时间内只允许一个写者,但是允许多个读者同时读对象。

34、说说什么是信号量?有什么作用?

概念:信号量本质上是一个计数器,用于多进程对共享数据对象的读取,它主要是用来保护共享资源(信号量也属于临界资源),使得资源在一个时刻只有一个进程独享。

原理:由于信号量只能进行两种操作等待和发送信号,即P(sv)和V(sv),具体的行为如下:

(1)P(sv)操作:如果sv的值大于零,就给它减1;如果它的值为零,就挂起该进程的执行(信号量的值为正,进程获得该资源的使用权,进程将信号量减1,表示它使用了一个资源单位)。

(2)V(sv)操作:如果有其他进程因等待sv而被挂起,就让它恢复运行,如果没有进程因等待sv而挂起,就给它加1(若此时信号量的值为0,则进程进入挂起状态,直到信号量的值大于0,若进程被唤醒则返回至第一步)。

作用:用于多进程对共享数据对象的读取,它主要是用来保护共享资源(信号量也属于临界资源),使得资源在一个时刻只有一个进程独享。

35、什么是上下文切换?进程、线程的切换过程是什么?

上下文切换指的是操作系统停止当前运行进程(从运行态改变成其它状态)并且调度其它进程(就绪态转变成运行态)。操作系统必须在切换之前存储许多部分的进程上下文,必须能够在之后恢复他们,所以进程不能显示它曾经被暂停过,同时切换上下文这个过程必须快速,因为上下文切换操作是非常频繁的。

上下文指的是任务所有共享资源的工作现场,每一个共享资源都有一个工作现场,包括用于处理函数调用,局部变量分配以及工作现场保护的栈顶指针,和用于指令执行等功能的各种寄存器

1、进程上下文切换

(1)保护被中断进程的处理器现场信息

(2)修改被中断进程的进程控制块有关信息,如进程状态等

(3)把被中断进程的进程控制块加入有关队列

(4)选择下一个占有处理器运行的进程

(5)根据被选中进程设置操作系统用到的地址转换和存储保护信息

        切换页目录以使用新的地址空间

        切换内核栈和硬件上下文(包括分配的内存,数据段,堆栈段等)

(6)根据被选中进程恢复处理器现场

2、线程上下文切换

(1)保护被中断线程的处理器现场信息

(2)修改被中断线程的线程控制块有关信息,如线程状态等

(3)把被中断线程的线程控制块加入有关队列

(4)选择下一个占有处理器运行的线程

(5)根据被选中线程设置操作系统用到的存储保护信息

        切换内核栈和硬件上下文(切换堆栈,以及各寄存器)

(6)根据被选中线程恢复处理器现场

35、线程之间私有和共享的资源有哪些?

私有:每个线程都有独立的私有的栈区,程序计数器,栈指针以及函数运行使用的寄存器

共有:代码区,堆区

35、线程是如何实现的?

用户线程:在用户空间实现的线程机制,它不依赖于操作系统的内核,由一组用户级的线程库函数来完成线程的管理,包括进程的创建终止同步和调度等。

内核线程:是指在操作系统的内核中实现的一种线程机制,由操作系统的内核来完成线程的创建终止和管理。

36、自旋锁和互斥锁的使用场景是什么?

互斥锁用于临界区持锁时间比较长的操作,比如下面这些情况都可以考虑

(1)临界区有IO操作

(2)临界区代码复杂或者循环量大

(3)临界区竞争非常激烈

(4)单核处理器

自旋锁就主要用在临界区持锁时间非常短且CPU资源不紧张的情况下。

37、说说sleep和wait的区别?

sleep是一个延时函数,让进程或线程进入休眠。休眠完毕后继续运行。

在linux下面,sleep函数的参数是秒,而windows下面sleep的函数参数是毫秒。

wait是父进程回收子进程PCB资源的一个系统调用。进程一旦调用了wait函数,就立即阻塞自己本身,然后由wait函数自动分析当前进程的某个子进程是否已经退出,当找到一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞,直到有一个出现为止。

区别: (1)sleep是一个延时函数,让进程或线程进入休眠。休眠完毕后继续运行。

(2)wait是父进程回收子进程PCB(Process Control Block)资源的一个系统调用。

38、简述Linux零拷贝的原理?

所谓「零拷贝」描述的是计算机操作系统当中,CPU不执行将数据从一个内存区域,拷贝到另外一个内存区域的任务。通过网络传输文件时,这样通常可以节省 CPU 周期和内存带宽。

零拷贝的好处:

(1)节省了 CPU 周期,空出的 CPU 可以完成更多其他的任务

(2)减少了内存区域之间数据拷贝,节省内存带宽

(3)减少用户态和内核态之间数据拷贝,提升数据传输效率

(4)应用零拷贝技术,减少用户态和内核态之间的上下文切换

在传统 IO 中,用户态空间与内核态空间之间的复制是完全不必要的,因为用户态空间仅仅起到了一种数据转存媒介的作用,除此之外没有做任何事情。

(1)Linux提供了sendfile( ) 用来减少数据拷贝和上下文切换次数。

a. 发起 sendfile() 系统调用,操作系统由用户态空间切换到内核态空间(第一次上下文切换)

b. 通过 DMA 引擎将数据从磁盘拷贝到内核态空间的输入的 socket 缓冲区中(第一次拷贝)

c. 将数据从内核空间拷贝到与之关联的 socket 缓冲区(第二次拷贝)

d. 将 socket 缓冲区的数据拷贝到协议引擎中(第三次拷贝)

e. sendfile() 系统调用结束,操作系统由内核态空间切换到用户态空间(第二次上下文切换)

根据以上过程,一共有 2 次的上下文切换,3 次的 I/O 拷贝。我们看到从用户空间到内核空间并没有出现数据拷贝,从操作系统角度来看,这个就是零拷贝。内核空间出现了复制的原因: 通常的硬件在通过DMA访问时期望的是连续的内存空间。

(2)mmap数据零拷贝。

39、内存交换和覆盖有什么区别?

内存覆盖:程序运行时并非任何时候都要访问程序及数据的各个部分(尤其是大程序),因此可以把用户空间分为一个固定区和若干个覆盖区。将经常活跃的部分放在固定区,其余部分按照调用关系分段,首先将那些即将要访问的段放入覆盖区其他段放在外存中,在需要调用前,系统将其调入覆盖区,替换覆盖区中原有的段。

内存交换:内存空间紧张时,系统将内存中某些进程暂时换出外存,把外存中某些已具备运行条件的进程换入内存(进程在内存与磁盘间动态调度)。

换入:把准备好竞争CPU运行的程序从辅存移到内存。

换出:把处于等待状态(或CPU调度原则下被剥夺运行权力)的程序从内存移到辅存,把内存空间腾出来。中级调度(策略)就是釆用交换技术。

与覆盖技术相比,交换不要求程序员给出程序段之间的覆盖结构,而且交换主要是在进程或作业之间进行;而覆盖则主要在同一个作业或进程中进行。另外,覆盖只能覆盖与覆盖程序段无关的程序段。

40、虚拟技术你了解吗?

虚拟技术把一个物理实体转换为多个逻辑实体。

主要有两种虚拟技术:时(时间)分复用技术和空(空间)分复用技术

多进程与多线程:多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。

虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。

41、mmap内存映射区的原理?

主要分为三个阶段:

1、进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域。

进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)。在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址。为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化。将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中。

2、调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系。

为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。

通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。

内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。

通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。

3、进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝。

进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。

缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。

调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。

之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

42、虚拟内存的作用?

虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。

为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。

这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。

从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。

例如有一台计算机可以产生 16 位地址,那么一个程序的地址空间范围是 0~64K。该计算机只有 32KB 的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序。

43、介绍几种典型的线程锁?

读写锁

1、多个读者可以同时进行读

2、写者必须互斥(只允许一个写者写,也不能读者写者同时进行)

3、写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

互斥锁

一次只能一个线程拥有互斥锁,其他线程只有等待。

互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁。

条件变量

互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

自旋锁

如果线程无法取得锁,线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

44、快表是什么?

快表,又称联想寄存器(TLB) ,是一种访问速度比内存快很多的高速缓冲存储器,用来存放当前访问的若干页表项,以加速地址变换的过程。与此对应,内存中的页表常称为慢表。

45、进程和线程?两者区别?

进程Process:是操作系统的一个重要概念,它包含着一个运行程序所需要的资源。一个正在运行的应用程序在操作系统中被视为一个进程,进程可以包括一个或多个线程。进程之间是相对独立的,一个进程无法访问另一个进程的数据(除非利用分布式计算方式),一个进程运行的失败也不会影响其他进程的运行,Windows系统就是利用进程把工作划分为多个独立的区域的。进程可以理解为一个程序的基本边界。是应用程序的一个运行例程,是应用程序的一次动态执行过程。

线程Thread是进程中的基本执行单元,是操作系统分配CPU时间的基本单位,一个进程可以包含若干个线程,在进程入口执行的第一个线程被视为这个进程的主线程。线程主要是由CPU寄存器、调用栈和线程本地存储器(Thread Local Storage,TLS)组成的。CPU寄存器主要记录当前所执行线程的状态,调用栈主要用于维护线程所调用到的内存与数据,TLS主要用于存放线程的状态信息。

两者区别:

1、一个程序至少有一个进程,一个进程至少有一个线程。

2、线程的划分尺度小于进程,使得多线程程序的并发性高。

3、进程在执行过程中拥有独立的内存单元(虚拟地址空间),而多个线程共享内存,从而极大地提高了程序的运行效率。

4、线程在执行过程中与进程还是有区别的。每个独立的进程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。

5、从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。

46、同步与异步、阻塞与非阻塞?

IO读取数据分为阶段:第一个阶段是内核准备好数据,第二个阶段是内核把数据从内核态拷贝到用户态。比如一个网络读写操作。服务器等待接收来自客户端的数据,如果客户端一直没有发送数据,那么内核中是没有数据的,这就是第一个阶段;如果接收到了数据,会将数据存放在内核缓冲区中,准备好后向用户态发出拷贝,这就是第二个阶段。

阻塞IO是当用户调用read系统调用后,用户线程会被阻塞,等内核数据准备好后并且从内核缓冲区拷贝到用户缓存区后,read才会返回,并继续后续操作。阻塞IO是在第一个阶段和第二个阶段都会阻塞,没有数据(第一个阶段)也会等待阻塞。

非阻塞IO是调用read后,如果内核没有数据就立马返回,需要通过不断轮询的方式去调用read(否则直接就跳过了读取数据的操作),直到数据被拷贝到用户态的应用程序缓冲区,read请求才获取到结果。非阻塞IO阻塞的是第二个阶段,第一阶段没有数据时不会阻塞,第二阶段等待内核把数据从内核态拷贝到用户态的过程中才会阻塞。所以,非阻塞IO不是不阻塞,而是在内核中没有数据的时候才是不阻塞,有数据往用户区拷贝时是阻塞的。

比如:在epoll多路复用模型中的边沿触发模式使用的就是同步非阻塞,当有读事件触发时,就说明内核区有数据了(第一阶段满足),此时可以去调用read函数读取,且需要循环读取,因为边沿触发模式不会因为没有读取完而再次触发事件。

同步IO是应用程序发起一个IO操作后,必须等待内核把IO操作(第一个阶段没有数据,不存在IO操作)处理完成后才返回。无论是阻塞IO还是非阻塞IO都是同步的,因为在read调用时,第二个阶段内核将数据从内核空间拷贝到用户空间的过程都是需要等待的。某种程度上都是阻塞操作。

异步IO是应用程序发起一个IO操作后,调用者不能立刻得到结果,而是在内核完成IO操作后,通过信号或者回调来通知调用者。异步IO是内核数据准备好和数据从内核拷贝到用户态这两个阶段都不需要等待。所以,只有同步才有阻塞和非阻塞之分,异步必定是非阻塞的。异步IO也是需要控制的,如果不加以控制,可能导致异步IO没有执行完,程序就结束了,那么异步IO也会结束。如下程序,如果不等待异步操作完成,那么程序直接完后走就结束了,执行时,可能出现读取了一部分数据,也有可能没有读取任何数据,情况是未知的。

47、异步的优缺点?

因为异步操作无须额外的线程负担,并且使用回调的方式进行处理,在设计良好的情况下,处理函数可以不必使用共享变量(即使无法完全不用,最起码可以减少 共享变量的数量),减少了死锁的可能。

编写异步操作的复杂程度较高,程序主要使用回调方式进行处理,与普通人的思维方式有些出入,而且难以调试。

50、Linux基础命令

所有的linux命令基本是都是command [参数] [作用对象]

ls [-a -l -h]

不加任何参数,以平铺展示当前目录中的所有文件;-l以列表形式展示;-a展示所有,包括隐藏文件;-h以人性化的方式展示,需要与-l并用,可以有数据单位。

cd [路径]

切换到某一个目录中。不需要任何参数。如果直接cd回车,则回到HOME目录(/home/yoyo)

pwd

不需要参数,也不要作用对象,直接打印出当前的工作目录

mkdir [-p] 路径

用于创建目录。如果要创建多级目录需要加上-p参数。

touch 文件名

用于创建文件,不需要任何参数。

cat 文件路径

输出指定文件的全部内容,不能是目录作为作用对象

more 文件路径

类似于cat,支持翻页(如果文件内容超过了一页,不会全部显示),通过空格翻页,q退出查看。

cp [-r] 参数1 参数2

将参数1拷贝到参数2,如果是文件夹拷贝,需要增加-r

mv 参数1 参数2

将参数1移动到参数2的位置,可以是文件或者是目录

rm [-r -f] 参数1….参数n

删除删除文件或者文件夹(-r表示删除目录;-f强制删除,不会弹出提示确认信息)。多个参数表示可以指定删除多个目录或者文件夹

which 命令

查找指定命令所在执行文件的路径

find 起始路径 -name “被查找的文件”

按照文件名在指定的起始路径一下找指定的文件所在的路径(递归搜索);

可以不按照文件名搜索:

-size +|-n[kMG] : 按照文件大小搜索

-type f|c|d|p…      按照文件类型搜索

grep [-n] “搜索的内容” 文件名

在文件中搜索指定的内容,并把搜索到内容的所在行输出,加上参数-n可以显示匹配行所在的行号

wc [-c -m -l -w] 文件名

做数量统计。-c 统计文件的bytes字节数;-m 统计文件中的字符数;-l 统计文件中的行数;-w统计文件中的单词数。

echo 输出的内容

直接将内容输出。如果输出命令的直接结果:echo `pwd`,输出pwd命令直接后的结果

重定向符>和>>

> 覆盖写,>> 追加写

tail [-f -num] 文件名

查看文件中的尾部内容,并且跟踪文件的最新更改,常用于日志跟踪。

tail -f xxx 表示持续跟踪文件尾部内容

-num 表示查看尾部多少行

history

查看历史记录

51、Linux系统命令

systemctl start | stop | status | enable | disable 服务名

控制服务的启动关闭等。

ln -s 参数1 参数2

-s表示创建软连接,默认硬链接。参数1表示被连接的文件或者文件夹,参数2是目的地

date [-d] [+格式化字符串]

显示日期时间,也可以根据自定义格式显示

hostname   hostnamectl set-hostname 主机名

hostname显示主机名

hostnamectl set-hostname 主机名  修改主机名

wget [-b] url

下载网络文件,-b表示后台下载,会将日志写入到当前工作目录的wget-log文件中。

curl [-O] url

可以发送http网络请求,用于下载文件、获取信息等。-O用于下载文件。

nmap ip地址

查看指定ip的对外暴露的端口

netstat -anp

查看网络状态

ps [-e -f]

默认显示当前终端中存在的进程;-e 显示全部的进程 -f以完全格式化的形式展示信息

kill [-9] 进程ID

-9表示强制关闭进程。

top

通过top命令查看CPU(进程)、内存使用情况,类似Windows的任务管理器

默认每5秒刷新一次,语法:直接输入top即可,按q或ctrl + c退出

df [-h]

查看磁盘的使用情况(已用、可用等信息),-h以更加人性化的方式展示

iostat [-x][num1][num2]

查看CPU、磁盘的相关信息。选项:-x,显示更多信息  num1:数字,刷新间隔,num2:数字,刷新几次

网络监控状态

使用sar命令查看网络的相关统计

语法:sar -n DEV num1 num2 选项:-n,查看网络,DEV表示查看网络接口 num1:刷新间隔(不填就查看一次结束),num2:查看次数(不填无限次数)

rz  sz

分别表示上传下载

 

52、Linux权限命令

su

switch user 切换用户的命令。su – username 切换用户,-表示切换用户后加载环境变量,可选操作。通过ctrl +d退回到之前的用户

getent

getent passwd 查看当前系统中有哪些用户;getent group查看系统全部组信息

chmod [-R] 权限 文件或者文件夹

修改文件或者文件夹的权限。-R表示对文件夹内部的全部内容执行相同操作。chmod u=rwx, g=rx, o=x hello.txt   chmod 751 hello.txt

chown [-R] [用户][:][用户组] 文件或者文件夹

选项,-R,同chmod,对文件夹内全部内容应用相同规则

用户,修改所属用户 ;用户组,修改所属用户组

: 用于分隔用户和用户组

chown root hello.txt,将hello.txt所属用户修改为root

chown :root hello.txt,将hello.txt所属用户组修改为root

53、谈谈对线程池的理解?

线程池是创建一堆就绪状态线程的池化技术,避免线程的重复创建和销毁带来的性能损耗。使用线程池可以降低资源消耗,提高响应速度,并且方便对线程的管理。

在项目中也自定义过线程池,当时使用了C++11的新特性,主要有包装器functional、多线程和泛型技术。这也是为了能够更加让线程池能够更加通用化。其中,我定义了三个主要的类,首先是任务模板类,一个任务类中封装了需要执行的操作及其所需要的参数,这个操作使用包装器funcational来封装,要求传入的回调函数是一个返回值为void,参数类型是泛型的实参。在这个类中还提供了执行任务的接口,便于外部的调用。

其次,设计了一个任务队列的类,这个类中用于存放传入的多个任务对象,是通过c++中的队列管理的。在这个类中还提供了多个接口,包括增加任务、判断任务队列是为空和取出任务等操作。因为多个线程之间会竞相从任务队列中获取队列,所以在任务队列中取出数据时需要用互斥锁来进行控制。

最后,是一个线程池类,线程池类中封装了一些线程池的状态信息,比如初始化时创建最小创建的线程个数,线程池最大能容纳多少线程,正在忙碌中的线程个数和正在工作中的线程个数等等。此外,还会开启一个管理者线程,这个线程主要是为了管理工作线程的,主要功能是当任务量很多时,增加工作线程个数,或者当任务量很少,减少存活的线程个数。工作线程则有若干个,用于获取任务队列中的任务进行执行。

九、MySQL

查询语句执行流程

MySQL的架构分为两层,Server层和存储引擎层

Server 负责建立连接、分析和执行 SQL。MySQL 大多数的核心功能模块都在这实现,主要包括连接器,查询缓存、解析器、预处理器、优化器、执行器等。另外,所有的内置函数(如日期、时间、数学和加密函数等)和所有跨存储引擎的功能(如存储过程、触发器、视图等。)都在 Server 层实现。

存储引擎层负责数据的存储和提取。支持 InnoDB、MyISAM、Memory 等多个存储引擎,不同的存储引擎共用一个 Server 层。现在最常用的存储引擎是 InnoDB,从 MySQL 5.5 版本开始, InnoDB 成为了 MySQL 的默认存储引擎。我们常说的索引数据结构,就是由存储引擎层实现的,不同的存储引擎支持的索引类型也不相同,比如 InnoDB 支持索引类型是 B+树 ,且是默认使用,也就是说在数据表中创建的主键索引和二级索引默认使用的是 B+ 树索引。

1、执行一条select语句,期间发生了什么?

1、连接器

与mysql服务建立连接(tcp),连接过程需要三次握手。如果用户名密码出错,就会收到一个Access denied for user的错误,然后客户端程序结束运行。如果用户名密码正常,连接器就会获取该用户的权限,然后保存起来,后续该用户在此链接上的操作,都会给予连接开始时读到的权限进行逻辑判断。所以,如果对已经建立连接的用户权限修改,也不会影响本次连接的权限。

MySQL连接数是有限制的。可以通过命令show variables like ‘max_connection’ 命令查看最大连接数。

长连接与短连接(共用一条tcp连接):执行一条sql语句之后就断开连接这就是短连接;在断开连接之前可以执行多条sql就是长连接。一般推荐使用长连接,可以减少资源消耗。但是长连接可能会占用内存增多,因为mysql在执行查询过程中临时使用内存管理连接对象,这些链接对象资源只有在断开连接的时候才会释放。如果长连接累计很多,将导致mysql服务占用内存增大,有可能会被系统强制杀死,会出现mysql服务异常重启的现象。

解决长连接的问题:1、定期断开长连接。释放内存资源。2、客户端主动重置连接。MySQL5.7实现了mysql_reset_connection()函数接口,当客户端执行了一个很大的操作后,在代码里调用mysql_reset_connection函数来重置连接,达到释放内存的效果。这个过程不需要重连和重新做权限验证,会将连接恢复到刚刚创建完成时的状态。

连接器工作总结:

  1. 与客户端进行tcp三次握手建立连接;
  2. 校验客户端的用户名和密码,如果出错,则会报错;
  3. 如果用户名和密码都正确,会读取该用户的权限,后续操作基于该权限。

2、查询缓存

连接器工作完成后,就进入了mysql的工作环境。客户端就可以向MySQL服务发送SQL语句了,MySQL服务收到SQL语句后,就会解析出SQL语句的第一个字段,看看是什么类型的语句。

如果是查询语句select,MySQL就会先去查询缓存中查找缓存数据,看看之前有没有执行这一条命令,这个查询缓存是以key-value形式保存在内存中的,key为SQL语句,value是查询结果

如果查询的语句命中查询缓存,那么就会直接返回 value 给客户端。如果查询的语句没有命中查询缓存中,那么就要往下继续执行,等执行完后,查询的结果就会被存入查询缓存中。对于频繁更新的表,查询缓存的命中率很低,因为只要有一个表有更新操作,那么这个表的查询缓存就会被清空。所以查询缓存显得很鸡肋,在MySQL8.0直接删除了查询缓存,将不再走这个阶段。对于 MySQL 8.0 之前的版本,如果想关闭查询缓存,我们可以通过将参数 query_cache_type 设置成 DEMAND。

3、解析器(解析SQL)

在正式执行 SQL 查询语句之前, MySQL 会先对 SQL 语句做解析,这个工作交由「解析器」来完成。

解析器主要做两件事:1、词法分析。MySQL会根据你输入的字符串识别出关键字,构建出SQL语法树,方便后面模块获取SQL类型、表名、字段名、where条件等等。2、语法分析。根据词法分析的结果,语法解析器会根据语法规则,判断输入的这个SQL语句是否满足MySQL语法。如果输入的语法不对,解析器会在这个阶段报错。(不检查表名是否存在,解析器只做语法检查)

4、执行SQL

经过解析器后,接着就要进入执行 SQL 查询语句的流程了,每条SELECT 查询语句流程主要可以分为下面这三个阶段:

prepare阶段:预处理阶段;

optimize阶段:优化阶段;

execute阶段:执行阶段。

1、预处理器

a、检查SQL查询语句中的表或者字段是否存在;

b、将select * 中的 * 符号扩展为表上所有的列。

2、优化器

经过预处理阶段后,需要为SQL查询语句先指定一个执行计划(优化器)。优化器主要负责将SQL查询语句的执行方案确定下来,比如在表里面有多个索引的时候,优化器会基于查询成本的考虑,来决定使用哪个索引。

当查询语句为select * from students where student_id = 1 很简单,选择的就是主键索引。如果想要知道使用的是哪个索引,可以在这条语句前面加上explain关键字。

如果表中没有主键或者设置索引那么就会全表扫描,这种查询扫描效率最低(type = all)。

在students表中只有student_id这个主键一级索引,现在将其中的last_name字段设置为普通索引(二级索引)create index sname on students (last_name);

此时执行select student_id from students student_id > 1 and last_name like ‘J%’; 这条查询语句的结果既可以使用主键索引,也可以使用普通索引,但是执行的效率会不同。这时,就需要优化器来决定使用哪个索引了。

很显然这条查询语句是覆盖索引,直接在二级索引就能查找到结果(因为二级索引的 B+ 树的叶子节点的数据存储的是主键值),就没必要在主键索引查找了,因为查询主键索引的 B+ 树的成本会比查询二级索引的 B+ 的成本大,优化器基于查询成本的考虑,会选择查询代价小的普通索引。

3、执行器

经历完优化器后,就确定了执行方案,接下来 MySQL 就真正开始执行语句了,这个工作是由「执行器」完成的。在执行的过程中,执行器就会和存储引擎交互了,交互是以记录为单位的。有主键索引查询、全表扫描、索引下推等。

总结

执行一条SQL查询语句,期间发生了:

  1. 连接器:建立连接,管理连接、校验用户身份;
  2. 查询缓存:查询语句如果命中查询缓存则直接返回,否则继续往下执行。MySQL 8.0 已删除该模块;
  3. 解析 SQL,通过解析器对 SQL 查询语句进行词法分析、语法分析,然后构建语法树,方便后续模块读取表名、字段、语句类型;
  4. 执行 SQL:执行 SQL 共有三个阶段:

预处理阶段:检查表或字段是否存在;将 select * 中的 * 符号扩展为表上的所有列。

优化阶段:基于查询成本的考虑,选择查询成本最小的执行计划;

执行阶段:根据执行计划执行 SQL 查询语句,从存储引擎读取记录,返回给客户端。

扩展:执行器中的主键索引查询、全表扫描和索引下推执行过程分析:

1、主键索引查询:

语句select * from students where student_id = 1;

这条查询语句的查询条件用到了主键索引,而且是等值查询,同时主键 id 是唯一,不会有 id 相同的记录,所以优化器决定选用访问类型为 const 进行查询,也就是使用主键索引查询一条记录,那么执行器与存储引擎的执行流程是这样的:

a、执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为 InnoDB 引擎索引查询的接口,把条件 id = 1 交给存储引擎,让存储引擎定位符合条件的第一条记录。

b、存储引擎通过主键索引的 B+ 树结构定位到 id = 1的第一条记录,如果记录是不存在的,就会向执行器上报记录找不到的错误,然后查询结束。如果记录是存在的,就会将记录返回给执行器;

c、执行器从存储引擎读到记录后,接着判断记录是否符合查询条件,如果符合则发送给客户端,如果不符合则跳过该记录。

d、执行器查询的过程是一个 while 循环,所以还会再查一次,但是这次因为不是第一次查询了,所以会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为一个永远返回 - 1 的函数,所以当调用该函数的时候,执行器就退出循环,也就是结束查询了。

至此,这个语句就执行完成了。

2、全表扫描

语句:select * from students where first_name = ‘Jone’;

这条查询语句的查询条件没有用到索引,所以优化器决定选用访问类型为 ALL 进行查询,也就是全表扫描的方式查询,那么这时执行器与存储引擎的执行流程是这样的:

a、执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 all,这个函数指针被指向为 InnoDB 引擎全扫描的接口,让存储引擎读取表中的第一条记录;

b、执行器会判断读到的这条记录的 name 是不是 iphone,如果不是则跳过;如果是则将记录发给客户端(Server 层每从存储引擎读到一条记录就会发送给客户端,之所以客户端显示的时候是直接显示所有记录的,是因为客户端是等查询语句查询完成后,才会显示出所有的记录)。

c、执行器查询的过程是一个 while 循环,所以还会再查一次,会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 all,read_record 函数指针指向的还是 InnoDB 引擎全扫描的接口,所以接着向存储引擎层要求继续读刚才那条记录的下一条记录,存储引擎把下一条记录取出后就将其返回给执行器(Server层),执行器继续判断条件,不符合查询条件即跳过该记录,否则发送到客户端;

d、一直重复上述过程,直到存储引擎把表中的所有记录读完,然后向执行器(Server层) 返回了读取完毕的信息;

e、执行器收到存储引擎报告的查询完毕的信息,退出循环,停止查询。

3、索引下推

索引下推能够减少二级索引在查询时的回表操作,提高查询的效率,因为它将 Server 层部分负责的事情,交给存储引擎层去处理了。

假设有select * from t_user  where age > 20 and reward = 100000; age和reward是联合索引。

联合索引当遇到范围查询 (>、<) 就会停止匹配,也就是 age 字段能用到联合索引,但是 reward 字段则无法利用到索引。

那么,不使用索引下推(MySQL 5.6 之前的版本)时,执行器与存储引擎的执行流程是这样的:

1、Server 层首先调用存储引擎的接口定位到满足查询条件的第一条二级索引记录,也就是定位到 age > 20 的第一条记录;

2、存储引擎根据二级索引的 B+ 树快速定位到这条记录后,获取主键值,然后进行回表操作,将完整的记录返回给 Server 层;

3、Server 层在判断该记录的 reward 是否等于 100000,如果成立则将其发送给客户端;否则跳过该记录;

4、接着,继续向存储引擎索要下一条记录,存储引擎在二级索引定位到记录后,获取主键值,然后回表操作,将完整的记录返回给 Server 层;

如此往复,直到存储引擎把表中的所有记录读完。

没有索引下推的时候,每查询到一条二级索引记录,都要进行回表操作,然后将记录返回给 Server,接着 Server 再判断该记录的 reward 是否等于 100000

1、Server 层首先调用存储引擎的接口定位到满足查询条件的第一条二级索引记录,也就是定位到 age > 20 的第一条记录;

2、存储引擎定位到二级索引后,先不执行回表操作,而是先判断一下该索引中包含的列(reward列)的条件(reward 是否等于 100000)是否成立。如果条件不成立,则直接跳过该二级索引。如果成立,则执行回表操作,将完成记录返回给 Server 层。

3、Server 层再判断其他的查询条件(本次查询没有其他条件)是否成立,如果成立则将其发送给客户端;否则跳过该记录,然后向存储引擎索要下一条记录。

如此往复,直到存储引擎把表中的所有记录读完。

可以看到,使用了索引下推后,虽然 reward 列无法使用到联合索引,但是因为它包含在联合索引(age,reward)里,所以直接在存储引擎过滤出满足 reward = 100000 的记录后,才去执行回表操作获取整个记录。相比于没有使用索引下推,节省了很多回表操作。

2、什么是索引?

索引:是帮助存储引擎快速获取数据的一种数据结构,形象的说索引就是数据的目录。索引使用了空间换时间的设计思想。

存储引擎:是实现如何存储数据、如何为存储的数据建立索引和如何更新、查询数据等技术的实现方法。MySQL存储引擎有InnoDB、MyISAM和Mymory

2、为什么使用索引?

1、通过创建唯一的索引,可以保证数据库表中的每一行数据都是唯一的。

2、可以大大加快数据的检索速度,这是使用索引的主要原因。

3、可以帮助排序,避免不必要的排序操作和临时表,提高查找性能。

4、将随机IO变为顺序IO,加快了磁盘IO的读写速度和减少了磁盘IO操作。

5、可以加快表和表之间的连接。

3、索引分类

按照四个角度分类:

按「数据结构」分类:B+tree索引、Hash索引、Full-text索引。

按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)。

按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引。

按「字段个数」分类:单列索引、联合索引。

再创建表时,InnoDb存储引擎会根据不同的场景选择不同的列作为索引:

  1. 如果有主键,默认会使用主键作为聚簇索引的索引键;
  2. 如果没有主键,就选择第一个不包含NULL值的唯一列作为聚簇索引的索引键;
  3. 如果上面两种都没有,InnoDB会自动生成一个隐式自增id作为聚簇索引的索引键。

除了聚簇索引,其他索引都是二级索引,聚簇索引是只有一个的,二级索引可以有多个。

4、什么时候需要创建索引?什么时候不需要?

索引最大的好处是提高查询速度,但是索引也是有缺点的,比如:

1、需要占用物理空间,数量越大,占用空间越大;

2、创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增大;

3、会降低表的增删改的效率,因为每次增删改索引,B+ 树为了维护索引有序性,都需要进行动态维护。

所以,索引不是万能钥匙,它也是根据场景来使用的。

需要创建索引的情况

1、字段有唯一性限制的,比如商品编码;

2、经常用于 where查询条件的字段,这样能够提高整个表的查询速度,如果查询条件不是一个字段,可以建立联合索引。

3、经常用于 GROUP BY 和 ORDER BY 的字段,这样在查询的时候就不需要再去做一次排序了,因为我们都已经知道了建立索引之后在 B+Tree 中的记录都是排序好的。

不需要创建索引的情况:

1、WHERE 条件,GROUP BY,ORDER BY 里用不到的字段,索引的价值是快速定位,如果起不到定位的字段通常是不需要创建索引的,因为索引是会占用物理空间的。

2、字段中存在大量重复数据,不需要创建索引,比如性别字段,只有男女,如果数据库表中,男女的记录分布均匀,那么无论搜索哪个值都可能得到一半的数据。在这些情况下,还不如不要索引,因为 MySQL 还有一个查询优化器,查询优化器发现某个值出现在表的数据行中的百分比很高的时候,它一般会忽略索引,进行全表扫描。

3、表数据太少的时候,不需要创建索引;

4、经常更新的字段不用创建索引,比如不要对电商项目的用户余额建立索引,因为索引字段频繁修改,由于要维护 B+Tree的有序性,那么就需要频繁的重建索引,这个过程是会影响数据库性能的。

5、为什么MySQL InnoDB选择B+tree作为索引的数据结构

B+树相比于其他数据结构有其特有的优点:

B+与 B树相比

  1. 存储相同数据量级别的情况下,B+树高比B树低,磁盘IO次数更少;
  2. B+树叶子节点用双向链表串起来,适合范围查询,B树无法做到这一点。

B+与二叉树相比

随着数据量的增加,二叉树的树高会越来越高,磁盘IO次数也会更多,B+树在千万级别的数据量下,高度依然维持在3-4层左右,也就是说一次数据查询操作只需要做3-4次的磁盘IO操作就能找到目标数据。

B+与哈希表相比

虽然hash的查询效率很高,但是无法做到范围查询。

要设计一个 MySQL 的索引数据结构,不仅仅考虑数据结构增删改的时间复杂度,更重要的是要考虑磁盘 I/0 的操作次数。因为索引和记录都是存放在硬盘,硬盘是一个非常慢的存储设备,我们在查询数据的时候,最好能在尽可能少的磁盘 I/0 的操作次数内完成

B+ 树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比存储既存索引又存记录的 B 树,B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以 B 树更「矮胖」查询底层节点的磁盘 I/O次数会更少

B+ 树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,比如删除根节点的时候,不会像 B 树那样会发生复杂的树的变化

B+ 树叶子节点之间用链表连接了起来,有利于范围查询,而 B 树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。

5、为什么Innodb使用自增id作为主键?

1、如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。

2、如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页的中间某个位置, 频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE(optimize table)来重建表并优化填充页面。

5、MyISAM和Innodb的区别?

1. InnoDB支持事务,MyISAM不支持,对于InnoDB每一条SQL语言都默认封装成事务,自动提交,这样会影响速度,所以最好把多条SQL语言放在begin和commit之间,组成一个事务;

2. InnoDB支持外键,而MyISAM不支持。对一个包含外键的InnoDB表转为MYISAM会失败;

3. InnoDB是聚集索引,使用B+Tree作为索引结构,数据文件是和(主键)索引绑在一起的(表数据文件本身就是按B+Tree组织的一个索引结构),必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大。

MyISAM是非聚集索引,也是使用B+Tree作为索引结构,索引和数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的。

4、InnoDB不保存表的具体行数,执行select count(*) from table时需要全表扫描。而MyISAM用一个变量保存了整个表的行数,执行上述语句时只需要读出该变量即可,速度很快(注意不能加有任何WHERE条件);

5.、MyISAM表格可以被压缩后进行查询操作

6、InnoDB支持表、行(默认)级锁,而MyISAM支持表级锁

7、InnoDB表必须有唯一索引(如主键)(用户没有指定的话会自己找/生产一个隐藏列Row_id来充当默认主键),而Myisam可以没有。

MyISAM适合:插入不频繁,查询非常频繁,如果执行大量的SELECT,MyISAM是更好的选择, 没有事务。

InnoDB适合:可靠性要求比较高,或者要求事务; 表更新和查询都相当的频繁, 大量的INSERT或UPDATE

5、MyISAM和InnoDB实现B树索引方式的区别是什么?

1、MyISAM,B+树叶节点的data域存放的是数据记录的地址,在索引检索的时候,首先按照B+树搜索算法搜索索引,如果指定的key存在,则取出其data域的值,然后以data域的值为地址读取相应的数据记录,这被称为非聚簇索引。

2、InnoDB,其数据文件本身就是索引文件,相比MyISAM索引文件和数据文件是分离的,其表数据文件本身就是按B+Tree组织的一个索引结构,树的节点data域保存了完整的数据记录,这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引,这被称为“聚簇索引”或者聚集索引,而其余的索引都作为辅助索引,辅助索引的data域存储相应记录主键的值而不是地址,这也是和MyISAM不同的地方。

在根据主索引搜索时,直接找到key所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。因此,在设计表的时候,不建议使用过长的字段为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。

5、什么是覆盖索引?

如果一个索引包含所有需要查询的字段的值,我们就称之为覆盖索引。

6、什么时候索引失效?

1、如果条件中有or,即使其中有条件带索引也不会使用索引。如果想要使用or又要使用索引,只能将or条件中的每个列都加上索引。

2、对于联合索引,不是使用的第一部分,则不会使用使用索引。假设联合索引c1,c2,c3,执行以下查询:select * from tablename where c2 = ‘xxx’; 尽管c2在联合索引中,但是没有从最左的c1开始匹配,则索引失效。

3、like模糊查询以%开头的列索引失效。

4、如果列类型是字符串,那一定要在条件中将数据使用引号引用起来,否则不使用索引。

5、不等于(!= ,<> ),EXISTS,not in,is  not null等会导致索引失效

6、如果mysql优化器估计是用全表扫描要比使用索引快,则不使用索引。

7、当我们在查询条件中对索引列做了计算、函数、类型转换操作,这些情况下都会造成索引失效。

7、索引优化的方法

1、前缀索引优化

2、覆盖索引优化

3、主键索引最好是自增

4、索引最好设置为NOT NULL

8、InnoDB如何存储数据?

记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。

因此,InnoDB 的数据是按「数据页」为单位来读写的,也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。

数据库的 I/O 操作的最小单位是,InnoDB 数据页的默认大小是 16KB,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。

图片

9、B+树如何进行查询?

B+树的每个节点都是一个数据页。

1、只有叶子节点(最底层的节点)才存放了数据,非叶子节点(其他上层节)仅用来存放目录项作为索引。

2、非叶子节点分为不同层次,通过分层来降低每一层的搜索量;

3、所有同层次节点按照索引键大小排序,构成一个双向链表,便于范围查询。

如果叶子节点存储的是实际数据的就是聚簇索引,一个表只能有一个聚簇索引;如果叶子节点存储的不是实际数据,而是主键值则就是二级索引,一个表中可以有多个二级索引。

在使用二级索引进行查找数据时,如果查询的数据能在二级索引找到,那么就是「索引覆盖」操作,如果查询的数据不在二级索引里,就需要先在二级索引找到主键值,需要去聚簇索引中获得数据行,这个过程就叫作「回表」。

9、为什么说B+树比B树更适合实际应用中操作系统的文件索引和数据库索引?

B+tree的磁盘读写代价更低,B+tree的查询效率更加稳定。数据库索引采用B+树而不是B树的主要原因:B+树只要遍历叶子节点就可以实现整棵树的遍历,而且在数据库中基于范围的查询是非常频繁的,而B树只能中序遍历所有节点,效率太低

B+树特点:

所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的;

不可能在非叶子结点命中;

非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层。

10、使用like “%x”,索引一定会失效吗?

使用左模糊匹配(like "%xx")并不一定会走全表扫描,关键还是看数据表中的字段。

如果数据库表中的字段只有主键+二级索引,那么即使使用了左模糊匹配,也不会走全表扫描(type=all),而是走全扫描二级索引树(type=index)。否则全表扫描。

相似的,联合索引要遵循最左匹配才能走索引,但是如果数据库表中的字段都是索引的话,即使查询过程中,没有遵循最左匹配原则,也是走全扫描二级索引树(type=index)

11、count(*)和count(1)有什么区别?哪个性能最好?

count(*) = count(1) > count(主键字段) > count(字段)

12、count() 是什么?

count() 是一个聚合函数,函数的参数不仅可以是字段名,也可以是其他任意表达式,该函数作用是统计符合查询条件的记录中,函数指定的参数不为 NULL 的记录有多少个

select count(name) from t_order;  // 统计name字段不为空的记录有多少个。

select count(1) from t_order;       // 1不为NULL,表示表中的记录数。

select count(id) from t_order; // id主键字段通常设为NOT NULL,可以统计表中记录数。

select count(*) from t_order;       //统计所有记录数,执行过程类似于count(1)

13、如何优化count(*)?

经常用count(*)来做统计,其实是很不好的。

1、近似值。如果业务对于统计个数不需要很精确,比如搜索引擎在搜索关键字的时候,给出的搜索结果条数是一个大概值。可以使用show table status 或者explain命令来估计算。

2、额外表保存计数值

如果想要精确获取表的记录总数,可以将这个计数值保存在单独一张计数表中。当插入数据时,将计数表中的计数字段 + 1。

14、MySQL单表上限超过2000万行合不合理?

LRU的预读 + 大内存就解决了这个问题。

14、事务有哪些特性?

事务是由存储引擎实现的,InnoDB支持事务。

原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。

一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。

隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。

持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?

持久性是通过 redo log (重做日志)来保证的;

原子性是通过 undo log(回滚日志) 来保证的;

隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的;

一致性则是通过持久性+原子性+隔离性来保证。

15、并发事务会引发什么问题?  

MySQL 服务端是允许多个客户端连接的,这意味着 MySQL 会出现同时处理多个事务的情况。

那么在同时处理多个事务的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题。

1、脏读

如果一个事务「读到」了另一个「未提交事务修改过的数据」,就意味着发生了「脏读」现象。

图片

因为事务 A 是还没提交事务的,也就是它随时可能发生回滚操作,如果在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。

2、不可重复读

在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。

图片

3、幻读

在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。

图片

16、不可重复读和幻读区别是什么?可以举个例子吗?

不可重复读的重点是修改,幻读的重点在于新增或者删除

  1. 首先事务A读取数据,然后事务B对该数据进行了修改,此时事务A再次读取,然后发现前后数据不一致了。这就是不可重复读。
  2. 假设某工资单中工资大于3000的有4人,事务A读取了所有工资大于3000的人,共四条记录,此时事务B又插入了一条工资大于3000的记录,事务A再次读取时查到的记录就变为了5条。这就是幻读。

16、事务的隔离级别有哪些?

当多个事务并发执行时可能会遇到「脏读、不可重复读、幻读」的现象,这些现象会对事务的一致性产生不同程序的影响。

脏读:读到其他事务未提交的数据;

不可重复读:前后读取的数据不一致;

幻读:前后读取的记录数量不一致。

按照严重性,脏读 > 不可重复读 > 幻读

SQL标准提出了四种隔离级别来规避这些现象,隔离级别越高越安全,效率越低。

1、读未提交(read uncommitted),指一个事务还没提交时,它做的变更就能被其他事务看到;

2、读提交(read committed),指一个事务提交之后,它做的变更才能被其他事务看到;

3、可重复读(repeatable read),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别

4、串行化(serializable );会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,访问的事务必须等前一个事务执行完成,才能继续执行。

在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象;

在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;

在「可重复读」隔离级别下,可能发生幻读现象,但是不可能脏读和不可重复读现象;

在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。

对于幻读现象,不建议将隔离级别升级为串行化,因为这会导致数据库并发时性能很差。MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象,解决的方案有两种:

针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。

针对当前读(select ... for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同:

「读提交」隔离级别是在每个 select 都会生成一个新的 Read View,也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

「可重复读」隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View,这样就保证了在事务期间读到的数据都是事务启动前的记录。

这两个隔离级别实现是通过「事务的 Read View 里的字段」和「记录中的两个隐藏列」的比对,来控制并发事务访问同一个记录时的行为,这就叫 MVCC(多版本并发控制)。

谈谈什么是MVCC以及对它的了解?

InnoDB默认的隔离级别是RR,RR解决脏读(快照读)、不可重复、幻读等问题,使用的是MVCC。MVCC全称多版本并发控制。它的最大优点是读不加锁,因此读写不冲突,并发性好。InnoDB实现MVCC多个版本的数据可以共存,主要基于以下技术和数据结构:

第一隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。

第二基于回滚日志的版本链:每行数据的隐藏列中包含了指向回滚日志的指针,而每行回滚日志也会指向更早版本的回滚日志,形成一条版本链。

第三ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。

对于当前读出现幻读和解决幻读的问题解析:

  1. 两个事务都开启了,事务1先进入当前读,读出了此时内存中的表数据;如果此时事务2尝试插入数据或者修改数据,但是,因为事务1进入了当前读,会加上锁,此时事务2无法进行插入和修改操作。除非事务1提交或者回滚了事务。如果事务1长时间不提交或者回滚,那么其他事务的插入和修改操作就会产生超时,导致执行失败。所以,一个事务不能长时间进入当前读,这样会导致其他事务无法正常执行。此外,这种情况,也说明了mysql使用这种机制可以解决幻读的问题(在事务开启之初就进入当前读,此时会对加上next-key lock,从而避免其他事务插入一条新数据或者更改数据)。

2、与上面的操作相反,同时开启了两个事务,此时,事务2率先插入一条数据,然后事务1希望使用当前读,那么此时执行之后就会进入阻塞状态,因为事务2因为插入数据获取了锁,导致其他事务无法直接进入当前读而阻塞,如果事务2长时间不提交或者回滚,事务1的当前读阻塞到超时失败。

如果,事务2插入数据之后提交了,然后事务1当前读将不会阻塞,而是读出了不属于事务1一开始所看到的数据。这也就出现了幻读现象。

16、数据库如何保证一致性?

数据库层面,数据库通过原子性、隔离性、持久性来保证一致性。也就是说ACID四大特性之中,C(一致性)是目的,A(原子性)、I(隔离性)、D(持久性)是手段,是为了保证一致性,数据库提供的手段。数据库必须要实现AID三大特性,才有可能实现一致性。例如,原子性无法保证,显然一致性也无法保证。

应用层面,通过代码判断数据库数据是否有效,然后决定回滚还是提交数据!

16、数据库如何保持原子性?

主要是利用 Innodb 的undo log。 undo log名为回滚日志,是实现原子性的关键,当事务回滚时能够撤销所有已经成功执行的 SQL语句,他需要记录你要回滚的相应日志信息。

undo log记录了这些回滚需要的信息,当事务执行失败或调用了rollback。导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。

16、数据库如何保持持久性?

主要是利用Innodb的redo log。重写日志。

当做数据修改的时候,不仅在内存中操作,还会在redo log中记录这次操作。当事务提交的时候,会将redo log日志进行刷盘(redo log一部分在内存中,一部分在磁盘上)。当数据库宕机重启的时候,会将redo log中的内容恢复到数据库中,再根据undo log和binlog内容决定回滚数据还是提交数据。

好处:就是将redo log进行刷盘比对数据页刷盘效率高,具体表现如下:

redo log体积小,毕竟只记录了哪一页修改了什么数据,因此体积小,刷盘快。

redo log是一直往末尾进行追加,属于顺序IO。效率显然比随机IO来的快。

17、MySQL可重复读隔离级别完全解决幻读了吗?

MySQL 可重复读隔离级别并没有彻底解决幻读,只是很大程度上避免了幻读现象的发生。

要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。

18、MySQL的可重复读隔离级别如何解决幻读?

在可重复读隔离级别下,InnoDB采用了MVCC机制来解决幻读的问题,MVCC就是一种乐观锁机制,它通过对于不同事务生成不同的的快照版本,然后通过UNDO的版本链来进行管理,并且在MVCC中规定高版本能够看到低版本的一个数据变更,低版本看不到高版本的一个数据变更,从而实现了不同事务之间的数据隔离解决幻读问题。但是如果在事务里存在当前读的情况,那么它是直接读取内存里面的数据跳过了快照读,所以还是会出现幻读问题。

可以通过两种方式解决幻读:1、尽量避免当前读的情况;2、引入一个LBCC的方式来解决。

18、关系型数据库和非关系型数据库?

非关系型数据库也叫NOSQL,采用键值对的形式进行存储。

它的读写性能度很高,易于扩展,可分为内存型数据库和文档型数据库,比如Redis、Mongodb等。适用于日志系统、地理位置存储、数据量巨大的场景等。

关系型数据库优点:1、容易理解。因为它采用了关系模型来组织数据。2、可以保持数据的一致性。3、数据更新的开销比较小。4、支持复杂查询(带where子句的查询)。

非关系型数据库优点:1、不需要经过SQL层的解析,读写效率高。2、基于键值对,数据的扩展性很好。3、可以支持多种类型数据的存储,如图片,文档等等。

19、说说delete、drop和truncate的共同点

三种都用来做删除操作。

delete用来删除表的全部或者部分数据行,执行delete之后,用户可以通过提交或者回滚来执行删除或者撤销删除。会触发这个表上所有的delete触发器。

truncate删除表中的所有数据,这个操作不能回滚,也不能触发这个表上的触发器,它的速度比delete更快,占用的空间更小。

drop用来删除表结构,会将整个表的数据行和表本身删除,索引和权限也会被删除,这个命令不能被回滚。

20、MySQL性能优化?从哪些方面可以优化?

1、为搜索字段创建索引

2、避免使用select * ,列出需要查询的字段

3、垂直分割分表

4、选择正确的存储引擎。

21、说说视图,游标呢?

视图是一种虚拟的表,通常是有一个表或者多个表的行或列的子集,具有和物理表相同的功能。游标是对查询出来的结果集作为一个单元来有效的处理。一般不使用游标,但是需要逐条处理数据的时候,游标显得十分重要。

视图的作用:使用视图可以简化复杂的 sql 操作,隐藏具体的细节,保护数据;视图创建后,可以使用与表相同的方式利用它们。

22、MySQL中为什么要有事务回滚机制?

在 MySQL 中,恢复机制是通过回滚日志(undo log)实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后在对数据库中的对应行进行写入。 当事务已经被提交之后,就无法再次回滚了。

回滚日志作用: 1、能够在发生错误或者用户执行 ROLLBACK 时提供回滚相关的信息 2、 在整个系统发生崩溃、数据库进程直接被杀死后,当用户再次启动数据库进程时,还能够立刻通过查询回滚日志将之前未完成的事务进行回滚,这也就需要回滚日志必须先于数据持久化到磁盘上,是我们需要先写日志后写数据库的主要原因。

23、假设你在的公司选择MySQL数据库作数据存储,一天五万条以上的增量,预计运维三年,你有哪些优化手段?

1、设计良好的数据库结构,允许部分数据冗余,减少join连接查询;

2、选择合适的表字段数据类型和存储引擎,适当的添加索引;

3、MySQL库主从读写分离,减少数据库读写压力;

4、添加缓存机制,防止每次都直接从数据库中搜索;

5、书写高效的SQL语句。比如select * 改为指定字段。

24、聚集索引和非聚集索引是什么?区别是什么?

聚集索引

该索引中键值的逻辑顺序与数据行的物理顺序相同,每个表 (InnoDB) 只能有一个聚集索引。

在 InnoDB 中,聚集索引 B+ 树的叶子节点中除了存放主键信息,还存放了主键对应的行数据,因此我们可以直接在聚集索引中查找到想要的数据。

查找时间短,但是内存占用高。

非聚集索引

非聚集索引,又称辅助索引、非聚簇索引,该索引中键值的逻辑顺序与数据行的物理顺序不同,每个表 (InnoDB、MyISAM) 可以有多个非聚集索引。

在 InnoDB 中,非聚集索引 B+ 树的叶子节点中存放了主键信息,当我们要查找数据时,需要先在非聚集索引中查找到对应的主键,然后再根据主键去聚集索引中查找到想要的数据。

如果使用了覆盖索引,则不需要回表,直接通过非聚集索引就可以查找到想要的数据;

覆盖索引:是指 select 查询的数据只需要在索引中就能取得,而不必去读取数据行,换句话说就是,查询列要被所建的索引覆盖。

非聚集索引:索引占用的存储空间较小,但查找时间较长

25、MySQL中的char和varchar有什么区别?

1、char的长度是不可变的,用空格填充到指定长度大小,而varchar的长度是可变的。

2、char的存取速度还是要比varchar要快得多

3、char的存储方式是:对英文字符(ASCII)占用1个字节,对一个汉字占用两个字节。varchar的存储方式是:对每个英文字符占用2个字节,汉字也占用2个字节。

26、索引有那么多优点,为什么不对表总的每一列创建一个索引呢?

当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,这样就降低了数据的维护速度。

索引需要占物理空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果要建立簇索引,那么需要的空间就会更大。

创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加。

27、介绍一下间隙锁?

InnoDB存储引擎有3种行锁的算法,间隙锁(Gap Lock)是其中之一。间隙锁用于锁定一个范围,但不包含记录本身。它的作用是为了阻止多个事务将记录插入到同一范围内,而这会导致幻读问题的产生。

27、InnoDB中行级锁是怎么实现的?

InnoDB行级锁是通过给索引上的索引项加锁来实现的。只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁。

当表中锁定其中的某几行时,不同的事务可以使用不同的索引锁定不同的行。另外,不论使用主键索引、唯一索引还是普通索引,InnoDB都会使用行锁来对数据加锁。

28、说说SQL语法中内连接、自连接、外连接(左、右、全)、交叉连接的区别分别是什么?

内连接:只有两个元素表相匹配的才能在结果集中显示。

外连接:左外连接: 左边为驱动表,驱动表的数据全部显示,匹配表的不匹配的不会显示。 右外连接:右边为驱动表,驱动表的数据全部显示,匹配表的不匹配的不会显示。 全外连接:连接的表中不匹配的数据全部会显示出来。

交叉连接: 笛卡尔效应,显示的结果是链接表数的乘积。

29、数据库高并发经常遇到,怎么解决?

1、使用缓存,减少数据库的读取负担,将高频访问的数据存入缓存中;

2、增加数据库索引,提高查询速度;

3、主从读写分离,让主服务器负责写,从服务器负责读;

4、将数据库拆分,是的数据库的表尽可能小,提高查询速度;

5、使用分布式架构,分散计算压力。

30、说说数据库设计的三大范式?

第一范式:在关系模型中,数据库表的每一列都是不可分割的原子数据项,而不能是集合,数组,记录等非原子数据项。简而言之,第一范式就是无重复的域。

第二范式:在1NF的基础上,非码属性必须完全依赖于候选码(在1NF基础上消除非主属性对主码的部分函数依赖)。

第三范式:在2NF基础上,任何非主属性不依赖于其它非主属性(在2NF基础上消除传递依赖)。

31、说说对redo log、undo log和binlog日志的了解?

binlog(Binary Log):

二进制日志文件就是常说的binlog。二进制日志记录了MySQL所有修改数据库的操作,然后以二进制的形式记录在日志文件中,其中还包括每条语句所执行的时间和所消耗的资源,以及相关的事务信息。默认情况下,二进制日志功能是开启的。

redo log

重做日志用来实现事务的持久性,即事务ACID中的D。它由两部分组成:一是内存中的重做日志缓冲(redo log buffer),其是易失的;二是重做日志文件(redo log file),它是持久的。

redo log用来保证事务的持久性,undo log用来帮助事务回滚及MVCC的功能。redo log基本上都是顺序写的,在数据库运行时不需要对redo log的文件进行读取操作。而undo log是需要进行随机读写的。

undo log

redo存放在重做日志文件中,如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。

33、MySQL主从复制怎么实现?

主要分为以下三步:

1、主服务器(master)把数据更改记录到二进制日志(binlog)中。

2、从服务器(slave)把主服务器的二进制日志复制到自己的中继日志(relay log)中。

3、从服务器重做中继日志中的日志,把更改应用到自己的数据库上,以达到数据的最终一致性。

34、说说为什么有Buffer Pool?

InnoDB储存引擎设计了一个缓冲池(buffer pool)来提高数据库的读写性能,防止每次都去磁盘读写数据。Buffer Pool 以页为单位缓冲数据,可以通过 innodb_buffer_pool_size 参数调整缓冲池的大小,默认是 128 M。

十、Redis

1、为什么用Redis做MySQL缓存?

具备高性能:假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。如果 MySQL 中的对应数据改变的之后,同步改变 Redis 缓存中相应的数据即可。

具备高并发:单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,Redis 单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。

1、Redis和Memcached有什么区别?

共同点:1、都是基于内存的数据库,一般都当作缓存使用。2、都有过期策略。3、两者的性能都非常高

不同点:1、redis支持更加丰富的数据类型,而memcached只支持最简单的key-value数据类型。2、redis支持数据的持久化,可以将内存中的数据保持在硬盘中,重启之后可以再次加载使用,而memcached没有持久化功能,数据全部存在于内存之中,memcached重启或者挂掉之后数据就没了。3、redis原生支持集群模式,memcached没有原生集群模式。4、redis支持发布订阅模式,Lua脚本和事务等功能,而memcached不支持。

2、Redis数据类型以及使用场景分别是什么?

Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。

1、String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。

2、List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。

3、Hash 类型:缓存对象、购物车等。

4、Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。

5、Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

1、BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;

2、HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;

3、GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;

4、Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

2、五种数据类型

3、Redis是单线程模型吗?

Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

Redis程序本身并不是单线程的,redis在启动的时候,是会启动后台线程的:

  1. Redis在2.6版本,会启动2个后台线程,分别处理关闭文件、AOF刷盘这两个任务。
  2. Redis在4.0之后,新增了一个后台线程,用来异步释放redis内存,也就是lazyfree线程。执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。

Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。

关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:

BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;

BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,

BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;

4、Redis单线程模式是怎么样的?

首先,调用epoll_create()创建一个epoll对象和调用socket()创建一个服务端socket;

然后,调用bind()绑定端口和调用listen()监听该socket;

然后,将调用epoll_ctl将listen socket加入到epoll中,同时注册连接时间处理函数。

初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:

首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。

接着,调用 epoll_wait 函数等待事件的到来:

如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;

如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;

如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。

5、Redis采用单线程为什么还这么快?

1、Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;

2、Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。

3、Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

6、Redis 6.0之后为什么引入多线程?

虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。

所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理

因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会额外创建 6 个线程(这里的线程数不包括主线程):

Redis-server : Redis的主线程,主要负责执行命令;

bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;

io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。

7、Redis如何实现数据不丢失?

Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。

Redis 共有三种数据持久化的方式:

AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;

RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;

混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点

8、AOF日志是如何实现的?

Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复

9、为什么先执行命令,再把数据写入日志呢?

Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处:

1、避免额外的检查开销:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。

2、不会阻塞当前写操作命令的执行:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。

这样做也会带来风险

1、数据可能会丢失: 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。

2、可能阻塞其他操作: 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行

10、AOF写回策略有哪几种?

Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘

Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘

No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘

11、AOF日志过大,会触发什么机制?

AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。 如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢(里面是一条条的命令需要执行)。

所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。(比如,内存中的命令set name xxx 可能设置了很多次,那么重写时只保留最新的即可)

首先,根据当前redis内存里面的数据重新构建一个新的AOF文件;

读取redis里面的数据,写入到新的AOF文件里面;

重写完成以后,用新的AOF文件覆盖现有的AOF文件。

因为redis在重写的时候需要读取redis内存中的所有键值数据,再去生成指令对数据进行保存,这个过程比较耗时,对用户会产生影响,所以会放到后台子进程中去处理。因此主进程仍然可以去处理请求,此时可能出现AOF重写文件中的数据和redis内存中的数据不一致的问题,redis做了一层优化,子进程在重写过程中,主进程的数据变更需要追加到AOF的重写缓冲区里面,等到AOF文件重写完成以后,再将重写缓冲区里的数据追加到AOF文件里面。

12、RDB快照是如何实现的?

因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。

为了解决这个问题,Redis 增加了 RDB 快照。所谓的快照就是记录某一个瞬间东西(全量),比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。

13、RDB做快照时会阻塞线程吗?

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:

执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程

执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。

14、RDB在执行快照的时候,数据能修改吗?

可以的,执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的,关键的技术就在于写时复制技术(Copy-On-Write, COW)。

执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。

如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。

15、为什么会有混合持久化这个机制?

RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。(RDB缺点)

AOF 优点是丢失数据少,但是数据恢复不快。(AOF缺点)

为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。

混合持久化优点

混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

混合持久化缺点

AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;

兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

16、Redis如何实现服务高可用?

要想设计一个高可用的 Redis 服务,一定要 Redis 的多服务节点来考虑,比如 Redis 的主从复制、哨兵模式、切片集群

1、主从复制

主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是「读写分离」的方式。

主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。

注意:主从服务器之间的命令复制是异步进行的

具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。

所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。

2、哨兵模式

在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。

为了解决这个问题,Redis 增加了哨兵模式(Redis Sentinel),因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。

功能:

1、集群监控:负责监控 Redis master 和 slave 进程是否正常工作。

2、消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。

3、故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。

4、配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。

3、主从赋值核心原理

当启动一个 slave node 的时候,它会发送一个 PSYNC 命令给 master node。

如果这是 slave node 初次连接到 master node,那么会触发一次 full resynchronization 全量复制。此时 master 会启动一个后台线程,开始生成一份 RDB 快照文件,同时还会将从客户端 client 新收到的所有写命令缓存在内存中。 RDB 文件生成完毕后, master 会将这个 RDB 发送给 slaveslave 会先写入本地磁盘,然后再从本地磁盘加载到内存中,接着 master 会将内存中缓存的写命令发送到 slaveslave 也会同步这些数据。slave node 如果跟 master node 有网络故障,断开了连接,会自动重连,连接之后 master node 仅会复制给 slave 部分缺少的数据。

17、Redis 使用的过期删除策略是什么?

Redis可以对key设置过期时间,对应的是过期键值删除策略。每当对一个key设置了过期时间时,redis会把该key带上过期时间存储到一个过期字典中,如果读取的数据不在这个过期字典中,则正常读取键值;如果存在,则获取该key的过期时间,然后与当前系统时间进行对比,如果比系统时间大,就没有过期,否则判断该key已过期。

Redis 使用的过期删除策略是「惰性删除+定期删除」这两种策略配和使用。

18、什么是惰性删除策略?

惰性删除策略:不主动删除过期键,每次从数据库访问key时,都检测key是否过期,如果过期,才会删除该key。

惰性删除策略的优点

因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。

惰性删除策略的缺点

如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。

19、什么是定期删除策略?

定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

Redis 的定期删除的流程

1、从过期字典中随机抽取 20 个 key;

2、检查这 20 个 key 是否过期,并删除已过期的 key;

3、如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

定期删除是一个循环的流程。那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限默认不会超过 25ms

定期删除策略的优点

通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。

定期删除策略的缺点

难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。

惰性删除策略和定期删除策略都有各自的优点,所以 Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

20、Redis持久化时,对过期键值会如何处理?

RDB文件分为两个阶段:

RDB 文件生成阶段:从内存状态持久化成 RDB(文件)的时候,会 key 进行过期检查,过期的键「不会」被保存到新的 RDB 文件中,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。

RDB 加载阶段:RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况:

1、如果 Redis 是「主服务器」运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键「不会」被载入到数据库中。所以过期键不会对载入 RDB 文件的主服务器造成影响;

2、如果 Redis 是「从服务器」运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。

AOF 文件分为两个阶段,AOF 文件写入阶段和 AOF 重写阶段。

AOF 文件写入阶段:当 Redis 以 AOF 模式持久化时,如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值。

AOF 重写阶段:执行 AOF 重写时,会对 Redis 中的键值对进行检查,已过期的键不会被保存到重写后的 AOF 文件中,因此不会对 AOF 重写造成任何影响。

21、Redis主从模式中,对过期键会如何处理?

当 Redis 运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。

从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key

22、Redis内存满了,会发生什么?

在 Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是我们设置的最大运行内存,此值在 Redis 的配置文件中可以找到,配置项为 maxmemory。

23、redis内存淘汰策略有哪些?

Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。

1、不进行数据淘汰的策略

noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误

2、进行数据淘汰的策略

针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。

在设置了过期时间的数据中进行淘汰:

1、volatile-random:随机淘汰设置了过期时间的任意键值;

2、volatile-ttl:优先淘汰更早过期的键值。

3、volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;

4、volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;

在所有数据范围内进行淘汰:

1、allkeys-random:随机淘汰任意键值;

2、allkeys-lru:淘汰整个键值中最久未使用的键值;

3、allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

24、什么是LRU算法?

LRU 全称是 Least Recently Used 翻译为最近最少使用,会选择淘汰最近最少使用的数据

传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。

Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题

1、需要用链表管理所有的缓存数据,这会带来额外的空间开销;

2、当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。

优缺点

不用为所有的数据维护一个大链表,节省了空间占用;

不用在每次数据访问时都移动链表项,提升了缓存的性能;

但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

25、什么是LFU算法(Least Frequently Used)?

LFU 全称是 Least Frequently Used 翻译为最近最不常用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。

26、什么是缓存雪崩、缓存击穿、缓存穿透?怎么解决?

缓存雪崩:当大量缓存数据在同一时间过期(失效)时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。(简而言之:缓存中大量数据过期,同时面临大量业务请求这些数据,将直接去数据库(MySQL等)中请求,压力骤增,可能会导致宕机)。

解决方案:1、将缓存失效时间随机打散,比如在原有失效时间基础上增加一个随机值,这样每个缓存的过期时间不会过多重复,降低了缓存集体失效的概率。2、设置缓存不过期:通过后台服务来更新缓存数据。

缓存击穿:如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

解决方案:1、互斥锁方案,保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放完后重新读取缓存,要么就返回空值或者默认值。2、不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间。

缓存穿透:当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。(业务误操作,黑客恶意攻击)

解决方案:1、非法请求限制,当有大量恶意请求访问不存在的数据,直接返回错误;2、设置空值或者默认值,发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。3、使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。

27、如何设计一个缓存策略,可以动态缓存热点数据?

热点数据动态缓存的策略总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据。

以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。具体细节如下:

1、先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前;

2、同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中;

3、这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回。

在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。

28、如何保证缓存与数据库双写时的数据一致性?

最经典的缓存+数据库读写的模式,就是 预留缓存模式Cache Aside Pattern。

1、读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。

2、更新的时候,先删除缓存,然后再更新数据库,这样读的时候就会发现缓存中没有数据而直接去数据库中拿数据了。

29、redis如何实现延迟队列?

延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:

1、在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;

2、打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单;

3、点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单;

在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间

使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。

30、Redis的大key如何处理?

大 key 并不是指 key 的值很大,而是 key 对应的 value 很大

String 类型的值大于 10 KB;

Hash、List、Set、ZSet 类型的元素的个数超过 5000个;

大key会带来哪些影响?

1、客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。

2、引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。

3、阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。

4、内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。

如何查找大key?

redis-cli --bigkeys 查找大key

使用 SCAN 命令查找大 key

使用 RdbTools 工具查找大 key

如何删除大key?

  1. 分批次删除:1、对于删除大hash,使用hscan命令,每次获取100个字段,再用hdel命令,每次删除一个字段。2、对于大list,通过ltrim命令,每次删除少量元素。3、对于大set,使用sscan命令,每次扫描集合中100个元素,再用srem命令每次删除一个键。4、对于大zset,使用zremrangebyrank命令,每次删除top 100个元素。
  2. 异步删除:用unlink命令代替del命令。Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。

31、Redis管道有什么用?

管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。

使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。

但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。

要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。

十一、计算机网络

1、说说网络体系结构?

计算机网络体系结构主要有三类:OSI七层网络模型、TCP/IP四层模型和五层模型。其中OSI是理论上的网络模型,TCP/IP是实际上的网络模型,五层是一种这种的网络模型。

OSI七层网络模型是国际标准化组织指定的一个用于计算机或者通信系统互联的标准体系。

物理层:功能(利用传输介质为数据链路层提供物理连接,实现比特流的透明传输,模拟信号和数字信息之间的相互转换。)作用(实现相邻计算机节点之间比特流的透明传输,尽可能屏蔽掉具体传输介质的和物理设备的差异,使得数据链路层不必考虑网络的具体传输介质是什么)。

数据链路层:功能(在物理层提供的比特流的基础上,通过差错控制、流量控制方法,使有差错的物理线路变为无差错的数据链路,即提供可靠的通过物理介质传输数据的方法。)

网络层:

2、说说各个网络层级对应的网络协议有哪些?

3、数据在各个层级中是怎么传输的?

对于发送方而言,从上层到下层层层包装,对于接收方而言,从下层到上层,层层解开包装。

4、从浏览器地址栏输入url到显示主页的过程是什么?

1、解析url,生成对应的http请求信息。

2、首先根据输入的域名地址进行域名解析,找到对应的ip地址。(浏览器缓存、操作系统缓存、hosts文件、本地域名服务器[联通移动提供]、根域名服务器、顶级域名服务器、授权域名服务器)。

3、通过TCP,与服务器三次握手,建立tcp连接。

4、向服务器发送请求数据;服务器解析并返回响应数据。

5、浏览器渲染解析响应数据并渲染页面。

5、四次挥手,断开tcp连接。

5、说说DNS的解析过程?

DNS,英文全称是 domain name system,域名解析系统,它的作用也很明确,就是域名和 IP 相互映射

搜索路径主要是:浏览器缓存 à 操作系统缓存 à hosts文件 à 本地域名服务器 à 根域名服务器 à 顶级域名服务器  à 授权域名服务器。

6、说说WebSocket和Socket的区别?

Socket是TCP/IP网络的API,是为了方便使用TCP或UDP而抽象出来的一层,是位于应用层和传输控制层之间的一组接口,为了方便开发者更好的网络编程;而WebSocket则是一个典型的应用层协议。

WebSocket 是 HTML5 一种新的协议。它实现了浏览器与服务器全双工通信,能更好的节省服务器资源和带宽并达到实时通讯,它建立在 TCP 之上,同 HTTP 一样通过 TCP 来传输数据。主要是用来解决http不支持持久化连接的问题。

7、说说你了解的端口及其服务?

8、说说HTTP常用的状态码及其含义?

HTTP状态码首先应该知道个大概的分类:

1XX:信息性状态码,提示目前是协议处理的中间状态还需要后续操作

2XX:成功状态码

3XX:重定向状态码

4XX:客户端错误状态码

5XX:服务端错误状态码

「200 OK」是最常见的成功状态码,表示一切正常。

「301 Moved Permanently」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。

「302 Found」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。

「304 Not Modified」不具有跳转的含义,表示资源未修改,重定向已存在的缓冲文件,也称缓存重定向,也就是告诉客户端可以继续使用缓存资源,用于缓存控制。

「400 Bad Request」表示客户端请求的报文有错误,但只是个笼统的错误。

「403 Forbidden」表示服务器禁止访问资源,并不是客户端的请求出错。

「404 Not Found」表示请求的资源在服务器上不存在或未找到,所以无法提供给客户端。

「500 Internal Server Error」与 400 类型,是个笼统通用的错误码,服务器发生了什么错误,我们并不知道。

「501 Not Implemented」表示客户端请求的功能还不支持,类似“即将开业,敬请期待”的意思。

「502 Bad Gateway」通常是服务器作为网关或代理时返回的错误码,表示服务器自身工作正常,访问后端服务器发生了错误。

「503 Service Unavailable」表示服务器当前很忙,暂时无法响应客户端,类似“网络服务正忙,请稍后重试”的意思。

301和302的区别:

301:永久性移动,请求的资源已被永久移动到新位置。服务器返回此响应时,会返回新的资源地址。

302:临时性性移动,服务器从另外的地址响应资源,但是客户端还应该使用这个地址。

9、HTTP有哪些请求方法?

10、GET和POST的区别?

1、从传参角度:GET是将信息放在url中,在地址栏就可以看到;POST请求是将信息放在请求体中。这一带你使得GET请求传递的参数是有限的,因为url本身有长度限制。

2、GET请求是幂等和安全的,而POST请求不是幂等和安全的。幂等性是指一次或者多次操作都是一样的结果。安全是指请求之后不会修改服务器中的数据。

3、GET请求的url会原原本本的缓存在浏览器的历史记录中。而POST请求则不会被缓存。

限制url长度的是浏览器,url本身并没有长度限制。firefox浏览器支持65536个字符。chrome浏览器支持8182个字符。

11、说说http的报文格式

HTTP报文有两种,HTTP请求报文和HTTP响应报文:

HTTP请求由请求行、请求头部、空行和请求体四个部分组成。

请求行:包括请求方法,访问的资源URL,使用的HTTP版本。 GET 和 POST 是最常见的HTTP方法,除此以外还包括 DELETE、HEAD、OPTIONS、PUT、TRACE 。

请求头:格式为“属性名:属性值”,服务端根据请求头获取客户端的信息,主要有 cookie、host、connection、accept-language、accept-encoding、user-agent 。

请求体:用户的请求数据如用户名,密码等。

HTTP响应也由四个部分组成,分别是:状态行、响应头、空行和响应体。

状态行:协议版本,状态码及状态描述。

响应头:响应头字段主要有 connection、content-type、content-encoding、contentlength、set-cookie、Last-Modified,、Cache-Control、Expires 。

响应体:服务器返回给客户端的内容。

12、URI和URL的区别?

URI,统一资源标识符(Uniform Resource Identifier, URI),标识的是Web上每一种可用的资源,如 HTML文档、图像、视频片段、程序等都是由一个URI进行标识的。

URL,统一资源定位符(Uniform Resource Location),它是URI的一种子集,主要作用是提供资源的路径。

它们的主要区别在于,URL除了提供了资源的标识,还提供了资源访问的方式。这么比喻,URI 像是身份证,可以唯一标识一个人,而 URL 更像一个住址,可以通过 URL 找到这个人-à人类住址协议://地球/中国/北京市/海淀区/xx职业技术学院/14号宿舍楼/525号寝/张三.男。

13、说说HTTP/1.0,1.1和2.0的区别?

关键:HTTP/1.0 默认是短连接,可以强制开启,HTTP/1.1 默认长连接,HTTP/2.0 采用多路复用。

HTTP/1.0

默认使用短连接,每次请求都需要建立一个 TCP 连接。它可以设置Connection: keep-alive 这个字段,强制开启长连接。

HTTP/1.1:

引入了长连接连接,即 TCP 连接默认不关闭,可以被多个请求复用。

管道机制,即在同一个 TCP 连接里面,客户端可以同时发送多个请求,可以减少整体的响应时间。

十二、Qt

1、Qt信号和槽的本质是什么?

信号槽,类似观察者模式(回调函数)。当某个事件发生之后,比如,按钮检测到自己被点击了一下,它就会发出一个信号(signal)。这种发出是没有目的的,类似广播。如果有对象对这个信号感兴趣,它就会使用连接(connect)函数,意思是,将想要处理的某个对象的信号和自己的一个函数(称为槽(slot))绑定来处理这个信号。也就是说,当信号发出时,被连接的槽函数会自动被回调。信号和槽是Qt特有的信息传输机制,是Qt设计程序的重要基础,它可以让互不干扰的对象建立一种联系。

槽的本质是类的成员函数,其参数可以是任意类型的。和普通C++成员函数几乎没有区别,它可以是虚函数;也可以被重载;可以是公有的、保护的、私有的、也可以被其他C++成员函数调用。唯一区别的是:槽可以与信号连接在一起,每当和槽连接的信号被发射的时候,就会调用这个槽。

2、信号槽机制有什么优势和不足? ************

优势:

类型安全。需要关联的信号槽的签名必须是等同的。即信号的参数类型和参数个数同接受该信号的槽的参数类型和参数个数相同。若信号和槽签名不一致,编译器会报错。

松散耦合。信号和槽机制减弱了Qt对象的耦合度。激发信号的Qt对象无需知道是那个对象的那个信号槽接收它发出的信号,它只需在适当的时间发送适当的信号即可,而不需要关心是否被接受和那个对象接受了。Qt就保证了适当的槽得到了调用,即使关联的对象在运行时被删除。程序也不会奔溃。

灵活性。一个信号可以关联多个槽,或多个信号关联同一个槽。

不足之处:速度较慢。与回调函数相比,信号和槽机制运行速度比直接调用非虚函数慢10倍。

原因:①需要定位接收信号的对象。②安全地遍历所有关联槽。③编组、解组传递参数。④多线程的时候,信号需要排队等待。然而与创建对象的new操作及删除对象的delete操作相比,信号和槽的运行代价只是他们很少的一部分。

3、多线程下,信号和槽分别在什么线程中执行,如何控制? *****

可以通过connect的第五个参数进行控制信号槽执行时所在的线程。

connect有几种连接方式,直接连接和队列连接、自动连接

直接连接(Qt::DirectConnection):信号槽在信号发出者所在的线程中执行。

队列连接 (Qt::QueuedConnection):信号在信号发出者所在的线程中执行,槽函数在信号接收者所在的线程中执行。

自动连接  (Qt::AutoConnection):多线程时为队列连接函数,单线程时为直接连接函数。

4、描述QT中的文件流(QTextStream)和数据流(QDataStream)的区别? ****

文件流(QTextStream)。操作轻量级数据(int,double,QString)数据写入文本件中以后以文本的方式呈现。

数据流(QDataStream)。通过数据流可以操作各种数据类型,包括对象,存储到文件中数据为二进制。

文件流,数据流都可以操作磁盘文件,也可以操作内存数据。通过流对象可以将对象打包到内存,进行数据的传输。

5、描述Qt的TCP通讯流程?      ****

服务端(QTcpServer):

  1. 创建QTcpServer对象
  2. 监听listen需要的参数是地址和端口号
  3. 当有新的客户端连接成功会发送newConnect信号
  4. 在newConnection信号槽函数中,调用nextPendingConnection函数获取新连接QTcpSocket对象
  5. 连接QTcpSocket对象的readyRead信号
  6. 在readyRead信号的槽函数中使用read接收数据
  7. 调用write成员函数发送数据。

客户端(QTcpSocket)

  ①创建QTcpSocket对象

  ②当对象与Server连接成功时会发送connected 信号

③调用成员函数connectToHost连接服务器,需要的参数是服务端地址和端口号

④connected信号的槽函数开启发送数据

⑤使用write发送数据,read接收数据

6、描述UDP之UdpSocket通讯?   ******

UDP(User Datagram Protocol即用户数据报协议)是一个轻量级的,不可靠的,面向数据报的无连接协议。在网络质量令人十分不满意的环境下,UDP协议数据包丢失严重。由于UDP的特性:它不属于连接型协议,因而具有资源消耗小,处理速度快的优点,所以通常音频、视频和普通数据在传送时使用UDP较多,因为它们即使偶尔丢失一两个数据包,也不会对接收结果产生太大影响。所以QQ这种对保密要求并不太高的聊天程序就是使用的UDP协议。

①创建QUdpSocket套接字对象

②如果需要接收数据,必须绑定端口 (发送者需要确定接收者的端口号和IP)

③发送数据用writeDatagram,接收数据用 readDatagram 。

7、多线程使用方法

在进行桌面应用程序开发的时候, 假设应用程序在某些情况下需要处理比较复杂的逻辑, 如果只有一个线程去处理,就会导致窗口卡顿,无法处理用户的相关操作。这种情况下就需要使用多线程,其中一个线程处理窗口事件,其他线程进行逻辑运算,多个线程各司其职,不仅可以提高用户体验还可以提升程序的执行效率。

使用方式一:①创建一个类从QThread类派生类②在子线程类中重写 run 函数, 将处理操作写入该函数中 ③在主线程中创建子线程对象, 启动子线程, 调用start()函数。

使用方式二:

    •  创建一个新的类,可以理解为业务类,这个类从QObject中派生;
    • 在这个类中添加一个公共的成员函数,函数体就是要让子线程去执行的业务逻辑;

    • 在主线程创建一个QThread对象,这就是子线程对象;

    • 在主线程中创建工作的类对象(千万不要指定给创建的对象指定父对象)

    • 将MyWork对象移动到创建的子线程对象中, 需要调用QObject类提供的moveToThread()方法

    • 启动子线程,调用 start(), 这时候线程启动了, 但是移动到线程中的对象并没有工作;
    • 调用MyWork类对象的工作函数,让这个函数开始执行,这时候是在移动到的那个子线程中运行的。

第二种创建方式的优点:假设有多个不相关的业务流程需要被处理,那么就可以创建多个类似于MyWork的类,将业务流程在这多个业务类的公共成员函数中,然后将这个业务类的实例对象移动到对应的子线程中moveToThread()就可以了,这样可以让编写的程序更加灵活,可读性更强,更易于维护。

8、C++协程?

线程:内核态线程、用户态线程。

协程本质是:处理自身挂起和恢复的用户态线程。协程切换要比线程切换速度更快,适合I0密集型任务。

协程分类:有栈协程(改变函数调用栈)和无栈协程(基于状态机或闭包)。

9、说说读写锁、互斥锁、自旋锁?

1、互斥锁。互斥锁在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量进行解锁。对互斥量加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞,直至当前线程释放该互斥量。

2、自旋锁。自旋锁与互斥量类似,但它不使线程进入阻塞态,而是在获取锁之前一直占用CPU,处于忙等自旋状态。自旋锁适用于锁被持有的时间短且线程不希望在重新调度上花费太多成本的情况。

3、读写锁。读写锁有三种状态:读模式加锁、写模式加锁和不加锁,一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。读写锁非常适合对数据结构读的次数远大于写的情况。

10、Qt的智能指针?

在Qt框架中,有两种主要类型的智能指针:QSharedPointer 和 QScopedPointer。

QSharedPointer 是Qt的共享指针,它基于引用计数来管理动态分配的对象。多个 QSharedPointer 实例可以共享同一个对象,并在没有引用时自动释放内存。通过与QWeakPointer弱指针配合使用,可以解决循环引用的问题。

QScopedPointer 是Qt的本地指针,它用于管理动态分配的对象的生命周期,但不支持共享。

11、Qt连接数据库及其步骤?

在Qt中连接MySQL数据库的原理和机制涉及使用Qt的数据库模块,其中包括了Qt的数据库驱动和Qt的SQL类。

1、包含必要的头文件和库 <QtSql>,配置Qt的项目配置文件Qt +=sql

2、建立数据库连接,设置必要的用户名和密码,以及需要连接的数据库名。

3、执行sql语句

4、关闭连接。

12、数组和链表的区别?

1、内存分配方式不同:数组在内存中分配在一块连续的内存空间中,数组元素按照索引顺序一次存储在这块内存中,支持随机存取。链表的元素在内存不一定按照连续的内存地址存储,每个节点都包含了数据和指向下一个节点的指针。不支持随机存取。

2、大小可变性:数组是大小固定的,一旦分配难以改变。链表没有大小限制,只需要将新的节点链接到链表中即可。

3、插入和删除操作:在数组中插入和删除操作可能会涉及到数组元素后移前移,效率较低。链表的插入和删除操作简单,只需要修改链表指向即可。

4、内存开销:数组通常会分配一块固定大小的内存,无论数据量多少,都会占用这个内存空间。链表内存空间更加灵活,仅分配所需的节点空间。

5、使用场景:数组:适用于需要快速随机访问元素、元素数量固定或者很少变化的情况。链表:适用于需要频繁插入和删除元素、元素数量动态变化的情况,以及内存有限或需要动态分配内存的情况。

13、栈和队列的区别?

栈是一种先进后出的数据结构,队列是一种先进先出的数据结构。

栈是在栈顶删除元素,队列是在队头 删除元素。

栈:适用于需要按照先进后出的顺序处理数据的场景,如函数调用的执行过程(函数调用栈)或回退操作的实现。队列:适用于需要按照先进先出的顺序处理数据的场景,如任务队列、缓冲区管理、广度优先搜索等。

14、自定义控件流程?

1、继承需要自定义的控件类,如QPushButton.

2、外观设计上,设计Qss,继承绘制函数实现重绘,继承QStyle相关类重绘、组合拼装等。

3、功能行为上:添加或者修改信号槽等等。

15、Qt的优势?

1、优良的跨平台特性。

2、完全面向对象

3、丰富的API                    

4、跨平台集成开发环境(QT Creator

16、Qt的MVD了解吗?

Qt的MVD包含三个部分Model(模型),View(视图),代理(Delegate)。Model否则保存数据View负责展示数据,Delegate负责ltem样式绘制或处理输入。这三部分通过信号槽来进行通信,当Model中数据发生变化时将会发送信号到View,在View中编辑数据时,Delegate负责将编辑状态发送给Model层。基类分别为QAbstractitemModel、QAbstractitemView、QAbstractitemDelegate。Qt中提供了默认实现的MVD类,如QTableWidget、QListWidget、QTreeWidget等。

17、Qt对象树?

QT提供了对象树机制,能够自动、有效的组织和管理继承自QObject的对象

每个继承自QObject类的对象通过它的对象链表(QObjectList)来管理子类对象,当用户创建一个子对象时,其对象链表相应更新子类对象的信息,对象链表可通过children0获取。当父类对象析构的时候,其对象链表中的所有(子类)对象也会被析构,父对象会自动,将其从父对象列表中删除,QT保证没有对象会被delete两次。开发中手动回收资源时建议使用deleteLater代替delete,因为deleteLater多次是安全的。

18、Qt三大核心机制?

1、信号槽。实现对象之间的通信。

2、元对象系统。元对象系统分为三大类:QObject类、Q_OBJECT宏和元对象编译器moc。Qt的类包含Q_OBJECT宏,moc元对象编译器(实际是一个预处理器)会对该类编译成标准的C++代码。

3、事件模型。Qt的事件主要有鼠标事件、键盘事件、窗口调整事件等。Qt通过调用虚函数QObject::event()来交付事件。主事件循环通过调用QCoreApplication::exec()启动。一般来说,事件是由触发当前的窗口系统产生的,但是也可以通过QCoreApplication::sendEvent()函数和postEvent()来手动产生事件。sendEvent会立即发送事件,postEvent则会将事件放在事件队列中分发。

19、对QObject的理解?

1、QObject类是Qt所有类的基类。

2、QObject是Qt对象模型的核心。这个模型的中心要素就是一种强大的叫做信号与槽无缝对象沟通机制。可以用connect()函数来把一个信号连接到槽,也可以用disconnect()函数来破坏这个连接。为了免永无止境的通知循环,你可以用blocksignal0 函数来暂时阻塞信号。保护函数connectNotify()和disconnectNotify()可以用来跟踪连接。

3、对象树都是通过QObject 组织起来的,当以一个对象作为父类创建一个新的对象时,这个新对象会被动加入到父类的 children()队列中。这个父类有子类的所有权。能够在父类的析构函数中自动删除子类。可以通过findChild()和findChildren()函数来寻找子类。

4、每个对象都一个对象名称objectName(),而且它的类名也可以通过metaObject()函数获取。你可以通过inherits()函数来决定一个类是否继承其他的类。当一个对象被删除时,它会发射destory() 信号,你可以抓住这个信号避免某些事情。

5、对象可以通过event()函数来接收事情以及过减来自其他对象的事件。就好比installEventFiter() 函数和eventFilter()函数。childEvent()函数能够重载实现子对象的事件。

十三、设计模式

1、什么是设计模式?六大原则是什么?

设计模式,是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性、程序的重用性。

六大原则:开放封闭原则(尽量通过扩展软件实体来解决需求变化,而不是通过修改已有代码来完成变化);里氏代换原则();依赖倒转原则(对接口编程,在程序代码中传递参数时或者在关联关系中,尽量引用层次高的抽象类);接口隔离原则(使用多个隔离的接口比使用单一接口更好);最少知道原则单一职责原则(一个方法只负责一件事情,降低耦合)。

2、什么是单例模式?在哪些场景中有所使用?

保证在整个应用程序运行过程中,只有一个实体对象。

1、网站的计数器,一般也是采用单例模式实现,否则难以同步。

2、应用程序的日志应用,一般都是单例模式实现,只有一个实例去操作才好,否则内容不好追加显示。

3、多线程的线程池的设计一般也是采用单例模式,因为线程池要方便对池中的线程进行控制

4、Windows的(任务管理器)就是很典型的单例模式,他不能打开俩个

5、windows的(回收站)也是典型的单例应用。在整个系统运行过程中,回收站只维护一个实例。

3、单例模式优缺点?

优点:

在单例模式中,活动的单例只有一个实例,对单例类的所有实例化得到的都是相同的一个实例。这样就 防止其它对象对自己的实例化,确保所有的对象都访问一个实例

单例模式具有一定的伸缩性,类自己来控制实例化进程,类就在改变实例化进程上有相应的伸缩性。

提供了对唯一实例的受控访问。

由于在系统内存中只存在一个对象,因此可以 节约系统资源,当 需要频繁创建和销毁的对象时单例模式无疑可以提高系统的性能。

允许可变数目的实例。

避免对共享资源的多重占用。

缺点:

不适用于变化的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。

由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。

单例类的职责过重,在一定程度上违背了“单一职责原则”。

滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。

  • 27
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
文件内包含 Apache C++ 、Standard Library、ASL、Boost、BDE、Cinder、Cxxomfort:轻量级的,只包含头文件的库,将C++ 11的一些新特性移植到C++03中。 Dlib:使用契约式编程和现代C++科技设计的通用的跨平台的C++库。 EASTL :EA-STL公共部分。 ffead-cpp :企业应用程序开发框架。 Folly:由Facebook开发和使用的开源C++库。 JUCE :包罗万象的C++类库,用于开发跨平台软件。 libPhenom:用于构建高性能和高度可扩展性系统的事件框架。 LibSourcey :用于实时的视频流和高性能网络应用程序的C++11 evented IO。 LibU : C语言写的多平台工具库。 Loki :C++库的设计,包括常见的设计模式和习语的实现。 MiLi :只含头文件的小型C++库。 openFrameworks :开发C++工具包,用于创意性编码。 Qt :跨平台的应用程序和用户界面框架。 Reason :跨平台的框架,使开发者能够更容易地使用Java,.Net和Python,同时也满足了他们对C++性能和优势的需求。 ROOT :具备所有功能的一系列面向对象的框架,能够非常高效地处理和分析大量的数据,为欧洲原子能研究机构所用。 STLport:是STL具有代表性的版本。 STXXL:用于额外的大型数据集的标准模板库。 Ultimate++ :C++跨平台快速应用程序开发框架。 Windows Template Library:用于开发Windows应用程序和UI组件的C++库。 Yomm11 :C++11的开放multi-methods。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

VVPU

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值