C++八股文

文章目录

C++语言

int function(int a[], int b)

​ C++中,定义函数 int function(int a[], int b),这里数组a会不会在内存中拷贝(传递的是指针还是啥),什么情况下传递的是指针?

​ 不会,因为这里的 a 传递的是指针,和 int * 是一样的。

指针数组和数组指针的区别?

优先级:() > [] > *

在这里插入图片描述

数组指针

​ 是一个指针,指向一个数组的起始地址。由于 [] 运算符的优先级比 * 运算符高,所以定义时需要使用小括号将 * 运算符与指针名括起来,表示这里定义的变量是个指针,括号外写数组中变量的类型,以及数组的大小。

指针数组

​ 是一个数组,数组的每个元素都是指针。定义时不需要小括号,变量名先与 [] 运算符结合,表示这是一个数组,前面的 * 运算符表示数组中的内容是指针。

函数指针和指针函数的区别

函数指针

​ 是一个指针,指针指向的地址是一个函数的入口地址。通过该指针可以调用目标函数。定义时需要用小括号将 * 运算符与指针名括起来。

指针函数

​ 是一个函数,函数的返回值是一个指针类型的变量。

常量指针和指针常量的区别

  • **常量指针:**指针本身是个常量,只想不能修改
  • **指针常量:**指向的是一个常量

数组和指针的区别

  • **赋值:**同类型的指针变量可以相互赋值,数组就不能相互赋值,只能一个元素一个元素的拷贝
  • **存储方式:**数组在内存中连续存放,必须开辟一段连续的内存空间,而指针和普通变量的存储方式相同
  • 求 sizeof: 64位操作系统中指针大小固定 8 字节,而数组则是一整片连续的内存大小

指针和引用的区别

  • 指针是一个变量,存储的是一个地址,而引用是原变量的别名,和原变量是同一个东西
  • 指针可以有多级,也就是存在指向指针的指针,而引用只能是一级
  • 指针在定义的时可以不初始化,引用必须在定义的时初始化
  • 引用一旦定义之后,就与变量绑定,无法再引用别的变量,而指针可以随时改变指向
  • 64位操作系统中,指针在内存中的大小恒为8字节,而引用的大小和引用的变量大小一致
  • 自增运算符的意义不同,指针自增就是指向当前元素的下一个元素,而引用则是根据引用元素的类型做加法
  • 引用可以直接使用,而指针获取指向的值要用 * 运算符
  • 当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以。

数组名 和 指针的区别?

  • 二者都可通过增减偏移量来访问数组中的元素。
  • 数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。
  • 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了。

在函数传参时,什么时候用指针,什么时候用引用?

  • 参数需要作为传出参数时,使用指针
  • 对栈空间大小比较敏感时用引用,引用传参不会创建临时对象,可以避免栈溢出
  • 类对象作为参数传递时要用引用,这是 C++ 类对象传递的标准方式

原始字面量

​ 通过R"()"的方式可以定义一个原始字面量,它可以直接得到原始意义的字符串,而不需要额外对字符串做转义或连接等操作。

​ 通过使用原始字面量可以简化一些打印操作。例如输出某个文件的路径,可以不用再写转义字符,输出多行字符串时不再需要使用连接符。

auto

​ C++ 提供了 auto 和 decltype 来静态推导类型,在我们知道类型没有问题但⼜不想完整地写出类型的时候, 就可以使⽤静态类型推导。 decltype ⽤于获取⼀个表达式的类型,⽽不对表达式进⾏求值。

注意:

​ auto定义的变量必须有初始值。

auto的自动类型推断发生在编译期,所以使用auto并不会造成程序运行时效率的降低。

​ 编译器可以根据初始值自动推导出类型。但是不能用于函数传参以及数组类型的推导。

​ auto可以推断基本类型,也可以推断引用类型,当推断引用类型时候,将引用对象的类型作为推断类型。

decltype

​ 当需要某个表达式的返回值类型而又不想实际执行它时用decltype。decltype是为了解决复杂的类型声明而使用的关键字。

与auto的区别:

auto忽略顶层const,decltype保留顶层const

​ 对引用操作,auto推断出原有类型,decltype推断出引用

​ 对解引用操作,auto推断出原有类型,decltype推断出引用

​ auto推断时会实际执行表达式,decltype不会执行表达式,只做分析

有了auto为什么还需要decltype?

decltype 可以获得编译期的类型。 auto不能。所以当你需要某个表达式的返回值类型而又不想实际执行它时用decltype

顶层 const 和 底层 const

  • 顶层const原理,对象本身是 const 的。是说顶层const对于拷贝无关紧要,其实是因为拷贝的是值,没拷那些修饰符;
  • 底层const原理,指向的,或者绑定的,是 const 的。是说底层const必须保持一致,其实是原对象的可写性(可修改性)在赋值过程中,不应有所扩大。假设原来不能修改,不能传着传着就可以修改了。假设原来能修改,那传着传着不能修改了,问题也不大。————一句话:指针再赋值过程中原对象的可写性不能扩大,但是可以缩小。

const 的作用

  • 定义只读常量,先不分配内存,放入符号表,如果程序中对 常量取了地址,系统才会给它开辟空间。如果是用一个变量初始化常量时,也会开辟空间,不会放入符号表。const 修饰自定义类型时,也会开辟空间。image-20230310095818603
  • 使用 const 和 & 共同修饰函数参数类型,可以避免函数传参时的拷贝开销,提高程序效率
  • const 可以修饰成员方法,表示该方法绝对不会修改成员变量,如果不小心修改了,编译器会报错
  • const 修饰函数返回值类型,使得函数调用表达式不能作为左值。修饰函数返回的指针或引用类型,使得返回值不为左值,从而保护指针指向的内容或引用的内容不被修改,常用于运算符重载(返回成员属性的运算符重载都会用到)。

说⼀下 C++ ⾥是怎么定义常量的?常量存放在内存的哪个位置?

对于局部常量,存放在栈区;
对于全局常量,存放在静态存储区;
字⾯值常量,存放在常量存储区。

C++ const 和 C 语言区别?

  • C 语言中的 const 功能单一,只能用来定义常量,虽然也能修饰函数的参数和返回值,但功能性都不是很强;而 C++ 中的 const 和 & 共同修饰函数参数可以避免参数进行拷贝,提高程序效率,还可以修饰成员方法,表明该方法不会修改成员属性。修饰函数返回值类型,使得函数调用表达式不能作为左值。
  • C 语言中的 const 不是绝对安全的,C 语言中的局部 const 存放在栈区,可以通过指针间接修改 const 的值。而 C++ 的 const 可以保证绝对安全。

智能指针

​ 智能指针用来动态的分配内存,当构造时分配内存,当离开作用域时,自动释放已分配的内存。使用智能指针能帮助程序员更简单的管理动态内存,解决很多潜在的内存泄漏的问题。

​ 智能指针本身是一个栈上分配的对象。根据栈上分配的特性,在离开作用域后,编译器会调用其析构函数,从而达到自动释放内存的效果。

​ unique指针无法共享所有权,只能有一个指针可以指向被管理的对象。unique_ptr 的拷贝构造函数和赋值运算符都是 delete 的,所以 unique_ptr 不能复制。但它有接受右值引用的拷贝构造和赋值运算符,所以可以通过转移语义将所有权转移到另外一个unique_ptr。(unique_ptr 比 auto_ptr 更安全)

​ shared指针可以共享所有权,可以存在多个指针指向同一个对象,当最后一个shared指针离开作用域时才会释放内存。shared指针内部有一个共享计数器来自动管理,计数器实际上就是指向该资源指针的个数,每当有一个shared指针指向该资源,引用计数就 + 1。当一个shared指针离开作用域时,引用计数 - 1,当引用计数为 0 时,就会释放内存。

可以通过成员函数 use_count() 来查看资源的所有者个数,除了可以通过 new 来构造,还可以通过传⼊unique_ptr,weak_ptr 来构造。当我们调⽤ release() 时,当前指针会释放资源所有权,计数减⼀。当计数等于 0 时,资源会被释放。

