C++中,定义函数 int function(int a[], int b),这里数组a会不会在内存中拷贝(传递的是指针还是啥),什么情况下传递的是指针?
不会,因为这里的 a 传递的是指针,和 int * 是一样的。
优先级:() > [] > *
是一个指针,指向一个数组的起始地址。由于 [] 运算符的优先级比 * 运算符高,所以定义时需要使用小括号将 * 运算符与指针名括起来,表示这里定义的变量是个指针,括号外写数组中变量的类型,以及数组的大小。
是一个数组,数组的每个元素都是指针。定义时不需要小括号,变量名先与 [] 运算符结合,表示这是一个数组,前面的 * 运算符表示数组中的内容是指针。
是一个指针,指针指向的地址是一个函数的入口地址。通过该指针可以调用目标函数。定义时需要用小括号将 * 运算符与指针名括起来。
是一个函数,函数的返回值是一个指针类型的变量。
通过R"()"
的方式可以定义一个原始字面量,它可以直接得到原始意义的字符串,而不需要额外对字符串做转义或连接等操作。
通过使用原始字面量可以简化一些打印操作。例如输出某个文件的路径,可以不用再写转义字符,输出多行字符串时不再需要使用连接符。
C++ 提供了 auto 和 decltype 来静态推导类型,在我们知道类型没有问题但⼜不想完整地写出类型的时候, 就可以使⽤静态类型推导。 decltype ⽤于获取⼀个表达式的类型,⽽不对表达式进⾏求值。
注意:
auto定义的变量必须有初始值。
auto的自动类型推断发生在编译期,所以使用auto并不会造成程序运行时效率的降低。
编译器可以根据初始值自动推导出类型。但是不能用于函数传参以及数组类型的推导。
auto可以推断基本类型,也可以推断引用类型,当推断引用类型时候,将引用对象的类型作为推断类型。
当需要某个表达式的返回值类型而又不想实际执行它时用decltype。decltype
是为了解决复杂的类型声明而使用的关键字。
与auto的区别:
auto忽略顶层const,decltype保留顶层const
对引用操作,auto推断出原有类型,decltype推断出引用
对解引用操作,auto推断出原有类型,decltype推断出引用
auto推断时会实际执行表达式,decltype不会执行表达式,只做分析
有了auto为什么还需要decltype?
decltype
可以获得编译期的类型。 auto
不能。所以当你需要某个表达式的返回值类型而又不想实际执行它时用decltype。
对于局部常量,存放在栈区;
对于全局常量,存放在静态存储区;
字⾯值常量,存放在常量存储区。
智能指针用来动态的分配内存,当构造时分配内存,当离开作用域时,自动释放已分配的内存。使用智能指针能帮助程序员更简单的管理动态内存,解决很多潜在的内存泄漏的问题。
智能指针本身是一个栈上分配的对象。根据栈上分配的特性,在离开作用域后,编译器会调用其析构函数,从而达到自动释放内存的效果。
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指针读写并不是线程安全的。
用weak指针对象如何判断该指针指向的对象是否销毁?
答:weak_ptr 类中有一个成员函数 lock() ,这个函数可以返回指向共享对象的 shared_ptr,如果 weak 指针所指向的资源不存在,那么 lock 函数返回一个空 shared 指针,通过这个可以判断。要通过 weak 指针访问资源也要用类似的方法,因为 weak_ptr 没有重载 operator* 和 operator->,所以要先获取到对应的 shared 指针,判断是否为空,然后再访问资源、
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 表达式可以分为五个部分,捕获列表、参数列表、可选项、返回值类型、以及函数体。其中除了捕获列表和函数体,都是可以省略的。捕获列表即可以按值捕获,也可以按引用捕获,按值捕获的变量实际上是原变量的拷贝,而且只能读不能写,如果要修改,就需要加上 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的元素数量
C++11对 int、char 这类基本数据类型进行了原子封装,使得同一时刻只能有一个线程对其访问,效率比互斥锁更高,实现数据结构的无锁设计。
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 的话编译器会报错。
C++ 借助虚函数实现了运行时多态,但 C++ 的虚函数有很多脆弱的地方:
为了解决这个问题,C++11 提供了 final 来禁止虚函数被重写或禁止类被继承,override 来显式的重写虚函数。这样编译器就能给一些不小心的行为提供错误和警告。
主动让编译器生成默认的构造函数。delete则相反。
如果内存没有对⻬,寄存器存取数据要进⾏很多额外操作,⼤⼤降低了 CPU 的性能。
结构体是把不同类型的数据组合成一个整体。struct 里每个成员都有自己独立的地址。sizeof(struct) 是内存对齐后所有成员长度的加和。
各成员共享一段内存空间, 一个union变量的长度等于各成员中最长的长度。共同体可被赋予任意成员的值,但每次只能赋一种值, 赋入新值则冲去旧值。 sizeof(union)是最长的数据成员的长度。
由编译器进⾏管理,在需要时由编译器⾃动分配空间,在不需要时候⾃动回收空间,⼀般保存的是局部变量和函数参数等。
栈是一段连续的内存空间,在函数调⽤的时候,⾸先⼊栈的是主函数中下⼀条可执⾏指令的地址,然后是函数的各个参数。一次函数调用结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的指令地址,程序由该点继续运行,不会产生碎片。
栈是高地址向低地址扩展,空间较小。
堆由程序员管理,需要手动分配和回收,如果不进行回收会造成内存泄漏的问题。
堆是不连续的空间,系统中有一个空闲链表,当有程序申请时,系统遍历空闲链表找到第一个大于等于申请大小的空间分配给程序,一般在分配的时候,也会把空间的起始地址写入内存,方便后续 delete 回收空间。如果有剩余空间也会插入到空闲链表中,因此堆中会产生碎片。
堆是低地址向高地址扩展的,空间很大。
堆栈对比:
- 管理方式:栈由系统管理,堆由程序员管理
- 分配效率:栈由系统分配,操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,速度快;堆由由C++库函数提供实现,分配速度慢
- 申请大小限制不同:栈的大小有限制而且很小,可以进行改变;堆空间很大,受限于计算机的虚拟地址。
- 碎片问题:对于堆频繁使用new/delete会产生碎片;栈的数据先进后出,进出一一对应,不会产生碎片
- 扩展方式:栈低地址向高地址扩展;堆反着来
在主函数中遇到一个函数调用时,⾸先将主函数中下⼀条可执⾏指令的地址入栈,以便函数调用结束后可以返回主函数继续运行。然后入栈函数的参数,入栈顺序是从右到左。然后然后跳转到该函数的入口地址开始执行,如果函数中有局部变量则也会入栈。一次函数调用结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的指令地址,程序由该点继续运行。
STL包含6大部件:容器、迭代器、算法、仿函数、适配器和空间配置器。
operator*
, operator->
, operator++
等指针操作赋予 重载的类模板。operator()
的类或者类模板。容器分类:
序列容器 sequence containers
- array
- vector
- deque
- list
- forward-list
关联容器 associative containers
(红黑树实现)
- set
- multiset
- map
- multimap
无序容器 (哈希表实现)
- unordered_map
- unordered_multimap
- unordered_set
- unordered_multiset
支持随机访问的容器:string, array, vector, deque
支持在任意位置插入 / 删除的容器:list, forward_list
支持在尾部插入元素:vector, string, deque
每次弹出优先级最高的元素,默认是大顶堆(less
),即最大的元素优先级最高,我们也可以传入 greater
使之变为小顶堆,此时最小的元素优先级最高,优先弹出最小的元素。第三个类型参数(可调用对象)是和堆的优先级反着来的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AXxOoHbQ-1678759203347)(C:\Users\86130\AppData\Roaming\Typora\typora-user-images\image-20230303102111687.png)]
通过模板编程可以实现通用的容器和算法,比如STL 和 Boost库等。模板对类型有很强的抽象能力,可以让容器和算法更加通用。模板编程在一些大型项目里也有利于写出高复用性的代码。
函数指针就是指向函数的指针变量,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。
能。因为在编译时对象就绑定了成员函数地址,和指针空不空无关,指针为空只代表 this 对象的指针为空,无法访问成员变量。编译时,成员函数的地址就和指针绑定,所以可以调用成员函数,如果成员函数中没有使用到 this 指针,那么就不会报错,如果用到了 this 指针,就会应为 this 指针是 nullptr 而报错。
就是指向的位置无法确定的指针。
主要是释放内存后指针不及时置空,任然指向原来的地方,就会出现非法访问的错误。
避免办法:
内联函数本质是一个函数,内联函数会在编译时进行代码插入,编译器会在没出调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用开销,提高效率,因此,内联函数中不能包含复杂的控制语句,否则,如果执行函数体内代码的时间比函数调用的开销大,那么效率可能会得到负提升,这就没有使用内联函数的必要了。
const 定义只读常量,define 定义宏,二者都可用于定义常量,但是有区别。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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)]
隐藏指的是,派生类中的函数屏蔽了基类中的同名函数,对参数列表没有要求,可以相同也可以不同。
**浅拷贝:**浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。
**深拷贝:**深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。
进程是系统中正在运行的一个应用程序,程序一旦运行就是一个进程。进程是系统进行资源分配的最小单位。每个进程拥有独立的地址空间。所以一个进程无法直接访问另一个进程的变量和数据结构。想访问的话要使用进程间通信,比如管道和消息队列等。
前两种分别是有名管道 pipe 和 无名管道 FIFO,他们唯一的区别就是:无名管道只能进行相关联进程之间的通信,比如父子进程。而有名管道可以进行任何进程之间的通信。
管道类似一个队列,管道里的数据是先进先出的,而且是半双工的。这意味着同一时间管道里的数据只能往一个方向流动。管道的实质就是在内核中创建一个缓冲区,一端的进程写入数据,另一端的进程读取数据。
由于管道通过内核交换数据,因此通信效率很低,不适合频繁交换数据。
消息队列本质是保存在内核中的链表,由多个独立数据块组成。与管道相比消息队列不一定按照先进先出的方式读取,可以按消息类型读取。
消息队列的生命周期与内核相关而与进程无关,如果不显式的删除,那消息队列就会一直存在。
消息队列无法实现实时通信,数据块有大小限制,而且消息队列通信过程中,存在用户态与内核态之间的拷贝开销。
共享内存用来解决用户态和内核态之间频繁的发生拷贝。现代操作系统普遍采用虚拟内存进行内存管理,每个进程有自己独立的虚拟内存空间,不同进程的虚拟内存空间映射到不同的物理内存。共享内存就是拿出一块虚拟地址空间,映射到同一块物理内存。好处是一个进程写入数据后另一个进程可以立即查看,而且不用拷贝,效率很高。所以这是进程间最快的通信方式。
由于多个进程都可以操作共享内存,所以需要同步对共享内存的读写。
它本质是一个计数器,表示的是资源的数量。它用于实现进程间的互斥与同步。当进程间使用共享内存通信时就要用信号量来同步数据的读写。
信号量有两个操作,P操作占用资源,会把信号量 -1,-1 之后如果信号量 < 0,就表示资源已被占用,其他进程要阻塞等待。如果 -1 之后还 >= 0 表明还剩余资源,进程正常执行。V 操作和 P 操作相反,他会释放资源,使信号量 + 1。一个进程使用完资源后会执行 V 操作,使信号量 + 1 以便其他进程访问资源。
信号量 与 互斥量 之间的区别:
互斥量用于线程互斥,信号量用于线程同步。
互斥 和 同步 的区别:
- 互斥指某资源同时只允许一个访问者对其进行访问,具有唯一性和排他性。但互斥不能限制访问者对资源的访问顺序,也就是说访问是无序的。
- 而同步可以在互斥的基础上,实现访问者对资源的有序访问。
可以在任何时刻给进程发送信号。收到信号的进程可以执行信号的默认操作,或是自定义信号处理函数,也可以直接忽略信号。
实现不同主机上进程的通信。
我用的最多的还是 socket ,进行不同主机上进程的通信。最直接的使用过程就是,客户端和服务端都创建 socket 并绑定 IP 和端口号作为网络通信的接口,然后服务器阻塞地 linten 等待客户端连接,客户端通过 connect 连接服务端,服务端地 accept 被触发,然后就可以通过 read、write 互相收发数据了。
可以是可以就是有点复杂,需要使用共享内存。开辟一块共享内存,使得要通信的进程可以访问同一块区域,然后把互斥锁定义在共享内存上,使相关进程都可以使用该锁。初始化该锁的时候,设置为进程间共享,这样两个进程连接到共享内存后,就都可以获得该互斥锁。
如果是不同主机上的进程间通信就要用分布式锁了。
条件变量本质是一个全局变量,它的功能是阻塞线程,直到接收到“条件成立”的信号后,被阻塞的线程才能继续执行。一个条件变量可以阻塞多个线程,当条件成立时,条件变量可以解除线程的“被阻塞状态”。
使用条件变量时要借助一把互斥锁共同完成功能。用条件变量阻塞某线程之前必须先对互斥锁完成“加锁”操作,然后条件变量就会阻塞线程,直到收到“条件成立的信号”,当线程被添加到等待队列上后,会自动将刚刚的互斥锁“解锁”。当其他线程发来“条件成立”信号后,条件变量不会立即结束对当前线程的阻塞,而是先完成对互斥锁的“加锁”操作,然后再解除阻塞。
我在设计 RPC 框架的日志模块时使用到了条件变量。我用一个队列存储各个 worker 线程的日志信息,最后由单独的线程将队列中的数据写入磁盘。各个线程通过条件变量进行互斥和同步。当队列为空时,磁盘 IO 线程被条件变量阻塞,当 worker 线程向日志队列写入日志后,会通知磁盘 IO 线程条件成立,此时该线程就会将队列中的日志数据写入磁盘。
是什么:死锁是指多个进程循环等待别人占有的资源而无限期僵持下去的情况。
形成的必要条件:
避免死锁的方法:
既然死锁形成有四个必要条件,那么我们避免四个条件同时产生就行了。其中 互斥条件 是必须的,所以具体有三个办法:
协程比线程更轻量级,开销远小于线程。就像一个进程可以拥有多个线程一样,一个线程也可以有多个协程;协程不被操作系统内核管理,完全由程序控制。
协程拥有自己寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,切换回来的时候,再将他们恢复回来。每个协程与其他协程共享全局数据。
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 的实现里都利用了零拷贝技术。
select 实现多路复用的方式是,把已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数把文件描述符集合拷贝到内核,让内核检查是否有网络事件产生,检查的方式很粗暴,就是遍历文件描述符集合,当检查到有事件产生后,将此 Socket 标记为可读或可写, 最后把整个文件描述符集合拷回用户态,然后用户态还也要遍历整个集合找到可读或可写的 Socket,再对其处理。对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,还会发生 2 次「拷贝」文件描述符集合,先从用户空间拷贝到内核空间,由内核修改后,再拷贝到用户空间中。
poll 和 select 并没有太大的本质区别,只不过 poll 使用链表存储文件描述符,没有最大连接数的限制。但他仍是线性结构,需要遍历整个集合找到可读或可写的 socket,而且还是有大量用户态到内核态的拷贝。
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高。
bool BigEndian()
{
int x = 0x12345678;
char *p = (char*)&x;
if (*p == 0x78) return false; // 小端序
else return true; // 大端序
}
OIS 七层模型是由国际标准化组织指定的网络七层模型。在实际中的应⽤意义并不是很⼤,但是它对于理解⽹络协议内部的运作很有帮助。它将计算机⽹络体系结构划分为7层,每层都为上⼀层提供了良好的接⼝。‘
七层从上到下分别是:物理层、数据链路层、⽹络层、传输层、会话层、表示层、应⽤层。
应用层为用户提供应用功能,不关心数据如何传输,工作在操作系统的用户态,他下面三层都工作在内核态。
为应用层提供网络支持,把接收到的数据包根据端口号传给应用层应用,或是使用 TCP / UDP 协议将应用层传下来的数据发送出去。
网络层通过 IP 协议将传输层的报文封装为 IP 报文,通过 IP 地址唯一确定另外一个设备。
网络接口层在 IP 报文的前面加上 MAC 头部,并封装成数据帧发送到网络上。网络接口层使用 MAC 地址标识网络上的设备,将数据帧发送到另一台设备的网络接口层,再由它层层上报,最终完成传输。
TCP通过检验和、确认应答、超时重传、连接管理、控制最大消息长度、流量控制、拥塞控制一起保证TCP传输的可靠性。
发送方有发送窗口,接收方有接收窗口,两个窗口的大小一般是一致的。发送窗口左边的数据是已发送且收到 ACK 确认的数据,发送窗口内的数据是在接收方处理范围内的最大数据量,他们有的已经发送了,有的还未发送。当未收到相应数据的确认时,即便发送窗口的所有字节都发送出去了,发送窗口也不能移动,因为 TCP 要保证可靠传输。一旦收到 ACK 确认,滑动窗口的左边界就可以移动到已收到确认的最后一个字节序号对应的位置。否则足够长时间没收到确认,就会触发超时重传。
TCP 通过维护三个指针唯一确定发送窗口的状态。指针 P1 指向发送窗口内的第一个字节序号,指针 P2 指向发送窗口内已发送字节的下一个序号。指针 P3 指向发送窗口右侧的下一个字节序号。
有了这三个指针就可以知道:
接收方确认时有累计确认和捎带确认机制,可以间接提升 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,说明恢复过程已经结束,可以再次进入拥塞避免状态。
发送端滑动窗口定义了网络中飞行报文的最大字节数,当它小于带宽时,就无法充分利用网络带宽时延积。要想提升发送速度必须提升滑动窗口的上限,在 Linux 下是通过设置 tcp_window_scaling
为 1 做到的,此时最大值可达到 1GB。
时延带宽积 = 传播时延 * 带宽
此外,内核缓冲区也会决定滑动窗口的上限,缓冲区分为:发送缓冲区 和 接收缓冲区。可以把缓冲区的上限设置为带宽时延积。然后设置发送缓冲区和接收缓冲区为自动调节。这样就既能最大程度地保持并发性,也能使得在系统资源充裕时 连接传输速度达到最大值。
连接前,客户端和服务端都处于 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 报文被对方接收;
双方都可以主动断开连接。以客户端主动断开连接为例。
客户端会发送 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 报文由 报文首部 和 报文主体 组成。而报文首部又可分为 起始行 和 头部,报文首部 和 报文主体之间通过一个空行分隔。
请求报文的请求行描述了客户端想要如何操作服务端的资源,它包括请求方法、请求目标、版本号三部分。中间使用空格分隔,最后要用 CRLF 换行表示结束。
请求头包含若干个属性,格式为 “属性名:属性值”,主要用于说明请求源、连接类型以及Cookie等信息。
请求体就是 HTTP 要传输的正文内容。
响应报文的起始行由 HTTP 版本、状态码、状态描述三个字段组成。状态码表示服务器处理此次 HTTP 请求的状态。
响应头也是一个个键值对,他们主要是一些服务器的基本信息,以及一些Cookie值。
响应体就是服务器返回的具体数据。
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 的 Keep-Alive 是由「应用程序」实现的,目的是可以用同一个 TCP 连接来发送和接收多个 HTTP 的请求和应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放造成的开销。
TCP 的 Keepalive 是一种 保活机制,由「内核」实现的,当客户端和服务端长达一定时间没交换过时,内核为了确认该连接是否还有效,就会发送探测报文,来检测对方是否还在线,从而决定是否要关闭该连接。
HTTPS是一种能在网络上进行安全通信的传输协议,它通过 HTTP 进行通信,利用 SSL/TLS 握手对数据包进行加密。
首先客户端和服务器进行 TCP 三次握手建立连接。然后双方要进行 4 次 SSL/TLS 握手。第一次握手由客户端发起加密通信请求,客户端向服务器发送自己支持的 TLS 协议版本,还有支持的加密算法表,最重要的还有 生成的随机数1,该随机数用于生成会话密钥。
服务端收到客户端请求后,会确认自己的 TLS 协议版本和加密算法,还会把数字证书也发给客户端,而且也会生成随机数2,也用于生成会话密钥。
客户端收到消息后,会先确认数字证书的真实性,然后从中取出服务器的公钥。然后告诉服务器后面的消息都要用会话密钥加密,并表示客户端的握手已经结束了,此外客户端还会生成随机数3,一并发送给服务器。这整个报文会用刚刚得到的公钥进行加密。
至此客户端和服务器各自都有了三个随机数,就能用刚刚商量好的加密算法生成本次通信的会话密钥了。
服务器计算出会话密钥后会通知客户端,随后的消息都用会话密钥加密,并且也表示这是服务器的握手结束了。
至此,完成4次握手,客户端与服务器进入加密通信。
当在浏览器中输入某域名时,浏览器先查自己的缓存中是否有该域名对应的 IP 地址。没有的话,操作系统会检查本地 hosts 文件是否有该域名对应的 IP。没有就再到路由器缓存中查找。这三个查找过程都在本地完成,算本地 DNS 缓存。如果都没找到就要使用互联网服务提供商提供的 DNS 缓存了。这时有两种解析策略 递归 和 迭代,两种策略中,都要先访问顶级域名服务器。
递归就是客户端向本地 DNS 服务器发送 DNS 请求,如果本地 DNS 服务器中找不到,他会转发给根域名服务器,根域名服务器收到请求后 解析域名后缀,然后转发给相应的顶级域名服务器,顶级域名服务器再转发给权限域名服务器。递归过程中一旦找到 IP 就立刻向上返回,最终返回给客户端。
而迭代查询则是客户端向本地 DNS 服务器发送 DNS 请求,如果本地 DNS 服务器中找不到,他会转发给根域名服务器,根域名服务器收到请求后 解析域名后缀,把顶级域名服务器的 IP 告知本地域名服务器,由本地域名服务器自己去访问顶级域名服务器,如果没有,就把权限域名服务器的 IP 告知本地域名服务器,再由本地服务器自己去访问权限域名服务器。最后把结果返回给客户端。
首先浏览器对 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 响应报文后,交给浏览器渲染页面,这样页面就显示出来了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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 高效获取数据的一种数据结构,使用索引可以快速查表。如果不加索引的话,查某一行数据就要遍历表的每行数据,效率很低。如果有了索引,利用数据结构就可以快速查找。
索引一般以文件的形式存储在磁盘上,在对表进行增删改时数据库也要不断维护索引。
单值索引:即一个索引只包含单个列,一个表可以有多个单值索引。(但不是越多越好,一般只对经常查询的字段建立索引)
唯一索引:索引列的值必须唯一,但允许有空值。
主键索引:设定为主键后会自动建立索引。
复合索引:一个索引包含多个列。
原子性:事务是不可分割的最小单位,一个事务内的操作要么同时成功,要么同时失败,不可再分。
一致性:事务执行前后数据状态应该保持一致。例如转账前后双方的账户总额应该保持不变。
隔离性:并发执行的两个事务之间不会相互干扰。
持久性:一旦事务执行成功,即便数据库在此时崩溃了,在数据库重启时也能够保证事务操作被持久化到数据库中。
原子性和持久性是基于日志实现的,隔离性是通过锁来实现的,三者做为基础共同确保一致性的实现。
结合操作系统可以知道,数据库索引的每一层向下查找一个节点都是一次独立的磁盘 IO。而磁盘 IO 操作非常费时,因此要想办法降低树的高度。
首先考虑普通的二叉树,在极端情况下二叉查找树会变成一个单链表,而且各节点高度不稳定,不如直接考虑平衡二叉树。平衡二叉树呢,虽然树的高度得到了降低,但是一个节点只能存放一个关键字和记录,考虑采用B树。
B树具有如下特点:一个节点可以存储多个关键字和记录,所有索引关键字不会重复出现,每个索引的关键字和记录都存放在一起。但是B树相比于B+树也存在如下问题:
所以最终选用B+树结构。
B+树索引支持范围查询、模糊查询,遵循最左匹配原则,而这些Hash索引都不支持,但是在等值查询上Hash索引的效率要比B+树效率高。
把请求轮流发送到每个服务器上。
在轮询的基础上,根据服务器的性能差异,为服务器赋予一定权值,性能高的服务器分配更高的权值。
轮询的缺点:每个请求的连接时间不一样,使用轮询可能会让一台服务器的当前连接数过大,
将请求发送给当前连接数最少的服务器。
在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数。
根据服务器的配置和负载情况,配置不同的权重。然后按照权重来随机选取服务器。
对客户端 IP 计算哈希值之后,再对服务器数量取模得到目标服务器的序号。可以保证相同的 IP 客户端,如果服务器列表不变,将映射到同一个后台服务器进行访问。
我理解的设计模式就是使用面向对象的手法实现可复用的代码,在应对需求发生变化时更易于扩展,避免重复发明轮子,提高代码复用性,使开发更简洁高效。
单例模式、
保证一个类只有一个实例,并提供一个该实例的全局访问点,该实例被所有程序模块共享。使用单例模式好处在于可以节省内存资源。
我觉得自己对业务这块比较感兴趣,客户端的话对自己的审美不是很有自信,算法方向的话对学历要求比较高,我自己是没有读研的打算,所以最后就选择了后端方向。
为什么是C++后端的话,主要是觉得写算法题 C++ 比较好用一点,索性就选了 C++ 后端,还有一个原因是 java 后端太卷了,C++ 后端虽然岗位少一点,但是可能没有 java 后端那么卷。
先拿出10000个建立小根堆,对于剩下的元素,如果大于堆顶元素的值,删除堆顶元素,再进行插入操作,否则直接跳过,这样知道所有元素遍历完,堆中的10000个就是最大的10000个。时间复杂度: m + (n-1)logm = O(nlogm)
**快速排序的主要思想:**是分治和递归。首先从数组里任意选一个元素作为分界点,然后根据该分界点把数组分成两个区间,然后调整数组元素,使得左边区间的所有元素都小于等于分界点,右边区间的所有元素都大于等于分界点。然后分别对左右两个区间递归地进行以上操作。最后就能得到有序数组。
**归并排序的主要思想:**也是分治和递归。假设数组长度是 n。首先选取数组的中间元素作为分界点将数组划分为两个区间,然后对得到的两个区间再进行递归的划分,最终会将整个数组划分为n个区间,每个区间内只有一个元素。然后,将成对的两个区间按照顺序有序地合并起来,就能得到有序的数组。
非递归快速排序的思路:非递快排的核心是用栈模拟递归。初始时把整个区间的边界入栈,然后取出来,对这段区间进行单趟快排,接收返回值 index,index 将区间划分为两个子区间,然后把这两个左右子区间分别入栈进行快排。当栈中元素为空时表示所有区间都已经完成排序。