面试八股知识总结

问题

序列化

  • 序列化:将数据结构或对象转换成二进制字节流的过程,协议可以理解两个节点之间为了协同工作实现信息交换,协商一定的规则和约定。协议有流程规范和编码规范。编码规范就是我们通常所说的编解码,序列化。我们说的编码格式就是一种协议,比如JSON协议,XML协议。
  • 反序列化:将在序列化过程中所生成的二进制字节流的过程转换成数据结构或者对象的过程

变量的声明和定义

  • 变量定义:用于为变量分配存储空间,还可为变量指定初始值。程序中,变量有且仅有一个定义。
  • 变量声明:变量的声明不分配地址。用于向程序表明变量的类型和名字。
  • 注意
    • 变量在使用前就要被定义或者声明。
    • 在一个程序中,变量只能定义一次,却可以声明多次。
    • 定义分配存储空间,而声明不会。
  • 定义也是声明,extern声明不是定义
  • extern声明不是定义:通过使用extern关键字声明变量名而不定义它。
  • 除非有extern关键字,否则都是变量的定义。
extern int i; //声明
int i; //定义       

C语言宏中“#”和“##”区别

  • # 字符串化操作符
#define example( instr ) printf( "the input string is:\t%s\n", #instr )
#define example1( instr )  #instr 
example( abc );//在编译时会展开成:printf("the input string is:\t%s\n","abc")
string str = example( abc );//会展开成:string str="abc"
  • ## 字符连接操作符
#define exampleNum( n ) num##n//##前后空格可有可无
int num9 = 9;
int num = exampleNum( 9 );//会展开成:int num = num9

C++中extern “C” 的作用

  • extern "C"是C++特有的指令(C无法使用该指令),目的在于支持C++与C混合编程。

了解C++中编译时的优化

编译原理 :C++开发时,编译的过程主要包含下面四个步骤:在这里插入图片描述
预处理器: 宏定义替换,头文件展开,条件编译展开,删除注释。

  • gcc -E选项可以得到预处理后的结果,扩展名为.i 或 .ii。

  • C/C++预处理不做任何语法检查,不仅是因为它不具备语法检查功能,也因为预处理命令不属于C/C++语句(这也是定义宏时不要加分号的原因),语法检查是编译器要做的事情。

  • 预处理之后,得到的仅仅是真正的源代码。

编译器: 生成汇编代码,得到汇编语言程序(把高级语言翻译为机器语言),该种语言程序中的每条语句都以一种标准的文本格式确切的描述了一条低级机器语言指令。

  • gcc -S选项可以得到编译后的汇编代码文件,扩展名为.s。

  • 汇编语言为不同高级语言的不同编译器提供了通用的输出语言。

汇编器: 生成目标文件。

  • gcc -c选项可以得到汇编后的结果文件,扩展名为.o。

  • .o文件,是按照的二进制编码方式生成的文件。

链接器: 生成可执行文件或库文件。

  • 静态库:指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件了,其后缀名一般为“.a”。

  • 动态库:在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可执行文件比较小,动态库一般后缀名为“.so”。

  • 可执行文件:将所有的二进制文件链接起来融合成一个可执行程序,不管这些文件是目标二进制文件还是库二进制文件。

编译优化

GCC提供了为了满足用户不同程度的的优化需要,提供了近百种优化选项,用来对编译时间,目标文件长度,执行效率这个三维模型进行不同的取舍和平衡。优化的方法不一而足,总体上将有以下几类:

  • ① 精简操作指令。
  • ② 尽量满足CPU的流水操作。
  • ③ 通过对程序行为地猜测,重新调整代码的执行顺序。
  • ④ 充分使用寄存器。
  • ⑤ 对简单的调用进行展开等等。

如果全部了解这些编译选项,对代码针对性的优化还是一项复杂的工作,幸运的是GCC提供了从O0-O3以及Os这几种不同的优化级别供大家选择,在这些选项中,包含了大部分有效的编译优化选项,并且可以在这个基础上,对某些选项进行屏蔽或添加,从而大大降低了使用的难度。

  • O0:不做任何优化,这是默认的编译选项。

  • O和O1:对程序做部分编译优化,编译器会尝试减小生成代码的尺寸,以及缩短执行时间,但并不执行需要占用大量编译时间的优化。

  • O2:是比O1更高级的选项,进行更多的优化。GCC将执行几乎所有的不包含时间和空间折中的优化。当设置O2选项时,编译器并不进行循环展开以及函数内联优化。与O1比较而言,O2优化增加了编译时间的基础上,提高了生成代码的执行效率。

  • O3:在O2的基础上进行更多的优化,例如使用伪寄存器网络,普通函数的内联,以及针对循环的更多优化。

  • Os:主要是对代码大小的优化, 通常各种优化都会打乱程序的结构,让调试工作变得无从着手。并且会打乱执行顺序,依赖内存操作顺序的程序需要做相关处理才能确保程序的正确性。

编译优化有可能带来的问题:

  • ① 调试问题:正如上面所提到的,任何级别的优化都将带来代码结构的改变。例如:对分支的合并和消除,对公用子表达式的消除,对循环内load/store操作的替换和更改等,都将会使目标代码的执行顺序变得面目全非,导致调试信息严重不足。

  • ② 内存操作顺序改变问题:在O2优化后,编译器会对影响内存操作的执行顺序。例如:-fschedule-insns允许数据处理时先完成其他的指令;-fforce-mem有可能导致内存与寄存器之间的数据产生类似脏数据的不一致等。对于某些依赖内存操作顺序而进行的逻辑,需要做严格的处理后才能进行优化。例如,采用Volatile关键字限制变量的操作方式,或者利用Barrier迫使CPU严格按照指令序执行。

C++的特点是什么

  • 保留了C语言原有的优点,与C语言兼容;增加了面向对象的机制“类class”,类(calss)为C++重要的数据类型,可以实现面向对象时进行封装、信息隐蔽、继承、派生、多态等;使用C++命名空间std;C++新标准中,使用不带后缀“.h”的头文件,但兼容C语言的头文件;

C++的异常处理机制

  • 传统错误处理机制:通过函数返回值来处理错误。
  • C++的异常处理机制使得异常的引发和异常的处理不必在同一个函数中,这样底层的函数可以着重解决具体问题,而不必过多的考虑异常的处理。上层调用者可以再适当的位置设计对不同类型异常的处理。

C和C++,java的区别

  • C语言面向过程, C++语言是面向对象加过程语言, java是纯面向对象的,没有指针的概念
  • C是面向过程的语言,C++是面向对象的语言,C++中有引用的概念,C++引入了类的概念,C++有函数重载,而C没有。
  • C变量只能在函数的开头处声明和定义,而C++随时定义随时使用。

C++ 11 nullptr 和 NULL

NULL: 预处理变量,是一个宏,它的值是0,定义在头文件中,即#define NULL 0。
nullptr: C++ 11 中的关键字,是一种特殊类型的字面值,可以被转换成任意其他类型。

#ifdef、#else、#endif和#ifndef的作用

  • 利用#ifdef、#endif将某程序功能模块包括进去,以向特定用户提供该功能。在不需要时用户可轻易将其屏蔽。

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

  • 在 C 中 static 用来修饰局部静态变量和外部静态变量、函数。而 C++中除了上述功能外,还用来定义类的成员变量和函数。即静态成员和静态成员函数。
  • 注意: 编程时 static 的记忆性,和全局性的特点可以让在不同时期调用的函数进行通信,传递信息,而 C++的静态成员则可以在多个对象实例间进行通信,传递信息。

在函数体内:只会被初始化一次,一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
在模块内(但在函数体外):一个被声明为静态的变量可以被模块内所用函数访问,但不能被模块外其它函数访问。它是一个本地的全局变量(只能被当前文件使用)。
在模块内:一个被声明为静态的函数只可被这一模块内的其它函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用(只能被当前文件使用)。

C 语言中struct 和union有什么区别(结构体与共联体)

**struct(结构体)union(联合体)**是C语言中两种不同的数据结构,两者都是常见的复合结构,其区别主要表现在以下两个方面。

  • 1.结构体与联合体虽然都是由多个不同的数据类型成员组成的,但不同之处在于联合体中所有成员共用一块地址空间,即联合体只存放了一个被选中的成员,而结构体中所有成员占用空间是累加的,其所有成员都存在,不同成员会存放在不同的地址。在计算一个结构型变量的总长度时,其内存空间大小等于所有成员长度之和(需要考虑字节对齐),而在联合体中,所有成员不能同时占用内存空间,它们不能同时存在,所以一个联合型变量的长度等于其最长的成员的长度
  • ⒉对于联合体的不同成员赋值,将会对它的其他成员重写,原来成员的值就不存在了,而对结构体的不同成员赋值是互不影响的。

如何判断大小端

  • 利用联合体数据存储的特点:
    • 联合体里面的数据会共用同一块内存,改变其中一个数据的值,其他数据的值可能也会随之变化

C++中struct和class的区别

  • 在C++中,可以用struct和class定义类,都可以继承。区别在于: structural的默认继承权限和默认访问权限是public,而class的默认继承权限和默认访问权限是private。另外,class还可以定义模板类形参,比如template。

typedef和define区别

  • define

    • #define是C语言中定义的语法,它是预处理指令,在预处理时进行简单而机械的字符串替换,不做正确性检查,不管含义是否正确照样代入,只有在编译已被展开的源程序时,才会发现可能的错误并报错。
    • #define不只是可以为类型取别名,还可以定义常量、变量、编译开关等。
  • typedef

    • typedef是关键字,它在编译时处理,所以typedef具有类型检查的功能。它在自己的作用域内给一个已经存在的类型一个别名,但是不能在一个函数定义里面使用标识符typedef。例如,typedef int工NTEGER,这以后就可用INTEGER来代替int作整型变量的类型说明了,例如︰INTEGER a,b;
    • typedef用来定义类型的别名,这些类型不仅包含内部类型(int、char等),还包括自定义类型(如struct),可以起到使类型易于记忆的功能。

c++和c中字符串区别

  • C语言中没有字符串这个数据类型,而是用了字符数组,也就是它是char 型的数组。null字符结尾的字符数组才是C字符串,否则只是一般的C字符数组。
  • C++中有字符串数据类型string,可以直接声明变量并进行赋值等字符串操作

C++里面inline有什么用

  • inline是C++关键字,在函数声明或定义中,函数返回类型前加上关键字inline,即可以把函数指定为内联函数。
  • 关键字 inline 必须与函数定义体放在一起才能使函数成为内联,仅将 inline 放在函数声明前面不起任何作用。
  • 为了消除函数调用的时空开销,C++ 提供一种提高效率的方法,即在编译时将函数调用处用函数体替换,类似于C语言中的宏展开。这种在函数调用处直接嵌入函数体的函数称为内联函数(Inline Function),又称内嵌函数或者内置函数。

Implicit,explicit关键字

explicit关键字只能用于修饰只有一个参数的类构造函数,表明该构造函数是显示的, implicit, 意思是隐藏的, 类构造函数默认情况下即声明为implicit(隐式).隐式转换可以通过消除不必要的类型转换来提高源代码的可读性

mutable关键字

在 C++ 中,mutable 是为了突破 const 的限制而设置的。被 mutable 修饰的变量,将永远处于可变的状态,即使在一个 const 函数中,甚至结构体变量或者类对象为 const,其 mutable 成员也可以被修改。
const 关键字,用于常成员函数,即“不允许在常成员函数内部修改对象状态的值。”
mutable 关键字,用于常成员函数,即“允许修改常成员函数内部不是对象状态的值。”

volatile关键字

保证内存可见性: volatile可见性是通过汇编加上Lock前缀指令,触发底层的MESI缓存一致性协议来实现的,保证变量对其他线程的可见性。
保证内存有序性: 禁止指令重排序,指令的执行顺序并不一定会像我们编写的顺序那样执行,为了保证执行上的效率,JVM(包括CPU)可能会对指令进行重排序。 volatile有序性是通过内存屏障实现的。JVM和CPU都会对指令做重排优化,所以在指令间插入一个屏障点,就告诉JVM和CPU,不能进行重排优化。
不保证原子性: 尽管volatile关键字可以保证内存可见性和有序性,但不能保证原子性。也就是说,对volatile修饰的变量进行的操作,不保证多线程安全。

实际使用过程中:

  • 并行设备的硬件寄存器:存储器映射的硬件寄存器通常加volatile,因为寄存器随时可以被外设硬件修改。当声明指向设备寄存器的指针时一定要用volatile,它会告诉编译器不要对存储在这个地址的数据进行假设。
  • 一个中断服务程序中修改的供其他程序检测的变量:volatile提醒编译器,它后面所定义的变量随时都有可能改变。因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
  • 多线程应用中被几个任务共享的变量:单地说就是防止编译器对代码进行优化

const在*的左右侧区别( const与指针)

去掉数据类型,以此判断const修饰的是哪部分

  • 1.只有一个cosnt时,如果const位于*的左侧:表示指针所指的数据常量,不能通过该指针修改实际数据;指针本身是变量,可以指向其他内存单元。

  • 2.只有一个const时,如果const位于*右侧:表示指针本身时常量,不能指向其他内存单元;所指向的数据可以修改

  • 3.如果有两个const位于*的左右两侧,表示指针和指针所指向的数据都不能修改

类的大小,空类

与类大小有关的因素:普通成员变量,虚函数,继承(单一继承,多重继承,重复继承,虚拟继承)

与类大小无关的因素:静态成员变量,静态成员函数及普通成员函数

空类即什么都没有的类,按上面的说法,照理说大小应该是0,但是,空类的大小为1,因为空类可以实例化,实例化必然在内存中占有一个位置,因此,编译器为其优化为一个字节大小。

空类中真的什么都没有吗??任何一个类中都有六个默认的成员函数,空类也不例外

  • 六大默认成员函数分别是:
    • 构造函数
    • 拷贝构造函数
    • 析构函数
    • 赋值运算符重载函数
    • 取地址操作符重载函数
    • 被const修饰的取地址操作符重载函数

void*、多重指针及其大小

所有类型指针大小都为4
用void* 定义一个void类型的指针,它不指向任何类型的数据,,它属于一种未确定类型的过渡型数据,因此如果要访问实际存在的数据,必须将void指针强转成为指定一个确定的数据类型的数据,如int、string等。void指针只支持几种有限的操作:与另一个指针进行比较;向函数传递void指针或从函数返回void指针;给另一个void指针赋值。:void只提供一个地址,没有指向。
**相当于指向指针的指针,本质还是一个指针,只是它指向的是一个地址。&很简单,即同级运算,从右到左,先进行地址&运算再进行指针运算。

void 和 void *

  • void 为不确定类型,不可以用来声明变量
  • void* 是步长未定的指针类型。一般用在函数参数、函数返回值要兼容不同指针类型的地方
  • void* 可以接受任何类型指针的赋值
  • void* 赋值给任何类型的变量,但需要强制转换结果才有意义

简述类成员函数的重写、重载和隐藏的区别

重载的定义为:在同一作用域中,同名函数的形式参数(参数个数、类型或者顺序)不同时,构成函数重载。
隐藏定义:指不同作用域中定义的同名函数构成隐藏(不要求函数返回值和函数参数类型相同)。比如派生类成员函数隐藏与其同名的基类成员函数、类成员函数隐藏全局外部函数。
重写/覆盖 override 的定义:派生类中与基类同返回值类型、同名和同参数的虚函数重定义,构成虚函数覆盖,也叫虚函数重写。
重写与重载主要有以下不同:
(1)范围的区别:被重写的和重写的函数在两个类中,而重载和被重载的函数在同一个类中;
(2)参数的区别:被重写函数和重写函数的参数列表一定相同,而被重载函数和重载函数的参数列表一定不同;
(3)virtual的区别:重写的基类中被重写的函数必须要有virtual修饰,而重载函数和被重载函数可以被virtual修饰,也可以没有
隐藏和重写,重载有以下不同:
(1)与重载的范围不同:和重写一样,隐藏函数和被隐藏函数不在同一个类中;
(2)参数的区别:隐藏函数和被隐藏的函数的参数列表可以相同也可以不同,但是函数名肯定要相同。当参数不同时,无论基类的参数是否被virtual修饰,基类的函数都是被隐藏,而不是被重写。
虽然重载和覆盖都是实现多态的基础,但是两者实现的技术完全不相同,达到的目的也是完全不同的。覆盖是动态绑定的多态,重载是静态绑定的多态。

初始化列表和构造函数初始化的区别?

构造函数初始化列表以一个冒号开始,接着是以逗号分隔的数据成员列表,每个数据成员后面跟一个放在括号中的初始化式。例如︰

//构造函数初始化列表
Example::Example() : ival(0),dval(0.0){}//ival和dval是类的两个数据成员
//构造函数初始化
Example::Example()
{
	ival = 0;
	dval = 0.0;
}

的确,这两个构造函数的结果是一样的。但区别在于∶上面的构造函数(使用初始化列表的构造函数)显示的初始化类的成员﹔而没使用初始化列表的构造函数是对类的成员赋值,并没有进行显示的初始化。

初始化和赋值对内置类型的成员没有什么大的区别,像上面的任一个构造函数都可以。但有的时候 必须用带有初始化列表的构造函数

  • 1.成员类型是没有默认构造函数的类。若没有提供显示初始化式,则编译器隐式使用成员类型的默认构造函数,若类没有默认构造函数,则编译器尝试使用默认构造函数将会失败。

  • 2.const成员或引用类型的员。因为const对象或引用类型只能初始化,不能对他们赋值。

在C++中,赋值与初始化列表的使用情况也不一样,只能用初始化列表,而不能用赋值的情况一般有以下3种:

  • 1.当类中含有const(常量)、reference (引用)成员变量时,只能初始化,不能对它们进行赋值。常量不能被赋值,只能被初始化,所以必须在初始化列表中完成,C++的引用也一定要初始化,所以必须在初始化列表中完成。

  • ⒉.派生类在构造函数中要对自身成员初始化,也要对继承过来的基类成员进行初始化当基类没有默认构造函数的时候,通过在派生类的构造函数初始化列表中调用基类的构造函数实现。

  • 3.如果成员类型是没有默认构造函数的类,也只能使用初始化列表。若没有提供显式初始化时,则编译器隐式使用成员类型的默认构造函数,此时编译器尝试使用默认构造函数将会失败

final和override说明符

  • 在写虚函数时,想让派生类中的虚函数覆盖掉基类虚函数,有时我们会不小心写错,造成了隐藏,这不是我们想要看到的结果。所以C++ 11新标准中我们可以使用override关键字来说明派生类中的虚函数。
  • 如果使用override标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。使用override是希望能覆盖基类中的虚函数,如果不符合则编译器报错。
  • 把某个函数指点为 final ,意味着任何尝试覆盖该函数的操作都将引发错误,final 和 override 说明符出现在形参列表以及尾置返回类型之后。final 还可以跟在类的后面,意思这个类不能当做其它类的基类。

设计一个不能被继承的类

  • 通过析构函数、构造函数私有化防止继承
    • 先创建一个基类A,将其构造函数和析构函数都声明为私有的;
    • 我们要求的不能被继承的类B声明为A的友元类,这样B可以访问A类的构造函数和析构函数,B可以正常构造;
    • 同时还需要让类B虚拟继承A类,此时B类的子类C在构造对象时,会直接调用A类的构造函数,但是由于友元关系是不能被继承的,所以,C类调用A类的构造函数会报错,也就是说C类不能成功构造出对象,所以,B类是不可以被继承的。
    • 这里需要说明的是:我们设计的不能被继承的类B对基类A的继承必须是虚继承,这样一来C类继承B类时会去直接调用A的构造函数,而不是像普通继承那样,先调用B的构造函数再调用A的构造函数;
    • C类直接调用A类的构造函数,由于A类的构造函数是私有的,而B是A的友元,C类不是A的友元,友元关系不会继承,因此会编译报错。
  • 除此之外,在C++11中已经像Java一样有了final关键字,被final修饰的虚函数(只能是虚函数)不能被重载,被final修饰的类不能被继承。

基类的构造函数/析构函数是否能被派生类继承?

  • 基类的构造函数析构函数不能被派生类继承。
  • 基类的构造函数不能被派生类继承,派生类中需要声明自己的构造函数。设计派生类的构造函数时,不仅要考虑派生类所增加的数据成员初始化,也要考虑基类的数据成员的初始化。声明构造函数时,只需要对本类中新增成员进行初始化,对继承来的基类成员的初始化,需要调用基类构造函数完成。
  • 基类的析构函数也不能被派生类继承,派生类需要自行声明析构函数。声明方法与一般(无继承关系时)类的析构函数相同,不需要显式地调用基类的析构函数,系统会自动隐式调用。需要注意的是,析构函数的调用次序与构造函数相反。

封装、继承、多态

封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块(类);它们的目的都是为了——代码重用。而多态则是为了实现另一个目的——接口重用
封装可以隐藏实现细节,使得代码模块化;封装是把过程和数据包围起来,对数据的访问只能通过已定义的界面。面向对象计算始于这个基本概念,即现实世界可以被描绘成一系列完全自治、封装的对象,这些对象通过一个受保护的接口访问其他对象。在面向对象编程上可理解为:把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。其继承的过程,就是从一般到特殊的过程。通过继承创建的新类称为“子类”或“派生类”。被继承的类称为“基类”、“父类”或“超类”。要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。在某些 OOP 语言中,一个子类可以继承多个基类。但是一般情况下,一个子类只能有一个基类,要实现多重继承,可以通过多级继承来实现。
继承概念的实现方式有三类:实现继承、接口继承和可视继承:
1)实现继承是指使用基类的属性和方法而无需额外编码的能力;
2)接口继承是指仅使用属性和方法的名称、但是子类必须提供实现的能力;
3) 可视继承是指子窗体(类)使用基窗体(类)的外观和实现代码的能力。
多态性(polymorphisn) 是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单的说,就是一句话:允许将子类类型的指针赋值给父类类型的指针。
实现多态,有二种方式,覆盖,重载覆盖: 是指子类重新定义父类的虚函数的做法。重载: 是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。

  • 多态性指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。C++支持两种多态性:编译时多态性,运行时多态性
    • a.编译时多态性:通过重载函数实现
    • b 运行时多态性:通过虚函数实现。