​ weak指针是一个不控制对象生命周期的弱指针,它可以指向shared指针管理的内存,但不会改变引用计数,也不能直接调用原生指针的方法。weak指针主要是为了解决 shared_ptr 循环引用造成的内存泄漏问题。由于shared指针通过引用计数来管理原生指针,那么循环引用就会导致内存泄漏,而weak指针不会增加引用计数,将循环引用改为弱引用就可以避免内存泄漏。

和 shared_ptr 之间可以相互转化, shared_ptr 可以直接赋值给它,它可以通过调⽤ lock 函数来获得shared_ptr

​ 分配内存空间时,用shared指针;引用对象的地方,使用weak指针。

​ 引用计数加减是原子操作,是线程安全的,而shared指针读写并不是线程安全的。

问题:

  1. 用weak指针对象如何判断该指针指向的对象是否销毁?

    答:weak_ptr 类中有一个成员函数 lock() ,这个函数可以返回指向共享对象的 shared_ptr,如果 weak 指针所指向的资源不存在,那么 lock 函数返回一个空 shared 指针,通过这个可以判断。要通过 weak 指针访问资源也要用类似的方法,因为 weak_ptr 没有重载 operator* 和 operator->,所以要先获取到对应的 shared 指针,判断是否为空,然后再访问资源、

lock_guard 和 unique_lock

​ C++11中新增了 lock_guard 可以防止线程使用 mutex 加锁后异常退出导致死锁的问题。lock_guard 创建时自动加锁,当离开作用域时自动解锁。使用起来非常方便。这一点和智能指针很像,lock_guard 也是利用了栈上分配的对象离开作用域时编译器自动调用其析构函数的特性,实现了自动解锁。lock_guard 内部封装了一个普通锁,他的构造函数中进行枷锁操作,析构函数中进行解锁操作。lock_guard 的拷贝构造和等号赋值运算符都是 delete 的,所以只能用在简单的临界区代码段的互斥操作中。

​ unique_lock 和 lock_guard 类似,也能做到创建时自动加锁,离开作用域自动解锁,不过 unique_lock 可以手动释放锁,还可以通过创建时传入参数设置是否上锁。同时他有接受右值引用的拷贝构造函数和等号赋值运算符,因此可以在函数调用中使用。

lambda表达式

​ lambda 表达式提供了一种类似匿名函数的特性,而匿名函数是在需要一个函数,但又不想费力去命名的情况下使用的。通过使用 lambda 表达式可以编写内嵌的匿名函数,用来替换独立函数或者函数对象,使代码更简洁可读,让开发更高效。

​ lambda 表达式的原理是,每当定义一个 lambda 表达式,编译器都会自动生成一个匿名类,这个类重载了小括号运算符,实际调用的就是重载的小括号运算符。

​ lambda 表达式可以分为五个部分,捕获列表、参数列表、可选项、返回值类型、以及函数体。其中除了捕获列表和函数体,都是可以省略的。捕获列表即可以按值捕获,也可以按引用捕获,按值捕获的变量实际上是原变量的拷贝,而且只能读不能写,如果要修改,就需要加上 mutable 可选项。

​ lambda 表达式的一个重要应用是可以用于函数的参数,通过这种方式可以实现回调函数。最常见的就是在 STL 算法中,比如你要统计一个数组中满足特殊条件的元素数量,通过 lambda 表达式给出条件,然后将其传递给 count_if 函数。

int val = 3;
vector<int> v {1, 8, 5, 3, 6, 10};
int count = std::count_if(v.beigin(), v.end(), [val](int x) { return x > val; });
// v中⼤于3的元素数量

atomic

C++11对 int、char 这类基本数据类型进行了原子封装,使得同一时刻只能有一个线程对其访问,效率比互斥锁更高,实现数据结构的无锁设计。

nullptr

​ nullptr 出现的目的是为了替代 NULL。NULL 并不是严格意义上的空,而是 0。使用 cout 输出 NULL,输出的结果是0。NULL会导致 C++ 的重载特性发生混乱,例如下面这两个函数:

void func(int);
void func(int *);

​ 如果 NULL 被定义为 0 那么 func(NULL) 这条语句会去调用 func(int) ,这就违反了语义。为了解决这个问题,C++11 引入了 nullptr,专门用来区分空指针和0,nullptr 就是严格意义上的空指针。使用 cout 输出 nullptr 的话编译器会报错。

final 和 override

C++ 借助虚函数实现了运行时多态,但 C++ 的虚函数有很多脆弱的地方:

  • 例如无法禁止子类重写虚函数。实际项目中可能继承到某一层级时,不希望子类继续重写某个虚函数了。
  • 还有就是容易不小心隐藏父类的虚函数,比如在重写时,不小心声明了一个签名不一致但同名的新函数。

为了解决这个问题,C++11 提供了 final 来禁止虚函数被重写或禁止类被继承,override 来显式的重写虚函数。这样编译器就能给一些不小心的行为提供错误和警告。

default 和 delete

主动让编译器生成默认的构造函数。delete则相反。

内存对齐的作用

​ 如果内存没有对⻬,寄存器存取数据要进⾏很多额外操作,⼤⼤降低了 CPU 的性能。

结构体和联合体的区别

结构体

​ 结构体是把不同类型的数据组合成一个整体。struct 里每个成员都有自己独立的地址。sizeof(struct) 是内存对齐后所有成员长度的加和。

联合体

​ 各成员共享一段内存空间, 一个union变量的长度等于各成员中最长的长度。共同体可被赋予任意成员的值,但每次只能赋一种值, 赋入新值则冲去旧值。 sizeof(union)是最长的数据成员的长度。

函数传递参数的⼏种⽅式

  • 值传递: 形参是实参的拷⻉,函数内部对形参的操作并不会影响到外部的实参。
  • 指针传递: 也是值传递的⼀种⽅式,只不过形参是指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进⾏操作。
  • 引⽤传递: 就是把引⽤对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上⾯。

堆 和 栈

​ 由编译器进⾏管理,在需要时由编译器⾃动分配空间,在不需要时候⾃动回收空间,⼀般保存的是局部变量和函数参数等。

​ 栈是一段连续的内存空间,在函数调⽤的时候,⾸先⼊栈的是主函数中下⼀条可执⾏指令的地址,然后是函数的各个参数。一次函数调用结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的指令地址,程序由该点继续运行,不会产生碎片。

​ 栈是高地址向低地址扩展,空间较小。

​ 堆由程序员管理,需要手动分配和回收,如果不进行回收会造成内存泄漏的问题。

​ 堆是不连续的空间,系统中有一个空闲链表,当有程序申请时,系统遍历空闲链表找到第一个大于等于申请大小的空间分配给程序,一般在分配的时候,也会把空间的起始地址写入内存,方便后续 delete 回收空间。如果有剩余空间也会插入到空闲链表中,因此堆中会产生碎片。

​ 堆是低地址向高地址扩展的,空间很大。

堆栈对比:

  • 管理方式:栈由系统管理,堆由程序员管理
  • 分配效率:栈由系统分配,操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,速度快;堆由由C++库函数提供实现,分配速度慢
  • 申请大小限制不同:栈的大小有限制而且很小,可以进行改变;堆空间很大,受限于计算机的虚拟地址。
  • 碎片问题:对于堆频繁使用new/delete会产生碎片;栈的数据先进后出,进出一一对应,不会产生碎片
  • 扩展方式:栈低地址向高地址扩展;堆反着来

C++ 程序的内存分区

  • **栈区:**由编译器自动分配释放
  • **堆区:**一般由程序员分配释放, 若程序员不释放,程序结束时由 OS 回收。
  • **全局或静态存储区:**全局变量和静态变量都存储在这里。C 语言中,未初始化的放在 .bss 段中,初始化的放在 .data 段中,C++ 中不再区分。程序结束后由操作系统释放。
  • **常量存储区:**存放字面值常量,程序结束后由操作系统释放。
  • **代码区:**存放编译后的二进制文件,不允许修改。

new/delete 和 malloc/free的区别

  • 前者是C++运算符,后者是C/C++语言标准库函数
  • new自动计算要分配的空间大小,malloc需要程序员自己计算
  • new是类型安全的,malloc不是,可以用 malloc 申请 double 类型变量的空间赋值给 int 类型的指针
  • new / delete 会调用构造函数和析构函数,而 malloc / free 不会,用他们给对象分配空间会很危险
  • malloc / free 返回的是 void 类型的指针,必须进行强转,new / delete 返回具体类型的指针

函数调用过程

​ 在主函数中遇到一个函数调用时,⾸先将主函数中下⼀条可执⾏指令的地址入栈,以便函数调用结束后可以返回主函数继续运行。然后入栈函数的参数,入栈顺序是从右到左。然后然后跳转到该函数的入口地址开始执行,如果函数中有局部变量则也会入栈。一次函数调用结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的指令地址,程序由该点继续运行。

STL

STL包含6大部件:容器、迭代器、算法、仿函数、适配器和空间配置器。

  • 容器:容纳一组元素的对象,提供各种数据结构。
  • 迭代器:提供一种访问容器中每个元素的方法,从实现的角度来说,迭代器是一种将operator*, operator->, operator++等指针操作赋予 重载的类模板。
  • 仿函数:一个行为类似函数的对象,调用它就像调用函数一样,重载了operator()的类或者类模板。
  • 算法:包括查找算法、排序算法等等。
  • 适配器:用来修饰容器等,比如 queue 和 stack ,底层借助了 deque。
  • 空间配置器:负责空间配置和管理,是一个实现了动态空间配置,空间管理,空间释放的类模板。

容器分类:

  1. 序列容器 sequence containers

    • array
    • vector
    • deque
    • list
    • forward-list
  2. 关联容器 associative containers

    (红黑树实现)

    • set
    • multiset
    • map
    • multimap
  3. 无序容器 (哈希表实现)

    • unordered_map
    • unordered_multimap
    • unordered_set
    • unordered_multiset
  4. 支持随机访问的容器:string, array, vector, deque

    支持在任意位置插入 / 删除的容器:list, forward_list

    支持在尾部插入元素:vector, string, deque

优先队列 priority_queue

​ 每次弹出优先级最高的元素,默认是大顶堆(less<int>),即最大的元素优先级最高,我们也可以传入 greater<int>使之变为小顶堆,此时最小的元素优先级最高,优先弹出最小的元素。第三个类型参数(可调用对象)是和堆的优先级反着来的。

容器操作使迭代器(指针、引用)失效问题

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AXxOoHbQ-1678759203347)(C:\Users\86130\AppData\Roaming\Typora\typora-user-images\image-20230303102111687.png)]

数组 和 链表对比

  • 存储方式:数组在内存中连续存储,且大小固定;链表在内存中分散存储,大小不固定,可随时拓展。存储同样的元素链表更占用空间,因为除了元素本身以外,链表的每个节点还要存储指向下一个节点的指针。
  • 随机访问:数组有下表操作,随机访问时间复杂度是O(1),而链表要从头节点开始遍历直到找到为止,时间复杂度是O(n)的
  • 头部插入删除:数组在头部插入和删除都是O(n)的,因为要把所有元素依次往后挪一个位置,才能把第一个位置空出来。而链表的头插和头删都是O(1)的,可以使用头指针在头部进行插入和删除。
  • 尾部插入删除:数组在尾部插入和删除都是O(1)的,因为有下标操作,可以直接定位到尾部进行删除。链表如果提前维护了尾指针的话可以利用尾指针做到O(1)的时间复杂度进行尾插和尾删,如果没有提前维护尾指针,就要从头结点遍历先找到尾节点,才能进行插入和删除操作。

rezise 和 reserve 的区别

  • resize 调整容器长度大小,reserve 只是预分配空间,不改变容器实际大小
  • resize 会创建或删除元素,而 reserve 不会

为什么c++要有模板编程?

​ 通过模板编程可以实现通用的容器和算法,比如STL 和 Boost库等。模板对类型有很强的抽象能力,可以让容器和算法更加通用。模板编程在一些大型项目里也有利于写出高复用性的代码。

什么是函数指针?

函数指针就是指向函数的指针变量,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。

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

​ 能。因为在编译时对象就绑定了成员函数地址,和指针空不空无关,指针为空只代表 this 对象的指针为空,无法访问成员变量。编译时,成员函数的地址就和指针绑定,所以可以调用成员函数,如果成员函数中没有使用到 this 指针,那么就不会报错,如果用到了 this 指针,就会应为 this 指针是 nullptr 而报错。

什么是野指针?怎么产生的?如何避免?

​ 就是指向的位置无法确定的指针。

​ 主要是释放内存后指针不及时置空,任然指向原来的地方,就会出现非法访问的错误。

避免办法:

  • 初始化置 nullptr
  • 释放内存后置 nullptr
  • 申请后判空
  • 使用智能指针

内联函数

​ 内联函数本质是一个函数,内联函数会在编译时进行代码插入,编译器会在没出调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用开销,提高效率,因此,内联函数中不能包含复杂的控制语句,否则,如果执行函数体内代码的时间比函数调用的开销大,那么效率可能会得到负提升,这就没有使用内联函数的必要了。

宏函数 和 内联函数 的区别

  • 宏定义不是函数。而内联函数满足函数的性质,有返回值和参数列表,是一个函数。
  • 宏函数是编译时把所有宏名用宏体替换,简单来说是字符串替换;而内联函数是在编译时执行代码插入,编译器会在调用内联函数的地方直接把内联函数展开,省去了函数的调用开销,提高效率。
  • 宏定义没有类型检查,无论对错都直接替换;而内联函数在编译时进行类型检查

宏定义和typedef区别?

  • 宏主要用于定义常量及书写复杂的内容;typedef用于定义类型别名。
  • 宏替换发生在预处理,属于文本插入替换;typedef是编译的一部分。
  • 宏不检查类型;typedef会检查数据类型。
  • 宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。

内联函数和普通函数的区别

  • 内联函数比普通函数多了 inline 关键字
  • 内联函数在编译时执行代码插入,省去了函数调用的开销。
  • 普通函数被调用时需要寻址,内联函数不需要寻址
  • 内联函数的函数体不能包含复杂的控制语句,普通函数没有这个要求。

static 的作用

  • 不考虑类的情况
    • 隐藏。所有不加static的全局变量和函数具有全局可见性,可以在其他文件中使用,加了之后只能在该文件所在的编译模块中使用
    • 默认初始化为0,包括未初始化的全局静态变量与局部静态变量,都存在全局未初始化区
    • 静态变量在函数内定义,始终存在,且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不能使用
  • 考虑类的情况
    • static成员变量:只与类关联,不与类的对象关联。定义时要分配空间,不能在类声明中初始化,必须在类定义体外部初始化,初始化时不需要标示为static;可以被非static成员函数任意访问。
    • static成员函数:不具有this指针,无法访问类对象的非static成员变量和非static成员函数;不能被声明为const、虚函数和volatile;可以被非static成员函数任意访问

const 和 define 的区别?

​ const 定义只读常量,define 定义宏,二者都可用于定义常量,但是有区别。

  • const 生效于编译阶段,define 生效于预处理阶段
  • const 定义的常量存储在内存,需要额外内存空间;define 定义的常量,运行时是直接的操作数,不在内存中
  • const 定义的常量带类型;define 定义的常量不带类型,因此不利于类型检查

strlen和sizeof区别?

  • sizeof是运算符,不是函数,编译时就能获得结果;strlen是字符处理的库函数,运行时才有结果。
  • sizeof参数可以是任何数据的类型或者数据;strlen的参数只能是字符指针且结尾是’\0’的字符串。

OOP 思想

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K6HuF4gq-1678759203348)(C:\Users\86130\AppData\Roaming\Typora\typora-user-images\image-20230310100939220.png)]

多态如何实现的

​ C++ 中多态有两种类型,静态多态 和 动态多态。

​ 静态多态通过函数重载实现。编译时编译器会根据参数判断调用的到底是重载函数的哪个版本。动态多态通过虚函数实现。

虚函数(动态多态是如何实现的)

​ 当⼀个类中包含虚函数时,编译器会为该类⽣成⼀个虚函数表,保存该类中虚函数的地址,同样,派⽣类继承基类,派⽣类中⾃然⼀定有虚函数,所以编译器也会为派⽣类⽣成⾃⼰的虚函数表。当我们定义⼀个派⽣类对象时,编译器检测该类型有虚函数,所以为这个派⽣类对象⽣成⼀个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数中完成的。