不能构成重载的情况

  • 形参相同的情况下,函数返回值类型不同,不能构成重载。构成函数重载的条件只与形参参数有关。
  • 形参的缺省值不同,不能构成重载。

重写和重载的区别?

  • 重写是指两个类或者多个类中,存在基类和派生类的关系。此时在派生类中由于某些需要,需要对基类的某个函数进行重新定义,但是参数列表、函数名都不变,此时称之为函数的重写,需要注意的是,函数的重写需要在前面加上virtual关键字。
  • 重载是指某些函数除了参数列表(参数的类型,个数,顺序不同)之外,其他条件(返回值类型,函数名)都相同的函数,我们称之为函数的重载。

函数重载底层原理

  • C++中函数重载
    • 函数重载是一种特殊情况,C++允许在同一作用域中声明几个类似的同名函数,这些同名函数的形参列表(参数个数,类型,顺序)必须不同,常用来处理实现功能类似数据类型不同的问题。
  • 为什么C语言不支持函数重载
    • 程序在预编译阶段会经历预处理、编译、汇编、链接、生成可执行程序的过程,值得一提的是,在汇编过程中编译器会收集全局符号并生成全局符号表。
    • 那么什么是符号表呢?将符号和其相应地址一一对应的表格称为符号表。当然在每个文件里都会生成本文件的符号表,对于暂时找不到地址的函数(比如只是一个函数的声明),它的地址是一个没有意义的填充值。
    • 在汇编的过程中我们生成了多个符号表,但最后我们只能有一个符号表,所以在链接过程中要对符号表进行合并。在合并的过程中发现Add函数出现了两次,这就涉及重定位了——Add函数有效的地址值就会作为Add函数最终的地址值。
    • 想必大家理解了为什么C语言中不允许函数重载,因为两个重名函数的地址都是有效值,所以在重定位的时候就会产生冲突和歧义。
  • C++是如何支持函数重载的
    • C语言不支持函数重载的原因是符号表中的出现了两个具有有效地址的同名函数名,所以发生了冲突,那么只要我们能解决函数名冲突的问题,相应的就可以实现函数重载的效果。C++对写入符号表的函数具有一个修正的过程。
    • 在linux下观察反汇编的效果,发现函数名’f’已经被修正为 ‘_Z1fid’,我们可对Linux下的命名规则做如下总结:
    • 在这里插入图片描述

静态多态和动态多态

多态性可以简单地概括为**“一个接口,多种方法”**。
静态多态: 也称为编译期间的多态,编译器在编译期间完成的,编译器根据函数实参的类型(可能会进行隐式类型转换),可推断出要调用那个函数,如果有对应的函数就调用该函数,否则出现编译错误。
静态多态有两种实现方式:
函数重载: 包括普通函数的重载和成员函数的重载
函数模板的使用
动态多态(动态绑定): 即运行时的多态,在程序执行期间(非编译期)判断所引用对象的实际类型,根据其实际类型调用相应的方法,是用虚函数机制实现的。
不同点:
1、本质不同,静态多态在编译期决定,由模板具现完成,而动态多态在运行期决定,由继承、虚函数实现;
2、动态多态中接口是显式的,以函数签名为中心,多态通过虚函数在运行期实现,静态多台中接口是隐式的,以有效表达式为中心,多态通过模板具现在编译期完成

虚函数原理及实现(虚函数表+虚函数指针)

  • C++中的虚函数的作用主要是实现了多态的机制。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。
  • 所谓多态性,顾名思义就是“多个性态”。更具体一点的就是,用一个名字定义多个函数,这些函数执行不同但相似的工作。最简单的多态性的实现方式就是函数重载和模板,这两种属于静态多态性。还有一种是动态多态性,其实现方式就是我们今天要说的虚函数。
  • 虚函数的实现是由两个部分组成的,虚函数指针与虚函数表。
  • 虚函数指针从本质上来说就只是一个指向函数的指针,与普通的指针并无区别。它指向用户所定义的虚函数,具体是在子类里的实现,当子类调用虚函数的时候,实际上是通过调用该虚函数指针从而找到接口。只有拥有虚函数的类才会拥有虚函数指针,每一个虚函数也都会对应一个虚函数指针。所以拥有虚函数的类的所有对象都会因为虚函数产生额外的开销,并且也会在一定程度上降低程序速度。
  • 在虚函数表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。编译器必需要保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证正确取到虚函数的偏移量)。这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
    C++中一个类是公用一张虚函数表的,基类有基类的虚函数表,子类是子类的虚函数表,这极大的节省了内存

虚函数和纯虚函数

在类成员方法的声明(注意不是定义)前面加个virtual,该函数就变为虚函数,在虚函数声明语句后面加个 = 0 ,虚函数就变为纯虚函数。

class<类名>
{
	virtual()函数返回值类型  虚函数名(形参表)=0...
}
  • 定义一个函数为虚函数,不代表函数为不被实现的函数。定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。

  • 定义一个函数为纯虚函数,才代表函数没有被实现, 定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。

  • 当类中有了纯虚函数,这个类也称为抽象类

  • 子类可以重新定义基类的虚函数,我们把这个行为称之为复写(override)

虚函数表是在什么时期建立的?

深度探索C++对象模型》的4.2节能够找到完美答案,具体摘抄如下:

“表格中的virtual functions地址是如何被建构起来的?在C++中,virtual functions(可经由其class object被调用)可以在 编译时期 获知。此外,这一组地址是固定不变的,执行期不可能新增或替换之。由于程序执行时,表格的大小和内容都不会改变,所以其建构和存取皆可以由编译器完全掌控,不需要执行期的任何介入。”

怎么查找虚函数表中的函数,虚函数指针存放的位置在哪里

转载图片: link
在这里插入图片描述
1.虚函数表是全局共享的元素,即全局仅有一个.

2.虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表.即虚函数表不是函数,不是程序代码,不肯能存储在代码段.

3.虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不再堆中.

根据以上特征,虚函数表类似于类中静态成员变量.静态成员变量也是全局共享,大小确定.

存放在全局数据区.

虚函数表具体实现运行多态

原理:

  • 虚函数表是一个类的虚函数的地址表,每个对象在创建时,都会有一个指针指向该类虚函数表,每一个类的虚函数表,按照函数声明的顺序,会将函数地址存在虚函数表中,当子类对象重写父类的虚函数的时候,父类的虚函数表中对应的位置会被子类的虚函数地址覆盖。

作用:

  • 在用父类的指针调用子类对象成员函数时,虚函数表会指明要调用的具体函数是哪个。

C语言是怎么进行函数调用的

  • 大多数CPU上的程序实现使用栈来支持函数调用操作,栈被用来传递函数参数、存储返回信息、临时保存寄存器原有的值以备恢复以及用来存储局部变量。
  • 函数调用操作所使用的栈部分叫做栈帧结构,每个函数调用都有属于自己的栈帧结构,栈帧结构由两个指针指定,帧指针(指向起始),栈指针(指向栈顶),函数对大多数数据的访问都是基于帧指针。下面是结构图:
    在这里插入图片描述
  • 栈指针和帧指针一般都有专门的寄存器,通常使用ebp寄存器作为帧指针,使用esp寄存器做栈指针。帧指针指向栈帧结构的头,存放着上一个栈帧的头部地址,栈指针指向栈顶。

构造函数中初始化成员的先后顺序

  • 构造函数与父类的其它成员(成员变量和成员方法)不同,它不能被子类继承。因此,在创建子类对象时,为了初始化从父类中继承来的成员变量,编译器需要调用其父类的构造函数。如果子类的构造函数没有显式地调用父类的构造函数,则默认调用父类的无参构造函数。
  • ⭐如果用户定义的构造函数是以参数列表的形式初始化成员变量,则成员变量的初始化顺序是按照成员变量的声明的顺序
  • 如果用户定义的构造函数是以参数列表的形式初始化成员变量,则成员变量的初始化顺序是按照成员变量的声明的顺序。

⭐这里顺便说一下继承体系下基类和派生类的构造函数/析构函数的调用次序:

  • 1.先调用派生类构造函数,再调基类构造函数;但是执行顺序相反,先执行基类的,再执行派生类的,即先构造基类的所有成员,再构造派生类成员。
  • 2.析构函数是先调派生类的,再调基类的,后构造出来的先销毁,先销毁派生类成员,再销毁基类成员(相当于栈)。

析构函数为什么是虚函数

析构函数可以且常常是虚函数

  • 此时 vtable(虚函数表) 已经初始化了,完全可以把析构函数放在虚函数表里面来调用。

  • C++类有继承时,基类的析构函数必须为虚函数。如果不是虚函数,则使用时可能存在内存泄漏的问题。

  • 虚函数就是类中使用关键 virtual修饰的成员函数,其目的是为了实现多态性。

  • 创建一个类时,系统默认我们不会将该类作为基类,所以就将默认的析构函数定义成非虚函数,这样就不会占用额外的内存空间。

  • 派生类自己的资源,同时又清理从基类继承过来的资源。而当基类的析构函数为非虚函数时,删除一个基类指针指向的派生类实例时,只清理了派生类从基类继承过来的资源,而派生类自己独有的资源却没有被清理,这显然不是我们希望的。所以说,如果一个类会被其他类继承,那么我们有必要将被继承的类(基类)的析构函数定义成虚函数。这样,释放基类指针指向的派生类实例时,清理工作才能全面进行,才不会发生内存泄漏。

构造函数不能是虚函数

构造函数不能是虚函数

  • 1.从vptr 角度解释

    • 虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的vptr指针(vptr可以参考C++的虚函数表指针vptr)指向,该指针存放在对象的内部空间中,需要调用构造函数完成初始化。如果构造函数是虚函数,那么调用构造函数就需要去找vptr,但此时vptr还没有初始化!
  • 2.从多态 角度解释

    • 虚函数主要是实现多态,在运行时才可以明确调用对象,根据传入的对象类型来调用函数,例如通过父类的指针或者引用来调用它的时候可以变成调用子类的那个成员函数。而构造函数是在创建对象时自己主动调用的,不可能通过父类的指针或者引用去调用,因此使用虚函数也没有实际意义。并且构造函数的作用是提供初始化,在对象生命期仅仅运行一次,不是对象的动态行为,没有必要成为虚函数。

内联函数不能是虚函数

  • inline是在编译器将函数类容替换到函数调用处,是静态编译的。而虚函数是动态调用的,在编译器并不知道需要调用的是父 类还是子类的虚函数,所以不能够inline声明展开,所以编译器会忽略

static成员函数不能是虚函数

  • 静态成员函数没有this指针。虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它。对于静态成员函数,它没有this指针,所以无法访问vptr. 这就是为何static函数不能为virtual.

纯虚函数的作用是什么

  • 用virtual关键字修饰,并且末尾加上 =0 的格式声明的函数就是纯虚函数,还有一个重要特征,就是在当前类里这个方法不能有具体的实现,也就是不能有方法体。同时拥有纯虚函数的类会被默认定义为抽象类。
  • 抽象类: 至少拥有一个或者一个以上的纯虚函数的类,这种类不能创建对象,但是可以被继承,通常通过继承抽象类,然后通过重写纯虚函数后,方可以通过抽象父类的子类的对象来访问函数和数据成员。
  • 作用: 抽象类通常被作为基类使用,假设需要创建一个水牛类和一个奶牛类,他们有共性,那就是都是牛,那么可以定义一个牛类作为共同的基类,让奶牛和水牛都继承牛的特征,然后在分别在他们自己的类中添加自己的特征。这样的设计方便面向对象程序的设计和管理,提供更为严谨的封装。

c++ 析构函数调用时间

对象生命周期结束被销毁时。
delete指向对象的指针时,或者delete指向对象的基类类型的指针,而基类析构函数是虚函数。
对象A是对象B的成员,B的析构函数被调用时,对象A的析构函数也会被调用。

C 语言的 malloc 和 C++ 中的 new 有什么区别

属性: new和delete是C++关键字,需要编译器支持;malloc和free是标准库函数,需要头文件支持。
参数: 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显式地指出所需内存的尺寸。
返回类型: new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。
自定义类型: new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。
malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
“重载”: C++允许自定义operator new 和 operator delete 函数控制动态内存的分配。
内存区域: new做两件事:分配内存和调用类的构造函数,delete是:调用类的析构函数和释放内存。而malloc和free只是分配和释放内存。
new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。自由存储区不等于堆,如上所述,布局new就可以不位于堆中。
分配失败: new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。
内存泄漏:内存泄漏对于new和malloc都能检测出来,而new可以指明是哪个文件的哪一行,malloc确不可以。

简述C、C++程序编译的内存分配情况

一个C,C++程序编译时内存分为5大存储区:堆区,栈区,全局区,文字常量区,程序代码区。
C,C++中内存分配方式可以分为三种:
(1)从静态存储区域分配:
内存在程序编译时就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量等。
(2)在栈上分配:
在执行函数时,函数内局部变量的存储单元都在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
(3)从堆上分配:
即动态内存分配。程序在运行的时候用malloc或new申请任意大小的内存,程序员负责在何时用free或delete释放内存。动态内存的生存期由程序员决定,使用灵活。
当在堆上分配了空间,就有责任回收它,否则运行的程序会出现内存泄漏。其次频繁地分配和释放不同大小的对空间将会产生堆

malloc、vmalloc、kmalloc区别

(1)kmalloc和vmalloc是分配内核的内存,malloc分配的是用户空间的内存。
(2)kmalloc 保证分配的内存在物理上是连续的,vmalloc保证的是在虚拟地址空间上的连续,malloc申请的内存不一定连续(用户空间存储以空间链表的方式组织(地址递增),每一个链表块包含一个长度、一个指向下一个链表块的指针以及一个指向自身的存储空间指针。)
(3)kmalloc能分配的大小有限,vmalloc与malloc能分配的空间大小相对较大。
(4)内存只有在要被DMA访问的时候才需要物理上连续。
(5)vmalloc要不kmalloc要慢。

什么是内存泄漏,如何避免

用动态存储分配函数动态开辟的空间,在使用完毕后未释放,结果导致一直占据该内存单元。直到程序结束。(就是该内存空间使用完毕之后未回收)即所谓内存泄漏。

常见的内存泄露:
1.指针重新赋值;
2.错误的内存释放;
3.返回值的不正确处理。

如何避免:
1.确保没有在访问空指针;
2.每个内存分配函数都应该有一个 free 函数与之对应,alloca 函数除外;
3.每次分配内存之后都应该及时进行初始化,可以结合 memset 函数进行初始化,calloc 函数除外;
4.每当向指针写入值时,都要确保对可用字节数和所写入的字节数进行交叉核对;
5.在对指针赋值前,一定要确保没有内存位置会变为孤立的;
6.每当释放结构化的元素(而该元素又包含指向动态分配的内存位置的指针)时,都应先遍历子内存位置并从那里开始释放,然后再遍历回父节点;7.始终正确处理返回动态分配的内存引用的函数返回值。

介绍一下申请内存

  • malloc(unsigned int n) 在堆上申请内存,其原理是维护一个内存空闲链表,分配时给你一个空闲大小和申请大小最接近的地址,当然,返回的是不确定类型的指针void*,需要转换位确定类型指针,如果没有空闲内存,则返回空指针。该函数调用后需要手动调用free函数释放内存,不然会造成内存泄漏,另外频繁调用该方法获取大小不一的内存会造成内存零散化。
    • malloc()函数用来在堆中申请内存空间,free()函数释放原先申请的内存空间。malloc()函数是在内存的动态存储区中分配一个长度为size字节的连续空间。其参数是一个无符号整型数,返回一个指向所分配的连续存储域的起始地址的指针。当函数未能成功分配存储空间时(如内存不足)则返回一个NULL指针。
    • malloc()函数返回值赋给p1,又把p1的值赋给p2,所以此时p1,p2都可作为free函数的参数。使用free()函数时,

需要特别注意下面几点:

  • 1)调用free()释放内存后,不能再去访问被释放的内存空间。内存被释放后, 很有可能该指针仍然指向该内存单元,但这块内存已经不再属于原来的应用程序,此时的指针为悬挂指针(可以赋值为NULL)。

  • 2)不能两次释放相同的指针。因为释放内存空间后,该空间就交给了内存分配子程序,再次释放内存空间会导致错误。也不能用free来释放非malloc()、calloc()和realloc()函数创建的指针空间,在编程时,也不要将指针进 行自加操作,使其指向动态分配的内存空间中间的某个位置,然后直接释放,这样也有可能引起错误。

  • 3)在进行C语言程序开发中,malloc/free是配套使用的,即不需要的内存空间都需要释放回收。

由于内存区域总是有限的,不能无限制地分配下去,而且程序应尽量节省资源 所以当分配的内存区域不用时,则要释放它,以便其他的变量或程序使用。

  • calloc(n,s) 申请n份大小为s的内存,并且会初始化这部分内存,这个内存申请后应该也要手动释放。
    • calloc是malloc函数的简单包装,它的主要优点是把动态分配的内存进行初始化,全部清零。其操作及语法类似malloc()函数。
  • realloc(m,new_size) 是找一块new_size大小的内存,然后把m内容拷贝过去,返回新内存地址,但如果原来地址后面有足够的内存,好像是直接扩展就行。
    • realloc()函数用来从堆上分配内存,当需要扩大一块内存空间时,realloc()试图直接从堆上当前内存段后面的字节中获得更多的内存空间,如果能够满足,则返回原指针;如果当前内存段后面的空闲字节不够,那么就使用堆上第一个能够满足这一要求的内存块,将目前的数据复制到新的位置,而将原来的数据块释放掉 如果内存不足,重新申请空间失败,则返回NULL。
    • 当调用realloc()函数重新分配内存时,如果申请失败,将返回NULL,此时原来指针仍然有效,因此在程序编写时需要进行判断,如果调用成功,realloc()函数会重新分配一块新内存,并将原来的数据拷贝到新位置 返回新内存的指针,而释放掉原来指针(realloc()函数的参数指针)指向的空间, 原来的指针变为不可用(即不需要再释放,也不能再释放)
  • alloca() 就是编译器自动调用的内存申请函数,自动释放内存,不需要手动释放。
    • alloca()函数用来在栈中动态分配size个字节的内存空间,因此函数返回时会自动释放掉空间。
    • alloca 与 malloc 的区别主要在于:
      • alloca是向 申请内存,无需释放,malloc申请的内存位于 中, 最终需要函数free来释放。
      • malloc函数并没有初始化申请的内存空间,因此调用malloc()函数之后,还需调用函数memset初始化这部分内存空间;alloca则将初始化这部分内存空间为0。

追问内存溢出、内存泄漏

内存溢出:(out of memory) 通俗理解就是内存不够,指程序要求的内存超出了系统所能分配的范围,通常在运行大型软件或游戏时,软件或游戏所需要的内存远远超出了你主机内安装的内存所承受大小,就叫内存溢出。比如申请一个int类型,但给了它一个int才能存放的数,就会出现内存溢出,或者是创建一个大的对象,而堆内存放不下这个对象,这也是内存溢出。
内存泄漏:(Memory Leak) 是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
内存泄露可能会导致内存溢出。
内存溢出会抛出异常,内存泄露不会抛出异常,大多数时候程序看起来是正常运行的。

内存分区

在C/C++中对于内存分区来说,可以划分为四大内存分区。他们分别是堆、栈、全局/静态存储区和代码区。
1.堆区:
由编程人员手动申请,手动释放,若不手动释放,程序结束后由系统回收,生命周期是整个程序运行期间.使用malloc或者new进行堆的申请,堆的总大小为机器的虚拟内存的大小。
new操作符本质上是使用了malloc进行内存的申请,new和malloc的区别如下:   
(1)malloc是C语言中的函数,而new是C++中的操作符。   
(2)malloc申请之后返回的类型是void*,而new返回的指针带有类型。   
(3)malloc只负责内存的分配而不会调用类的构造函数,而new不仅会分配内存,而且会自动调用类的构造函数。
2.栈区:
由系统进行内存的管理。主要存放函数的参数以及局部变量。在函数完成执行,系统自行释放栈区内存,不需要用户管理。整个程序的栈区的大小可以在编译器中由用户自行设定,VS中默认的栈区大小为1M,可通过VS手动更改栈的大小。
3.全局/静态存储区:
全局/静态存储区内的变量在程序编译阶段已经分配好内存空间并初始化。这块内存在程序的整个运行期间都存在,它主要存放静态变量、全局变量和常量。
(1) 是因为静态存储区内的变量若不显示初始化,则编译器会自动以默认的方式进行初始化,即静态存储区内不存在未初始化的变量。
(2)静态存储区内的常量分为常变量和字符串常量,一经初始化,不可修改。静态存储内的常变量是全局变量,与局部常变量不同,区别在于局部常变量存放于栈,实际可间接通过指针或者引用进行修改,而全局常变量存放于静态常量区则不可以间接修改。   
(3)字符串常量存储在全局/静态存储区的常量区,字符串常量的名称即为它本身,属于常变量。   
(4)数据区的具体划分,有利于我们对于变量类型的理解。不同类型的变量存放的区域不同。
4.代码区:
存放程序体的二进制代码。比如我们写的函数,都是在代码区的。

malloc和new区别

  • 相比于 new 的性能提升在哪?(避免了系统调用的开销),new 的系统调用开销有多少?(纳秒级),可以忽略吗?(不能,在高并发场景下,越往后的连接延迟越明显)
  • new 的主要开销在哪?(系统调用和构造函数)
  • new 一定会陷入内核态吗?(不一定,因为底层是 malloc ,malloc 根据分配内存的大小不同有两种分配方式,小于128k使用brk(),并且 malloc 调用 brk() 时是预先申请一大段内存,不需要每次都陷入内核态去申请,大于128k使用 mmap() )
  • 那你内存池对于小内存的申请相比 new 优势还明显吗?(到这里才明白面试官挖的坑,赶紧sorrymaker,考虑不周),但是对于缓存的对象是有必要走内存池的,下去再好好理一理

malloc的底层原理

malloc 函数的实质是它有一个将可用的内存块连接为一个长长的列表的所谓空闲链表
调用 malloc()函数时,它沿着连接表寻找一个大到足以满足用户请求所需要的内存块。(如果没有搜索到,那么就会用sbrk()才推进brk指针来申请内存空间)。
然后,将该内存块一分为二(一块的大小与用户申请的大小相等,另一块的大小就是剩下来的字节)。 接下来,将分配给用户的那块内存存储区域传给用户,并将剩下的那块(如果有的话)返回到连接表上。
调用 free 函数时,它将用户释放的内存块连接到空闲链表上。
到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段, 那么空闲链表上可能没有可以满足用户要求的片段了。于是,malloc() 函数请求延时,并开始在空闲链表上检查各内存片段,对它们进行内存整理,将相邻的小空闲块合并成较大的内存块。

malloc如何分配空间(mmap,brk)

  • 从操作系统的角度来看,进程分配内存的原理有两种方式,分别由两个系统调用完成。
    • brk是将数据段(.data)的最高地址指针_edata往高地址推
    • mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存
  • 这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系

堆栈溢出发生在什么时候

  • 堆栈尺寸设置过小
  • 递归层数太深
  • 动态申请内存后没有释放

C语言的指针和c++的引用有什么区别(指针和引用的区别?为什么要引入引用?好处有哪些?)

相同:
(1)都是地址的概念,指针指向某一内存、它的内容是所指内存的地址;引用则是某块内存的别名。
(2)从内存分配上看∶两者都占内存,程序为指针会分配内存,一般是4个字节;而引用的本质是指针常量,指向对象不能变,但指向对象的值可以变。两者都是地址概念,所以本身都会占用内存。

区别:
(1)引用必须初始化,但是不分配存储空间。指针不声明时初始化,在初始化的时候需要分配存储空间。引用较比指针更加安全;
(2)引用指向一块特定的内存,不能被更改。不存在指向空值的引用,但是存在指向空值的指针。指针可指向任意一块内存,可以改变所指的对象;
(3)指针的大小确定,引用的大小根据所引用的类型确定;
(4)指针使用时必须解引用;
(5)定义一个指针和引用在汇编语言中表示相同;
(6)指针可以多级引用。
ps: 引用即指针常量,指向后不能修改指向的是哪个对象,想较于指针用法更简单,不需要担心内存泄漏,也更安全不会有野指针哪些乱七八糟的问题

转换:
(1)把指针用*就可以转换成对象,可以用在引用参数中
(2)引用类型用&取地获得指针

句柄和指针的区别和联系是什么

句柄和指针其实是两个截然不同的概念。Windows系统用句柄标记系统资源,隐藏系统的信息。你只要知道有这个东西,然后去调用就行了,它是个32it的uint。指针则标记某个物理内存地址,两者是不同的概念。

静态链接和动态链接

在C语言中,我们知道要生成可执行文件,必须经历两个阶段,即编译、链接。在编译过程中,只有编译,不会涉及到链接。
在链接过程中,静态链接和动态链接就出现了区别。静态链接的过程就已经把要链接的内容已经链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;而动态链接这个过程却没有把内容链接进去,而是在执行的过程中,再去找要链接的内容,生成的可执行文件中并没有要链接的内容,所以当你删除动态库时,可执行程序就不能运行。

智能指针

所谓的智能指针本质就是一个类模板,它可以创建任意的类型的指针对象,当智能指针对象使用完后,对象就会自动调用析构函数去释放该指针所指向的空间。所有的智能指针类模板中都需要包含一个指针对象,构造函数和析构函数。
1)auto_ptr
auto_ptr是c++98版本库中提供的智能指针,该指针解决上诉的问题采取的措施是管理权转移的思想,也就是原对象拷贝给新对象的时候,原对象就会被设置为nullptr,此时就只有新对象指向一块资源空间。 如果auto_ptr调用拷贝构造函数或者赋值重载函数后,如果再去使用原来的对象的话,那么整个程序就会崩溃掉(因为原来的对象被设置为nullptr),这对程序是有很大的伤害的.所以很多公司会禁用auto_ptr智能指针。
⭐auto_ptr以前是用在C98中,C++11被抛弃,头文件一般用来作为独占指针
⭐auto_ptr被赋值或者拷贝后,失去对原指针的管理
⭐auto_ptr不能管理数组指针,因为auto_ptr的内部实现中,析构函数中删除对象使用delete而不是delete[],释放内存的时候仅释放了数组的第一个元素的空间,会造成内存泄漏。
⭐ auto_ptr不能作为容器对象,因为STL容器中的元素经常要支持拷贝,赋值等操作。
2) unique_ptr
unique_ptr是c++11版本库中提供的智能指针,它直接将拷贝构造函数和赋值重载函数给禁用掉,因此,不让其进行拷贝和赋值。
⭐ C++11中用来替代auto_ptr
⭐拷贝构造和赋值运算符被禁用,不能进行拷贝构造和赋值运算
⭐ 虽然禁用了拷贝构造和赋值运算符,但unique_ptr可以作为返回值,用于从某个函数中返回动态申请内存的所有权,本质上是移动拷贝,就是使用std:move()函数,将所有权转移。
3) share_ptr
share_ptr是c++11版本库中的智能指针,shared_ptr允许多个智能指针可以指向同一块资源,并且能够保证共享的资源只会被释放一次,因此是程序不会崩溃掉。
shared_ptr采用的是引用计数原理来实现多个shared_ptr对象之间共享资源:
⭐ 多个指针可以指向相同的对象,调用release()计数-1,计数0时资源释放
⭐ .use_count()查计数
⭐ .reset()放弃内部所有权
⭐ share_ptr多次引用同一数据会导致内存多次释放
⭐ 循环引用会导致死锁,
⭐ 引用计数不是原子操作。
4) weak_ptr
⭐ 解决两个share_ptr互相引用产生死锁,计数永远降不到0,没办法进行资源释放,造成内存泄漏的问题。
⭐ 使用时配合share_ptr使用,把其中一个share_ptr更换为weak_ptr。

sharedPtr和uniquePtr

  • shared_ptr:解决资源忘记释放的内存泄漏问题,及悬空指针问题

  • unique_ptr:对象对其有唯一所有权

智能指针中引用计数在什么时候开始计数?什么时候销毁

如何来让指针知道还有其他指针的存在呢?这个时候我们该引入引用计数的概念了。引用计数是这样一个技巧,它允许有多个相同值的对象共享这个值的实现。引用计数的使用常有两个目的:

  • 简化跟踪堆中(也即C++中new出来的)的对象的过程。一旦一个对象通过调用new被分配出来,记录谁拥有这个对象是很重要的,因为其所有者要负责对它进行delete。但是对象所有者可以有多个,且所有权能够被传递,这就使得内存跟踪变得困难。引用计数可以跟踪对象所有权,并能够自动销毁对象。可以说引用计数是个简单的垃圾回收体系。这也是本文的讨论重点。

  • 节省内存,提高程序运行效率。如何很多对象有相同的值,为这多个相同的值存储多个副本是很浪费空间的,所以最好做法是让左右对象都共享同一个值的实现。C++标准库中string类采取一种称为”写时复制“的技术,使得只有当字符串被修改的时候才创建各自的拷贝,否则可能(标准库允许使用但没强制要求)采用引用计数技术来管理共享对象的多个对象。这不是本文的讨论范围。

什么是野指针和悬空指针?

  • 若指针指向一块内存空间,当这块内存空间被释放后,该指针依然指向这块内存空间,此时,称该指针为悬空指针。
    • 当指针被free或delete释放掉时,如果没有把指针设置为NULL,则会产生野指针,因为释放掉的仅仅是指针指向的内存,并没有把指针本身释放掉。
  • 野指针是指不确定其指向的指针,未初始化的指针为“野指针”。
    • 野指针是指向不可用内存的指针,当指针被创建时,指针不可能自动指向NULL,这时,默认值是随机的,此时的指针成为野指针。
    • 指针操作超越了变量的作用范围。

有指针为何还要STL迭代器

  • 迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的一些功能,通过重载了指针的一些操作符,->、、++、–等。迭代器封装了指针,是一个“可遍历STL容器内全部或部分元素”的对象, 本质是封装了原生指针,是指针概念的一种提升,提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。
  • 迭代器返回的是对象引用而不是对象的值,所以 cout 只能输出迭代器使用取值后的值而
    不能直接输出其自身。

了解哪些C++11新特性

  • 右值引用

  • auto and decltype

    • 二者都是类型推断符,让编译器帮我们做类型推断
    • 最大的区别在于:
      • auto a=1+2 是让编译器通过=右边的初始值1+2来推断变量类型
      • decltype(expr) 是让编译器通过分析括号中的表达式expr,取得表达式的类型作为变量类型
  • 智能指针

  • bitset

    • bitset是一种类似数组的结构,它的每一个元素只能是0或1,每个元素仅用1bit空间。常用于位运算
  • tuple元组

    • tuple是一个固定大小的不同类型值的集合,是泛化的std::pair。std::tuple理论上可以有无数个任意类型的成员变量,而std::pair只能是2个成员,因此在需要保存3个及以上的数据时就需要使用tuple元组了。
  • 列表初始化

  • lambda匿名函数

  • using三种用法

    • 导入命名空间
    • 指定别名:通常用typedef给一个类型取别名,但是typedef不能为模版取别名,c++11提供 的using ,可以用于模板别名
    • 使用 using 关键字在派生类中引用基类成员,同时修改访问权限
  • 非受限联合体(union)

  • noexcept

    • 使用noexcept表明函数或操作不会发生异常
    • 好处是可以减少运行时开销,使得编译器有更大的优化空间。
  • initializer_list

    • 当函数的实参数量未知,但是我们知道全部实参的类型相同,那么就可以用initializer_list类型的形参。即initializer_list用于表示某种特定类型的值的数组

C++设计模式有哪些

目的是为提升软件的高内聚、低耦合特性。它无法像算法解决具体的实际问题,只是一种优化代码的推荐方式。

  • 单例模式
    • 1.懒汉模式(线程非安全)
    • 2.懒汉模式(线程安全)
    • 3.饿汉模式
      ps: 单例模式入口
  • 工厂模式
    • 1.简单工厂
    • 2.抽象工厂

说一下工厂模式(大白话版)

  • 简单工厂

    • 顾名思义工厂模式,并没有那么简单。他不是像简单工厂那样一个水果工厂可以生产各种水果,而是一种工厂生产一种水果。就比如苹果工厂生产苹果,梨子工厂生产梨子。这样分开的好处是什么呢???
  • 开放-封闭原则,我用我自己的话来说,就是如果你想增加新西瓜的产品的话。用简单工厂模式需要增加一个西瓜类,然后在水果工厂里面增加分支。用工厂模式的话需要增加一个西瓜类和一个西瓜类工厂。。。

  • 不要改源码!!!如果用的是简单工厂模式就必须改源码中的条件分支,就必须修改原始代码。所以用工厂模式就不用改源码,只用增加代码即可,妈妈再也不用担心我被骂了。

  • 抽象工厂

    • 顾名思义这个模式比普通的工厂模式抽象一点。按照我个人的理解,简单工厂模式,工厂模式,抽象模式主要的差别就在与工厂的分类不同。
    • 简单工厂模式:一个产品一个工厂,十个产品一个工厂。总结:工厂可以生产所有产品
    • 工厂模式:一个产品一个工厂,十个产品十个工厂。总结:工厂只能生产一个产品,抽象工厂模式:N个产品M个工厂。总结:工厂可以灵活的生产商品。
  • 现在要卖苹果,苹果汁,梨子,梨子汁。简单工厂模式就是1个工厂卖四个产品。工厂模式就是创建四个工厂卖四个产品。抽象工厂模式就不一样:我可以创建两个工厂。一个专门卖苹果类的,一个专门卖梨子类的,除了这样的分类,还可以进行其他的分类,十分的灵活!

push_back() 和emplace_back() 的区别

底层实现的机制不同

  • push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);
  • 而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。

介绍一下深拷贝与浅拷贝

  • 1.浅拷贝: 将原对象或原数组的引用直接赋给新对象,新数组,新对象/数组只是原对象的一个引用

  • 2.深拷贝: 创建一个新的对象和数组,将原对象的各项属性的“值”(数组的所有元素)拷贝过来,是“值”而不是“引用”
    总结: 浅拷贝只是对指针的拷贝,拷贝后两个指针指向同一个内存空间,深拷贝不但对指针进行拷贝,而且对指针指向的内容进行拷贝,经深拷贝后的指针是指向两个不同地址的指针。

  • 当对象中存在指针成员时,除了在复制对象时需要考虑自定义拷贝构造函数,还应该考虑以下两种情形:

    • 1.当函数的参数为对象时,实参传递给形参的实际上是实参的一个拷贝对象,系统自动通过拷贝构造函数实现;
    • 2.当函数的返回值为一个对象时,该对象实际上是函数内对象的一个拷贝,用于返回函数调用处。
    • 3.浅拷贝带来问题的本质在于析构函数释放多次堆内存,使用std::shared_ptr,可以完美解决这个问题。

如果想让你用深拷贝拷贝一个字典,你怎么做

  • 第一种:使用字典存储从开始元素到对应下标所有的元素及其对应的次数,最后形成一个以字典为元素的数组,最终查询时只需要得到右侧和左侧的字典中该元素的值即可。
    • 字典的默认值last = tmp.get(arr[i], 0),不存在的键不能直接操作,需要判断一下
    • 深拷贝,否则使用的是同一片空间,不是一个字典数组,仅仅是一个字典的元素
    • 如果左侧元素=val需要最终结果加1
  • 第二种
    • 字典存储的键也是val,但是值是其出现的下标self.occurence = defaultdict(list)
    • 顺序遍历数组初始化哈希表
    • n = len(arr)
    • for i in range(n):
    • self.occurence[arr[i]].append(i)
    • 比如元素3出现的下标是[2,5,7,8],字典对应的值存储该列表
    • 通过两次二分查找寻找到数组中第一个大于等于left 的位置 l 与第一个大于right 的位置 r,此时 r−l 即为符合要求的下标个数

如何解决内存碎片问题

  • 内存分配有静态分配和动态分配两种

    • 静态分配在程序编译链接时分配的大小和使用寿命就已经确定,而应用上要求操作系统可以提供给进程运行时申请和释放任意大小内存的功能,这就是内存的动态分配。
    • 因此动态分配将不可避免会产生内存碎片的问题,那么什么是内存碎片?内存碎片即“碎片的内存”描述一个系统中所有不可用的空闲内存,这些碎片之所以不能被使用,是因为负责动态分配内存的分配算法使得这些空闲的内存无法使用,这一问题的发生,原因在于这些空闲内存以小且不连续方式出现在不同的位置。因此这个问题的或大或小取决于内存管理算法的实现上。
  • 为什么会产生这些小且不连续的空闲内存碎片呢?

    • 实际上这些空闲内存碎片存在的方式有两种:a.内部碎片 b.外部碎片
    • 内部碎片的产生:因为所有的内存分配必须起始于可被 4、8 或 16 整除(视处理器体系结构而定)的地址或者因为MMU的分页机制的限制,决定内存分配算法仅能把预定大小的内存块分配给客户。假设当某个客户请求一个 43 字节的内存块时,因为没有适合大小的内存,所以它可能会获得 44字节、48字节等稍大一点的字节,因此由所需大小四舍五入而产生的多余空间就叫内部碎片。
    • 外部碎片的产生: 频繁的分配与回收物理页面会导致大量的、连续且小的页面块夹杂在已分配的页面中间,就会产生外部碎片。假设有一块一共有100个单位的连续空闲内存空间,范围是0-99。如果你从中申请一块内存,如10个单位,那么申请出来的内存块就为0-9区间。这时候你继续申请一块内存,比如说5个单位大,第二块得到的内存块就应该为10-14区间。如果你把第一块内存块释放,然后再申请一块大于10个单位的内存块,比如说20个单位。因为刚被释放的内存块不能满足新的请求,所以只能从15开始分配出20个单位的内存块。现在整个内存空间的状态是0-9空闲,10-14被占用,15-24被占用,25-99空闲。其中0-9就是一个内存碎片了。如果10-14一直被占用,而以后申请的空间都大于10个单位,那么0-9就永远用不上了,变成外部碎片。

消息队列的特点

1.什么是消息队列

  • 消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。消息队列是消息的链接表 ,存放在内核中并由消息队列标识符标识。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。我们可以通过发送消息来避免命名管道的同步和阻塞问题(命名管道要读端和写端都存在,否则出现阻塞)。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。

2.消息队列的特点

  • 消息队列可以实现消息的随机查询 。消息不一定要以先进先出的次序读取,编程时可以按消息的类型读取;

  • 消息队列允许一个或多个进程向它写入或者读取消息;

  • 与无名管道、命名管道一样,从消息队列中读出消息,消息队列中对应的数据都会被删除;

  • 每个消息队列都有消息队列标识符,消息队列的标识符在整个系统中是唯一的;

  • 消息队列是消息的链表,存放在内存中,由内核维护。只有内核重启或人工删除消息队列时,该消息队列才会被删除。若不人工删除消息队列,消息队列会一直存在于系统中。

3.抽象的理解消息队列

  • 对于消息队列的操作,我们可以类比为这么一个过程:假如 A 有个东西要给 B,因为某些原因 A 不能当面直接给 B,这时候他们需要借助第三方托管(如银行),A 找到某个具体地址的建设银行,然后把东西放到某个保险柜里(如 1 号保险柜),对于 B 而言,要想成功取出 A 的东西,必须保证去同一地址的同一间银行取东西,而且只有 1 号保险柜的东西才是 A 给自己的。

  • 而在消息队列操作中,键(key)值相当于地址,消息队列标示符相当于具体的某个银行,消息类型相当于保险柜号码。

  • 同一个键(key)值可以保证是同一个消息队列,同一个消息队列标示符才能保证不同的进程可以相互通信,同一个消息类型才能保证某个进程取出是对方的信息。

为什么使用消息队列啊?消息队列有什么好处?

  • 这两个问题其实是一样的,

  • 提高系统响应速度

    • 使用了消息队列,生产者一方,把消息往队列里一扔,就可以立马返回,响应用户了。无需等待处理结果。处理结果可以让用户稍后自己来取,如医院取化验单。也可以让生产者订阅(如:留下手机号码或让生产者实现listener接口、加入监听队列),有结果了通知。获得约定将结果放在某处,无需通知。
  • 提高系统稳定性

    • 考虑电商系统下订单,发送数据给生产系统的情况。电商系统和生产系统之间的网络有可能掉线,生产系统可能会因维护等原因暂停服务。如果不使用消息队列,电商系统数据发布出去,顾客无法下单,影响业务开展。两个系统间不应该如此紧密耦合。应该通过消息队列解耦。同时让系统更健壮、稳定。
  • 消息队列主要优点是可以实现:解耦、异步、削峰

消息队列有什么优点和缺点啊?

缺点:
1)系统可用性降低:系统引入的外部依赖越多,越容易挂掉,本来你就是A系统调用BCD三个系统的接口就好了,人ABCD四个系统好好的,没啥问题,你偏加个MQ进来,万一MQ挂了咋整?MQ挂了,整套系统崩溃了,你不就完了么。

2)系统复杂性提高:硬生生加个MQ进来,你怎么保证消息没有重复消费?怎么处理消息丢失的情况?怎么保证消息传递的顺序性?头大头大,问题一大堆,痛苦不已

3)一致性问题:A系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是BCD三个系统那里,BD两个系统写库成功了,结果C系统写库失败了,咋整?你这数据就不一致了。
(ps:MQ——Message Queue)

Linux 消息队列是咋实现的

  • 1.什么是消息队列
      消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。消息队列是消息的链接表,存放在内核中并由消息队列标识符标识。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。我们可以通过发送消息来避免命名管道的同步和阻塞问题(命名管道要读端和写端都存在,否则出现阻塞)。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。

  • 2.消息队列的特点
    消息队列可以实现消息的随机查询。消息不一定要以先进先出的次序读取,编程时可以按消息的类型读取;

消息队列允许一个或多个进程向它写入或者读取消息;

与无名管道、命名管道一样,从消息队列中读出消息,消息队列中对应的数据都会被删除;

每个消息队列都有消息队列标识符,消息队列的标识符在整个系统中是唯一的;

消息队列是消息的链表,存放在内存中,由内核维护。只有内核重启或人工删除消息队列时,该消息队列才会被删除。若不人工删除消息队列,消息队列会一直存在于系统中。

  • 3.抽象的理解消息队列
    对于消息队列的操作,我们可以类比为这么一个过程:假如 A 有个东西要给 B,因为某些原因 A 不能当面直接给 B,这时候他们需要借助第三方托管(如银行),A 找到某个具体地址的建设银行,然后把东西放到某个保险柜里(如 1 号保险柜),对于 B 而言,要想成功取出 A 的东西,必须保证去同一地址的同一间银行取东西,而且只有 1 号保险柜的东西才是 A 给自己的。