​ 后续如果有⼀个基类类型的指针,指向派⽣类,那么当调⽤虚函数时,就会根据所指真正对象的虚函数表指针去寻找虚函数的地址,就可以调⽤派⽣类虚函数表中的虚函数,以此实现多态。

构造函数能不能是虚函数?为什么?

​ 不能。一个类中所有虚函数的地址都记录在虚函数表中,类的每个对象都有一个虚函数表指针指向该类的虚函数表,进而能够调用相应的虚函数。调用虚构造函数需要虚表指针,但是虚表指针是在构造函数中初始化的,如果构造函数是虚函数就无法初始化虚表指针。

父类的析构函数为什么必须是虚函数?

​ 如果不是虚函数的话,指针对象在析构时就无法触发多态,只会调用父类的析构函数,子类的析构函数没有被调用,造成内存泄漏的情况。

抽象类

​ 抽象类是指含有纯虚函数的类,纯虚函数没有自己的实现,定义纯虚函数是为了定义一个接口,起到强制规范的作用,规范抽象类的子类必须实现这个函数,否则子类也是一个抽象类。

​ 抽象类不能实例化对象,不仅仅因为他的纯虚函数没有实现,也是因为在大多数情况下,基类本身生成对象是不合理的。例如动物作为基类可以派生出老虎、狮子等子类,但动物本身生成对象明显不合理。所以动物类应该被定义为抽象类,只定义一些纯虚函数作为接口,强制子类实现这些接口。

公有、私有、受保护继承

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u0SpigTj-1678759203349)(C:\Users\86130\AppData\Roaming\Typora\typora-user-images\image-20230302124259702.png)]

重载和重写(覆盖)的区别

  • 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
  • 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
  • 重写中,实际调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体

什么是隐藏?

隐藏指的是,派生类中的函数屏蔽了基类中的同名函数,对参数列表没有要求,可以相同也可以不同。

浅拷贝和深拷贝的区别

**浅拷贝:**浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。

**深拷贝:**深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。

操作系统

什么是进程

​ 进程是系统中正在运行的一个应用程序,程序一旦运行就是一个进程。进程是系统进行资源分配的最小单位。每个进程拥有独立的地址空间。所以一个进程无法直接访问另一个进程的变量和数据结构。想访问的话要使用进程间通信,比如管道消息队列等。

  • **孤儿进程:**父进程退出后,其子进程还在运行,他们就是孤儿进程,他们会被 init 进程收养,并由 init 进程完成状态收集工作。
  • **僵尸进程:**一个进程使用 fork 函数创建子进程,子进程退出后,没调用 wait() 获取子进程的终止状态,但子进程的描述符仍然保存在系统中占用资源。(使用 kill 命令解决僵尸进程)

进程与线程的区别

  • 进程是操作系统资源分配的最小单位,线程是 CPU 调度的最小单位。
  • 进程拥有完整独立的内存单元,而线程只有必不可少的资源,比如寄存器和栈
  • 一个线程独属于一个进程,一个进程可以包含多个线程
  • 进程上下文切换的开销远大于线程,很影响系统性能。
  • 进程间的信息难以共享,必须使用进程间通信来共享信息。而多个线程共享进程的内存。

进程间通信

① 和 ② 管道有两种

​ 前两种分别是有名管道 pipe 和 无名管道 FIFO,他们唯一的区别就是:无名管道只能进行相关联进程之间的通信,比如父子进程。而有名管道可以进行任何进程之间的通信。

​ 管道类似一个队列,管道里的数据是先进先出的,而且是半双工的。这意味着同一时间管道里的数据只能往一个方向流动。管道的实质就是在内核中创建一个缓冲区,一端的进程写入数据,另一端的进程读取数据。

​ 由于管道通过内核交换数据,因此通信效率很低,不适合频繁交换数据。

③ 消息队列

​ 消息队列本质是保存在内核中的链表,由多个独立数据块组成。与管道相比消息队列不一定按照先进先出的方式读取,可以按消息类型读取。

​ 消息队列的生命周期与内核相关而与进程无关,如果不显式的删除,那消息队列就会一直存在。

​ 消息队列无法实现实时通信,数据块有大小限制,而且消息队列通信过程中,存在用户态与内核态之间的拷贝开销。

④ 共享内存

​ 共享内存用来解决用户态和内核态之间频繁的发生拷贝。现代操作系统普遍采用虚拟内存进行内存管理,每个进程有自己独立的虚拟内存空间,不同进程的虚拟内存空间映射到不同的物理内存。共享内存就是拿出一块虚拟地址空间,映射到同一块物理内存。好处是一个进程写入数据后另一个进程可以立即查看,而且不用拷贝,效率很高。所以这是进程间最快的通信方式。

​ 由于多个进程都可以操作共享内存,所以需要同步对共享内存的读写。

⑤ 信号量

​ 它本质是一个计数器,表示的是资源的数量。它用于实现进程间的互斥与同步。当进程间使用共享内存通信时就要用信号量来同步数据的读写。

​ 信号量有两个操作,P操作占用资源,会把信号量 -1,-1 之后如果信号量 < 0,就表示资源已被占用,其他进程要阻塞等待。如果 -1 之后还 >= 0 表明还剩余资源,进程正常执行。V 操作和 P 操作相反,他会释放资源,使信号量 + 1。一个进程使用完资源后会执行 V 操作,使信号量 + 1 以便其他进程访问资源。

信号量 与 互斥量 之间的区别:

互斥量用于线程互斥,信号量用于线程同步。

互斥同步 的区别:

  • 互斥指某资源同时只允许一个访问者对其进行访问,具有唯一性和排他性。但互斥不能限制访问者对资源的访问顺序,也就是说访问是无序的。
  • 而同步可以在互斥的基础上,实现访问者对资源的有序访问。

⑥ 信号

​ 可以在任何时刻给进程发送信号。收到信号的进程可以执行信号的默认操作,或是自定义信号处理函数,也可以直接忽略信号。

⑦ socket

​ 实现不同主机上进程的通信。

你是怎么使用的?

​ 我用的最多的还是 socket ,进行不同主机上进程的通信。最直接的使用过程就是,客户端和服务端都创建 socket 并绑定 IP 和端口号作为网络通信的接口,然后服务器阻塞地 linten 等待客户端连接,客户端通过 connect 连接服务端,服务端地 accept 被触发,然后就可以通过 read、write 互相收发数据了。

进程间通信可以用互斥锁吗?

​ 可以是可以就是有点复杂,需要使用共享内存。开辟一块共享内存,使得要通信的进程可以访问同一块区域,然后把互斥锁定义在共享内存上,使相关进程都可以使用该锁。初始化该锁的时候,设置为进程间共享,这样两个进程连接到共享内存后,就都可以获得该互斥锁。

​ 如果是不同主机上的进程间通信就要用分布式锁了。

线程间通信

  • **互斥锁:**只有拥有锁的线程才能访问,保证了公共资源不被多个线程同时访问。
  • **信号量:**本质是个计数器,实现了多个线程对同一资源的有序访问。
  • **条件变量:**通过条件变量通知的操作保持多线程同步。
  • **读写锁:**与互斥锁类似,不过读写锁允许同一时间只有一个线程写,可以有多个线程读,效率比互斥锁高。

条件变量

​ 条件变量本质是一个全局变量,它的功能是阻塞线程,直到接收到“条件成立”的信号后,被阻塞的线程才能继续执行。一个条件变量可以阻塞多个线程,当条件成立时,条件变量可以解除线程的“被阻塞状态”。

​ 使用条件变量时要借助一把互斥锁共同完成功能。用条件变量阻塞某线程之前必须先对互斥锁完成“加锁”操作,然后条件变量就会阻塞线程,直到收到“条件成立的信号”,当线程被添加到等待队列上后,会自动将刚刚的互斥锁“解锁”。当其他线程发来“条件成立”信号后,条件变量不会立即结束对当前线程的阻塞,而是先完成对互斥锁的“加锁”操作,然后再解除阻塞。

​ 我在设计 RPC 框架的日志模块时使用到了条件变量。我用一个队列存储各个 worker 线程的日志信息,最后由单独的线程将队列中的数据写入磁盘。各个线程通过条件变量进行互斥和同步。当队列为空时,磁盘 IO 线程被条件变量阻塞,当 worker 线程向日志队列写入日志后,会通知磁盘 IO 线程条件成立,此时该线程就会将队列中的日志数据写入磁盘。

进程状态转换

image-20230305104746111

死锁

是什么:死锁是指多个进程循环等待别人占有的资源而无限期僵持下去的情况。

形成的必要条件

  • ① 互斥。某个资源同时只允许一个进程访问。如果已经有进程访问该资源,其他线程就不能访问,直到该进程访问结束。
  • ② 请求保持(占有的同时还在等待)。一个进程占有一部分资源的同时,还有资源未得到,需要其他进程释放该资源。
  • ③ 不可抢占。别的进程已经占有某种资源,自己不能去抢。
  • ④ 循环等待。存在一个循环,每个进程都需要其他进程的资源,但每个进程又因为缺少所需资源而不能继续运行。

避免死锁的方法:

​ 既然死锁形成有四个必要条件,那么我们避免四个条件同时产生就行了。其中 互斥条件 是必须的,所以具体有三个办法:

  • ① 破坏“占有的同时还在等”:资源一次性分配。用完资源就释放。
  • ② 破坏“不可抢占”:当进程想要的资源在其他进程手里时,手里的资源就别占着了,先把已经获取到的资源也释放掉,供其他进程使用。
  • ③ 破坏“循环等待:实现资源的有序分配,所有进程申请资源必须按照顺序。

协程是什么

​ 协程比线程更轻量级,开销远小于线程。就像一个进程可以拥有多个线程一样,一个线程也可以有多个协程;协程不被操作系统内核管理,完全由程序控制。

​ 协程拥有自己寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,切换回来的时候,再将他们恢复回来。每个协程与其他协程共享全局数据。

协程和线程的区别是什么?

  • 线程和进程都是同步机制,而协程是异步。
  • 协程能保留上一次调用时的状态
  • 线程是抢占式,协程是非抢占式,需要程序员自己释放使用权来切换到其他协程,同一时间只能有一个协程运行。

LRU 算法

​ LRU 算法一般使用链表作为数据结构来实现,链表的每个节点存储缓存的数据。链表头部数据是最近使用的,而末尾的数据是最久没被使用的。当内存空间不够时,就从链表末尾淘汰最久没使用的节点,从而腾出内存空间。

​ 传统的 LRU 算法是这样的,当访问的页在内存里,就直接把该页对应的链表节点移动到链表头;当访问的页不在内存时,就要把该页放入到链表头,并淘汰掉链表末尾的页。

​ 传统 LRU 算法存在预读失效和缓存污染导致的缓存命中率下降问题,这会大大增加磁盘 I/O 的次数,耗费极大的性能。

​ 为了解决预读失效带来的损耗,Linux 实现了两个 LRU 链表,活跃 LRU 链表和非活跃 LRU 链表,真正要使用的页放在活跃 LRU 链表,而预读取的页先放在非活跃 LRU 链表。

​ 为了解决缓存污染带来的损耗,Linux 会把第一次访问的页放入非活跃链表,防止他们挤占了常用页的空间,当他们第二次被访问的时候才会进入活跃链表。

零拷贝技术

​ 零拷贝技术的目的就是为了减少在文件传输或数据拷贝过程中,CPU 参与拷贝的次数,以及用户态和内核态之间切换的次数。

​ Linux 内核版本2.1引入 sendfile() 系统调用,它可以代替 read() 和 write() 这两个系统调用,从而减少两次用户态和内核态之间的切换。以网络传输为例,应用进程调用 sendfile 后,会切换到内核态,CPU 通知 DMA 把磁盘数据拷贝到内核缓冲区,然后再直接从内核缓冲区拷贝到 socket 缓冲区,不用再拷贝到用户态,最后由 DMA 将 socket 缓冲区的数据拷贝到网卡。整个过程中只有 2 次上下文切换,CPU 只参与一次数据拷贝。但这还不是完全的零拷贝。

​ Linux 内核 2.4 开始,对于网卡支持 SG-DMA 技术的情况下,CPU 可以完全不参与数据的拷贝工作。当数据从 DMA 拷贝到内核缓冲区后,SG-DMA 可直接把内核缓冲中的数据拷贝到网卡,不需要再从内核缓冲区拷贝到 socket 缓冲区,减少了一次数据拷贝。

​ 据我所知 kafka 和 nginx 的实现里都利用了零拷贝技术。

I/O 多路复用模型

select

​ select 实现多路复用的方式是,把已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数把文件描述符集合拷贝到内核,让内核检查是否有网络事件产生,检查的方式很粗暴,就是遍历文件描述符集合,当检查到有事件产生后,将此 Socket 标记为可读或可写, 最后把整个文件描述符集合回用户态,然后用户态还也要遍历整个集合找到可读或可写的 Socket,再对其处理。对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,还会发生 2 次「拷贝」文件描述符集合,先从用户空间拷贝到内核空间,由内核修改后,再拷贝到用户空间中。

poll

​ poll 和 select 并没有太大的本质区别,只不过 poll 使用链表存储文件描述符,没有最大连接数的限制。但他仍是线性结构,需要遍历整个集合找到可读或可写的 socket,而且还是有大量用户态到内核态的拷贝。

epoll

​ epoll 在内核中维护一棵红黑树,红黑树的每个节点就是我们关注的一个 socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。把需要监控的 socket 通过 epoll_ctl() 函数加入内核的红黑树里,这样就省去了反复拷贝文件描述符集合的开销。

​ epoll 使用事件驱动机制,内核维护一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数将其加入到就绪事件列表,当调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符,不用像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

​ epoll 有两种工作模式。水平触发 和 边沿触发。水平触发就是当epoll_wait检测到某事件被触发时,若应用程序不把数据全读完,那么下次调用epoll_wait函数还会再次向应用程序报告该事件,直到事件被处理。select 和 poll就只有这一种工作模式。边沿触发下,当epoll_wait检测到某事件被触发时,只会报告一次,直到下次再有新数据流入之前都不会在报告,无论文件描述符中是否还有数据可读。所以只能一次性把数据读完。这变向降低了同一事件被重复触发的次数,因此效率比select 和 poll高。

高性能网络模式 reactor 和 proactor

说说大小字节序,用一个简单函数判别大小序

  • 大端字节序:高位字节放在内存的低地址端,低位字节放在内存的高地址端
  • 小端字节序:低位字节放在内存的低地址端,高位字节放在内存的高地址端。
bool BigEndian()
{
	int x = 0x12345678;
    char *p = (char*)&x;
    if (*p == 0x78) return false;	// 小端序
    else return true;	// 大端序
}

计算机网络

七层模型

​ OIS 七层模型是由国际标准化组织指定的网络七层模型。在实际中的应⽤意义并不是很⼤,但是它对于理解⽹络协议内部的运作很有帮助。它将计算机⽹络体系结构划分为7层,每层都为上⼀层提供了良好的接⼝。‘

​ 七层从上到下分别是:物理层、数据链路层、⽹络层、传输层、会话层、表示层、应⽤层。

  • 应用层,负责给应用程序提供统一的接口;(HTTP)
  • 表示层,负责把数据转换成另一个系统能识别的格式;
  • 会话层,负责建立、管理和终止表示层实体之间的通信会话;
  • 传输层,负责端到端的数据传输;(TCP、UDP)
  • 网络层,负责数据的路由、转发、分片;(IP)
  • 数据链路层,负责把数据封装成帧 和 差错检测,以及 MAC 寻址;
  • 物理层,负责在物理网络中传输数据帧;

TCP / IP 网络模型有那几层?

应用层

​ 应用层为用户提供应用功能,不关心数据如何传输,工作在操作系统的用户态,他下面三层都工作在内核态。

传输层

​ 为应用层提供网络支持,把接收到的数据包根据端口号传给应用层应用,或是使用 TCP / UDP 协议将应用层传下来的数据发送出去。

网络层

​ 网络层通过 IP 协议将传输层的报文封装为 IP 报文,通过 IP 地址唯一确定另外一个设备。