而在消息队列操作中,键(key)值相当于地址,消息队列标示符相当于具体的某个银行,消息类型相当于保险柜号码。

同一个键(key)值可以保证是同一个消息队列,同一个消息队列标示符才能保证不同的进程可以相互通信,同一个消息类型才能保证某个进程取出是对方的信息。

select、poll、epoll区别

在这里插入图片描述
链接: 表格来源

select函数

  • 文件描述符的数量
    单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量;(在linux内核头文件中定义∶#define _FD_SETSIZE 1024)
  • 就绪fd采用轮询的方式扫描
    select返回的是int,可以理解为返回的是ready(准备好的)一个或者多个文件描述符,应用程序需要遍历整个文件描述符数组才能发现哪些fd句柄发生了事件,由于select采用轮询的方式扫描文件描述符(不知道那个文件描述符读写数据,所以需要把所有的fd都遍历),文件描述符数量越多,性能越差
  • 内核/用户空间内存拷贝
    select每次都会改变内核中的句柄数据结构集(fd集合),因而每次调用select都需要从用户空间向内核空间复制所有的句柄数据结构(fd集合),产生巨大的开销
  • select的触发方式
    select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次调用select还是会将这些文件描述符通知进程。

⭐优点

  • select的可移植性较好,可以跨平台;
  • select可设置的监听时间timeout精度更好,可精确到微秒,而poll为毫秒。

⭐缺点︰

  • select支持的文件描述符数量上限为1024,不能根据用户需求进行更改;
  • select每次调用时都要将文件描述符集合从用户态拷贝到内核态,开销较大;
  • select返回的就绪文件描述符集合,需要用户循环遍历所监听的所有文件描述符是否在该集合中,当监听描述符数量很大时效率较低。

epoll原理

int epoll_creat(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

首先创建一个epoll对象,然后使用epoll_ctl对这个对象进行操作,把需要监控的描述添加进去,这些描述如将会以epoll_event结构体的形式组成一颗红黑树,接着阻塞在epoll_wait,进入大循环,当某个fd上有事件发生时,内核将会把其对应的结构体放入到一个链表中,返回有事件发生的链表

epoll的两种触发模式(ET、LT模式区别)

  • 电平触发(level 模式):该模式就是只要还有没有处理的事件就会一直通知

    • 当epoll_wait检测到文件描述符上有事件发生,并将此事件通知应用程序之后,应用程序可以不立即处理该事件,当下次调用epoll_wait时,还会向应用程序通知这个事件,直到此事件被处理。

    • 如果用户没有处理就绪的文件描述符或者没有处理完,则内核会再次提醒

  • 边沿触发(edge 模式):该模式是当状态发生变化时才会通知

    • 当epoll_wait检测到文件描述符上有事件发生,并将此事件通知应用程序之后,应用程序必须立即处理该事件,并且需要将该事件处理完成,因为epoll_wait下次再被调用时,不会再向应用程序通知该事件。从而降低了同一事件被重复触发的次数,从而效率比LT模式高一些。

    • 内核只会将就绪描述符通知用户一次,如果用户没有处理就绪的文件描述符或者没有处理完,则内核不会再次提醒,只能等下次事件触发,内核将fd重新插入到rdllist中去。

epoll rd准备队列如何维护

  • 就绪列表引用着就绪的socket,所以它应能够快速的插入数据。

  • 程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。

  • 所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。

  • 既然epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构(对应上图的rbr)。

ps:因为操作系统要兼顾多种功能,以及由更多需要保存的数据,rdlist并非直接引用socket,而是通过epitem间接引用,红黑树的节点也是epitem对象。同样,文件系统也并非直接引用着socket。为方便理解,本文中省略了一些间接结构。

EPOLL跟你怎么处理多个任务有关系吗?

epoll为什么用红黑树

  • 为什么采用红黑树呢? 因为和epoll的工作机制有关。 epoll在添加一个socket或者删除一个socket或者修改一个socket的时候,它需要查询速度更快,操作效率最高,因此需要一个更加优秀的数据结构能够管理这些socket。
  • 我们想到的比如链表,数组,二叉搜索树,B+树等都无法满足要求, 因为链表在查询,删除的时候毫无疑问时间复杂度是O (n);
  • 数组查询很快,但是删除和新增时间复杂度是O (n);
  • 二叉搜索树虽然查询效率是lgn,但是如果不是平衡的,那么就会退化为线性查找,复杂度直接来到O (n);
  • B+树是平衡多路查找树,主要是通过降低树的高度来存储上亿级别的数据,但是它的应用场景是内存放不下的时候能够用最少的IO访问次数从磁盘获取数据。
  • 在这里插入图片描述

高并发下的epoll模型

  • epoll 接口 是为解决 Linux 内核处理大量文件描述符而提出的方案。该接口属于 Linux 下 多路 I/O 复用接口 中 select/poll 的增强。其经常应用于 Linux 下高并发服务型程序, 特别是在大量并发连接中只有少部分连接处于活跃下的情况 (通常是这种情况),在该情况下能显著的提高程序的 CPU 利用率。

  • epoll 采用的是 事件驱动,并且设计的十分高效。在用户空间获取事件时,不需要去遍历被监听描述符集合中所有的文件描述符,而是遍历那些被内核 I/O 事件异步唤醒之后 加入到就绪队列 并返回到用户空间的描述符集合。

  • epoll 提供了 两种触发模式,水平触发 (LT) 和边沿触发(ET)。当然,涉及到 I/O 操作也必然会有阻塞和非阻塞两种方案。目前效率相对较高的是 epoll+ET + 非阻塞 I/O 模型,在具体情况下应该合理选用当前情形中最优的搭配方案。

socket编程的流程(socket,bind,listen,accept,read/write,close)

为了能够正常让客户端能正常连接到服务器,服务器必须遵循以下处理流程:

  • ①、调用 socket()函数打开套接字,得到套接字描述符;
  • ②、调用 bind()函数将套接字与 IP 地址、端口号进行绑定;
  • ③、调用 listen()函数让服务器进程进入监听状态;
  • ④、调用 accept()函数获取客户端的连接请求并建立连接;
  • ⑤、调用 read/recv、 write/send 与客户端进行通信;
  • ⑥、调用 close()关闭套接字。

总结: 服务端先建立一个sockaddr_in这样的一个结构体存放ip地址和port号,然后再使用bind()函数把它和一个socket变量(sock)结合,然后开始listen监听套接字sock,再新建一个client的sockaddr_in这样一个结构,然后就可以利用accept函数去sock接受这样的一个client连接了。再之后就可以用recv函数来接收数据了。

socket api详细说Bind绑定哪些信息,可以不bind吗?

accept是阻塞还是非阻塞,有什么区别,怎么设置

  • 阻塞 accept

    • 服务器在繁忙过程时, 在建立三次握手之后, 调用accept之前, 如果出现客户端突然断开连接的情况, POSIX 指出这种情况 errno 设置为 CONNABORTED.

    • 如: 三次握手之后, 客户端发送 RST后断开连接, 之后服务端调用accept准备执行连接但并不知道对端已经关闭, 这个时候accept就会阻塞, 直到有下一个已完成的连接准备好被accept为止. 这个问题在 TCP可能出现的异常[1] 中提到过, 只是实验没有成功.

    • 上述描述的整个过程如下 :

      • 服务器的监听的 socket 可读, 但服务器要在一段时间之后 (实验中采用sleep模仿) 才能调用accept.
      • 服务器调用accept之前, 收到从客户发送过来的 RST;
      • 这个已经完成的连接被从已完成连接队列中被删除;
      • 服务器调用 accept, 但是由于没有其它已完成的连接存在, 因而服务器被阻塞了.
      • 服务器会被一直阻塞在accept调用上, 直到另外一个客户建立一个连接为止; 但如果一直没有其它客户建立连接, 那么服务器将仍然一直被阻塞在accept调用上, 不处理任何其他已就绪的 socket. 这样的就严重降低了服务器的利用率.
  • 非阻塞 accept

    • 上述的问题也很容易解决, 解决这个问题的办法:

    • 如果使用 select 来获知何时有已完成连接时, 总是把监听 socket 设置为非阻塞模式,并且在 accept 调用中忽略以下错误 : EWOULDBLOCK (Berkeley : 客户放弃连接时出现的错误)、 ECONNABORTED (POSIX : 客户放弃连接时出现的错误)、 EPROTO (SVR4 : 客户放弃连接时出现的错误) 和 EINTR (信号中断).

⭐怎么设置

  • 默认情况下socket是blocking的,即函数accept(), recv/recvfrom, send/sendto,connect等,需等待函数执行结束之后才能够返回(此时操作系统切换到其他进程执行)。accpet()等待到有client连接请求并接受成功之后,recv/recvfrom需要读取完client发送的数据之后才能够返回。

  • 可设置socket为non-blocking模式,即调用函数立即返回,而不是必须等待满足一定条件才返回。

  • 设置socket为非阻塞non-blocking

    • 使用socket()创建的socket(file descriptor),默认是阻塞的(blocking);使用函数fcntl()(file control)可设置创建的socket为非阻塞的non-blocking。
 #include <unistd.h>
    #include <fcntl.h>

    sock = socket(PF_INET, SOCK_STREAM, 0);

    int flags = fcntl(sock, F_GETFL, 0);
    fcntl(sock, F_SETFL, flags | O_NONBLOCK); 

这样使用原本blocking的各种函数,可以立即获得返回结果。通过判断返回的errno了解状态:

  • accept(): 在non-blocking模式下,如果返回值为-1,且errno == EAGAIN或errno == EWOULDBLOCK表示no connections没有新连接请求;

端口只有65536个,那么连接只能建立这么多吗

系统用一个4四元组来唯一标识一个TCP连接:
在这里插入图片描述
client最大tcp连接数

  • client每次发起tcp连接请求时,除非绑定端口,通常会让系统选取一个空闲的本地端口(local port),该端口是独占的,不能和其他tcp连接共享。tcp端口的数据类型是unsigned short,因此本地端口个数最大只有65536,端口0有特殊含义,不能使用,这样可用端口最多只有65535,所以在全部作为client端的情况下,一个client最大tcp连接数为65535,这些连接可以连到不同的serverip。

server最大tcp连接数

  • server通常固定在某个本地端口上监听,等待client的连接请求。不考虑地址重用(unix的SO_REUSEADDR选项)的情况下,即使server端有多个ip,本地监听端口也是独占的,因此server端tcp连接4元组中只有remoteip(也就是clientip)和remote port(客户端port)是可变的,因此最大tcp连接为客户端ip数×客户端port数,对IPV4,不考虑ip地址分类等因素,最大tcp连接数约为2的32次方(ip数)×2的16次方(port数),也就是server端单机最大tcp连接数约为2的48次方。

实际的tcp连接数

  • 上面给出的是理论上的单机最大连接数,在实际环境中,受到机器资源、操作系统等的限制,特别是sever端,其最大并发tcp连接数远不能达到理论上限。在unix/linux下限制连接数的主要因素是内存和允许的文件描述符个数(每个tcp连接都要占用一定内存,每个socket就是一个文件描述符),另外1024以下的端口通常为保留端口。
vim /etc/sysctl.conf 这个文件进行修改
net.ipv4.ip_local_port_range = 60000 60009

OSI七层模型

应用层:网络与用户应用软件之间的接口服务
表示层:格式化的表示与转换服务,例:加密、压缩
会话层:访问验证和会话管理在内的建立、维护之间的通信机制
传输层:建立、维护和取消传输连接功能,负责可靠传输数据PC
网络层:处理网络路由,确保数据及时传送(路由器)
数据链路层:负责无错传输,确认帧、发错重传(交换机)
物理层:提供机械、电气和过程特性,例:网卡、网线等

其中应用层、表示层和会话层由软件控制,传输层、网络层和数据链路层由操作系统控制,物理层有物理设备控制。

UDP需要LT和ET吗,为什么

TCP/IP参考模型及协议

  • ⭐TCP/IP协议称为传输控制协议/互联网协议,又称网络通讯协议。由网络层的IP协议传输层的TCP协议组成。
  • 物理层和数据链路层支持所有标准和专用协议
  • 网络层定义了网络互联——IP协议(包括IP、ARP、RARP、ICMP、IGMP)
  • 传输层定义了TCP和UDP协议
  • 应用层定义了HTTP、FTP、DNS协议

TCP数据流传输如何理解(怎么确定边界)

  • TCP的数据流大致可以分为两类,交互数据流成块的数据流 。交互数据流就是发送控制命令的数据流,比如relogin,telnet,ftp命令等等;成块数据流是用来发送数据的包,网络上大部分的TCP包都是这种包。
  • 很明显,TCP在传输这两种类型的包时的效率是不一样的,因此为了提高TCP的传输效率,应该对这两种类型的包采用不同的算法。
  • 总之,TCP的传输原则是尽量减少小分组传输的数量。
    • TCP成块数据流相关的东西有很多,比如流量控制,紧急数据传输,数据窗口大小调整等等。

TCP讲一下三次握手和四次挥手

1、建立连接协议(三次握手)

(1)客户端发送一个带SYN标志的TCP报文到服务器。这是三次握手过程中的报文1.
  (2) 服务器端回应客户端的,这是三次握手中的第2个报文,这个报文同时带ACK标志和SYN标志。因此它表示对刚才客户端SYN报文的回应;同时又标志SYN给客户端,询问客户端是否准备好进行数据通讯。
  (3) 客户必须再次回应服务段一个ACK报文,这是报文段3.
  在这里插入图片描述

2、连接终止协议(四次握手)

由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。这原则是当一方完成它的数据发送任务后就能发送一个FIN来终止这个方向的连接。收到一个 FIN只意味着这一方向上没有数据流动,一个TCP连接在收到一个FIN后仍能发送数据。首先进行关闭的一方将执行主动关闭,而另一方执行被动关闭。

(1) TCP客户端发送一个FIN,用来关闭客户到服务器的数据传送(报文段4)。
  (2) 服务器收到这个FIN,它发回一个ACK,确认序号为收到的序号加1(报文段5)。和SYN一样,一个FIN将占用一个序号。
  (3) 服务器关闭客户端的连接,发送一个FIN给客户端(报文段6)。
  (4) 客户段发回ACK报文确认,并将确认序号设置为收到序号加1(报文段7)。
  在这里插入图片描述

为什么要三次握手

  • TCP 之所以需要三次握手的主要原因是为了防止在网络环境比较差的情况下不会进行无效的连接,同时三次握手可以实现 TCP 初始化序列号的确认工作,TCP 需要初始化一个序列号来保证消息的顺序。如果是两次握手则不能确认序列号是否正常,如果是四次握手的话会浪费系统的资源,因此 TCP 三次握手是最优的解决方案,所以 TCP 连接需要三次握手。

UDP和TCP的区别

  • TCP/IP协议是一个协议簇。里面包括很多协议的,UDP只是其中的一个, 之所以命名为TCP/IP协议,因为TCP、IP协议是两个很重要的协议,就用他两命名了。
  • TCP/IP协议集包括应用层,传输层,网络层,网络访问层。
  • 1、基于连接与无连接;
  • 2、对系统资源的要求(TCP较多,UDP少);
  • 3、UDP程序结构较简单;
  • 4、流模式与数据报模式 ;
  • 5、TCP保证数据正确性,UDP可能丢包;
  • 6、TCP保证数据顺序,UDP不保证。

TCP三次握手过程,有什么状态,状态机如何变化?

TCP的应答机制

  • 在TCP中,当发送端的数据达到接收主机时,接收端主机会返回一个已收到消息的通知,这个消息叫做ACK(确认应答
  • TCP通过肯定的ACK实现可靠的数据传输。当发送端将数据发出之后会等待对端的确认应答,如果有确认应答,说明数据已经成功到达,如果没有,那么数据有可能丢失了,在一定时间内没有等到确认应答,发送端就可以认为数据已经丢失,就会进行重发
  • 未收到确认应答也并不意味着数据一定丢失,有时也有可能是因为数据收到,但是ACK却在传输的途中丢了。因此这种情况也会导致发送端因没有及时收到ACK,而认为数据没有到达目的地,从而进行重传

TCP为了保证可靠性有哪些措施

TCP保证可靠性一般有以下几种方法:
(1)确认应答:ACK和序列号
(2)超时重传:发送数据包在一定的时间周期内没有收到相应的ACK,等待一定的时间,超时之后就认为这个数据包丢失,就会重新发送
(3)流量控制:控制发送方发送窗口的大小来实现流量控制
(4)拥塞控制:控制传输上流量

TCP的三次握手一定能保证传输可靠么?

  • 不能,三次握手比两次更可靠,但也不是完全可靠,而追加更多次握手也不能使连接更可靠了。因此选择了三次握手。
    世界上不存在完全可靠的通信协议。从通信时间成本空间成本以及可靠度来讲,选择了“三次握手”作为点对点通信的一般规则。

为什么 TCP 第二次握手的 SYN 和 ACK 要合并成一次?

  • 分开两次发送,浪费资源

TCP握手的目的有哪些?

三次握手的目的是同步连接双方的序列号和确认号并交换 TCP 窗口大小信息。

什么是 TIME_WAIT 状态,为什么需要 TIME_WAIT 状态?时间是多久,为什么?

  • 四次挥手客户端接受到服务端 FIN 报文后返回 ACK 报文的状态
  • 可以防止 ACK 报文丢失,服务器没有收到会重复发 FIN 报文
  • 而 TIME_WAIT 的长度为 2*MSL 这样 ACK 丢失了,FIN 再次发送,在这时间里客户端还能收到 FIN报文

服务器大量closewait 客户端只有少量连接怎么回事

  • 在被动关闭连接情况下,在已经接收到FIN,但是还没有发送自己的FIN的时刻,连接处于CLOSE_WAIT状态。
    通常来讲,CLOSE_WAIT状态的持续时间应该很短,正如SYN_RCVD状态。但是在一些特殊情况下,就会出现连接长时间处于CLOSE_WAIT状态的情况。

  • 出现大量close_wait的现象,主要原因是某种情况下对方关闭了socket链接,但是我方忙与读或者写,没有关闭连接。代码需要判断socket,一旦读到0,断开连接,read返回负,检查一下errno,如果不是AGAIN,就断开连接。

解决方法
基本的思想就是要检测出对方已经关闭的socket,然后关闭它。

1.代码需要判断socket,一旦read返回0,断开连接,read返回负,检查一下errno,如果不是AGAIN,也断开连接。(注:在UNP 7.5节的图7.6中,可以看到使用select能够检测出对方发送了FIN,再根据这条规则就可以处理CLOSE_WAIT的连接)
2.给每一个socket设置一个时间戳last_update,每接收或者是发送成功数据,就用当前时间更新这个时间戳。定期检查所有的时间戳,如果时间戳与当前时间差值超过一定的阈值,就关闭这个socket。
3.使用一个Heart-Beat线程,定期向socket发送指定格式的心跳数据包,如果接收到对方的RST报文,说明对方已经关闭了socket,那么我们也关闭这个socket。

大量SYN半连接,怎么预防?

半连接攻击:

  • 半连接攻击是一种攻击协议栈的攻击方式,坦白说就是攻击主机的一种攻击方式。通过将主机的资源消耗殆尽,从而导致应用层的程序无资源可用,导致无法运行。在正常情况下,客户端连接服务端需要通过三次握手,首先客户端构造一个SYN连接数据包发送至服务端,自身进入SYN_SEND状态,当服务端收到客户端的SYN包之后,为其分配内存核心内存,并将其放置在半连接队列中,服务端接收客户SYN包并会向客户端发送一个SYN包和ACK包,此刻服务端进入SYN_RECV态。客户端收到包之后,再次向服务端发送ACK确认包。至此连接建立完成,双方都进入ESTABLSHEDZ状态。半连接就是通过不断地构造客户端的SYN连接数据包发向服务端,等到服务端的半连接队列满的时候,后续的正常用户的连接请求将会被丢弃,从而无法连接到服务端。此为半连接攻击方式。根据服务端的半连接队列的大小,不同主机的抵抗这种SYN攻击的能力也是不一样。

SYN攻击简介:

  • SYN攻击属于DoS攻击的一种,它利用TCP协议缺陷,通过发送大量的半连接请求,耗费CPU和内存资源,在所有黑客攻击事件中,SYN攻击是最常见又最容易被利用的一种攻击手法。相信很多人还记得2000年YAHOO网站遭受的攻击事例,当时黑客利用的就是简单而有效的SYN攻击,有些网络蠕虫病毒配合SYN攻击造成更大的破坏

SYN攻击原理与实现:

  • TCP三次握手的第二次握手时服务器接收到连接请求(syn= j),将此信息加入未连接队列,并发送请求包给客户(syn=k,ack=j+1),此时进入SYN_RECV状态。当服务器未收到客户端的确认包时,重发请求包,一直到超时或半连接数量超过半连接队列的最大值时,将此条目从未连接队列删除。
    SYN攻击利用TCP协议三次握手的原理,大量发送伪造源IP的SYN包也就是伪造第一次握手数据包,服务器每接收到一个SYN包就会为这个连接信息分配核心内存并放入半连接队列,如果短时间内接收到的SYN太多,半连接队列就会溢出,操作系统会把这个连接信息丢弃造成不能连接,当攻击的SYN包超过半连接队列的最大值时,正常的客户发送SYN数据包请求连接就会被服务器丢弃(CPU利用达到最大)。目标系统运行缓慢,严重者引起网络堵塞甚至系统瘫痪。每种操作系统半连接队列大小(Backlog参数)不一样所以抵御SYN攻击的能力也不一样。

TCP 和 UDP 的区别?

在这里插入图片描述
TCP: 面向连接、可靠的、基于字节流的传输层协议

  • 面向连接的
  • 通过三次握手建立连接,四次挥手解除连接
  • 可靠通信方式:超时重传、确认应答、拥塞控制确保无差错、不丢包、不重复、有序
  • TCP首部开销占20个字节
  • 点对点连接

UDP: 利用IP来提供面向无连接的一种通信协议

  • 面向无连接,不保证完整传输
  • 最大的传输效率不保证可靠交付
  • UDP首部占8个字节
  • 支持一对一、一对多、多对一及多对多交互通信

CAN和UDP的不同

⭐以太网采用超时重发机制,单点的故障容易扩散,造成整个网络系统的瘫痪;对工业环境的适应能力问题,目前工业以太网的鲁棒性和抗干扰能力等都是值得关注的问题;数据的传输距离长、传输速率高;易与Internet连接,低成本、易组网,与计算机、服务器的接口十分方便,受到了广泛的技术支持。

⭐CAN现场总线的数据通信具有突出的可靠性、实时性和灵活性。主要表现在CAN为多主方式工作; CAN总线的节点分成不同的优先级;采用非破坏仲裁技术;报文采用短帧结构,数据出错率极低;节点在错误严重的情况下可自动关闭输出。不能与Internet互连,不能实现远程信息共享。其次,它不易与上位控制机直接接口,现有的CAN接口卡与以太网网卡相比大都价格昂贵。还有, CAN现场总线无论是其通信距离还是通信速率都无法和以太网相比。

UDP和CAN总线的网络协议规范都遵循ISO /OSI参考模型的基本层次结构。
  • 以太网采用IEEE802参考模型,相当于OSI模型的最低两层,即物理层和数据链路层,其中数据链路层包含介质访问控制子层(MAC)和逻辑链路控制子层(LLC) 。
  • CAN现场总线的ISO /OSI参考模型也是分为两层,并与工业以太网的分层结构完全相同,但是二者在各层的物理实现及通信机理上却有很大的差别。工业以太网和CAN现场总线的各层在具体网络协议实现上的分析比较如下表所示。

TCP 拥塞控制?慢启动的时候窗口在什么情况下会增长?为什么会呈指数增长?

  • TCP 拥塞控制由三部分组成
    • 慢启动:每次收到一个 ACK 报文将拥塞窗口(cwnd)加上一个 MSS,从 1 开始成指数级增长

    • 拥塞避免:当 cwnd ≥ 慢启动阈值(sstresh)时,窗口按线性增长,当收到三个连续的冗余 ACK 后,进入快重启

    • 快重启:sstresh=cwnd, cwnd = sstresh + 3*MSS(三次冗余的)并发送丢失的报文,每次收到冗余的就指数级增长直到收到新的 ACK,进入拥塞避免或者超时进入慢重启
      在这里插入图片描述
      物理层的任务就是透明地传送比特流。在物理层上所传数据的单位是比特。传递信息所利用的一些物理媒体,如双绞线、同轴电缆、光缆等,并不在物理层之内而是在物理层的下面。因此也有人把物理媒体当做第0层。
      1、慢开始
      最开始发送方的拥塞窗口为1,由小到大逐渐增大发送窗口和拥塞窗口。每经过一个传输轮次,拥塞窗口cwnd加倍。当cwnd超过慢开始门限,则使用拥塞避免算法,避免cwnd增长过大。
      2、拥塞避免
      每经过一个往返时间RTT,cwnd就增长1。在慢开始和拥塞避免的过程中,一旦发现网络拥塞,就把慢开始门限设为当前值的一半,并且重新设置cwnd为1,重新慢启动。(乘法减小,加法增大)
      3、快重传接收方每次收到一个失序的报文段后就立即发出重复确认,发送方只要连续收到三个重复确认就立即重传(尽早重传未被确认的报文段)。
      4、快恢复
      当发送方连续收到了三个重复确认,就乘法减半(慢开始门限减半),将当前的cwnd设置为慢开始门限,并且采用拥塞避免算法(连续收到了三个重复请求,说明当前网络可能没有拥塞)。

采用快恢复算法时,慢开始只在建立连接和网络超时才使用。达到什么情况的时候开始减慢增长的速度?
采用慢开始和拥塞避免算法的时候
一旦cwnd>慢开始门限,就采用拥塞避免算法,减慢增长速度一旦出现丢包的情况,就重新进行慢开始,减慢增长速度
采用快恢复和快重传算法的时候
一旦cwnd>慢开始门限,就采用拥塞避免算法,减慢增长速度
一旦发送方连续收到了三个重复确认,就采用拥塞避免算法,减慢增长速度

GET和POST两种基本请求方法的区别

  • 表面上区别,GET把参数包含在URL中,POST通过request body传递参数
  • GET和POST本质上就是TCP链接,并无差别。但是由于HTTP的规定和浏览器/服务器的限制,导致他们在应用过程中体现出一些不同。
  • GET产生一个TCP数据包;POST产生两个TCP数据包。对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据);

https为什么是安全的

  • HTTPS 协议之所以是安全的是因为 HTTPS 协议会对传输的数据进行加密,而加密过程是使用了非对称加密实现。但其实,HTTPS 在内容传输的加密上使用的是对称加密,非对称加密只作用在证书验证阶段。
  • HTTPS的安全基础是TLS/SSL

讲一下https和http的区别

  1. HTTP 明文传输,数据都是未加密的,安全性较差,HTTPS(SSL+HTTP) 数据传输过程是加密的,安全性较好。
  2. 使用 HTTPS 协议需要到 CA(Certificate Authority,数字证书认证机构) 申请证书,一般免费证书较少,因而需要一定费用。证书颁发机构如:Symantec、Comodo、GoDaddy 和 GlobalSign 等。
  3. HTTP 页面响应速度比 HTTPS 快,主要是因为 HTTP 使用 TCP 三次握手建立连接,客户端和服务器需要交换 3 个包,而 HTTPS除了 TCP 的三个包,还要加上 ssl 握手需要的 9 个包,所以一共是 12 个包。
  4. http 和 https 使用的是完全不同的连接方式,用的端口也不一样,前者是 80,后者是 443。
  5. HTTPS 其实就是建构在 SSL/TLS 之上的 HTTP 协议,所以,要比较 HTTPS 比 HTTP 要更耗费服务器资源。

Web服务器为什么要去关闭长时间没有请求的连接

HTTP的长连接和短连接

HTTP长连接和短连接实质上是TCP的长连接和短连接。

  • TCP的短连接,client/server之间传递一次读写操作,然后由client断开连接,特点是管理起来比较简单,存在的链接都是有用的连接(主要此时的连接都会进行数据传输),不需要额外的控制手段。
  • TCP的长连接,C/S进行一次读写操作以后不进行关闭连接,后续读写操作还回去使用这个连接。
  • 此时使用的是TCP的保活功能,保活功能主要是为服务器应用提供的,服务器应用希望知道客户主机是否崩溃。
    长连接:
    建立连接——数据传输。。。(保持连接)。。。数据传输——关闭连接
    短连接:
    建立连接——数据传输——关闭连接。。。建立连接——数据传输。。。
    长短连接优缺点
  • 长连接可以省去较多的TCP建立和关闭的操作,减少浪费,节约时间。对于频繁请求资源的客户来说,适用长连接。长连接需要进行TCP的保活探测,还有就是它只是探测TCP的存活,如果遇到恶意的链接,保活功能就不应该使用。
  • 在长连接下,client不会主动去关闭连接,这样随着客户端连接越来越多,server就会出现问题,太多连接要去分配数据结构来操作。
    这个时候就需要采取一些措施,例如关闭一部分长时间没有使用的连接,防止出现服务器崩溃,如果条件允许,就可以限制客户端的最大长连接数目,防止一台客户端机器去占有太多对服务器产生损害。

输入URL到显示页面的全过程

  • 浏览器根据请求的 URL 交给 DNS 域名解析,找到真实 IP ,向服务器发起请求;

  • 服务器交给后台处理完成后返回数据,浏览器接收⽂件( HTML、JS、CSS 、图象等);

  • 浏览器对加载到的资源( HTML、JS、CSS 等)进⾏语法解析,建立相应的内部数据结构 (如 HTML 的 DOM);

  • 载⼊解析到的资源⽂件,渲染页面,完成。

详细简版:
1.从浏览器接收 url 到开启⽹络请求线程(这⼀部分可以展开浏览器的机制以及进程与线程 之间的关系)
2.开启⽹络线程到发出⼀个完整的 HTTP 请求(这⼀部分涉及到dns查询, TCP/IP 请求,五层因特⽹协议栈等知识)
3.从服务器接收到请求到对应后台接收到请求(这⼀部分可能涉及到负载均衡,安全拦截以及后台内部的处理等等)
4.后台和前台的 HTTP 交互(这⼀部分包括 HTTP 头部、响应码、报⽂结构、 cookie 等知 识,可以提下静态资源的 cookie 优化,以及编码解码,如 gzip 压缩等)
5.单独拎出来的缓存问题, HTTP 的缓存(这部分包括http缓存头部, ETag , catchcontrol 等)
6.浏览器接收到 HTTP 数据包后的解析流程(解析 html、 词法分析然后解析成 dom 树、解析 css ⽣成 css 规则树、合并成 render 树,然后 layout 、 painting 渲染、复合图层的合成、 GPU 绘制、外链资源的处理、 loaded 和 DOMContentLoaded 等)
7.CSS 的可视化格式模型(元素的渲染规则,如包含块,控制框, BFC , IFC 等概念)
8.JS 引擎解析过程( JS 的解释阶段,预处理阶段,执⾏阶段⽣成执⾏上下⽂, VO ,作 ⽤域链、回收机制等等)
9.其它(可以拓展不同的知识模块,如跨域,web安全, hybrid 模式等等内容)

搜索baidu会用到计算机网络中的什么层?每层的作用

浏览器中输入URL浏览器要将URL解析为IP地址,解析域名就要用到DNS协议,首先主机会查询DNS的缓存,如果没有就给本地DNS发送查询请求。DNS查询分为两种方式,一种是递归查询,一种是迭代查询。如果是迭代查询,本地的DNS服多器,回根理(服务器发送查询请求,根域名服务器告知该域名的一级域名服务器,然后本地服务器给该一级域名服务器发送查询请求,然后依次类推直到查询到该域名的IP地址。DNS服务器是基于UDP的,因此会用到UDP协议。
得到IP地址后,浏览器就要与服务器建立一个http连接。因此要用到http协议,http协议报文格式上面已经提到。http生成一个get请求报文,将该报文传给TCP层处理,所以还会用到TCP协议。如果采用https还会使用https协议先对http数据进行加密。TCP层如果有需要先将HTTP数据包分片,分片依据路径MTU和MSS。TCP的数据包然后会发送给IP层,用到IP协议。IP层通过路由选路,一跳一跳发送到目的地址。当然在一个网段内的寻址是通过以太网协议实现(也可以是其他物理层协议,比如PPP,SLIP),网协议需要直到目的IP地址的物理地址,有需要ARP协议。

1、DNS协议,http协议,https协议属于应用层
应用层是体系结构中的最高层。应用层确定进程之间通信的性质以满足用户的需要。这里的进程就是指正在运行的程序。应用层不仅要提供应用进程所需要的信息交换和远地操作,而且还要作为互相作用的应用进程的用户代理,来完成一些为进行语义上有意义的信息交换所必须的功能。应用层直接为用户的应用进程提供服务。
2、TCP/UDP属于传输层
传输层的任务就是负责主机中两个进程之间的通信。因特网的传输层可使用两种不同协议:即面向连接的传输控制协议TCP,和无连接的用户数据报协议UDP。面向连接的服务能够提供可靠的交付,但无连接服务则不保证提供可靠的交付,它只是“尽最大努力交付”。这两种服务方式都很有用,备有其优缺点。在分组交换网内的各个交换结点机都没有传输层。

3、IP协议,ARP协议属于网络层
网络层负责为分组交换网上的不同主机提供通信。在发送数据时,网络层将运输层产生的报文段或用户数据报封装成分组或包进行传送。在TCP/IP体系中,分组也叫作IP数据报,或简称为数据报。网络层的另一个任务就是要选择合适的路由,使源主机运输层所传下来的分组能够交付到目的主机。
4、数据链路层当发送数据时,数据链路层的任务是将在网络层交下来的IP数据报组装成帧,在两个相邻结点间的链路上传送以帧为单位的数据。每一帧包括数据和必要的控制信息(如同步信息、地址信息、差错控制、以及流量控制信息等)。控制信息使接收端能够知道一个帧从哪个比特开始和到哪个比特结束。控制信息还使接收端能够检测到所收到的帧中有无差错。
5、物理层
物理层的任务就是透明地传送比特流。在物理层上所传数据的单位是比特。传递信息所利用的一些物理媒体,如双绞线、同轴电缆、光缆等,并不在物理层之内而是在物理层的下面。因此也有人把物理媒体当做第0层。

进程是什么?

  • 进程是操作系统中最重要的抽象概念之一,是资源分配的基本单位,是独立运行的基本单位。

  • 进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。

  • 上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

  • 进程一般由以下的部分组成:

    • 进程控制块PCB,是进程存在的唯一标志,包含进程标识符PID,进程当前状态,程序和数据地址,进程优先级、CPU现场保护区(用于进程切换),占有的资源清单等。

    • 程序段

    • 数据段

程序和进程各是什么?

  • 程序 只是一段可以执行的代码文件,通俗讲在 linux 上就是一个可执行文件。当一个程序运行时就被称为进程,即进程是运行状态的程序。

  • 程序存储了一系列文件信息,这些信息描述了如何在运行时创建一个进程,包含了下面的内容:

    • 二进制格式标识:  描述可执行文件的元信息,内核利用该信息解释文件中的其他信息
    • 机器语言指令:   对程序进行编码
    • 程序入口地址:   标示程序开始执行的起始指令位置
    • 数据:   程序所包含变量的初始值和程序所使用的字面常量值
    • 符号表和重定位表: 描述程序中函数和变量的位置,重定位表记录要修改的符号引用的位置,以及如何修改
    • 共享库和动态链接信息:列出程序运行时需要使用的共享库以及加载共享库的动态链接器的路径名
    • 其他信息: 描述如何创建进程

内核在加载程序的时候会为其分配一个唯一标识符即进程号,linux 内核限制进程号需要小于等于32767每当创建一个进程的时候,内核就会顺序将下一个可用进程分配给其使用, 当进程号大于32767时,内核会重置进程号计数器,然后开始重新分配。 因为内核会运行一些守护进程和系统进程,所有一般会预留一些进程号给这些程序使用,所以一般从300开始重置, 类似于端口号1-1024为系统占用。

什么是线程?

  • 是进程划分的任务,是一个进程内可调度的实体,是CPU调度的基本单位,用于保证程序的实时性,实现进程内部的并发。

  • 线程是操作系统可识别的最小执行和调度单位。每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。

  • 每个线程完成不同的任务,但是属于同一个进程的不同线程之间共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。

为什么需要线程?

线程产生的原因: 进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;但是其具有一些缺点:

  • 进程在同一时刻只能做一个任务,很多时候不能充分利用CPU资源。

  • 进程在执行的过程中如果发生阻塞,整个进程就会挂起,即使进程中其它任务不依赖于等待的资源,进程仍会被阻塞。

引入线程就是为了解决以上进程的不足,线程具有以下的优点:

  • 从资源上来讲,开辟一个线程所需要的资源要远小于一个进程。

  • 从切换效率上来讲,运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间(这种时间的差异主要由于缓存的大量未命中导致)。

  • 从通信机制上来讲,线程间方便的通信机制。对不同进程来说,它们具有独立的地址空间,要进行数据的传递只能通过进程间通信的方式进行。线程则不然,属于同一个进程的不同线程之间共享同一地址空间,所以一个线程的数据可以被其它线程感知,线程间可以直接读写进程数据段(如全局变量)来进行通信(需要一些同步措施)。

线程的同步和互斥

  • 初始时设置mutex=1
  • thread1 进入临界区 进行P操作(减1到0),退出时V操作,唤醒thread2,thread2开始执行(同步操作)。

进程有哪些状态?

进程在运行过程有三种状态:就绪、运行、阻塞,创建和退出状态描述的是进程的创建过程和退出过程。

  • 就绪(Ready):程序等待执行

  • 运行(Running):程序正在执行,此线程正在执行,正在占用时间片

  • 阻塞(Blocked):进程执行了某些操作需要等待其运行,如:IO 操作

还有两种特殊的状态

  • 初始(Initial):进程在创建时的状态,操作系统在创建进程时要进行的工作包括分配和建立进程控制块表项、建立资源表格并分配资源、加载程序并建立地址空间

  • 最终(Final):退出但没有清理,使得其他进程可以得取返回值,时间片已用完,此线程被强制暂停,等待下一个属于他的时间片到来,进程已结束,所以也称结束状态,释放操作系统分配的资源

进程的状态与状态转换

进程在运行时有三种基本状态:就绪态、运行态和阻塞态

  • 运行(running)态:进程占有处理器正在运行的状态。进程已获得CPU,其程序正在执行。在单处理机系统中,只有一个进程处于执行状态;在多处理机系统中,则有多个进程处于执行状态。

  • 就绪(ready)态:进程具备运行条件,等待系统分配处理器以便运行的状态。 当进程已分配到除CPU以外的所有必要资源后,只要再获得CPU,便可立即执行,进程这时的状态称为就绪状态。在一个系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列。

  • 阻塞(wait)态:又称等待态或睡眠态,指进程不具备运行条件,正在等待某个时间完成的状态。

各状态之间的转换:

  • 就绪→执行 处于就绪状态的进程,当进程调度程序为之分配了处理机后,该进程便由就绪状态转变成执行状态。

  • 执行→就绪 处于执行状态的进程在其执行过程中,因分配给它的一个时间片已用完而不得不让出处理机,于是进程从执行状态转变成就绪状态。

  • 执行→阻塞 正在执行的进程因等待某种事件发生而无法继续执行时,便从执行状态变成阻塞状态。

  • 阻塞→就绪 处于阻塞状态的进程,若其等待的事件已经发生,于是进程由阻塞状态转变为就绪状态。

进程调度算法

  • 最短任务优先(SJF)

  • 先来先出(FIFO)

  • 高响应比优先(HRRN)

  • 最短完成时间优先(STCF)

  • 时间片轮转(RR)

  • 多级反馈队列(MLFQ)

进程、线程区别,何时用进程何时用线程

⭐进程和线程的主要差别在于它们是 不同的操作系统资源管理方式

  • 1.进程有独立的地址空间,线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间;

  • 2.进程和线程切换时,需要切换进程和线程的上下文,进程的上下文切换时间开销远远大于线程上下文切换时间,耗费资源较大,效率要差一些;

  • 3.进程的并发性较低,线程的并发性较高;

  • 4.每个独立的进程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制

  • 5.系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源;

  • 6.一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。

  • 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。

  • 进程在执行过程中拥有独立的地址空间,而多个线程共享进程的地址空间。(资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)

  • 进程是资源分配的最小单位,线程是CPU调度的最小单位。

  • 通信:由于同一进程中的多个线程具有相同的地址空间,使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信(需要一些同步方法,以保证数据的一致性)。

  • 进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。

  • 进程间不会相互影响;一个进程内某个线程挂掉将导致整个进程挂掉。

  • 进程适应于多核、多机分布;线程适用于多核。

进程和线程的区别?哪些资源是线程共享的,哪些是线程独占的?

区别:

  • 1.线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;

  • 2.一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;

  • 3.进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;

  • 4.调度和切换:线程上下文切换比进程上下文切换要快得多。

共享资源

  • 1、进程申请的堆内存
  • 2、进程打开的文件描述符
  • 3、进程的全局数据(可用于线程之间通信)
  • 4、进程ID、进程组ID
  • 5、进程目录
  • 6、信号处理器

独占资源

  • 1、线程ID
    • 同一进程中每个线程拥有唯一的线程ID。
  • 2、寄存器组的值
    • 由于线程间是并发运行的,每个线程有自己不同的运行线索,当从一个线 程切换到另一个线程上 时,必须将原有的线程的寄存器集合的状态保存,以便将来该线程在被重新切换到时能得以恢复。
  • 3、线程堆栈
    • 线程可以进行函数调用,必然会使用大函数堆栈。
  • 4、错误返回码
    • 线程执行出错时,必须明确是哪个线程出现何种错误,因此不同的线程应该拥有自己的错误返回码变量。
  • 5、信号屏蔽码
    • 由于每个线程所感兴趣的信号不同,所以线程的信号屏蔽码应该由线程自己管理。但所有的线程都共享同样的信号处理器。
  • 6、线程的优先级
    • 由于线程需要像进程那样能够被调度,那么就必须要有可供调度使用的参数,这个参数就是线程的优先级。

进程和线程的基本API

在这里插入图片描述

什么是孤儿进程?僵尸进程?

  • 1、孤儿进程:父进程退出,子进程还在运行的这些子进程都是孤儿进程,孤儿进程将被init进程(1号进程)所收养,并由init进程对他们完成状态收集工作。

  • 2、僵尸进程:进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait 获waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中的这些进程是僵尸进程

  • 1.孤儿进程

    • 孤儿进程是指一个父进程退出后,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。
      孤儿进程将被init进程(进程号为1)所收养,并且由init进程对它们完整状态收集工作,孤儿进程—般不会产生任何危害。
  • 2.僵尸进程

    • 僵尸进程是指一个进程使用fork()函数创建子进程,果子进程退出,而父进程并没有调用wt()或者wtpid()系统调用取得子进程的终止状态,那么子进程的进程描述符仍然保存在系统中,占用系统资源,这种进程称为僵尸进程。
  • 3.解决僵尸进程

    • —般,为了防止产生僵尸进程,在fork()子进程之后我们都要及时在父进程中使用wt()或者wtpid()系统调用,等子进程结束后,父进程回收子进程PCB的资源。同时,当子进程退出的时候,内核都会给父进程一个SIGCHLD信号,所以可以建立一个捕获SIGCHLD的信号处理函数,在函数体中调用wt()或wtpid(),就可以清理退出的子进程以达到防止僵尸进程的目的。

fork,wait,exec函数

  • 父进程产生子进程使用fork拷贝出来一个父进程的副本,此时只拷贝了父进程的页表,两个进程都读同一块内存,当有进程写的时候使用写实拷贝机制分配内存
  • exec函数可以加载一个elf文件去替换父进程,从此父进程和子进程就可以运行不同的程序
  • fork从父进程返回子进程的pid,从子进程返回0
  • 调用了wait的父进程将会发生阻塞,直到有子进程状态改变,执行成功返回0,错误返回-1
  • exec执行成功则子进程从新的程序开始运行,无返回值,执行失败返回-1

写时拷贝

⭐写时拷贝顾名思义就是“写的时候才分配内存空间”,这实际上是一种拖延战术。

  • 传统的fork()系统调用直接把所有的资源复制给新创建的进程,这种实现过于与并且效率低下,因为它拷贝的数据或许可以共享,或者有时候fork()创建新的子进程后,子进程往往要调用一种exec函数以执行另一个程序。
  • 而exec 函数会用磁盘关上的一个新程序替换当前子进程的正文段、数据段、支于段和栈段,如果之前fork()时拷贝了内存,则这时被换了,这是没有意义的。Linux的 fork()使用写时拷贝(Copy-on-write)页实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。
  • 内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进出行,在此之前,只有以只读方式共享。这种技术使空间上的页的拷贝被推迟到实际发生写入的时候,大大提高了效率。

进程上下文切换的内容

  • 保存虚拟内存,栈,寄存器,程序计数器等

进程在哪些场景会进行上下文切换?

  • 时间片到了,IO 堵塞

上下文切换为什么资源消耗会比较高?消耗在什么地方?

  • 虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。哪个资源的切换效率更低?

  • 虚拟内存(因为切换后 TLB 无法被命中)

为什么需要进程间通信

数据传输:一个进程需要把它的数据发送给另一个进程 通知事件 资源共享 进程控制
进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联,
不能在一个进程中直接访问另一个进程的资源(例如打开的文件描述符)。但是,进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信

进程间通信方式

  • 管道,信号量,共享内存,消息队列,套接字通信

线程的通信方式

线程间无需特别的手段进行通信,因为线程间可以共享一份全局内存区域,其中包括初始化数据段、未初始化数据段,以及堆内存段等,所以线程之间可以方便、快速地共享信息。只需要将数据复制到共享(全局或堆)变量中即可。不过,要考虑线程的同步和互斥,应用到的技术有:

  • 1.信号
    • Linux中使用pthread_kill()函数对线程发信号。
  • 2.互斥锁、读写锁、自旋锁
    • 互斥锁确保同一时间只能有一个线程访问共享资源,当锁被占用时试图对其加锁的线程都进入阻塞状态(释放CPU资源使其由运行状态进入等待状态),当锁释放时哪个等待线程能获得该锁取决于内核的调度。
    • 读写锁当以写模式加锁而处于写状态时任何试图加锁的线程(不论是读或写)都阻塞,当以读状态模式加锁而处于读状态时“读”线程不阻塞,“写”线程阻塞。读模式共享,写模式互斥。
    • 自旋锁上锁受阻时线程不阻塞而是在循环中轮询查看能否获得该锁,没有线程的切换因而没有切换开销,不过对CPU的霸占会导致CPU资源的浪费。所以自旋锁适用于并行结构(多个处理器)或者适用于锁被持有时间短而不希望在线程切换产生开销的情况。
  • 3.条件变量
    • 条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的,条件变量始终与互斥锁一起使用。
  • 4.信号量
    • 信号量实际上是一个非负的整数计数器,用来实现对公共资源的控制。在公共资源增加的时候,信号量就增加;公共资源减少的时候,信号量就减少;只有当信号量的值大于0的时候,才能访问信号量所代表的公共资源。

进程间同步方式

  • 临界区、互斥、信号量、事件

  • 操作系统中,进程是具有不同的地址空间的,两个进程是不能感知到对方的存在的。有时候,需要多个进程来协同完成一些任务。当多个进程需要对同一个内核资源进行操作时,这些进程便是竞争的关系,操作系统必须协调各个进程对资源的占用,进程的互斥是解决进程间竞争关系的方法。进程互斥指若干个进程要使用同一共享资源时,任何时刻最多允许一个进程去使用,其他要使用该资源的进程必须等待,直到占有资源的进程释放该资源。当多个进程协同完成一些任务时,不同进程的执行进度不一致,这便产生了进程的同步问题。需要操作系统干预,在特定的同步点对所有进程进行同步,这种协作进程之间相互等待对方消息或信号的协调关系称为进程同步。进程互斥本质上也是一种进程同步。进程的同步方法:

  • 互斥锁

  • 读写锁

  • 条件变量

  • 记录锁(record locking)

  • 信号量

  • 屏障(barrier)

为什么会有线程同步的出现,从底层汇编的角度说一下

  • 操作系统中,属于同一进程的线程之间具有相同的地址空间,线程之间共享数据变得简单高效。遇到竞争的线程同时修改同一数据或是协作的线程设置同步点的问题时,需要使用一些线程同步的方法来解决这些问题。

    • 线程同步的方法:

    • 互斥锁

    • 读写锁

    • 条件变量

    • 信号量

    • 自旋锁

    • 屏障(barrier)

进程同步与线程同步有什么区别

  • 进程之间地址空间不同,不能感知对方的存在,同步时需要将锁放在多进程共享的空间。而线程之间共享同一地址空间,同步时把锁放在所属的同一进程空间即可。

使用线程有哪些好处、坏处?

  • 上下文切换代价小,通信方便
  • 资源同步麻烦,容易出错

多线程是怎么处理许多个任务一起到来的

线程数量在运行中是一成不变的吗

并发和并行的区别

  • 并发(concurrency):指宏观上看起来两个程序在同时运行,比如说在单核cpu上的多任务。但是从微观上看两个程序的指令是交织着运行的,指令之间交错执行,在单个周期内只运行了一个指令。这种并发并不能提高计算机的性能,只能提高效率(如降低某个进程的相应时间)。

  • 并行(parallelism):指严格物理意义上的同时运行,比如多核cpu,两个程序分别运行在两个核上,两者之间互不影响,单个周期内每个程序都运行了自己的指令,也就是运行了两条指令。这样说来并行的确提高了计算机的效率。所以现在的cpu都是往多核方面发展。

什么场景下用多线程比较好

为什么多线程比多进程性能好

如何解决线程安全问题

多线程如何保证线程同步(介绍一下动态线程池)

上下文切换

借用皮卡丘的介绍: 什么是协程?

⭐操作系统保持跟踪进程运行所需的所有状态信息,这种状态,也就是上下文。

为什么会有上下文的切换?

  • 在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从它上次停止的地方开始。

上下文切换为什么要陷入内核?

  • (1)上一个进程的上下文信息还在内存和处理器当中,我们要保存这些信息的话,就必须陷入到内核态才可以。

  • (2)创建一个新的进程,以及它的上下文信息,并且将控制权交给这个新进程,这些都只有在内核态才能实现。

在这里插入图片描述
进程的上下文切换涉及到从【用户态】->【内核态】->【用户态】的过程
在这里插入图片描述
线程 的上下文切换涉及到从【用户态】->【内核态】->【用户态】的过程
在这里插入图片描述
协程 只需在【用户态】即可完成上下文的切换

协程

  • 协程,即用户态线程。我们知道,在Linux下,线程有PCB,然后可以占用时间片去调度,但是在用户态线程中,该线程的执行不由内核做调度,由用户自己实现

  • 可以这么理解,在用户进程A中,再实现了个调度器,调度用户线程,这些线程不像之前的线程,内核是感知不到的,它们只能感知到A的存在,用户态线程之间时间片只能争取内核分给进程A的时间片。

线程和协程

协程上下文切换只涉及CPU上下文切换,而所谓的CPU上下文切换是指少量寄存器(PC / SP / DX)的值修改,协程切换非常简单,就是把当前协程的 CPU 寄存器状态保存起来,然后将需要切换进来的协程的 CPU 寄存器状态加载的 CPU 寄存器上就 ok 了。

大量的进程 / 线程出现了新的问题

  • 系统线程会占用非常多的内存空间
  • 过多的线程切换会占用大量的系统时间。

而协程刚好可以解决上述2个问题。协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。并且,协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。

协程的实现原理?无栈协程和有栈协程?独立栈和共享栈?

  • 协程本质是一个用户态的线程,通过跳转来实现
  • 有栈协程把局部变量放在新开的空间上,无栈协程直接使用系统栈使得CPU cache局部性更好,同时也使得无栈协程的中断和函数返回几乎没有区别
  • 通过独立栈实现的协程库中的每一个协程都有自己独立的栈空间,协程栈大小固定且互不干扰。
  • 通过共享栈实现的协程库中的每一个协程在运行时都使用一个公共的栈空间,当协程挂起时将自己的数据从共享栈拷贝到自己的独立栈,协程运行时又将数据从独立栈拷贝到共享栈运行

从请求队列里取请求是一个连接对应一个线程吗?

请求队列中请求的插入与取出的过程

Linux 中一个进程的虚拟内存分布长什么样?内核空间+用户空间

进程内存布局
在这里插入图片描述

    1. 代码区(Text egment):
    • 程序段(Text):程序代码在内存中的映射,存放函数体的二进制代码。

    • 代码区指令根据程序设计流程依次执行,对于顺序指令,则只会执行一次(每个进程),如果反复,则需要使用跳转指令,如果进行递归,则需要借助栈来实现。

    • 代码段: 代码段(code segment/textsegment )通常是指用来存放程序执行代码的一块内存区域。 这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序 在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。

    • 代码区的指令中包括操作码和要操作的对象(或对象地址引用)。如果是立即数(即具体的 数值,如5),将直接包含在代码中;如果是局部数据,将在栈区分配空间,然后引用该数据地址;如果是BSS区和数据区 在代码中同样将引用该数据地址。另外,代码段还规划了局部数据所申请的内存空间信息。

    1. 全局初始化数据区/静态数据区(Data Segment)
    • 初始化过的数据(Data):在程序运行初已经对变量进行初始化的数据。

    • 只初始化一次。

    • 数据段: 数据段(data segment )通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。

    • data段中的静态数据区存放的是程序中已初始化的全局变量、静态变量和常量。

    1. 未初始化数据区(BSS):
    • 未初始化过的数据(BSS):在程序运行初未对变量进行初始化的数据。

    • 在运行时改变其值。

    • BSS 段: BSS 段(bss segment )通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS 是英文Block Started by Symbol 的简称。

    • BSS 段属于静态内存分配, 即程序一开始就将其清零了。一般在初始化时BSS段部分将会清零。

    1. 堆区(heap):
    • 堆 (Heap):存储动态内存分配,需要程序员手工分配,手工释放.注意它与数据结构中的堆是两回事,分配方式类似于链表。

    • 用于动态内存分配。堆在内存中位于bss区和栈区之间。一般由程序员分配和释放,若程序员不释放,程序结束时有可能由OS回收。

    • 堆(heap): 堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc 等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。

    • 在将应用程序加载到内存空间执行时,操作系统负责代码段、数据段和BSS段的加载, 并将在内存中为这些段分配空间。栈段亦由操作系统分配和管理,而不需要程序员显示地管理;堆段由程序员自己管理,即显式地申请和释放空间。

    1. 栈区(stack):
    • 栈 (Stack):存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。

    • 由编译器自动分配释放,存放函数的参数值、局部变量的值等。

    • 存放函数的参数值、 局部变量的值,以及在进行任务切换时存放当前任务的上下文内容。其操作方式类似于数据结构中的栈。 每当一个函数被调用,该函数返回地址和一些关于调用的信息,比如某些寄存器的内容,被存储到栈区。 然后这个被调用的函数再为它的自动变量和临时变量在栈区上分配空间, 这就是C实现函数递归调用的方法。每执行一次递归函数调用,一个新的栈框架就会被使用, 这样这个新实例栈里的变量就不会和该函数的另一个实例栈里面的变量混淆。

    • 栈(stack) :栈又称堆栈, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧"{}"中定义的变量 (但不包括static 声明的变量,static 意味着在数据段中存放变量)。除此以外,在函数被调用时, 其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放 回栈中。由于栈的先进先出特点, 所以栈特别方便用来保存/ 恢复调用现场。

    • 从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。

内核空间和用户空间

  • Linux的虚拟地址空间范围为0~4G(intel x86架构32位),Linux内核将这4G字节的空间分为两部分,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为“用户空间”。

  • 因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。

  • Linux使用两级保护机制:0级供内核使用,3级供用户程序使用,每个进程有各自的私有用户空间(0~3G),这个空间对系统中的其他进程是不可见的,最高的1GB字节虚拟内核空间则为所有进程以及内核所共享。

  • 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。 虽然内核空间占据了每个虚拟空间中的最高1GB字节,但映射到物理内存却总是从最低地址(0x00000000),另外,使用虚拟地址可以很好的保护内核空间被用户空间破坏,虚拟地址到物理地址转换过程有操作系统和CPU共同完成(操作系统为CPU设置好页表,CPU通过MMU单元进行地址转换)。

  • 注: 多任务操作系统中的每一个进程都运行在一个属于它自己的内存沙盒中,这个沙盒就是虚拟地址空间(virtual address space),在32位模式下,它总是一个4GB的内存地址块。这些虚拟地址通过页表(page table)映射到物理内存,页表由操作系统维护并被处理器引用。每个进程都拥有一套属于它自己的页表。在这里插入图片描述

为什么要设计内核态、用户态两种状态

  • 为了避免操作系统和关键数据被用户程序破坏,将处理器的执行状态分为内核态和用户态。

  • 内核态是操作系统管理程序执行时所处的状态,能够执行包含特权指令在内的一切指令,能够访问系统内所有的存储空间。

  • 用户态是用户程序执行时处理器所处的状态,不能执行特权指令,只能访问用户地址空间。

  • 用户程序运行在用户态,操作系统内核运行在内核态。

  • 用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用;内核态线程共享内核地址空间

如何实现内核态和用户态的切换?

  • 处理器从用户态切换到内核态的方法有三种:系统调用、异常和外部中断

    • 系统调用是操作系统的最小功能单位,是操作系统提供的用户接口,系统调用本身是一种软中断。
    • 异常,也叫做内中断,是由错误引起的,如文件损坏、缺页故障等。
    • 外部中断,是通过两根信号线来通知处理器外设的状态变化,是硬中断。

这些方式都需要进入内核态吗(共享内存不需要)

malloc是通过什么方式进入内核

malloc()是一个API,这个函数在库中封装了系统调用brk。因此如果调用malloc,那么首先会引发brk系统调用执行的过程。brk()在内核中对应的系统调用服务例程为SYSCALL_DEFINE1(brk, unsigned long, brk),参数brk用来指定heap段新的结束地址,也就是重新指定mm_struct结构中的brk字段。

brk系统调用服务例程首先会确定heap段的起始地址min_brk,然后再检查资源的限制问题。接着,将新老heap地址分别按照页大小对齐,对齐后的地址分别存储与newbrk和okdbrk中。

brk()系统调用本身既可以缩小堆大小,又可以扩大堆大小。缩小堆这个功能是通过调用do_munmap()完成的。如果要扩大堆的大小,那么必须先通过find_vma_intersection()检查扩大以后的堆是否与已经存在的某个虚拟内存重合,如何重合则直接退出。否则,调用do_brk()进行接下来扩大堆的各种工作。

共享内存优缺点

⭐什么是共享内存?
共享内存

  • 优点:采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据: 一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建 立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回 文件的。因此,采用共享内存的通信方式效率是非常高的。

  • 缺点: 共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段(信号量)来进行进程间的同步工作。

什么是虚拟地址,什么是物理地址?

  • 地址空间是一个非负整数地址的有序集合。

  • 在一个带虚拟内存的系统中,CPU 从一个有N=pow(2,n)个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space),现代系统通常支持 32 位或者 64 位虚拟地址空间。

  • 一个系统还有一个物理地址空间(physical address space),对应于系统中物理内存的M 个字节。

  • 地址空间的概念是很重要的,因为它清楚地区分了数据对象(字节)和它们的属性(地址)。

  • 一旦认识到了这种区别,那么我们就可以将其推广,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟内存的基本思想。

  • 主存中的每字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。

什么是虚拟内存?

  • 为了更加有效地管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。通过一个很清晰的机制,虚拟内存提供了三个重要的能力:

  • 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。

  • 它为每个进程提供了一致的地址空间,从而简化了内存管理。

  • 它保护了每个进程的地址空间不被其他进程破坏。

为什么要用虚拟内存?

  • 虚拟内存作为缓存的工具 :将主存当作辅存的高速缓存,经常活动的东西放在主存中,就像 GTA5 几十 GB 大的东西都放主存中是放不下的,因此可以高效利用主存
  • 虚拟内存作为内存管理的工具 。操作系统为每个进程提供了一个独立的页表,也就是独立的虚拟地址空间。多个虚拟页面可以映射到同一个物理页面上。每个进程地址空间都一样,方便管理
  • 虚拟内存作为内存保护的工具。不应该允许一个用户进程修改它的只读段,也不允许它修改任何内核代码和数据结构,不允许读写其他进程的私有内存,不允许修改任何与其他进程共享的虚拟页面。避免进程破坏其他进程的地址空间

为什么虚拟内存的切换效率更低?

因为切换后 TLB 无法被命中

虚拟地址映射为物理地址的过程?

  • 虚拟地址由虚拟页号和页偏移两部分组成。通过虚拟地址的页面号,首先在快表中查询是否有该映射,查询不成功,在页表中找到该页对应的物理地址。然后通过页物理地址+页偏移,得到真实的物理地址

缺页中断

  • 什么是缺页中断 ,简单来说是因为操作系统采用了虚拟内存技术,程序代码/数据对应的内容并不一定是完全读入到内存中,在使用到时候发生缺页中断将对应的内容读入到内存中。

  • 当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:

    • 检查要访问的虚拟地址是否合法
    • 查找/分配一个物理页
    • 填充物理页内容(读取磁盘,或者直接置0,或者啥也不干)
    • 建立映射关系(虚拟地址到物理地址)
    • 重新执行发生缺页中断的那条指令

编程API接口

分配内存kmalloc()
重新分配内存krealloc()
释放内存kfree()

slab分配器

slab分配器(内存缓存的数据结构):每个内存缓存对应一个kmem_cache实例。每个内存节点对应一个kmem_cache_node实例。

kfree()函数如何知道对象属于哪个通用的内存缓存?

  • 根据对象的虚拟地址得到物理地址;
  • 根据物理地址获得物理页号;
  • 根据物理页号得到page实例;
  • 如果是复合页,需要得到首页的page实例;
  • 根据page实例的成员slab_cache得到kmem_cache实例。

slab为一个或者多个连续的物理页,起始地址总是页长度的整数倍,不同slab中相同偏移的位图在处理器的一级缓存中索引相同。

内存缓存存在的缺点?

  • 我们请求的内存长度和内存缓存对象长度相差太多
  • 动手实现:需要工程师专门创建内存缓存,主要编程API接口:创建内存缓存kmem_cache_create()、从刚创建内存缓存分配对象kmem_cache_alloc()、释放对象kmem_cache_free()\销毁内存缓存kmem_cache_destroy()。
    在这里插入图片描述
  • *name缓存名称;size缓存创建对象的大小;align每个对象对齐;flags分配缓存选项(比如我们加一个参数:SLAB_HWCACHE_ALIGN分配的空间对于硬件来说是对齐);ctor可选对象构造器(用户提供的回调函数)
    在这里插入图片描述

读写锁说一下,应用场景、原理

读写锁与互斥锁类似,但读写锁允许更高的并行性,其特性为:读共享,写独占,写锁优先级最高
读写锁特性:读写锁是写模式加锁时,解锁前,所有对该锁加锁的线程都会被阻塞
读写锁是读模式加锁时,如果线程以读模式对其加锁已经成功,其他线程试图以写模式加锁的线程将阻塞,以读模式加锁的线程不受影响;如果当前同时有试图读模式加锁和写模式加锁的线程,优先满足写模式加锁,读锁、写锁并行阻塞。

互斥锁和自旋锁

互斥锁

  • 互斥锁也称互斥量,保护临界区的特殊变量,有锁定和解锁两个状态:
    • 互斥锁锁定,即有线程持有这个互斥锁
    • 互斥锁解锁,即无线程持有互斥锁
  • 互斥锁内部有一个线程等待队列,用来保存等待互斥锁的线程。
    • 互斥锁锁定时,如果某个线程试图获得这个互斥锁,那么这个线程将阻塞在互斥锁的等待队列
    • 互斥锁解锁时,如果某个线程试图获得这个互斥锁,可以直接得到这个互斥锁而不会被阻塞
  • 互斥锁加锁失败而阻塞是由操作系统内核实现的,当加锁失败后,内核将线程置为睡眠状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程加锁成功后就可以继续执行。

互斥锁加锁失败后,会从用户态陷入到内核态,让内核帮助我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。

性能开销成本:两次线程上下文切换的成本。
1、当线程加锁失败时,内核将线程的状态从 【运行】 切换到睡眠状态,然后把CPU切换给其他线程运行;
2、当锁被释放时,之前睡眠状态的线程会变成就绪状态,然后内核就会在合适的时间把CPU切换给该线程运行;

线程切换的上下文?
  当两个线程属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
上下切换的耗时大概在几十纳秒到几微秒之间,如果锁住的代码执行时间比较短,可能上下文切换的时间比锁住的代码执行时间还要长。

自旋锁

  • 自旋锁和互斥锁类似,但其不是通过休眠使进程阻塞,而是在获取锁之前一直处于阻塞

  • 加锁过程:

    • 查看锁的状态,若是空闲的则执行2
    • 将锁设置为当前线程持有;
  • 使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会忙等待,直到拿到锁。忙等待可以通过while循环实现,不过最好是使用CPU提供的PAUSE指令来实现。

  • 自旋锁利用CPU周期一直自旋直到锁可用。由于一个自旋的线程永远不会放弃CPU,因此在单核CPU上,需要抢占式的调度器(不断通过时钟中断一个线程,运行其他线程)。

  • 自旋的时间和被锁住的代码执行的时间成正比关系。

选择

  • 若是能确定被锁住的代码执行时间很短,就不应该使用互斥锁,而应该选择自旋锁。
  • 当加锁失败,互斥锁使用线程切换应对,自旋锁用忙等待应对。

死锁是怎样产生的?(死锁)

死锁是指两个或两个以上进程在执行过程中,因争夺资源而造成的下相互等待的现象。产生死锁需要满足下面四个条件:

  • 互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源。

  • 占有并等待条件:进程获得一定的资源后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但该进程不会释放自己已经占有的资源。

  • 非抢占条件:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放。

  • 循环等待条件:进程发生死锁后,必然存在一个进程-资源之间的环形链。

如何解决死锁问题?

解决死锁的方法即破坏产生死锁的四个必要条件之一,主要方法如下:

  • 资源一次性分配,这样就不会再有请求了(破坏请求条件)。

  • 只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏占有并等待条件)。

  • 可抢占资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可抢占的条件。

  • 资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反,从而破坏环路等待的条件

了解过什么无锁的实现方式

  • 基于锁的编程的缺点

    • 多线程编程是多CPU系统在中应用最广泛的一种编程方式,在传统的多线程编程中,多线程之间一般用各种锁的机制来保证正确的对共享资源(share resources)进行访问和操作。
    • 在多线程编程中只要需要共享某些数据,就应当将对它的访问串行化。比如像++count(count是整型变量)这样的简单操作也得加锁,因为即便是增量操作这样的操作,,实际上也是分三步进行的:读、改、写(回)。
    • 更进一步,甚至内存变量的赋值操作都不能保证是原子的,比如在32位环境下运行这样的函数
    • 执行的过程中,这两条指令之间也是可以被打断的,而不是一条原子操作。(也就是所谓的写撕裂)
    • 所以修改共享数据的操作必须以原子操作的形式出现,这样才能保证没有其它线程能在中途插一脚来破坏相应数据。
    • 而在使用锁机制的过程中,即便在锁的粒度(granularity),负载(overhead),竞争(contention),死锁(deadlock)等需要重点控制的方面解决的很好,也无法彻底避免这种机制的如下一些缺点:
      • 1.锁机制会引起线程的阻塞(block),对于没有能占用到锁的线程或者进程,将一直等待到锁的占有者释放锁资源后才能继续执行,而等待时间理论上是不可设置和预估的。
      • 2.申请和释放锁的操作,增加了很多访问共享资源的消耗,尤其是在锁竞争(lock-contention)很严重的时候
      • 3.现有实现的各种锁机制,都不能很好的避免编程开发者设计实现的程序出现死锁或者活锁的可能
      • 4.优先级反转(prorithy inversion)和锁护送(Convoying)的现象
      • 5.难以调试
    • 无锁编程(Lock-Free)就是在某些应用场景和领域下解决以上基于锁机制的并发编程的一种方案。
  • 无锁编程的定义

    • 无锁编程按字面最直观的理解是不使用锁的情况下实现多线程之间对变量同步和访问的一种程序设计实现方案。一个锁无关的程序能够确保它所有线程中至少有一个能够继续往下执行,而有些线程可能会被的延迟。然而在整体上,在某个时刻至少有一个线程能够执行下去。作为整体进程总是在前进的,尽管有些线程的进度可能没有其它线程进行的快。
    • 无锁编程的原理
      • 无锁编程具体使用技术方法包括:原子操作(atomic operations), 内存栅栏(memory barriers), 内存顺序冲突(memory order), 指令序列一致性(sequential consistency)和顺ABA现象等等。
      • 其中最基础最重要的是操作的原子性或说原子操作 。原子操作可以理解为在执行完毕之前不会被任何其它任务或事件中断的一系列操作。原子操作是非阻塞编程最核心基本的部分,没有原子操作的话,操作会因为中断异常等各种原因引起数据状态的不一致从而影响到程序的正确。
      • 对于原子操作的实现机制 ,在硬件层面上CPU处理器会默认保证基本的内存操作的原子性,CPU保证从系统内存当中读取或者写入一个字节的行为肯定是原子的,当一个处理器读取一个字节时,其他CPU处理器不能访问这个字节的内存地址。但是对于复杂的内存操作CPU处理器不能自动保证其原子性,比如跨总线宽度或者跨多个缓存行(Cache Line),跨页表的访问等。这个时候就需要用到CPU指令集中设计的原子操作指令,现在大部分CPU指令集都会支持一系列的原子操作。而在无锁编程中经常用到的原子操作是Read-Modify-Write (RMW)这种类型的,这其中最常用的原子操作又是 COMPARE AND SWAP(CAS),几乎所有的CPU指令集都支持CAS的原子操作,比如X86平台下中的是 CMPXCHG。
      • 继续说一下CAS,CAS操作行为是比较某个内存地址处的内容是否和期望值一致,如果一致则将该地址处的数值替换为一个新值。CAS能够操作的位数越多,使用它来实现锁无关的数据结构就越容易(细节可以在intel手册中查看)。CAS操作具体的实现原理主要是两种方式:总线锁定和缓存锁定。所谓总线锁定,就是CPU执行某条指令的时候先锁住数据总线的, 使用同一条数据总线的CPU就无法访问内存了,在指令执行完成后再释放锁住的数据总线。锁住数据总线的方式系统开销很大,限制了访问内存的效率,所以又有了基于CPU缓存一致性来保持操作原子性作的方法作为补充,简单来说就是用CPU的缓存一致性的机制来防止内存区域的数据被两个以上的处理器修改(可详见CPU缓存的MESI协议)。
      • 最后这里随便说一下CAS操作的ABA的问题,所谓的ABA的问题简要的说就是,线程a先读取了要对比的值v后,被线程b抢占了,线程b对v进行了修改后又改会v原来的值,线程1继续运行执行CAS操作的时候,无法判断出v的值被改过又改回来。
        解决ABA的问题的一种方法是,一次用CAS检查双倍长度的值,前半部是指针,后半部分是一个计数器;或者对CAS的数值加上版本号。

同步、异步、阻塞、非阻塞IO

  • 同步和异步描述的是一种消息通知的机制,主动等待消息返回还是被动接受消息。同步io指的是调用方通过主动等待获取调用返回的结果来获取消息通知。
  • 异步io指的是被调用方通过某种方式(如,回调函数)来通知调用方获取消息。
  • 阻塞和非阻塞描述的是调用方在获取消息过程中的状态,阻塞等待还是立刻返回。阻塞io指的是调用方在获取消息的过程中会挂起阻塞,知道获取到消息。
  • 非阻塞io指的是调用方在获取io的过程中会立刻返回而不进行挂起。

brk是系统调用吗

brk频繁调用有什么问题(上下文切换消耗)

知道JeMalloc、tcMalloc、ptMalloc吗(不知道)

brk如何申请小字节数(申请至少一页,即使小字节数低于一页)

系统调用的开销(CPU上下文切换)

  • 用户进程位于用户空间,内核进程位于系统空间,磁盘只能被内核直接访问。

  • 在运行内核代码时,CPU工作在管理员模式,这对应于一些特殊的堆栈和内存环境,必须在系统调用时切换到这个环境中。系统调用结束后,CPU要切换到用户模式,又要将堆栈和内存环境恢复到用户模式的状态,这种内存环境的切换要耗费很多时间。

  • 因此,系统调用所耗费的时间主要在两次环境切换上,如果用户程序中普通代码和系统调用交替出现,那么将产生很大的环境切换的开销。

上下文切换

CPU寄存器,与程序计数器(存储CPU正在执行的指令位置,或者即将执行的下一条指令的位置)共同组成CPU上下文。

CPU上下文切换指的是:在多任务操作系统中,为了提高CPU的利用率,可以让当前系统运行远多于CPU核数的线程。但是由于同时运行的线程数是由CPU核数来决定的,所以为了支持更多的线程运行,CPU会把自己的时间片轮流分给其他线程,这个过程就是上下文切换。

根据任务的不同,CPU的上下文切换可以分为几个不同场景(进程上下文切换、线程上下文切换、中断上下文切换)

进程上下文切换

  • 系统调用:

已知进程运行空间分为内核空间和用户空间。内核空间可以访问所有资源,用户空间只能访问受限资源,不能访问内存等硬件设备。

从用户态到内核态的转变需要通过系统调用来完成。如open、read、write、close等对于文件的操作都属于系统调用。

系统调用的过程中会发生CPU上下文切换,先切换到内核态,执行内核态代码,再跳转回用户态代码。所以一次系统调用会发生两次CPU上下文切换,又称特权模式切换,不过仍然是同一个进程在运行。

  • 进程上下文切换性能问题:

进程由内核管理和调度,进程的切换只能发生在内核态,进程上下文不仅包括虚拟内存、栈、全局变量等用户空间资源,还包括内核堆栈、寄存器等内核空间状态。每次进程上下文切换需要几十纳秒到数微秒的CPU时间。

并且Linux通过TLB来管理虚拟内存到物理内存之间的映射,当虚拟内存更新后,TLB也需要刷新,内存的访问也会随之变慢。特别在多处理器系统上,缓存被多个处理器共享,刷新缓存不仅会影响当前处理器的进程,还会影响共享缓存的其他处理器的进程。

  • 进程上下文切换发生的时机:

    • 在进程调度的时候,需要进行切换上下文。Linux为每个CPU维护一个就绪队列,将活跃进程(正在运行和正在等待CPU的进程)

    • 按照优先级和等待CPU的时间来排序,然后选择最需要CPU的进程运行。(优先级高和等待时间长的进程)

      • 1、CPU时间被划分为一段段时间片,当某个进程的时间片耗尽,就会被系统挂起,切换到其他正在等待的进程

      • 2、进程在系统资源不足时,要等到资源满足后才可以运行,此时这个进程也会被挂起,CPU让给其他进程

      • 3、进程通过sleep睡眠函数主动挂起,CPU让给其他进程

      • 4、当有优先级更高的进程运行,当前进程会被挂起

      • 5、发生硬件中断,CPU进程会被中断挂起,执行内核中的中断服务程序

进程上下文切换

线程是调度的基本单位,而进程是资源拥有的基本单位。当进程中只有一个线程时,可以认为进程就等于线程。

当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。

线程主要就是私有数据、栈和寄存器等资源。

线程上下文切换分为两种情况:

  • 1、两个线程属于不同线程,资源不共享,所以等同于进程上下文切换

  • 2、两个线程属于同一个进程,只需要切换私有数据、寄存器等不共享的数据

中断上下文切换

与系统调用不同,中断上下文切换不涉及进程的用户态。所以中断过程打断了一个正处于用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户资源。它只包括内核态中断服务程序执行所必须的状态:CPU寄存器、内核堆栈、硬件中断参数

如何优化brk减少频繁系统调用(JeMalloc,tcMalloc,ptMalloc)

线程池原理 线程池几种状态

线程池工作的四种情况:

    1. 没有任务要执行,缓冲队列为空
    1. 队列中任务数量,小于等于线程池中线程任务数量
    1. 任务数量大于线程池数量,缓冲队列未满
    1. 任务数量大于线程池数量,缓冲队列已满

线程池怎么建立起来的,为什么要用线程池

线程池: 当进行并行的任务作业操作时,线程的建立与销毁的开销是,阻碍性能进步的关键,因此线程池,由此产生。使用多个线程,无限制循环等待队列,进行计算和操作。帮助快速降低和减少性能损耗。

多线程技术主要解决处理器单元内多个线程执行的问题,它可以显著减少处理器单元的闲置时间,增加处理器单元的吞吐能力。
假设一个服务器完成一项任务所需时间为:T1 创建线程时间,T2 在线程中执行任务的时间,T3 销毁线程时间。

如果:T1 + T3 远大于 T2,则可以采用线程池,以提高服务器性能。

构建一个线程池最重要的部分

线程池的组成

  • 线程池管理器(ThreadPool):用于创建并管理线程池,包括 创建线程池,销毁线程池,调配任务
  • 工作线程(PoolWorker):线程池中线程,在没有任务时处于等待状态,可以循环的执行任务
  • 任务接口(Task):每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等
  • 任务队列(taskQueue):用于存放没有处理的任务,提供一种缓冲机制,同时具有调度功能,高优先级的任务放在队列前面

线程池如何调优

  • (1)首先,根据不同的需求选择线程池,如果需要单线程顺序执行,使用SingleThreadExecutor,如果已知并发压力,使用FixedThreadPool,固定线程数的大小,执行时间小的任务,可以使用CachedThreadPool,创建可缓存的线程池,可以无限扩大线程池,可以灵活回收空闲线程,最多可容纳几万个线程,线程空余60s会被回收,需要后台执行周期任务的,可以使用ScheduledThreadPool,可以延时启动和定时启动线程池,

  • (2)如何确认线程池的最大线程数目,分CPU密集型和IO密集型,如果是CPU密集型或计算密集型,因为CPU的利用率高,核心线程数可设置为n(核数)+1,如果是IO密集型,CPU利用率不高,可多给几个线程数,来进行工作,核心线程数可设置为2n(核数)

liunx常用指令

链接: link

linux 命令,如何查看主机 CPU 核数?如何查看内存还剩多少?

  • cat /proc/cpuinfo
  • cat /proc/meminfo

如何查看哪个进程正在监听 80 端口?

  • lsof -i :80

  • netstat -tunlp | grep 80

netstat -n 是什么意思?-a 是什么意思?-p 是什么意思?

  • a (all)显示所有选项,默认不显示LISTEN相关
  • p 显示建立相关链接的程序名
  • n 拒绝显示别名,能显示数字的全部转化成数字。

系统启动的1号进程是什么?那2号呢?

0号进程

  • 0号进程,通常也被称为idle进程,或者也称为swapper进程。

  • 0号进程是linux启动的第一个进程,它的task_struct的comm字段为"swapper",所以也成为swpper进程。当系统中所有的进程起来后,0号进程也就蜕化为idle进程,当一个core上没有任务可运行时就会去运行idle进程。一旦运行idle进程则此core就可以进入低功耗模式了,在ARM上就是WFI。

1号进程

  • 当一条b start_kernel指令运行后,内核就开始的内核的全面初始化操作
    我们通常将init称为1号进程,其实在刚才kernel_init的时候1号线程已经创建成功,也可以理解kernel_init是1号进程的内核态,而我们所熟知的init进程是用户态的。start_kernel函数就是内核各个重要子系统的初始化,比如mm, cpu, sched, irq等等。最后会调用一个rest_init剩余部分初始化

至此1号进程就完美的创建成功了,而且也成功执行了init可执行文件。
2号进程

  • 2号进程,是由1号进程创建的。而且2号进程是所有内核线程父进程。

  • 2号进程就是刚才rest_init中创建的另外一个内核线程。kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);

  • 当kernel_thread(kthreadd)返回时,2号进程已经创建成功了。而且会回调kthreadd函数

  • 然后就是while循环,设置当前的进程的状态是TASK_INTERRUPTIBLE是可以中断的

  • 判断kthread_create_list链表是不是空,如果是空则就调度出去,让出cpu
    如果不是空,则从链表中取出一个,然后调用kthread_create去创建一个内核线程。

  • 所以说所有的内核线程的父进程都是2号进程,也就是kthreadd。

⭐总结:
linux启动的第一个进程是0号进程,是静态创建的
在0号进程启动后会接连创建两个进程,分别是1号进程和2和进程。
1号进程最终会去调用可init可执行文件,init进程最终会去创建所有的应用进程。
2号进程会在内核中负责创建所有的内核线程
所以说0号进程是1号和2号进程的父进程;1号进程是所有用户态进程的父进程;2号进程是所有内核线程的父进程。

系统调用会不会引起模式切换和进程切换。外部中断呢?

模式切换 不等同于 进程上下文切换

  • 当进程调用系统调用或者发生中断时,CPU从用户模式(用户态)切换成内核模式(内核态),此时,无论是系统调用程序还是中断服务程序,都处于当前进程的上下文中,并没有发生进程上下文切换。

  • 当系统调用或中断处理程序返回时,CPU要从内核模式切换回用户模式,此时会执行操作系统的调用程序。如果发现就需队列中有比当前进程更高的优先级的进程,则会发生进程切换:当前进程信息被保存,切换到就绪队列中的那个高优先级进程;否则,直接返回当前进程的用户模式,不会发生上下文切换。

每个进程都拥有两个堆栈:用户空间的堆栈和内核空间堆栈

  • 用户进程:执行用户空间的代码的程序,使用用户堆栈
  • 系统进程:执行内核空间代码(系统调用或中断)的程序,使用内核堆栈(系统堆栈)