网络接口层

​ 网络接口层在 IP 报文的前面加上 MAC 头部,并封装成数据帧发送到网络上。网络接口层使用 MAC 地址标识网络上的设备,将数据帧发送到另一台设备的网络接口层,再由它层层上报,最终完成传输。

TCP为什么可靠(如何实现可靠传输)

​ TCP通过检验和、确认应答、超时重传、连接管理、控制最大消息长度、流量控制、拥塞控制一起保证TCP传输的可靠性。

  • **检验和:**TCP把一个16位的字段作为校验和,发送方和接收方验证校验和是否相同。不相同则数据传输有误,相同也可能有问题。
  • **确认应答:**ACK 和 序列号一应一答机制能够保证数据的完整性
  • **超时重传:**发送数据包在一定时间周期内没有收到相应的ACK,超时后就认为该数据包丢失,发送方重新发送
  • **连接管理:**TCP通过三次握手,四次挥手实现稳定的连接
  • **控制最大消息长度:**TCP会控制最大消息长度,理想的情况下数据刚好不被网络层分块,保证传输效率
  • **流量控制:**TCP用滑动窗口控制发送方的发送速率,防止接收方来不及接收
  • **拥塞控制:**TCP刚开始发送数据很慢,先发送一点数据探测网络是否拥塞,如果不拥塞了,就大量发送数据。如果突然拥塞,就又会很慢的发送数据。这样避免由于网络拥塞造成一系列问题

TCP 滑动窗口是如何实现的?

​ 发送方有发送窗口,接收方有接收窗口,两个窗口的大小一般是一致的。发送窗口左边的数据是已发送且收到 ACK 确认的数据,发送窗口内的数据是在接收方处理范围内的最大数据量,他们有的已经发送了,有的还未发送。当未收到相应数据的确认时,即便发送窗口的所有字节都发送出去了,发送窗口也不能移动,因为 TCP 要保证可靠传输。一旦收到 ACK 确认,滑动窗口的左边界就可以移动到已收到确认的最后一个字节序号对应的位置。否则足够长时间没收到确认,就会触发超时重传。

​ TCP 通过维护三个指针唯一确定发送窗口的状态。指针 P1 指向发送窗口内的第一个字节序号,指针 P2 指向发送窗口内已发送字节的下一个序号。指针 P3 指向发送窗口右侧的下一个字节序号。
有了这三个指针就可以知道:

  • 小于 P1 的是已发送且已收到确认的部分
  • 大于等于 P3 的是不允许发送的部分
  • P3 - P1 可以得到发送窗口的尺寸
  • P2 - P1 可以得到已发送但尚未收到确认的字节数
  • P3 - P2 得到允许发送但当前还没发送的字节数

​ 接收方确认时有累计确认和捎带确认机制,可以间接提升 TCP 传输的效率。

拥塞控制算法有哪些,怎么样实现?

慢开始

​ 连接建立完成后,初始化拥塞窗口=1,表示可以传1个 MSS 大小的数据。当收到一个 ACK 确认后,拥塞窗口+1,此时能一次发送两个。当收到2个 ACK 确认后,拥塞窗口+2,此时一次发送4个, 当收到4个 ACK 确认后,拥塞窗口+4,此时能一次发送8个。以此类推,可以看出一次性发包的个数呈指数增长 。

​ 这种增长不会无限持续下去,有一个慢启动门限,当拥塞窗口大小 >= 慢启动门限时就会使用拥塞避免算法。

拥塞避免

​ 假设慢启动门限就是8,那么当收到 8 个 ACK 确认后,每个确认只会为 拥塞窗口 增加 1 / 8,8个确认就只增加1,此时,一次能发送 9 个 MSS 大小的数据。可以看出,使用拥塞避免算法后,发包个数变成线性增长。

拥塞发生

​ 当网络出现拥堵,会发生数据包重传,重传机制主要有两种:超时重传 和 快速重传。发生这两种情况使用的拥塞发生算法不一样。

​ 超时重传是,当网络非常拥塞,发生超时重传时,会更新慢启动门限为拥塞窗口的一半,并重置拥塞窗口大小为1。然后重新开始慢启动算法。

​ 快速重传就是,当接收方发现只丢了一个中间包的时候,发送三次对前一个包的 ACK ,发送端连续收到三个 ACK 时,就不必等待超时重传。这种情况拥塞并不严重,不用再从起点执行慢启动算法。而是会把拥塞窗口大小变为原来的一半,再让慢启动门限=拥塞窗口的大小。然后进入快恢复算法。

快恢复

​ 快恢复算法会先设置拥塞窗口大小为新的慢启动门限+3,然后重传丢失的数据包,当收到重复数据的 ACK 后,拥塞窗口+1,当收到新数据的 ACK 后,把 拥塞窗口设置为慢启动门限的值,因为已经接收到了新数据的 ACK,说明恢复过程已经结束,可以再次进入拥塞避免状态。

如何提升 TCP 传输的效率?

​ 发送端滑动窗口定义了网络中飞行报文的最大字节数,当它小于带宽时,就无法充分利用网络带宽时延积。要想提升发送速度必须提升滑动窗口的上限,在 Linux 下是通过设置 tcp_window_scaling 为 1 做到的,此时最大值可达到 1GB。

时延带宽积 = 传播时延 * 带宽

​ 此外,内核缓冲区也会决定滑动窗口的上限,缓冲区分为:发送缓冲区 和 接收缓冲区。可以把缓冲区的上限设置为带宽时延积。然后设置发送缓冲区和接收缓冲区为自动调节。这样就既能最大程度地保持并发性,也能使得在系统资源充裕时 连接传输速度达到最大值。

TCP 和 UDP 的区别

  • TCP 是有连接的,在传输数据前必须通过三次握手建立连接。而 UDP 是无连接的,可以直接发送数据。
  • TCP 能保证数据按序发送,按序到达,并提供超时重传保证可靠性,而 UDP 提供不可靠服务,只是努力交付数据,不保证按序送到。
  • TCP 首部开销大,最小20字节,而 UDP 首部开销小,只有 8 字节。
  • TCP 有流量控制和拥塞控制,UDP 没有,网络拥堵不会影响发送端的发送速率。
  • TCP 是一对一连接,而 UDP 可以支持一对一、一对多 和 多对多通信。
  • TCP 是面向字节流的服务,而 UDP 对应用层交付的报文直接打包。

TCP 连接

三次握手

TCP 三次握手

​ 连接前,客户端和服务端都处于 CLOSE 状态,先是服务端主动监听某个端口,处于 LISTEN 状态。当客户端发起请求时,会随机初始化序列号 client_isn,把该序列号至于 TCP 首部的序号字段中,同时把 SYN 标志位置1。然后把该 SYN 报文发送给服务端,之后客户端进入 SYN_SENT 状态。

​ 服务端收到客户端的 SYN 报文后,也随机初始化自己的序号 server_isn ,把该序号写入 TCP 首部的序号字段中,然后在 TCP 首部的确认应答号字段填入 client_isn + 1,然后把 SYN 和 ACK 标志位置1,然后就能发送了。发送后,服务端处于 SYN_RCVD 状态。

​ 客户端收到报文后,还需发送一个应答报文。先把 TCP 首部的 ACK 字段值1,然后在确认应答号字段写入 server_isn + 1,然后就可以发送了。发送后客户端转为 ESTABLISHED 状态。服务端收到应答报文后也转为 ESTABLISHED 状态。

注:三次握手只有第三次,客户端的应答报文中可以携带数据。

为什么是三次?不是2次或4次?

  • 三次握手才可以阻止重复历史连接的初始化(主要原因)
  • 三次握手才可以同步双方的初始序列号
  • 三次握手才可以避免资源浪费

为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢?

  • 为了防止历史报文被下一个相同四元组的连接接收(主要方面);
  • 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收;

四次挥手

客户端主动关闭连接 —— TCP 四次挥手

​ 双方都可以主动断开连接。以客户端主动断开连接为例。

​ 客户端会发送 FIN 报文,TCP 首部的 FIN 标志位置1,然后客户端进入 FIN_WAIT_1 状态。