这里的用户进程和系统进程使用同一个PCB,他们并不是两个实体进程,而是同一个进程的两个侧面。当调用系统调用或发生中断时,CPU切换到内核态,用户进程“变身”系统进程,此时的寄存器上下文保存在系统进程的堆栈上,以便系统调用返回后的恢复。

⭐与用户程序相关联的处理器执行模式(用户模式)和与操作系统相关联的处理器模式(内核模式)之间的切换,
进程切换必须在操作系统的内核模式下进行

⭐模式切换可在不改变运行态进程状态(有一些中断/异常不会引起进程状态转换,不会引起进程切换,只是在处理完成后把控制权交还给被中断进程。)的情况下发生,此时保存上下文并在以后恢复上下文需要的开销很少。但是若当前正运行进程将转换为另一种状态(就绪(如时钟中断)、阻塞(如系统调用)等),则操作系统必须让环境产生实质性的变化。

有哪些缓存淘汰算法

  • FIFO

    • FIFO(First in First out) 先进先出。可以理解为是一种类似队列的算法实现
    • 实现概念: 最先进来的数据,被认为在未来被访问的概率也是最低的,因此,当规定空间用尽且需要放入新数据的时候,会优先淘汰最早进来的数据。
    • 优点: 没什么优点
    • 缺点: 算法逻辑设计所实现的缓存的命中率是比较低,没有任何额外逻辑能够尽可能的保证常用数据不被淘汰掉。
  • LRU

    • LRU(The Least Recently Used) 最近最久未使用算法
    • 实现概念: 如果一个数据最近很少被访问到,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰最久未被访问的数据。
    • 优点: LRU很好的应对了针对于突发流量涌入的情况
    • 缺点: 对于周期性、偶发性的访问数据,有大概率可能造成缓存污染,置换出去了热点数据,把这些偶发性数据留下了,从而导致LRU的数据命中率急剧下降。
    • 例如: 当一个新剧热播,流量涌入,此剧的相关缓存登顶。但当新剧热度降低趋势变得平缓,其他周期性播出的综艺类节目涌入后将新剧缓存挤出, 这是我又访问新剧,则不得不又将新剧加入缓存,这显然不合时宜,因为新剧我已经访问过很多次了
  • LFU

    • LFU(The Least Frequently Used) 最近很少使用算法,与LRU的区别在于LRU是以时间衡量,LFU是以时间段内的次数
    • 实现概念: 如果一个数据在一定时间内被访问的次数很低,那么被认为在未来被访问的概率也是最低的,当规定空间用尽且需要放入新数据的时候,会优先淘汰时间段内访问次数最低的数据。
      针对以上的实现概念可以看出,它相较于LRU其多出了访问次数的概念,可以理解为当有缓存命中时其对应该缓存的计数器会+1。
    • 优点: LFU有效的保护了缓存,因为是以次数为基准所以更加准确,针对于访问概率差不多时,缓存命中率较高。
    • 缺点: LFU需要额外的空间来存储计数器来记录访问频次。针对于一个数据的洪峰。后来又衰弱,由于其频率过高导致其缓存很难失效。
    • 例如: 当一个新剧热播,流量涌入,此剧的相关缓存登顶。但当新剧热度降低趋势变得平缓,但是由于其缓存命中次数过高,导致其缓存一直处于缓存队列中,一直不会失效,直到剩余所有缓存计数都超过了此缓存才将其排出。

链接: LRU\LFU

  • W-TinyLFU
    • W-TinyLFU(Window Tiny Least Frequently Used) 是对LFU的的优化和加强
    • 实现概念: 当一个数据进来的时候,会进行筛选比较,进入W-LRU窗口队列,以此应对流量突增,经过淘汰后进入过滤器,通过访问访问频率判决是否进入缓存。如果一个数据最近被访问的次数很低,那么被认为在未来被访问的概率也是最低的,当规定空间用尽的时候,会优先淘汰最近访问次数很低的数据
    • 它也存储一个次数,但它为什么是对LFU的优化呢,因为其存储的值为Count-Min Sketch,Count-Min Sketch是一个hash操作,它扩增为多个hash,在多个hash地址上记录缓存命中次数。这样多个hash会导致原来hash冲突的概率降低,当查询数据时,且从当多个hash取得数据,取出多个hash数据中取其中的最小值,来定义为此缓存的命中次数。也就是Count Min的含义所在。
    • 当某一个key的的计数器大于触发值(k),则整体计数器会除以2,这样解决了LFU的缓存难失效问题。
    • 这样我就可以使用申请固定的空间大小来存储缓存计数,就算是hash冲突也没关系,因为从概率上来说我获取的最小值是最准确的。
    • 其类似于布隆过滤器的实现,可以将布隆过滤器看成一种概率性数据结构,本质是高效的插入和查询。相比于传统的List、Map其更高效、占用空间更少。其实现是当有变量加入布隆过滤器时,通过K个映射函数将变量映射为位图中的K个点,将K个点的值由0置为1.当有新的元素加入时候继续映射,就算是点位重复也没关系,该点位依旧为1.当查询时,元素交于K个点。
      • 1、若k个位置有一个为0,则该元素肯定不在集合中
      • 2、若K个位置全部为1,则该元素有可能存在集合中
    • 优点: 在空间和时间维度上拥有巨大优势。
    • 缺点: 误差率的存在,应用较少,只有Caffine使用此算法