​ 服务端接收到后会发送 ACK 应答报文,并进入 CLOSE_WAIT 状态。

​ 客户端接收到 ACK 应答报文后,进入 FIN_WAIT_2 状态,继续等待下一个报文。

​ 服务端处理完数据后,也向客户端发送 FIN 报文,并进入 LAST_ACK 状态。

​ 客户端收到 FIN 报文后,回复一个 ACK 应答报文,并进入 TIME_WAIT 状态。

​ 服务端收到 ACK 应答报文后,进入 CLOSE 状态,至此服务端完成连接的关闭。

​ 客户端经过一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。

为什么挥手要四次?

  • 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
  • 服务端收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。

为什么 TIME_WAIT 等待的时间是 2倍的 MSL?TIME_WAIT 在哪个阶段,会发生什么作用?

  • 防止历史连接中的数据,被后面相同四元组的连接错误的接收。
  • 保证「被动关闭连接」的一方,能被正确的关闭连接。如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 FIN 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。

HTTP 的报文结构

​ HTTP 报文由 报文首部 和 报文主体 组成。而报文首部又可分为 起始行 和 头部,报文首部 和 报文主体之间通过一个空行分隔。

请求报文

​ 请求报文的请求行描述了客户端想要如何操作服务端的资源,它包括请求方法、请求目标、版本号三部分。中间使用空格分隔,最后要用 CRLF 换行表示结束。

​ 请求头包含若干个属性,格式为 “属性名:属性值”,主要用于说明请求源、连接类型以及Cookie等信息。

​ 请求体就是 HTTP 要传输的正文内容。

响应报文

​ 响应报文的起始行由 HTTP 版本、状态码、状态描述三个字段组成。状态码表示服务器处理此次 HTTP 请求的状态。

​ 响应头也是一个个键值对,他们主要是一些服务器的基本信息,以及一些Cookie值。

​ 响应体就是服务器返回的具体数据。

HTTP 常见状态码(小林coding

https://www.xiaolincoding.com/network/2_http/http_interview.html#http-%E5%B8%B8%E8%A7%81%E7%9A%84%E7%8A%B6%E6%80%81%E7%A0%81%E6%9C%89%E5%93%AA%E4%BA%9B

HTTP 常见字段

  • **Host:**表示此次请求的服务器域名
  • **Content-Length:**表示此次回应的数据长度
  • **Connection:**常用于客户端要求服务器使用 HTTP 长连接机制,以便其他请求复用本次连接。
  • **Content-Type:**告知客户端本次响应的数据格式。
  • **Content-Encoding:**告知客户端服务器返回的数据使用的压缩格式。

TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?

​ HTTP 的 Keep-Alive 是由「应用程序」实现的,目的是可以用同一个 TCP 连接来发送和接收多个 HTTP 的请求和应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放造成的开销。

​ TCP 的 Keepalive 是一种 保活机制,由「内核」实现的,当客户端和服务端长达一定时间没交换过时,内核为了确认该连接是否还有效,就会发送探测报文,来检测对方是否还在线,从而决定是否要关闭该连接。

GET 和 POST 的区别

  • GET 是从服务器上请求数据,POST 是向服务器提交数据。
  • GET 请求会被浏览器主动缓存,留下历史记录,而 POST 默认不会
  • GET 只能进行 URL 编码,只能接收 ASCII 字符,而 POST 没有限制
  • GET 传递的数据以键值对的形式放在 URL 末尾,在网址中能直接看到,安全性差。而 POST 把请求参数放在请求体中,适合传输敏感数据。
  • GET 只读取服务器的数据,是幂等的,而 POST 执行新增或提交数据的操作,可能修改服务器上的资源,所以不是幂等的。

介绍下 HTTPS

​ HTTPS是一种能在网络上进行安全通信的传输协议,它通过 HTTP 进行通信,利用 SSL/TLS 握手对数据包进行加密。

HTTPS 建立连接的过程

​ 首先客户端和服务器进行 TCP 三次握手建立连接。然后双方要进行 4 次 SSL/TLS 握手。第一次握手由客户端发起加密通信请求,客户端向服务器发送自己支持的 TLS 协议版本,还有支持的加密算法表,最重要的还有 生成的随机数1,该随机数用于生成会话密钥。

​ 服务端收到客户端请求后,会确认自己的 TLS 协议版本和加密算法,还会把数字证书也发给客户端,而且也会生成随机数2,也用于生成会话密钥。

​ 客户端收到消息后,会先确认数字证书的真实性,然后从中取出服务器的公钥。然后告诉服务器后面的消息都要用会话密钥加密,并表示客户端的握手已经结束了,此外客户端还会生成随机数3,一并发送给服务器。这整个报文会用刚刚得到的公钥进行加密。

​ 至此客户端和服务器各自都有了三个随机数,就能用刚刚商量好的加密算法生成本次通信的会话密钥了。

​ 服务器计算出会话密钥后会通知客户端,随后的消息都用会话密钥加密,并且也表示这是服务器的握手结束了。

至此,完成4次握手,客户端与服务器进入加密通信。

http 和 https 的区别

  • HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。
  • HTTP 连接建立相对简单, TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。
  • 两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。
  • HTTPS 协议需要向 证书权威机构 申请数字证书,来保证服务器的身份是可信的。

DNS 寻址过程

​ 当在浏览器中输入某域名时,浏览器先查自己的缓存中是否有该域名对应的 IP 地址。没有的话,操作系统会检查本地 hosts 文件是否有该域名对应的 IP。没有就再到路由器缓存中查找。这三个查找过程都在本地完成,算本地 DNS 缓存。如果都没找到就要使用互联网服务提供商提供的 DNS 缓存了。这时有两种解析策略 递归 和 迭代,两种策略中,都要先访问顶级域名服务器。

​ 递归就是客户端向本地 DNS 服务器发送 DNS 请求,如果本地 DNS 服务器中找不到,他会转发给根域名服务器,根域名服务器收到请求后 解析域名后缀,然后转发给相应的顶级域名服务器,顶级域名服务器再转发给权限域名服务器。递归过程中一旦找到 IP 就立刻向上返回,最终返回给客户端。

​ 而迭代查询则是客户端向本地 DNS 服务器发送 DNS 请求,如果本地 DNS 服务器中找不到,他会转发给根域名服务器,根域名服务器收到请求后 解析域名后缀,把顶级域名服务器的 IP 告知本地域名服务器,由本地域名服务器自己去访问顶级域名服务器,如果没有,就把权限域名服务器的 IP 告知本地域名服务器,再由本地服务器自己去访问权限域名服务器。最后把结果返回给客户端。

从 url 输入到显示页面发生了什么?

  • 首先浏览器对 URL 进行解析,从而生成发送给 Web 服务器的 HTTP 请求信息。

  • 发送前要确定 web 服务器的 IP 地址,浏览器先查看自身有没有目标域名的缓存,如果有就直接返回,否则要到 DNS 服务器查询域名对应的 IP。

  • 有了 IP 地址就可以把 HTTP 的传输工作交给操作系统中的协议栈。首先会进行三次握手建立 TCP 连接,之后组装好含有HTTP请求的 TCP 报文交给下面的网络层处理。

    • ICMP 用于告知网络包传送过程中产生的错误以及各种控制信息。
    • ARP 用于根据 IP 地址查询相应的以太网 MAC 地址。
  • 网络层又把 TCP 报文封装成 IP 报文,包含源地址 IP 和目标地址 IP。

  • 接下来还要在 IP 头部前面加上 MAC 头部,它包含接收方和发送方的 MAC 地址等信息。

  • 经过层层封装,数据来到网卡,网卡将 MAC 帧传为电信号通过网线发送出去。

  • 信号首先会到达交换机,交换机根据 MAC 地址表查找 MAC 地址,然后将信号发送到相应的端口。

  • 经过交换机后到达路由器,由路由器选择一条网络通路将数据转发给另一个路由器。

  • 最终数据包抵达服务器,服务器先检查数据包的 MAC 头部,查看是否和自己的 MAC 地址符合,若符合继续检查 IP 头是否符合,根据协议字段确定是 TCP 协议,然后根据端口号得知该数据包要转发给 HTTP 进程。HTTP 进程将相应数据封装在 HTTP 响应报文里,然后再经过 TCP、IP、MAC 等协议的封装,发回给客户端。客户端收到 HTTP 响应报文后,交给浏览器渲染页面,这样页面就显示出来了。

image-20230217201959927

MySQL数据库

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DVSm4BfD-1678759203349)(C:\Users\86130\AppData\Roaming\Typora\typora-user-images\image-20230224103719421.png)]

第一范式

数据库的每一列都是不可分割的最小数据项单元。例如:地址:四川省宜宾市叙州区就不满足第一范式,应该拆分为:省份:四川省 城市:宜宾市 区域:叙州区

第二范式

在第一范式的基础上,要求非主键字段完全依赖于主键(注:该主键不能是联合主键)

第三范式

在第二范式的基础上,要求非主键字段不依赖于其他非主键字段,消除传递依赖。例如:ID:1, name:张三, name_id:13 这里的name和name_id之间就存在传递依赖,不符合第三范式。

索引是什么?

​ 索引是帮助 MySQL 高效获取数据的一种数据结构,使用索引可以快速查表。如果不加索引的话,查某一行数据就要遍历表的每行数据,效率很低。如果有了索引,利用数据结构就可以快速查找。

​ 索引一般以文件的形式存储在磁盘上,在对表进行增删改时数据库也要不断维护索引。

索引分类

单值索引:即一个索引只包含单个列,一个表可以有多个单值索引。(但不是越多越好,一般只对经常查询的字段建立索引)

唯一索引:索引列的值必须唯一,但允许有空值。

主键索引:设定为主键后会自动建立索引。

复合索引:一个索引包含多个列。

事务的四大特性ACID

原子性:事务是不可分割的最小单位,一个事务内的操作要么同时成功,要么同时失败,不可再分。

一致性:事务执行前后数据状态应该保持一致。例如转账前后双方的账户总额应该保持不变。

隔离性:并发执行的两个事务之间不会相互干扰。

持久性:一旦事务执行成功,即便数据库在此时崩溃了,在数据库重启时也能够保证事务操作被持久化到数据库中。

原子性和持久性是基于日志实现的,隔离性是通过锁来实现的,三者做为基础共同确保一致性的实现。

为什么InnoDB表必须有主键(没有也会自动将某一字段设为主键),并且尽量使用整型的自增主键?

  1. InnoDB的数据存储本身就是在主键索引树当中,没有主键就无法存储数据?
  2. 整型的存储空间比其它字段类型要小,并且查询时要对主键进行大量比较操作,整型数据的比较效率高于其它数据类型。
  3. 对于自增的主键而言,B+树是有序的,插入数据与范围查询都非常方便

为什么InnoDB表的非主键索引结构叶子结点存储的是主键值

  • 节约存储空间。主键索引树已经存储了完整的表记录信息,无需再次存储数据。
  • 其次是一致性考虑。非主键索引叶子节点统一去主键索引树再查找,共享同一份数据,就能确保一致性。
  • 最后是可以降低二级索引的维护成本。当数据发生修改时只需要修改主键索引的叶子结点,非主键索引就不用改了。

为什么是B+树?

结合操作系统可以知道,数据库索引的每一层向下查找一个节点都是一次独立的磁盘 IO。而磁盘 IO 操作非常费时,因此要想办法降低树的高度。

首先考虑普通的二叉树,在极端情况下二叉查找树会变成一个单链表,而且各节点高度不稳定,不如直接考虑平衡二叉树。平衡二叉树呢,虽然树的高度得到了降低,但是一个节点只能存放一个关键字和记录,考虑采用B树。

B树具有如下特点:一个节点可以存储多个关键字和记录,所有索引关键字不会重复出现,每个索引的关键字和记录都存放在一起。但是B树相比于B+树也存在如下问题:

  1. 在B树中越靠近根节点的记录查找时间会更快,导致不同记录的查询效率不稳定;而B+树不论什么数据都需要查询到叶子节点才可以找到,效率稳定
  2. B+树的非叶子节点不存放记录,在 innoDB 中页的默认大小是 16KB,如果不存记录就可以存储更多的关键字,树的高度就能进一步降低,磁盘IO次数也得到降低
  3. B+树的叶子节点之间采用了指针连接,这样一来就方便了顺序遍历,适合于范围查询

所以最终选用B+树结构。

Hash索引与B+树索引的区别?

B+树索引支持范围查询、模糊查询,遵循最左匹配原则,而这些Hash索引都不支持,但是在等值查询上Hash索引的效率要比B+树效率高。

负载均衡算法

轮询

​ 把请求轮流发送到每个服务器上。

加权轮询

​ 在轮询的基础上,根据服务器的性能差异,为服务器赋予一定权值,性能高的服务器分配更高的权值。

轮询的缺点:每个请求的连接时间不一样,使用轮询可能会让一台服务器的当前连接数过大,

最少链接

​ 将请求发送给当前连接数最少的服务器。

加权最少链接

​ 在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数。

加权随机算法

​ 根据服务器的配置和负载情况,配置不同的权重。然后按照权重来随机选取服务器。

源地址哈希算法

​ 对客户端 IP 计算哈希值之后,再对服务器数量取模得到目标服务器的序号。可以保证相同的 IP 客户端,如果服务器列表不变,将映射到同一个后台服务器进行访问。

设计模式

设计模式了解吗?

​ 我理解的设计模式就是使用面向对象的手法实现可复用的代码,在应对需求发生变化时更易于扩展,避免重复发明轮子,提高代码复用性,使开发更简洁高效。

用过哪些设计模式?

​ 单例模式、

单例模式

​ 保证一个类只有一个实例,并提供一个该实例的全局访问点,该实例被所有程序模块共享。使用单例模式好处在于可以节省内存资源。

其他

为什么选择 C++ 后端

​ 我觉得自己对业务这块比较感兴趣,客户端的话对自己的审美不是很有自信,算法方向的话对学历要求比较高,我自己是没有读研的打算,所以最后就选择了后端方向。

​ 为什么是C++后端的话,主要是觉得写算法题 C++ 比较好用一点,索性就选了 C++ 后端,还有一个原因是 java 后端太卷了,C++ 后端虽然岗位少一点,但是可能没有 java 后端那么卷。

一亿个数据中选最大的1万个

​ 先拿出10000个建立小根堆,对于剩下的元素,如果大于堆顶元素的值,删除堆顶元素,再进行插入操作,否则直接跳过,这样知道所有元素遍历完,堆中的10000个就是最大的10000个。时间复杂度: m + (n-1)logm = O(nlogm)

一个5升瓶子一个3升瓶子,弄出4升水

  • 5升装满,倒入三升
  • 五升剩 2 升,三升倒掉
  • 五升全倒入三升,五升空,三升有 2 升
  • 五升装满,补满三升,剩下的就是 4 升

各种排序算法的时间复杂度

  • 冒泡排序:最好O(n),最差O(n^2)
  • 选择排序:最好O(n),最差O(n^2)
  • 插入排序:最好O(n),最差O(n^2)
  • 希尔排序:最好O(n),最差O(n^2)
  • 快速排序:平均是O(nlogn),最差O(n^2)
  • 归并排序:恒为O(nlogn)

**快速排序的主要思想:**是分治和递归。首先从数组里任意选一个元素作为分界点,然后根据该分界点把数组分成两个区间,然后调整数组元素,使得左边区间的所有元素都小于等于分界点,右边区间的所有元素都大于等于分界点。然后分别对左右两个区间递归地进行以上操作。最后就能得到有序数组。

**归并排序的主要思想:**也是分治和递归。假设数组长度是 n。首先选取数组的中间元素作为分界点将数组划分为两个区间,然后对得到的两个区间再进行递归的划分,最终会将整个数组划分为n个区间,每个区间内只有一个元素。然后,将成对的两个区间按照顺序有序地合并起来,就能得到有序的数组。

非递归快速排序的思路:非递快排的核心是用栈模拟递归。初始时把整个区间的边界入栈,然后取出来,对这段区间进行单趟快排,接收返回值 index,index 将区间划分为两个子区间,然后把这两个左右子区间分别入栈进行快排。当栈中元素为空时表示所有区间都已经完成排序。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值