简述快表

  • 快表也称为页表高速缓存。其会存储一定数量的页表项,以此加快虚拟地址到物理地址的映射速度。

简述页表

  • 页表用于存储虚拟地址中的虚拟页面号和物理页面号的映射关系。除此之外,有些页的读写有限制,页表也通过其他存储位,标记该页访问位,是否在内存中(可能被页面置换出去了)等等。

跳表查找,插入,删除时间复杂度

链表加多层索引的结构,就是跳表

  • 跳表中数据查询的时间复杂度为 log(n)
  • 跳表中数据查询的空间复杂度还是 O(n)
  • 跳表数据插入和删除的时间复杂度为 O(logn)

跳表和哈希表的区别

跳表的使用场景

lru和lfu有什么优缺点,应用场景

几亿条数据,查找topk(堆,快排)

一个文件,有几千万行,有IP地址、访问时间、url,访问topk出现频率的IP或url的命令是什么样的

快排的思路

快速排序是对冒泡排序的一种改进

基本思想:

  • 通过一趟排序将要排序的数据分割成独立的两部分。
  • 其中一部分的所有数据都比另外一部分的所有数据都要小。
  • 然后再按此方法对这两部分数据分别进行快速排序,整个排序可以递归进行,一次整个数据变成有序序列

注意:

  • 其主要运用了分治的思想
  • 有不同种方法将该段数据分成两段(小于等于key的一段在一边,大于key的一段在一边,key的数据在这两段段中间)
  • 快排一次只能把一个数排好,放在正确的位置。
  • key可以选最左边的数,此时先移动右边的数j,
  • key也可以选最右边的数,此时先移动左边的数i
  • 快速排序主要有两种方法,一种是标准算法,另一种是两头交换法,基本思想是一样的,但有些细节上有所不同。

其他时间复杂度为nlogn的排序算法

在这里插入图片描述

map和set区别与实现

⭐map和set都是C++的关联容器,其底层实现都是红黑树(RB-Tree )

  • 由于map和set所开放的各种操作接口,RB-tree也都提供了,所以几乎所有的map和set的操作行为,都只是转调RB-tree的操作行为。
    ⭐map和set的区别在于∶
  • map中的元素是 key-value(键值对) 对∶关键字起到索引的作用,值则表示与索引相关联的数据;Set与之相对就是关键字的简单集合,set中每个元素只包含一个关键字
  • set的迭代器是const的,不允许修改元素的值;map允许修改value,但不允许修改key。
  • 其原因是因为map和set是根据关键字排序来保证其有序性的,如果允许修改key的话,那么首先需要删除该键,然后调节平衡,再插入修改后的键值,调节平衡,如此一来,严重破坏了map和set的结构,导致iterator失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const,不允许修改迭代器的值;而map的迭代器则不允许修改key值,允许修改value值。map支持下标操作,set不支持下标操作
  • map可以用key做下标,map的下标运算符[]将关键码作为下标去执行查找,如果关键码不存在,则插入一个具有该关键码和mapped_type类型默认值的元素至map中,因此**下标运算符[ ]**在map应用中需要慎用,const_map不能用,只希望确定某一个关键值是否存在而不希望插入元素时也不应该使用,mapped_type类型没有默认值也不应该使用。如果find能解决需要,尽可能用find。

vector的第二个模板类形参

 template<typename _Tp, typename _Alloc = std::allocator<_Tp> >
    class vector : protected _Vector_base<_Tp, _Alloc>

vector其实也是一个模板,是顺序表的模板

  • 模板第一个参数是一个类(一般传的是需要存储数据的类型名,可以是int这种普通类型,也可以是string这种类)
  • 模板第二个参数也是一个类,是一个空间配置器(内存池),第二个参数给了缺省值,这个缺省值其实就是官方库给的vector,如果不想使用官方库的vector那么也可以自己写一个vector传过来
函数名称功能说明
vector()无参构造
vector(size_type n, const value_type& val = value_type())构造并初始化n个val
vector (const vector& x)拷贝构造
vector (InputIterator first, InputIterator last)使用迭代器进行初始化构造

STL的allocator有什么作用

STL的分配器用于封装STL容器在内存管理上的底层细节。在C++中,其内存配置和释放如下:

  • new运算分两个阶段∶
    • (1)调用::operator new配置内存;
    • (2调用对象构造函数构造对象内容
  • delete运算分两个阶段∶
    • (1)调用对象希构函数﹔
    • (2)掉员工::operator delete释放内存
  • 为了精密分工,STL allocator将两个阶段操作区分开来∶
    • 内存配置有alloc:allocate()负责
    • 内存释放由alloc::deallocate()负责
    • 对象构造由::construct()负责
    • 对象析构由::destroy()负责
  • 同时为了提升内存管理的效率,减少申请小内存造成的内存碎片问题,SGI STL采用了两级配置器
    • 当分配的空间大小超过128B时,会使用第一级空间配置器
    • 当分配的空间大小小于128B时,将使用第二级空间配置器
    • 第一级空间配置器直接使用malloc()、realloc()、free()函数进行内存空间的分配和释放,

STL迭代器如何删除元素

  • 对于序列容器vector,deque来说,使用erase(itertor)后,后边的每个元素的迭代器都会失效,但是后边每个元素都会往前移动一个位置,但是erase会返回下一个有效的迭代器;
  • 对于关联容器map set来说,使用了erase(iterator)后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素的,不会影响到下一个元素的迭代器,所以在调用erase之前,记录下一个元素的迭代器即可。
  • 对于list来说,它使用了不连续分配的内存,并且它的erase方法也会返回下一个有效的iterator,因此上面两种正确的方法都可以使用。

STL中的map与unordered_map区别

  • map在底层使用了红黑树来实现
  • unordered_map是C++11标准中新加入的容器,它的底层是使用hash表的形式来完成映射的功能
  • map是按照operator<比较判断元素是否相同,以及比较元素的大小,然后选择合适的位置插入到树中。所以,如果对map进行遍历(中序遍历)的话,输出的结果是有序的。顺序就是按照operator<定义的大小排序。
  • unordered_map是计算元素的Hash值,根据Hash值判断元素是否相同。所以,对unordered_map进行遍历,结果是无序的。
  • 使用map时,需要为key定义operator<。
  • unordered_map的使用需要定义hash_value函数并且重载operator==。
  • 对于内置类型,如string,这些都不用操心,可以使用默认的。对于自定义的类型做key,就需要自己重载operator<或者hash_value()了。

⭐所以说,当不需要结果排好序时,最好用unordered_map,插入删除和查询的效率要高于map。

迭代器与指针的区别

迭代器不是指针,是类模板,表现的像指针。

  • 模拟了指针的一些功能,通过重载了指针的一些操作符,>、*、++、–等。迭代器封装了指针,是一个可遍历STL ( Standard Template Library)容器内全部或部分元素"的对象,本质是封装了原生指针,是指针概念的一种提升,提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。
  • 迭代器返回的是对象引用而不是对象的值,所以cout只能输出迭代器使用*取值后的值而不能直接输出其自身。

B+树和哈希表的区别,应用场景

数组array下标可以有负值么

  • 可以,因为下标只是给出了一个与当前地址的偏移量而已,只要根据这个偏移量能定位得到目标地址即可。即向前寻址

链表list和哈希表hash的区别

数组与链表的区别

  • (1)存储空间上
    链表存放的内存空间可以是连续的,也可以是不连续的,数组则是连续的一段内存空间。一般情况下存放相同多的数据时,数组占用较小的内存,而链表还需要存放其前驱和后继的空间。
  • (2)长度的可变性
    链表的长度是按实际需要可以伸缩的,而数组的长度是在定义时要给定的,如果存放的数据个数超过了数组的初始大小,则会出现溢出现象。
  • (3)查找效率:
    按序号查找时,数组可以随机访问,时间复杂度为O(1),而链表不支持随机访问,平均需要O(n);按值查找时,若数组无序,数组和链表时间复杂度均为O(n),但是当数组有序时,可以采用折半查找将时间复杂度降为O(logn);
  • (4)插入删除时:
    数组平均需要移动n/2个元素,而链表只需修改指针即可;
  • (5)空间分配方面:
    (静态)数组从栈中分配空间, 对于程序员(某些编程语言如java对于堆内存的管理也交由程序自动控制,但性能肯定有差异)方便快速,但是自由度小。链表从堆中分配空间, 自由度大但是申请管理比较麻烦。

哈希表

  • 哈希表将需要查找的key值,通过hash函数的计算,换算为数组的位置值。这样在查找时就可以直接定位数据的位置。它结合了数组的快速查询的优点又能融合链表方便快捷的增加删除元素的优势
  • 在程序变量中,数组和链表都只需要在栈上存储一个起始位置,占用一个变量位置即可。
  • hash将需要定位的值通过hash函数换算为数据的位置值,以空间换时间。
    在这里插入图片描述
    在这里插入图片描述

链表list和队列queue的区别

  • 队列是一个特殊的线性表. 它仅允许在表的前面进行删除操作,并在表的后面进行插入操作. 执行插入操作的末端称为队列的尾部,执行删除操作的末端称为队列的首部

  • 队列和栈是描述数据存取方式的概念,队列是先进先出,而堆栈是后进先出;队列和栈都可以使用数组或者链表实现。

  • 链表(Linked list)是一种常见的基本数据结构,是一个线性表,但不以线性顺序存储数据,而是由节点组成,每个节点(节点)都存储数据变量(数据)和指针变量(节点下一个),并且有一个头节点(head)连接到下面的节点,最后一个节点指向null(null). 可以在链接列表类中定义添加,删除,插入,遍历,修改和其他方法,因此通常用于存储数据.

  • 数组与链表是更加偏向数据存储方式的概念,数组在连续的空间中存储数据,随机读取效率高,但是数据添加删除的效率较低; 链表可以在非连续的空间中存储数据,随机访问效率低,数据添加删除效率高。

详细介绍一下unordered_map

由于unordered_map内部采用的hashtable的数据结构存储,所以,每个特定的key会通过一些特定的哈希运算映射到一个特定的位置,我们知道,hashtable是可能存在冲突的(多个key通过计算映射到同一个位置),在同一个位置的元素会按顺序链在后面。所以把这个位置称为一个bucket是十分形象的(像桶子一样,可以装多个元素)。
在这里插入图片描述
所以unordered_map内部其实是由很多哈希桶组成的,每个哈希桶中可能没有元素,也可能有多个元素。

  • 关联性:通过key去检索value,而不是通过绝对地址(和顺序容器不同)
  • 无序性:使用hash表存储,内部无序
  • Map : 每个值对应一个键值
  • 键唯一性:不存在两个元素的键一样
  • 动态内存管理:使用内存管理模型来动态管理所需要的内存空间

哈希表中已经存了 key,为什么链表中还要存 key 和 val 呢,只存 val 不就行吗?

  • 链表插入时间复杂度为 \mathcal{O}(1)O(1),哈希表查找复杂度为 \mathcal{O}(1)O(1),双向链表删除复杂度为 \mathcal{O}(1)O(1),要删除节点时没有 key 就无法删除哈希表中的值

用哈希函数,那么肯定有哈希冲突,遇到冲突怎么办,分析一下时间复杂度

1、开放定址法——线性探测

  • 线性探测法的地址增量di = 1, 2, … , m-1,其中,i为探测次数。该方法一次探测下一个地址,知道有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。

  • 线性探测容易产生“聚集”现象。当表中的第i、i+1、i+2的位置上已经存储某些关键字,则下一次哈希地址为i、i+1、i+2、i+3的关键字都将企图填入到i+3的位置上,这种多个哈希地址不同的关键字争夺同一个后继哈希地址的现象称为“聚集”。聚集对查找效率有很大影响。

2、开放地址法——二次探测

  • 二次探测法的地址增量序列为 di = 12, -12, 22, -22,… , q2, -q2 (q <= m/2)。二次探测能有效避免“聚集”现象,但是不能够探测到哈希表上所有的存储单元,但是至少能够探测到一半。

3、链地址法

  • 链地址法也成为拉链法。其基本思路是:将所有具有相同哈希地址的而不同关键字的数据元素连接到同一个单链表中。如果选定的哈希表长度为m,则可将哈希表定义为一个有m个头指针组成的指针数组T[0…m-1],凡是哈希地址为i的数据元素,均以节点的形式插入到T[i]为头指针的单链表中。并且新的元素插入到链表的前端,这不仅因为方便,还因为经常发生这样的事实:新近插入的元素最优可能不久又被访问。

    • (1)拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
    • (2)由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
    • (3)开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
    • (4)在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。

map线程安全

HashMap是一个线程不安全的容器,主要体现在容量大于总量负载因子发生扩容时会出现环形链表从而导致死循环.

HashMap会进行resize操作,在resize操作的时候会造成线程不安全。下面将举两个可能出现线程不安全的地方。

  • 1、put的时候导致的多线程数据不一致。
    这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

  • 2、另外一个比较明显的线程不安全的问题是HashMap的get操作可能因为resize而引起死循环(cpu100%)

常见的排序算法?

堆排序原理?

堆是一棵顺序存储的完全二叉树。

其中每个结点的关键字都不大于其孩子结点的关键字,这样的堆称为小根堆。

其中每个结点的关键字都不小于其孩子结点的关键字,这样的堆称为大根堆。

堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。

在堆中定义以下几种操作:

  • 最大堆调整(Max-Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点

  • 创建最大堆(Build-Max-Heap):将堆所有数据重新排序,使其成为最大堆

  • 堆排序(Heap-Sort):移除位在第一个数据的根节点,并做最大堆调整的递归运算

快排原理,最坏情况是什么样

首先考虑,如何在不排序全部数组的情况下,只将第一个元素的最终序列位置找到呢?方法就是,将比这个元素大的数放到右半区,将比这个元素小的数放到左半区,那么这个元素插进这两半区的中间就行了呀。而快速排序就是将第一个元素排好之后,再将左右半区各看成是一个数组,再次排出两个半区第一个元素的最终序列,那么一直递归下去,直到每一个元素都排序好。

以下是具体操作流程:

  • 1.第一个元素作为基准值。
  • 2.在最左边和最右边各设置一个虚拟指针,左指针一格格向右移动,右指针一格格向左移动。
  • 3.每当左指针指向比基准值大的数就停下,每当右指针指向比基准值小的数就停下。
  • 4.两个指针停下后,交换两个指针指的数。这时,大的数就会跑到右边,小的数就会跑到左边
  • 5.一直这样移动、停下、交换、移动、停下、交换、移动、…,直到两个指针撞在一起。
  • 6.这时所有大的数都会跑到右半区,所有小的数都会跑到左半区。我们就把基准值放进左右半区中间,此时基准值索引就是排序准确的了。
  • 7.重复以上操作,不断细分左右半区直到排序完成。)

快速排序最坏情况以及最坏时间复杂度:O(n^2)
最坏情况为,每一轮分不成两组,每一轮都只能把基准值索引放到第一个或者最后一个,因此一共需要n轮。
因此,最坏时间复杂度为O(n^2)

正序排列的越有序越坏,逆序排列的越有序越坏

举个例子,比如要从小到大排列,1 2 3 4 5 6 7 8 9 10
就排{1,2,3,4,5,6,7,8,9,10},
或者{10,9,8,7,6,5,4,3,2,1},都是最坏情况
因为这几种情况每一轮只能排出一个最大的或者最小的。

归并排序原理?

在这里插入图片描述
原理如下(假设序列共有n个元素):

  • 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列

  • 设定两个指针,最初位置分别为两个已经排序序列的起始位置

  • 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置

  • 重复步骤3直到某一指针到达序列尾

  • 将另一序列剩下的所有元素直接复制到合并序列尾

什么是稳定排序?

  • 排序算法的稳定性:若待排序的序列中,存在多个具有相同关键字的记录,经过排序, 这些记录的相对次序保持不变,则称该算法是稳定的;若经排序后,记录的相对 次序发生了改变,则称该算法是不稳定的。
    稳定性的好处:排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位相同的元素其顺序再高位也相同时是不会改变的。另外,如果排序算法稳定,可以避免多余的比较;
  • 稳定的排序算法:冒泡排序、插入排序、归并排序和基数排序
  • 不是稳定的排序算法:选择排序、快速排序、希尔排序、堆排序

GDB一般用哪些

GDB 调试常用命令:

    1. l 显示代码
    • l 1(行号)显示第一行,
    • l add.c(文件名):3 跳转到add.c 的第三行
    1. b:加断点
      • b+行号
      • b+函数名
    1. info break 显示断点信息
    1. delete 删除断点
    1. r 启动程序 //调试对象是进程
    1. n 单步执行,下一步
    1. c 继续执行
    1. s 进入函数
    1. finish 跳出函数
    1. p 打印

      • display 打印/持续显示
    1. q 退出调试
    1. bt 显示函数调用栈
    1. 多进程中,跟踪子进程:set follow-fork-mode child
    1. 多线程中,A 查看线程信息 info threads B 切换到指定线程 thread id
  • 15) set scheduler-locking[on|off|step]

    • off 不锁定,所有线程都可以执行
    • on 锁定,只有当前的线程执行
    • step 只有当前的程序单步执行时,其他线程不执行
  • 16)attach pid 添加一个进程调试

钩子函数

钩子实际上是一个处理消息的程序段,通过系统调用,把它挂入系统。每当特定的消息发出,在没有到达目的窗口前,钩子程序就先捕获该消息,亦即钩子函数先得到控制权。这时钩子函数即可以加工处理(改变)该消息,也可以不作处理而继续传递该消息,还可以强制结束消息的传递。

一个Hook都有一个与之相关联的指针列表,称之为钩子链表,由系统来维护。这个列表的指针指向指定的,应用程序定义的,被Hook子程调用的回调函数,也就是该钩子的各个处理子程。当与指定的Hook类型关联的消息发生时,系统就把这个消息传递到Hook子程。一些Hook子程可以只监视消息,或者修改消息,或者停止消息的前进,避免这些消息传递到下一个Hook子程或者目的窗口。最近安装的钩子放在链的开始,而最早安装的钩子放在最后,也就是后加入的先获得控制权。

回调函数

把一段可执行的代码像参数传递那样传给其他代码,而这段代码会在某个时刻被调用执行,这就叫做回调。如果代码立即被执行就称为同步回调,如果在之后晚点的某个时间再执行,则称之为异步回调。回调函数其实就是一个通过函数指针调用的函数!

在回调中,主程序把回调函数像参数一样传入库函数。这样一来,只要我们改变传进库函数的参数,就可以实现不同的功能,这样有没有觉得很灵活?并且丝毫不需要修改库函数的实现,这就是解耦。再仔细看看,主函数和回调函数是在同一层的,而库函数在另外一层,想一想,如果库函数对我们不可见,我们修改不了库函数的实现,也就是说不能通过修改库函数让库函数调用普通函数那样实现,那我们就只能通过传入不同的回调函数了,这也就是在日常工作中常见的情况。

#include<stdio.h>
#include<softwareLib.h> // 包含Library Function所在读得Software library库的头文件

int Callback() // Callback Function
{
    // TODO
    return 0;
}
int main() // Main program
{
    // TODO
    Library(Callback);
    // TODO
    return 0;
}

协议

    1. xml协议
    • xml是一种通用和轻量级的数据交换格式语言,是指可扩展标记语言(extensible markup language),以文本结构进行存储。它可以用来标记数据、定义数据类型,提供统一的方法来描述和交换,而且独立于程序语言或供应商的结构化数据。和JSON一样,数据在序列化成字节流之前都转换成字符串。可读性强,性能差,异构系统、Open API类型的应用中常用。
    1. Json协议
    • Json是一种通用和轻量级的数据交换格式,也是以文本结构进行存储,是一种简单的消息格式,全称为JavaScript Object Notation。Json作为数据包格式传输时具有更高的效率,这是因为Json不像xml那样需要有严格的闭合标签,这就让有效数据量与总数据包比有着显著的提升,从而减少同等数据流量的情况下网络的传输压力!将Java POJO对象转换成JSON结构化字符串。基于HTTP协议,在Web应用、移动开发中是常用的编码方式,因为JSON的可读性较强。但是它的性能稍差。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值