C++
基础知识
1.
指针和引用的区别
1.
指针是一个变量,存储的是一个地址;引用是原变量的别名,与原变量实际上是一个东西
2.
有多级指针,没有多级引用
3.
指针在定义时可以不初始化,引用必须初始化
4.
指针在初始化后可以改变方向,引用初始化后不可再改变
5. sizeof
指针得到的是指针的大小,而
sizeof
引用得到的是引用的变量的大小
6.
指针的
++/--
操作是将指针向前或者向后偏移一个类型的大小;而引用是对引用的实体值
+1/-1
7.
指针在访问指向的实体时需要显示解引用;引用是编译器自己处理
8.
引用使用起来比指针相对安全
2.
传递函数参数时指针和引用的选择
1.
需要返回函数内局部变量的内存时使用指针,返回局部变量的引用没有意义
2.
对栈空间的大小比较敏感时使用引用,比如递归,因为使用引用不需要创建临时变量,开销要小
3.
类对象作为参数传递的时候使用引用,是
C++
类对象传递的标准方式
3.
堆和栈的区别
1.
申请方式不同:堆是自己申请和释放的,栈是由系统自动分配的
2.
大小不同:在系统内存中栈顶和栈底是预设好的,栈是向下扩展的,大小固定;而堆是向上扩展
的,不是连续的内存区域,大小可以灵活的调整
3.
申请效率不同:栈由系统分配,速度快不会有碎片;堆是由程序员自己分配,速度相对慢一些,且
会有内存碎片
4.
一般栈区空间默认是
4M
,堆区空间是
1G~4G
4.
区别以下指针类型
1. int *p[10]
:指针数组,大小为
10
,数组元素是指向
int
类型的指针变量
2. int (*p)[10]
:数组指针,指向一个大小为
10
的
int
类型的数组
3. int *p(int)
:函数声明,函数名是
p
,参数类型是
int
,返回值是
int *
类型
4. int (*p)(int)
:函数指针:指向一个参数是和返回值都是
int
类型的函数
5. new/delete
与
malloc/free
的区别
相同点:都用于内存的申请和释放
不同点:
1. new/delete
是运算符,
malloc/free
是标准库函数
2. new
自动计算要分配的空间大小,
malloc
需要手动计算
3. new
是类型安全的,
malloc
不是,
malloc
返回的是
void
类型的指针需要进行类型转换,
new
返回的
具体类型的指针
4. malloc/free
仅仅分配和释放内存空间,不具备调用构造函数和析构函数的功能,用
malloc
分配空
间来存储对象存在风险;
new/delete
除了能分配和释放空间外,还会调用构造函数和析构函数
5. new
和
delete
的实现
new
调用
operator new
标准库函数,分配足够足够大的原始类型化化内存,以保存指定类型
的一个对象;再运行该类型的一个构造函数,用指定初始化构造对象;最后返回指向新分配并
构造后的对象的指针
delete
:对指针指向的对象运行适当的析构函数;然后通过调用
operator delete
标准库函数
释放该对象所用内存
6.
宏函数和普通函数的区别
1.
宏在预处理阶段完成替换,被替换的文本直接参与编译,执行速度更快;函数调用在运行时需要跳
转到具体调用函数
2.
宏函数相当于直接在结构中插入代码,没有返回值,函数有返回值
3.
宏函数定义参数没有类型,不进行内存检查;函数参数具有类型,需要进行内存检查
7.
常量指针和指针常量的区别
1.
指针常量是一个指针,指向一个只读变量,指针的指向是可以改变的,例如:
const int *p
,
int
const *p
2.
常量指针是一个不能改变指向的指针,必须初始化,但是可以改变指针指向的内容,例如:
int
*const p
8.
数组名和
&
数组名的去区别
1.
数组名是数组首元素的地址,
+1
表示偏移一个数组存储的变量的类型的大小
2. &
数组名是数组的指针,指向整个数组,
+1
表示偏移整个数组的大小
9. C++
和
Java
的区别
语言特性上
1. Java
的语法相对简洁,完全面向对象,
JVM
可以安装到任意的操作系统上,可移植性很强;而
C++
虽然也可以在其他系统运行,但是需要不同的编码,例如一个变量在
windows
下是大端存储,
在
linux
下是小端存储
2. Java
没有指针,并且引入了真正的数组,不同于
C++
中用指针实现的伪数组
3. Java
用接口技术取代
C++
的抽象类,功能性同,但省去了在实现和维护上的复杂性
垃圾回收上
1. C++
需要手动释放或者析构函数来回收垃圾
2. Java
中内存的回收是自动进行的,程序员无需考虑内存泄漏问题
应用场景上
1. Java
在桌面程序上不如
C++
实用,
C++
可以直接编译成
exe
文件
2. Java
在
Web
应用上相对有着无可比拟的优势,具有丰富的框架
3.
对于底层程序的编程以及控制方面,
C++
因为有句柄的存在要更加灵活
10. C++
中
struct
和
class
的区别
相同点
1.
两者都拥有成员函数,公有和私有的部分
不同点
1.
如果不对成员指定权限,
struct
默认是公有的,
class
默认是私有的
2. class
默认是
private
继承,
struct
默认是
public
继承
C
和
C++
中
struct
的区别
1. C
中
struct
没有权限控制,成员不可以是函数,只是一些变量的集合体
2. C++
中
struct
增加了控制权限,并且可以有成员函数,成员默认访问权限为
public
是为了与
C
兼容
11. const/static/voaltile
的作用
const
1.
修饰普通变量:表示该变量是个常量,不能被修改,必须初始化
2.
修饰指针:指针指向的内容可变,指针指向不可变,
eg
:
int* const ptr
3.
修饰函数参数:函数内部不能改变该参数
4.
修饰返回值:上层不能使用返回的引用修改对象
5.
修饰类成员变量:该成员变量不能在类成员函数中被修改,必须在初始化列表初始化
6.
修饰类成员函数:在该函数中不能修改对象中任意成员变量(除非被
nutable
修饰),该成员函数
中只能调用
const
成员变量
static
1.
修饰局部变量:表示该变量为静态变量,存储在静态区
2.
修饰全局变量:表示该变量只能被包含该定义的文件访问
3.
修饰函数:表示该函数只能在包含该函数定义的文件中被访问
4.
修饰成员变量
不能在初始化列表初始化
被类所有对象共享
不包含在类的大小中
程序启动时就完成了初始化
5.
修饰成员函数
没有
this
指针
不能访问非静态成员变量
不能调用非静态成员函数
不能是虚函数
valatile
提醒编译器被其修饰的变量随时都有可能改变,编译后的程序每次需要读取该变量时,都要直接从
变量地址中读取;如果没有
valatile
修饰,编译器可能会优先读取寄存器中的值
12.
数组名和指针
(
指向数组首元素的指针
)
的区别
1.
两个都可以通过增减偏移量来访问数组中的元素
2.
数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增自减操作
3.
当数组名作为形参传递给调用函数后,就会退化成一般指针,但是
sizeof
运算符不能再通过计算数
组名得到原数组的大小了
13. final
和
override
关键字
override
:当父类使用了虚函数,在子类进行虚函数的重写时使用
override
修饰,可以显示的表明
该函数是重写父类的,函数名不对编译是无法通过的
final
:在类名或者虚函数后加上
final
关键字,表示该类不被继承,该虚函数不被重写
14.
拷贝初始化和直接初始化
1.
当用于类类型对象时:直接初始化直接调用与实参匹配的构造函数,拷贝初始化是先使用指定的构
造函数创建一个临时对象,然后用拷贝构造函数将临时对象拷贝到正在创建的对象
2.
编译器为了提高效率,允许跳过创建临时对象这一步,直接调用构造函数构造要创建的对象,等价
于直接初始化
15. extern “C”
的用法
意义:按照
C
语言的语法进行编译
1. C++
中调用
C
语言代码
2.
在
C++
的头文件中使用
3.
在多人协作开发时,可能有人擅长
C
语言,有人擅长
C++
16.
野指针和悬空指针
都是指向不安全不可控的内存区域的指针,访问会导致未定义的行为
野指针:没有被初始化过的指针
=>
定义指针变量后及时初始化
悬空指针:指向已经被释放的空间的指针
=>
释放操作完成后及时置空
17.
重载、重写(覆盖)和隐藏的区别
重载:两个函数的函数名相同,参数类型或数目不同
重写:在派生类中重写基类的同名虚函数,要求与基类虚函数的参数类型、参数个数和返回值类型全部
相同
隐藏:某些情况下,派生类中的函数屏蔽了基类中的同名函数
两个函数参数相同,但是基类不是虚函数的情况下,会被隐藏
两个函数参数不同,无论基类是不是虚函数,都会被隐藏
18.
深拷贝和浅拷贝的区别
浅拷贝
只是拷贝一个指针,并没有开辟新的地址,拷贝的指针和原指针指向同一个地址,如果原指针指向
的空间释放了,再释放浅拷贝的指针空间就会出现错误
深拷贝
深拷贝是开辟一块新的空间用来存放拷贝的值,即使原空间被释放掉,也不会影响到深拷贝的值,
在自己实现拷贝赋值时,如果有指针变量的话还是要手动实现深拷贝
19. lambda
1.
利用
lambda
表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象;
2.
每当你定义一个
lambda
表达式后,编译器会自动生成一个匿名类(这个类当然重载了
()
运算符),
我们称为闭包类型(
closure type
)。那么在运行时,这个
lambda
表达式就会返回一个匿名的闭包
实例,其实一个右值。
所以,我们上面的
lambda
表达式的结果就是一个个闭包。闭包的一个强大之处是其可以通过传值或者引
用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为
lambda
捕捉块。
3. lambda
表达式的语法定义如下
4. (1) capture list
<1> []
:不捕获任何变量
<2> [variable]
:捕获变量
variable
<3> [=]
:值传递方式捕获所有父作用域变量
<4> [&variable]
:引用方式捕获变量
variable
<5> [&]
:引用方式捕获所有父作用域变量
<6> [this]
:捕获当前
this
指针
<7> [=, &variable]
:引用方式捕获变量
variable
,值传递方式捕获其他变量
(2) mutable
:
lambda
表达式默认总是
const
函数,
mutable
取消常量属性
5. lambda
必须使用尾置返回来指定返回类型,可以忽略参数列表和返回值,但必须永远包含捕获列
表和函数体;
20. C++11
新特性
nullptr
替代
NULL
引入了
auto
和
decltype
这两个关键字实现了类型推导
基于范围的
for
循环
for(auto& i : res){}
类和结构体的中初始化列表
Lambda
表达式(匿名函数)
std::forward_list
(单向链表)
右值引用和
move
语义
无序容器和正则表达式
成员变量默认初始化
智能指针等
21. C/C++
内存分区
内存分区,分别是堆、栈、自由存储区、全局
/
静态存储区、常量存储区和代码区

[
capture
]
(
parameters
)
mutable
->
return
-
type
{
statement
};
即
[
捕获列表
](
参数
)
mutable
-&
gt
;
返回值
{
函数体
}
栈
:在执行函数时,函数内局部变量的存储单元都可以在栈上创建。
堆
:就是那些由
new
分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个
new
就要对应一个
delete
。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收
自由存储区
:如果说堆是操作系统维护的一块内存,那么自由存储区就是
C++
中通过
new
和
delete
动态
分配和释放对象的抽象概念。需要注意的是,自由存储区和堆比较像,但不等价。
全局
/
静态存储区
:全局变量和静态变量被分配到同一块内存中,在以前的
C
语言中,全局变量和静态变
量又分为初始化的和未初始化的,在
C++
里面没有这个区分了,它们共同占用同一块内存区,在该区定
义的变量若没有初始化,则会被自动初始化,例如
int
型变量自动初始为
0
常量存储区
:这是一块比较特殊的存储区,这里面存放的是常量,不允许修改
代码区
:存放函数体的二进制代码
22. move
的作用
std::move
的使用场景
当需要将资源从一个对象转移到另一个对象时,可以使用
std::move
。例如,在容器中移动元素、
在算法中交换数据等。需要注意的是,只有可移动的对象才能使用移动语义,否则可能导致未定义
行为。
避免不必要的拷贝
使用移动语义可以避免不必要的拷贝操作,从而提高性能。例如,在复制一个大型对象时,如果使
用移动语义,只需要进行一次内存分配和一次指针拷贝,而不需要进行多次拷贝操作。
移动构造函数:
std::move
可以将一个左值转换为右值引用,从而实现资源的转移
移动赋值运算符:
std::move
也可以用于将一个对象的资源转移到另一个对象
23.
常用智能指针的原理和实现
原理
智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄
漏。动态分配的资源,交给一个类对象去管理,当类对象声明周期结束时,自动调用析构函数释放资源
常用的智能指针
shared_ptr
实现原理:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象
时,指向该对象的所有智能指针内部的引用计数加
1
,每当减少一个智能指针指向对象时,引用计数会减
1
,当计数为
0
的时候会自动的释放动态分配的资源。
unique_ptr
unique_ptr
采用的是独享所有权语义,一个非空的
unique_ptr
总是拥有它所指向的资源。
weak_ptr
weak_ptr
:弱引用。
引用计数有一个问题就是互相引用形成环(环形引用),这样两个指针指向的内
存都无法释放。需要使用
weak_ptr
打破环形引用。
weak_ptr
是一个弱引用,它是为了配合
shared_ptr
而
引入的一种智能指针,它指向一个由
shared_ptr
管理的对象而不影响所指对象的生命周期,也就是说,
它只引用,不计数。如果一块内存被
shared_ptr
和
weak_ptr
同时引用,当所有
shared_ptr
析构了之后,
不管还有没有
weak_ptr
引用该内存,内存也会被释放。所以
weak_ptr
不保证它指向的内存一定是有效
的,在使用之前使用函数
lock()
检查
weak_ptr
是否为空指针。
auto_ptr
主要是为了解决
“
有异常抛出时发生内存泄漏
”
的问题 。因为发生异常而无法正常释放内存
24.
内存对齐的原因
内存对齐
:指在内存中,数据按照特定的边界进行排列,以提高内存访问的效率
1.
分配内存的顺序是按照声明的顺序。
2.
每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏
移量是整数倍为止。
3.
最后整个结构体的大小必须是里面变量类型最大值的整数倍。
原因
大部分处理器并不是按字节块来存取内存的,而是以
2
字节、
4
字节、
8
字节、
16
字节甚至
32
字节为
单位来存取内存。如果数据没有经过内存对齐,处理器在取数据时需要做很多额外的操作,这会降
低效率。而有了内存对齐后,处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的
操作,从而提高了效率。
25.
多态的实现原理
C++
的多态性,
一言以蔽之
就是:
在基类的函数前加上
virtual
关键字,在派生类中重写该函数,运行时将会根据所指对象的实际类型来调
用相应的函数,如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函
数。
虚表:虚函数表的缩写,类中含有
virtual
关键字修饰的方法时,编译器会自动生成虚表,它是在编译器
确定的
虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针,它是在构
造函数中被初始化的

1
、编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数
组,虚表里保存了虚函数的入口地址
2
、编译器会在每个对象的前四个字节中保存一个虚表指针,即
vptr
,指向对象所属类的虚表。
在构造时,根据对象的类型去初始化虚指针
vptr
,从而让
vptr
指向正确的虚表,从而在调用虚函数时,
能找到正确的函数
3
、所谓的合适时机,在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对
虚表指针进行初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只
“
看到了
”
父类,
并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚
表指针,令它指向子类的虚表
4
、当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚
函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中
将此虚函数地址添加在后面
这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现
多态性。
26.
为什么析构函数一般写成虚函数
由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派
生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。
如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函
数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。
所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析
构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。
27. select/poll/epoll
的区别
阻塞式
IO
、非阻塞式
IO
、
IO
多路复用、信号驱动式
IO
和异步
IO
Select
、
Poll
、
Epoll
的功能
Select
select
允许程序监视一组文件描述符,等待一个或多个变为就绪状态,即它们可以进行非阻塞的
I/O
操作。
它使用三个文件描述符集合(读、写、异常)来监视不同的
I/O
事件,并在超时时间内阻塞等待。
select
的缺点是它支持的文件描述符数量有限,并且随着文件描述符数量的增加,其效率会下
降。
Poll
poll
与
select
类似,但没有文件描述符数量的限制。
poll
使用一个
pollfd
结构数组来监视多个文件描述符。
poll
提供了更多的事件类型,并且在处理大量文件描述符时,性能比
select
好。
Epoll
epoll
是较新的
I/O
多路复用技术,它解决了
select
和
poll
的一些性能问题。
epoll
可以处理大量的文件描述符,而且当文件描述符就绪时,它无需遍历整个列表,因此效率更
高。
epoll
有两种工作模式:
LT
(水平触发)和
ET
(边缘触发)。
ET
模式在处理大量活跃的文件描述
符时,效率更高。
原理
select/poll
select/poll
系统调首先把关注的
fd
集合通过 用从用户态拷贝到内核态,然后由内核检测事件,当
有网络事件产生时,内核需要遍历进程关注
fd
集合,找到对应的
fd
,并设置其状态为可读
/
可写,然
后把整个
fd
集合从内核态拷贝到用户态,用户态还要继续遍历整个
fd
集合找到可读
/
可写的
fd
,然后
对其处理。
很明显发现,
select
和
poll
的缺陷在于,当客户端越多,也就是
fd
集合越大,
fd
集合的遍历和拷贝会带
来很大的开销。
epoll
和
select/poll
最大的不同是
epoll
是基于事件驱动的,而
select
是基于轮询的
epoll
epoll
在内核里使用「红黑树」来关注进程所有待检测的
Socket
,红黑树是个高效的数据结构,增
删改一般时间复杂度是
O(logn)
,通过对这棵黑红树的管理,不需要像
select/poll
在每次操作时都
传入整个
Socket
集合,减少了内核和用户空间大量的数据拷贝和内存分配。
epoll
使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的
Socket
集合传递给应用程序,不需要像
select/poll
那样轮询扫描整个集合(包含有和无事件的
Socket
),大大提高了检测的效率。
Select
、
Poll
、
Epoll
的区别
文件描述符数量限制
Select
受限于
FD_SETSIZE
常量,通常是
1024
,而
Poll
没有这种限制。
Epoll
也没有文件描述符数量限
制,可以处理成千上万的文件描述符。
性能
Select
和
Poll
在每次调用时都需要在内核和用户空间之间复制整个文件描述符集合,这可能导致性能问
题。
Epoll
只在文件描述符状态变化时通知进程,减少了不必要的复制。
可扩展性
Select
和
Poll
的可扩展性较差,尤其是在处理大量文件描述符时。
Epoll
通过使用高效的数据结构和事件
通知机制,提供了更好的可扩展性。
事件通知模式
Select
和
Poll
都是水平触发(
Level Trigger
)的,意味着只要文件描述符处于活动状态,就会持续通知。
Epoll
支持水平触发和边缘触发(
Edge Trigger
),后者只在文件描述符状态首次变化时通知,减少了不
必要的事件通知。
使用场景
Select
适合小型网络应用,特别是那些文件描述符数量有限且不需要处理大量并发连接的场景。
Poll
适合需要处理大量文件描述符的应用,尤其是在不支持
select
的平台上。
Epoll
适合大型网络服务器,特别是那些需要处理大量并发连接和高吞吐量的场景。
Epoll
的高效性能和可扩展
性使其成为现代高性能网络服务器的首选
I/O
多路复用技术。
28. push_back
和
emplace_back
的区别
emplace_back
通常在性能上优于
push_back
,因为它可以避免不必要的复制或移动操作。
push_back()
向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中
(如果是拷贝的话,事后会自行销毁先前创建的这个元素)
而
emplace_back()
在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。
29.
缺省函数有哪些?
/
如果有一个空类,它会默认添加哪
些函数?
在
C++
中,如果一个类没有显式地定义「构造函数、析构函数、拷贝构造函数、赋值运算符重载函
数」,那么编译器会自动生成这些函数,这些函数被称为缺省函数。
30. unordered_map
和
map
以及
set
和
unordered_set
的
区别和应用场景
map
map
支持键值的自动排序,底层机制是红黑树,红黑树的查询和维护时间复杂度均为
$O(logn)$
,但是
空间占用比较大,因为每个节点要保持父节点、孩子节点及颜色的信息
set
set
与
map
类似,
Set
的底层实现通常也是红黑树。
Set
是一种特殊的
Map
,只有键没有值。
unordered_map
unordered_map
是
C++ 11
新添加的容器,底层机制是哈希表,通过
hash
函数计算元素位置,其查询时
间复杂度为
O(1)
,维护时间与
bucket
桶所维护的
list
长度有关,但是建立
hash
表耗时较大
.
unordered_set
unordered_set:
与
unordered_map
类似,
unordered_set
的底层实现通常也是哈希表。
unordered_set
是一种特殊的
unordered_map
,只有键没有值。
从底层机制和特点可以看出:
map
适用于有序数据的应用场景,
unordered_map
适用于高效查询的应
用场景
.
31. strcpy
函数和
strncpy
函数的区别?哪个函数更安全?
strcpy
函数
如果参数
dest
所指的内存空间不够大,可能会造成缓冲溢出
(buffer Overflow)
的错误情况,在编写程序
时请特别留意,或者用
strncpy()
来取代。
strncpy
函数
用来复制源字符串的前
n
个字符,
src
和
dest
所指的内存区域不能重叠,且
dest
必须有足够的空间放置
n
个字符。
32.
什么是内存泄露,如何检测与避免
内存泄露
一般我们常说的内存泄漏是指
堆内存的泄漏
。堆内存是指程序从堆中分配的,大小任意的
(
内存块的
大小可以在程序运行期决定
)
内存块,使用完后必须显式释放的内存。应用程序般使用
malloc,
、
realloc
、
new
等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用
free
或
delete
释
放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了
避免内存泄露的几种方式
计数法:使用
new
或者
malloc
时,让该数
+1
,
delete
或
free
时,该数
-1
,程序执行完打印这个计
数,如果不为
0
则表示存在内存泄露
一定要将基类的析构函数声明为
虚函数
对象数组的释放一定要用
delete []
有
new
就有
delete
,有
malloc
就有
free
,保证它们一定成对出现
检测工具
Linux
下可以使用
Valgrind
工具
Windows
下可以使用
CRT
库
33.
如何使用
C
语言实现
C++
的继承
#include <iostream>
using namespace std
;
//C++
中的继承与多态
struct
A
{
virtual
void
fun
()
//C++
中的多态
:
通过虚函数实现
{
cout
<<
"A:fun()"
<<
endl
;
}
int
a
;
};
struct
B
:
public A
//C++
中的继承
:B
类公有继承
A
类
{
virtual
void
fun
()
//C++
中的多态
:
通过虚函数实现(子类的关键字
virtual
可加可不加)
{
cout
<<
"B:fun()"
<<
endl
;
}
int
b
;
};
//C
语言模拟
C++
的继承与多态
typedef
void
(
*
FUN
)();
//
定义一个函数指针来实现对成员函数的继承
struct
_A
//
父类
{
FUN _fun
;
//
由于
C
语言中结构体不能包含函数,故只能用函数指针在外面实现
int
_a
;
};
struct
_B
//
子类
{
_A _a_
;
//
在子类中定义一个基类的对象即可实现对父类的继承
int
_b
;
};
void
_fA
()
//
父类的同名函数
{
printf
(
"_A:_fun()\n"
);
}
void
_fB
()
//
子类的同名函数
{
printf
(
"_B:_fun()\n"
);
}
void
Test
()
{
//
测试
C++
中的继承与多态
A a
;
//
定义一个父类对象
a
B b
;
//
定义一个子类对象
b
A
*
p1
= &
a
;
//
定义一个父类指针指向父类的对象
p1
->
fun
();
//
调用父类的同名函数
p1
= &
b
;
//
让父类指针指向子类的对象
p1
->
fun
();
//
调用子类的同名函数
//C
语言模拟继承与多态的测试
_A _a
;
//
定义一个父类对象
_a
_B _b
;
//
定义一个子类对象
_b
_a
.
_fun
=
_fA
;
//
父类的对象调用父类的同名函数
_b
.
_a_
.
_fun
=
_fB
;
//
子类的对象调用子类的同名函数
_A
*
p2
= &
_a
;
//
定义一个父类指针指向父类的对象
p2
->
_fun
();
//
调用父类的同名函数
34. C/C++
程序从开始编译到生成可执行文件的完整过程会
经历那些过程?
1.
预处理阶段:宏替换、条件编译、去掉注释、展开头文件
2.
编译阶段:检查语法生成汇编代码
3.
汇编阶段:将汇编代码翻译成机器码,生成可重定位目标文件
4.
链接阶段:将目标文件链接生成可执行程序
35.
手搓
shared_ptr
p2
=
(
_A
*
)
&
_b
;
//
让父类指针指向子类的对象
,
由于类型不匹配所以要进行强转
p2
->
_fun
();
//
调用子类的同名函数
}
namespace myshared_ptr
{
template
<
typename T
>
class shared_ptr
{
private
:
T
*
_ptr
;
int*
_count
;
std
::
mutex
*
_mutex
;
public
:
shared_ptr
(
T
*
ptr
=
nullptr
) :
_ptr
(
ptr
),
_count
(
new
int
(
1
)),
_mutex
(
new
std
::
mutex
())
{}
~shared_ptr
()
{
destory
();
}
shared_ptr
(
const
shared_ptr
<
T
>&
ptr
)
{
_ptr
=
ptr
.
_ptr
;
_mutex
=
ptr
.
_mutex
;
_count
=
ptr
.
_count
;
add
();
}
shared_ptr
<
T
>&
operator
=
(
shared_ptr
<
T
>&
ptr
)
{
if
(
this
!= &
ptr
)
{
destory
();
_ptr
=
ptr
.
_ptr
;
_mutex
=
ptr
.
_mutex
;
_count
=
ptr
.
_count
;
add
();
36.
智能指针出现循环引用怎么解决?
弱指针
用于专门解决
shared_ptr
循环引用的问题,
weak_ptr
不会修改引用计数,即其存在与否并不影响
对象的引用计数器。
循环引用就是:两个对象互相使用一个
shared_ptr
成员变量指向对方,弱引用并不对对象的内存进行管
理,在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释
放,从而避免访问非法内存。
37.
友元函数和友元类的基本情况
友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,一
个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。友元的正确使用能提高程
序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。
友元函数
}
return
*
this
;
}
int
use_count
()
const
{
return
*
_count
;
}
private
:
void
add
()
{
_mutex
->
lock
();
(
*
_count
)
++
;
_mutex
->
unlock
();
}
void
destory
()
{
_mutex
->
lock
();
bool
isDelete
=
false
;
if
(
--
(
*
_count
)
==
0
&&
_ptr
!=
nullptr
)
{
delete _ptr
;
_ptr
=
nullptr
;
delete _count
;
isDelete
=
true
;
}
_mutex
->
unlock
();
if
(
isDelete
)
{
delete _mutex
;
}
}
};
}
友元函数是定义在类外的普通函数,不属于任何类,可以访问其他类的私有成员。但是需要在类的
定义中声明所有可以访问它的友元函数。一个函数可以是多个类的友元函数,但是每个类中都要声
明这个函数。
友元类
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成
员和保护成员)。但是另一个类里面也要相应的进行声明
注意
友元关系不能被继承。
友元关系是单向的,不具有交换性。若类
B
是类
A
的友元,类
A
不一定是类
B
的友元,要看在类中是
否有相应的声明。
友元关系不具有传递性。若类
B
是类
A
的友元,类
C
是
B
的友元,类
C
不一定是类
A
的友元,同样要看
类中是否有相应的申明
38.
模版和泛型的区别
C++
模板和泛型都是
C++
中的一种编程技术,用于实现通用的代码。它们的区别在于:
模板是在编译时进行实例化的,而泛型是在运行时进行实例化的。
模板可以用于实现通用的数据结构和算法,而泛型可以用于实现通用的类和函数。
模板可以用于实现泛型编程,而泛型可以用于实现模板编程。
39.
模版类的作用可以介绍下吗?
C++
模板类的作用是实现泛型编程,即编写一个通用的类或函数,可以适用于多种不同的数据类型。使
用模板类可以避免重复编写相似的代码,提高代码的复用性和可维护性。
在
C++
中,模板类通常由两部分组成:模板声明和模板定义。模板声明指定了模板参数,而模板定义则
实现了具体的功能。在使用模板类时,需要为每个要使用的类型提供一个对应的模板参数。
40.
基类的虚函数表存放在内存的什么区,虚表指针
vptr
的
初始化时间
首先整理一下虚函数表的特征:
虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
虚函数表类似一个数组,类对象中存储
vptr
指针,指向虚函数表,即虚函数表不是函数,不是程序
代码,不可能存储在代码段
虚函数表存储虚函数的地址
,
即虚函数表的元素是指向类成员函数的指针
,
而类中虚函数的个数在编
译时期可以确定,即虚函数表的大小可以确定
,
即大小是在编译时期确定的,不必动态分配内存空间
存储虚函数表,所以不在堆中
根据以上特征,虚函数表类似于类中静态成员变量
.
静态成员变量也是全局共享,大小确定,因此最有可
能存在全局数据区,测试结果显示:
虚函数表
vtable
在
Linux/Unix
中存放在可执行文件的只读数据段中
(rodata)
,这与微软的编译器将虚函数
表存放在常量段存在一些差别
由于虚表指针
vptr
跟虚函数密不可分,对于有虚函数或者继承于拥有虚函数的基类,对该类进行实例化
时,在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面。
一般分为五个区域:栈区、堆区、函数区(存放函数体等二进制代码)、全局静态区、常量区
C++
中
虚函数表位于只读数据段(
.rodata
),也就是
C++
内存模型中的常量区;而虚函数则位于代码段
(
.text
),也就是
C++
内存模型中的代码区。
41.
内联函数的缺点?
内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。
所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;
另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函
数:
函数体内的代码比较长,将导致内存消耗代价
函数体内有循环,函数执行时间要比函数调用开销大
42.
智能指针有什么缺点?
C++
中的智能指针确实有一些缺点,以下是其中一些常见的缺点:
多个线程在读写同一个
shared_ptr
时是线程不安全的
内存消耗:智能指针通常会占用额外的内存来存储引用计数或其他相关信息。这可能会增加程序的
内存消耗。
运行时开销:由于智能指针需要进行引用计数或其他管理操作,因此在创建、复制、销毁等操作时
可能会引入一定的运行时开销。
循环引用问题:如果存在循环引用(两个或多个对象相互引用),那么智能指针可能导致内存泄
漏,因为循环引用的对象无法被正确释放。
不适合所有情况:智能指针并不适用于所有情况。例如,在某些性能关键的场景中,手动管理内存
可能更加高效。
引用计数不准确:智能指针使用引用计数来跟踪对象的引用情况,但在某些情况下,引用计数可能
不准确。例如,如果使用裸指针进行对象的拷贝或赋值操作,那么引用计数可能无法正确更新。
不支持循环数据结构:智能指针通常无法正确处理循环数据结构,如循环链表等。这可能导致内存
泄漏或其他问题。
需要注意的是,尽管智能指针有一些缺点,但它们仍然是一种非常有用的工具,可以大大简化内存管
理,并帮助避免常见的内存错误。
43.
什么是纯虚函数,与虚函数的区别
虚函数是为了实现动态编联产生的,目的是通过基类类型的指针指向不同对象时,自动调用相应
的、和基类同名的函数(使用同一种调用形式,既能调用派生类又能调用基类的同名函数)。虚函
数需要在基类中加上
virtual
修饰符修饰,因为
virtual
会被隐式继承,所以子类中相同函数都是虚函
数。当一个成员函数被声明为虚函数之后,其派生类中同名函数自动成为虚函数,在派生类中重新
定义此函数时要求函数名、返回值类型、参数个数和类型全部与基类函数相同。
纯虚函数只是相当于一个接口名,但含有纯虚函数的类不能够实例化。
纯虚函数首先是虚函数,其次它没有函数体,取而代之的是用
“=0”
。
既然是虚函数,它的函数指针会被存在虚函数表中,由于纯虚函数并没有具体的函数体,因此它在虚函
数表中的值就为
0
,而具有函数体的虚函数则是函数的具体地址。
一个类中如果有纯虚函数的话,称其为抽象类。抽象类不能用于实例化对象,否则会报错。抽象类一般
用于定义一些公有的方法。子类继承抽象类也必须实现其中的纯虚函数才能实例化对象。
44. C++11
中的
auto
是怎么实现识别自动类型的?模板是怎
么实现转化成不同类型的?
auto
C++11
中的
auto
关键字是用来自动推导表达式或变量的实际类型的。使用
auto
关键字做类型自动推导
时,依次施加一下规则:如果初始化表达式是引用,则去除引用语义。
补充
auto
在
C++11
中是一种新的类型说明符
,
它可以根据初始化表达式自动推导出变量的类型。
auto
的工作原理是
:
编译器看到
auto,
会查看初始化表达式的类型
,
并将该类型作为
auto
变量的类型。
如果初始化表达式的类型可以确定
,
则使用该类型。如果初始化表达式包含了多个类型
,
则使用与初
始化表达式兼容的共同类型。
如果无法确定类型
,
则报错。
需要注意的是
,auto
只在声明时确定一次变量类型
,
之后变量类型不再改变,并且
auto
变量必须初始化
,
才
能推导出类型。
模板
模板是一种通用的编程技术,可以用于实现泛型编程,即编写一个通用的类或函数,可以适用于多种不
同的数据类型。在
C++
中,模板是通过模板参数来实现的。模板参数可以是一个类型、一个整数或者一
个枚举类型。在使用模板时,需要为每个要使用的类型提供一个对应的模板参数。
45.
创建三个线程,依次打印
1
到
100
使用
C++
标准库中的
std::thread
来创建线程可以做到
#include <iostream>
#include <thread>
using namespace std
;
static
int
state
=
1
;
static
std
::
mutex d_mutex
;
static const
int
times
=
100
;
void
printNum
(
int
targetState
,
string flag
){
while
(
state
<
100
){
d_mutex
.
lock
();
if
(
state
%
3
==
targetState
){
cout
<<
"thread"
<<
flag
<<
":"
<<
state
<<
endl
;
state
++
;
}
d_mutex
.
unlock
();
}
}
int
main
(){
thread t1
(
printNum
,
0
,
"A"
);
thread t2
(
printNum
,
1
,
"B"
);
thread t3
(
printNum
,
2
,
"C"
);
t1
.
join
();
t2
.
join
();
t3
.
join
();
return
0
;
46.
多线程怎么保证引用计数的安全的?
引用计数这个变量是
std::atomic
,操作时自带锁
47. ET
模式
epoll
支持两种事件触发模式,分别是边缘触发(
edge-triggered
,
ET
)和水平触发(
level-triggered
,
LT
)。
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
使用边缘触发模式时,当被监控的
Socket
描述符上有可读事件发生时,服务器端只会从
epoll_wait
中
苏醒一次,即使进程没有调用
read
函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一
次性将内核缓冲区的数据读取完;
使用水平触发模式时,当被监控的
Socket
上有可读事件发生时,服务器端不断地从
epoll_wait
中苏
醒,直到内核缓冲区数据被
read
函数读完才结束,目的是告诉我们有数据需要读取;
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去
取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取
出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地
把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同
样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是
否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,
I/O
事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在
收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那
么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下
执行。所以,边缘触发模式一般和非阻塞
I/O
搭配使用,程序会一直执行
I/O
操作,直到系统调用(如
read
和
write
)返回错误,错误类型为
EAGAIN
或
EWOULDBLOCK
。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少
epoll_wait
的系统调用次
数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
48. GDB
用过吗?你用过哪些命令?
编译时添加调试信息
在编译程序时,需要使用
-g
选项,以便将调试信息嵌入可执行文件中。例如:
启动
GDB
在终端中执行以下命令:
这将启动
GDB
并加载你的可执行文件。
设置断点
}
g++ -g -o my_program my_program.cpp
gdb ./my_program
设置断点以在程序执行到指定位置时暂停。可以使用
break
命令:
当程序执行到断点时,会暂停执行。
查看变量的值
使用
print
或简写的
p
命令来查看变量的值:
单步执行
使用
step
命令来单步执行程序,进入函数内部:
下一步执行
使用
next
命令来执行下一行代码,不进入函数内部:
继续执行
使用
continue
或简写的
c
命令来继续执行程序直到下一个断点:
查看堆栈
使用
backtrace
或简写的
bt
命令来查看函数调用堆栈:
退出
GDB
使用
quit
或简写的
q
命令退出
GDB
:
49.
迭代器和指针有什么区别?
泛化性:
break main
这将在
main
函数的开头设置一个断点。你也可以使用文件名和行号设置断点。
###
运行程序
在
GDB
中执行
run
命令启动程序:
```c
run
print variable_name
step
next
continue
backtrace
quit
迭代器:迭代器是一种更抽象的概念,它提供对容器中元素的访问方式,不仅限于数组。迭代器可以用
于各种容器,如链表、数组、集合等。
指针:指针是一种特定类型的变量,直接指向内存地址。通常,指针主要用于数组和动态内存分配。
语法和操作:
迭代器:迭代器通过使用
begin()
和
end()
等方法获得,并通过
++
、
--
等操作符进行遍历。
指针:指针通过
&
获取地址,通过
*
解引用,使用
++
、
--
进行遍历。
容器独立性:
迭代器:迭代器是与容器独立的,同一种迭代器接口可以应用于不同类型的容器。
指针:指针的类型与指向的数据类型直接相关。
范围:
迭代器:迭代器可以表示容器中的任意位置,包括容器的开头、结尾和中间。
指针:指针通常表示数组中的某个位置。
安全性:
迭代器:在一些情况下,迭代器可能提供更安全的访问,因为它们可以在编译时检测到类型不匹配等错
误。
指针:指针的使用可能更容易导致内存越界和类型不匹配的问题。
50. STL
中
vector
是如何实现的?
vector
是一种序列式容器,其数据安排以及操作方式与
array
非常类似,两者的唯一差别就是对于空间运
用的灵活性,众所周知,
array
占用的是静态空间,一旦配置了就不可以改变大小,如果遇到空间不足的
情况还要自行创建更大的空间,并手动将数据拷贝到新的空间中,再把原来的空间释放。
vector
则使用
灵活的动态空间配置,维护一块
连续的线性空间
,在空间不足时,可以自动扩展空间容纳新元素,做到
按需供给。其在扩充空间的过程中仍然需要经历:
重新配置空间,移动数据,释放原空间
等操作。这里
需要说明一下动态扩容的规则:以原大小的两倍配置另外一块较大的空间(或者旧长度
+
新增元素的个
数),源码:
Vector
扩容倍数与平台有关,在
Win + VS
下是
1.5
倍,在
Linux + GCC
下是
2
倍
51. Vector
的
resize
和
reserve
有什么区别?
resize
resize(n)
会改变
vector
的大小,使其包含
n
个元素。如果
n
大于当前的大小,那么新的元素会被添加到
vector
的末尾,如果
n
小于当前的大小,那么末尾的元素会被删除。
resize
会改变
vector
的
size()
。
reserve
reserve(n)
不会改变
vector
的大小,它只是预先分配足够的内存,以便在未来可以容纳
n
个元素。
reserve
不会改变
vector
的
size()
,但可能会改变
capacity()
。
reserve
的主要目的是为了优化性能,避免在
添加元素时频繁进行内存分配。
简单来说,
resize
改变的是
vector
中元素的数量,而
reserve
改变的是
vector
的内存容量。
52. C++
的四种强制转换
static_cast
const
size_type len
=
old_size
+
max
(
old_size
,
n
);
作用:任何编写程序时能够明确的类型转换都可以使用
static_cast( static_cast
不能转换掉底层
const
,
valatile
和
_unaligned
属性
)
使用场景
(1)
用于基类和派生类之间指针或引用的转换
<1>
上行转换:派生类的指针或引用转换成基类
->
安全
<2>
下行转换:基类指针或引用转换成派生类
->
不安全
(2)
内置数据类型之间的转换
(3)
空指针转换成目标类型的空指针
(4)
任何类型的表达式转换成
void
类型
使用特点
(1)
要执行非多态的转换操作,用于代替
C
中通常的转换操作
(2)
隐式类型转换建议都使用
static_case
进行标明和替换
const_cast
作用:仅用于进行去除
const
属性
使用场景
(1)
常量指针转换为非常量指针
(2)
常量引用被转换成非常量引用
使用特点
去除常量性是一个危险的动作,尽量避免使用
reinterpret_cast
作用:用于进行各种不同类型的指针之间、不同类型的引用之间以及指针和能容纳指针的整数类型之间
的转化
使用场景
不到万不得已,不用使用这个转换符,高危操作
使用特点
(1)
从底层数据进行重新编译,依赖具体的平台,可移植性差
(2)
将整形转换为指针,也可以把指针转换为数组
(3)
可以在指针和引用里进行肆无忌惮的转换
dynamic_cast
作用:将多态基类的指针或引用强制转换为派生类的指针或引用而且能够检查转换的安全性
使用场景
只有再派生类之间转换时才使用
dynamic_cast
使用特点
(1)
基类必须要有虚函数,因为
dynamic_cast
是运行时检查,需要运行时类型信息,而这个信息是存储
在类的虚函数表中,只有一个类定义了虚函数,才会有虚函数表
(2)
对于下行转换,
dynamic_cast
是安全的
(3) dynamic_cast
还可以进行交叉转换
53.
你知道空类的大小是多少吗?
C++
空类的大小不为
0
,不同编译器设置不一样,
vs
设置为
1
;
C++
标准指出,不允许一个对象(当然包括类对象)的大小为
0
,不同的对象不能具有相同的地址;
带有虚函数的
C++
类大小不为
1
,因为每一个对象会有一个
vptr
指向虚函数表,具体大小根据指针大小确
定;
C++
中要求对于类的每个实例都必须有独一无二的地址
,
那么编译器自动为空类分配一个字节大小,这样
便保证了每个实例均有独一无二的内存地址。
54.
构造函数一般不定义为虚函数的原因
(
1
)创建一个对象时需要确定对象的类型,而虚函数是在运行时动态确定其类型的。在构造一个对象
时,由于对象还未创建成功,编译器无法知道对象的实际类型
(
2
)虚函数的调用需要虚函数表指针
vptr
,而该指针存放在对象的内存空间中,若构造函数声明为虚函
数,那么由于对象还未创建,还没有内存空间,更没有虚函数表
vtable
地址用来调用虚构造函数了
(
3
)虚函数的作用在于通过父类的指针或者引用调用它的时候能够变成调用子类的那个成员函数。而构
造函数是在创建对象时自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函
数
55. this
指针
作用:指向被调用函数类实例的地址
实现:只是规则,类函数内部隐藏;全局、静态函数不存在
this
指针
this
指针是类的指针,指向对象的首地址。
this
指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用
this
。
this
指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置。
this
指针的用处
一个对象的
this
指针并不是对象本身的一部分,不会影响
sizeof(
对象
)
的结果。
this
作用域是在类内部,
当在类的
非静态成员函数
中访问类的
非静态成员
的时候(全局函数,静态函数中不能使用
this
指针),
编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上
this
指针,
编译器在编译的时候也是加上
this
的,它作为非静态成员函数的隐含形参,对各成员的访问均通过
this
进
行
this
指针的使用
一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用
return *this
;
另外一种情况是当形参数与成员变量名相同时用于区分,如
this->n = n
(不能写成
n = n
)
类的
this
指针有以下特点
(1
)
this
只能在成员函数中使用,全局函数、静态函数都不能使用
this
。实际上,
传入参数为当前对象地
址,成员函数第一个参数为
为
T * const this
如:
其中,
func
的原型在编译器看来应该是:
int func(A * const this,int p);
(
2
)由此可见,
this
在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函
数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的
this
参
数传递进去。如:
class A{public: int func(int p){}};
看起来和静态函数没差别,对吗?不过,区别还是有的。编译器通常会对
this
指针做一些优化,因此,
this
指针的传递效率比较高,例如
VC
通常是通过
ecx
(计数寄存器)传递
this
参数的。
56.
解决哈希冲突有哪些方法?
线性探测
使用
hash
函数计算出的位置如果已经有元素占用了,则向后依次寻找,找到表尾则回到表头,直到找到
一个空位
开链
每个表格维护一个
list
,如果
hash
函数计算出的格子相同,则按顺序存在这个
list
中
再散列
发生冲突时使用另一种
hash
函数再计算一个地址,直到不冲突
二次探测
使用
hash
函数计算出的位置如果已经有元素占用了,按照
$1^2$
、
$2^2$
、
$3^2$…
的步长依次寻找,如
果步长是随机数序列,则称之为伪随机探测
公共溢出区
一旦
hash
函数计算的结果相同,就放入公共溢出区
57.
如果同时有大量客户并发建立连接,服务器端有什么机
制进行处理
两种方法:多线程同步阻塞和
I/O
多路复用
socket
的建立。
多线程同步阻塞
多线程同步阻塞是指在每个客户端连接到来时,都会创建一个新的线程来处理该连接,这样可以实现并
发处理。
I/O
多路复用
socket
的建立
在一个线程中同时监听多个端口,当有新的连接请求到来时,该线程会将该连接请求分配给一个空闲的
线程来处理。
58. C++
中
NULL
和
nullptr
区别
算是为了与
C
语言进行兼容而定义的一个问题吧
NULL
来自
C
语言,一般由宏定义实现,而
nullptr
则是
C++11
的新增关键字。
在
C
语言中,
NULL
被定义为
(void*)0,
而在
C++
语言中,
NULL
则被定义为整数
0
59. malloc
、
realloc
、
calloc
的区别
malloc
函数
申请指定大小的空间
A a;a.func(10);//
此处,编译器将会编译成:
A::func(&a,10);
calloc
函数
malloc
申请的空间的值是随机初始化的,
calloc
申请的空间的值是初始化为
0
的;
realloc
函数
给动态分配的空间分配额外的空间,用于扩充容量。空间够就直接在原空间后面扩容,不够就重新开空
间扩容再拷贝原空间的值
60.
引用是否能实现动态绑定,为什么可以实现?
可以。
引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用
哪个函数。注意
只有虚函数才具有动态绑定
61.
继承和组合的区别
继承
继承是
Is a
的关系,比如说
Student
继承
Person,
则说明
Student is a Person
。继承的优点是子类可以重
写父类的方法来方便地实现对父类的扩展。
继承的缺点有以下几点:
①:父类的内部细节对子类是可见的。
②:子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行
为。
③:如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所
以说子类与父类是一种高耦合,违背了面向对象思想。
组合
组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
组合的优点:
①:当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不
可见的。
②:当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的
代码。
③:当前对象可以在运行时动态的绑定所包含的对象。可以通过
set
方法给所包含对象赋值。
组合的缺点:①:容易产生过多的对象。②:为了能组合多个对象,必须仔细对接口进行定义。
void* malloc(unsigned int num_size);
int *p = malloc(20*sizeof(int));
申请
20
个
int
类型的空间;
void* calloc(size_t n,size_t size);
int *p = calloc(20, sizeof(int));
void realloc(void *p, size_t new_size);
62.
程序在执行
int main(int argc, char *argv[])
时的内存
结构,你了解吗?
参数的含义是程序在命令行下运行的时候,需要输入
argc
个参数,每个参数是以
char
类型输入的,依次
存在数组里面,数组是
argv[]
,所有的参数在指针
char *
指向的内存中,数组的中元素的个数为
argc
个,第一个参数为程序的名称。
63.Debug
和
Release
的区别
1.
调试版本,包含调试信息,所以容量比
Release
大很多,并且不进行任何优化(优化会使调试复杂
化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。
Debug
模式下生成两个文件,
除了
.exe
或
.dll
文件外,还有一个
.pdb
文件,该文件记录了代码中断点等调试信息;
2.
发布版本,不对源代码进行调试,编译时对应用程序的速度进行优化,使得程序在代码大小和运行
速度上都是最优的。(调试信息可在单独的
PDB
文件中生成)。
Release
模式下生成一个文件
.exe
或
.dll
文件。
3.
实际上,
Debug
和
Release
并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照
预定的选项行动。事实上,我们甚至可以修改这些选项,从而得到优化过的调试版本或是带跟踪语
句的发布版本。
64.
红黑树
1
、它是二叉排序树(继承二叉排序树特显):
若左子树不空,则左子树上所有结点的值均小于或等于它的根结点的值。
若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值。
左、右子树也分别为二叉排序树。
2
、它满足如下几点要求:
树中所有节点非红即黑。
根节点必为黑节点。
红节点的子节点必为黑(黑节点子节点可为黑)。
从根到
NULL
的任何路径上黑结点数相同。
3
、查找时间一定可以控制在
O(logn)
。
65.
十大排序
十大排序中的稳定排序

冒泡排序(
bubble sort
)
— O(n2)
插入排序 (
insertion sort
)
— O(n2)
归并排序 (
merge sort
)
— O(n log n)
十大排序中的非稳定排序
面试考察中一般问快排,选择,希尔,堆这几种非稳定排序
选择排序 (
selection sort
)
— O(n2)
希尔排序 (
shell sort
)
— O(n log n)
堆排序 (
heapsort
)
— O(n log n)
快速排序 (
quicksort
)
— O(n log n)
快速排序
//
找到基准值
->
左边找大,右边找小,进行交换
->
左右指针相遇后相遇点和
key
点进行值交换
// hoare
版本
void
quickSort
(
int
arr
[],
int
left
,
int
right
)
{
if
(
left
>=
right
)
return
;
int
begin
=
left
;
int
end
=
right
;
int
key
=
begin
;
while
(
begin
<
end
)
{
while
(
begin
<
end
&&
arr
[
end
]
>=
arr
[
key
])
--
end
;
while
(
begin
<
end
&&
arr
[
begin
]
<=
arr
[
key
])
++
begin
;
std::swap
(
arr
[
begin
],
arr
[
end
]);
}
std::swap
(
arr
[
key
],
arr
[
begin
]);
key
=
begin
;
quickSort
(
arr
,
left
,
key
-
1
);
quickSort
(
arr
,
key
+
1
,
right
);
堆排序
}
//
双指针
void
quickSort
(
int*
arr
,
int
begin
,
int
end
)
{
if
(
begin
>=
end
)
return
;
int
prev
=
begin
;
int
cur
=
begin
+
1
;
int
keyi
=
begin
;
while
(
cur
<=
end
)
{
if
(
arr
[
cur
]
<
arr
[
keyi
]
&& ++
prev
!=
cur
)
{
std::swap
(
arr
[
cur
],
arr
[
prev
]);
}
cur
++
;
}
int
meeti
=
prev
;
std::swap
(
arr
[
keyi
],
arr
[
meeti
]);
quickSort
(
arr
,
begin
,
meeti
);
quickSort
(
arr
,
meeti
+
1
,
end
);
}
void
adJustDown
(
int
arr
[],
int
n
,
int
parent
)
{
int
child
=
parent
*
2
+
1
;
//
左孩子
while
(
child
<
n
)
{
//
大堆:保证
child
指向大的那个孩子
if
(
child
+
1
<
n
&&
arr
[
child
+
1
]
>
arr
[
child
])
{
child
++
;
}
//
孩子大于父亲就交换,并继续向下比较
if
(
arr
[
child
]
>
arr
[
parent
])
{
std
::
swap
(
arr
[
child
],
arr
[
parent
]);
parent
=
child
;
child
=
parent
*
2
+
1
;
}
else
{
break
;
}
}
}
//
升序建大堆,降序建小堆
void
heapSort
(
int
arr
[],
int
n
)
{
//
建堆:从最后一个元素的父节点开始依次向前可以遍历到每棵树的父节点
for
(
int
i
=
(
n
-
2
)
/
2
;
i
>=
0
;
--
i
)
{
归并排序
希尔排序
adJustDown
(
arr
,
n
,
i
);
}
int
end
=
n
-
1
;
while
(
end
>
0
)
{
std
::
swap
(
arr
[
0
],
arr
[
end
]);
//
交换首尾元素
adJustDown
(
arr
,
end
,
0
);
//
从首元素开始向下调整
--
end
;
}
}
void
_MergeSort
(
int *
arr
,
int
left
,
int
right
,
int *
tmp
)
//
子函数
{
if
(
left
>=
right
)
return
;
//
归并结束条件:当只有一个数据或是序列不存在时则不需要再分
解
int
mid
=
left
+
(
right
-
left
)
/
2
;
//
中间下标
_MergeSort
(
arr
,
left
,
mid
,
tmp
);
//
对左序列进行归并
_MergeSort
(
arr
,
mid
+
1
,
right
,
tmp
);
//
对右序列进行归并
int
begin1
=
left
,
end1
=
mid
;
int
begin2
=
mid
+
1
,
end2
=
right
;
//
将两段子区间进行归并,归并结果放在
tmp
中
int
i
=
left
;
while
(
begin1
<=
end1
&&
begin2
<=
end2
)
{
//
将较小的数据优先放入
tmp
if
(
arr
[
begin1
]
<
arr
[
begin2
])
tmp
[
i
++
]
=
arr
[
begin1
++
];
else
tmp
[
i
++
]
=
arr
[
begin2
++
];
}
//
当遍历完其中一个区间,将另一个区间剩余的数据直接放到
tmp
的后面
while
(
begin1
<=
end1
)
tmp
[
i
++
]
=
arr
[
begin1
++
];
while
(
begin2
<=
end2
)
tmp
[
i
++
]
=
arr
[
begin2
++
];
//
归并完后,拷贝回原数组
int
j
=
0
;
for
(
j
=
left
;
j
<=
right
;
j
++
)
arr
[
j
]
=
tmp
[
j
];
}
void
MergeSort
(
int*
a
,
int
n
)
{
int *
tmp
=
new
int
[
n
];
_MergeSort
(
a
,
0
,
n
-
1
,
tmp
);
//
归并排序
delete
[]
tmp
;
//
释放空间
}
void
shellSort
(
int
arr
[],
int
n
)
67.
手撕单例模式
饿汉模式:系统一运行,就初始化创建实例,当需要时,直接调用即可。这种方式本身就线程安
全,没有多线程的线程安全问题
{
//
预排序:缩小增量
gap
进行排序,使数据更加接近于有序
// gap > 1 ->
预排序
// gap = 1 ->
直接插入排序
int
gap
=
n
;
while
(
gap
>
1
)
{
// gap = gap / 3 + 1; //
保证
gap
最后一次为
1
gap
=
gap
/
2
;
//
保证
gap
最后一次为
1
for
(
int
i
=
0
;
i
<
n
-
gap
;
++
i
)
{
int
end
=
i
;
int
tmp
=
arr
[
end
+
gap
];
while
(
end
>=
0
)
{
if
(
tmp
<
arr
[
end
])
{
arr
[
end
+
gap
]
=
arr
[
end
];
end
-=
gap
;
}
else
{
break
;
}
}
arr
[
end
+
gap
]
=
tmp
;
}
}
}
void
ShellSort
(
int
arr
[],
int
n
)
{
int
i
,
j
,
tmp
,
gap
;
for
(
gap
=
n
/
2
;
gap
>
0
;
gap
=
gap
/=
2
)
//
增量
gap
为
0
排序完成
{
{
tmp
=
arr
[
i
];
for
(
j
=
i
-
gap
;
j
>=
0
&&
tmp
<
arr
[
j
];
j
-=
gap
)
{
arr
[
j
+
gap
]
=
arr
[
j
];
}
arr
[
j
+
gap
]
=
tmp
;
}
}
}
class
Singleton
{
懒汉模式:系统运行中,实例并不存在,只有当需要使用该实例时,才会去创建并使用实例。这种
方式要考虑线程安全。
public
:
//3
、提供一个全局访问点获取单例对象
static
Singleton
*
GetInstance
()
{
return
_inst
;
}
private
:
//1
、将构造函数设置为私有,并防拷贝
Singleton
({}
Singleton
(
const
Singleton
&
)
=
delete
;
Singleton
&
operator
=
(
const
Singleton
&
)
=
delete
;
//2
、提供一个指向单例对象的
static
指针
static
Singleton
*
_inst
;
};
//
在程序入口之前完成单例对象的初始化
Singleton
*
Singleton::_inst
=
new
Singleton
;
class
Singleton
{
public
:
//3
、提供一个全局访问点获取单例对象
static
Singleton
*
GetInstance
()
{
//
双检查
if
(
_inst
==
nullptr
)
{
_mtx
.
lock
();
if
(
_inst
==
nullptr
)
{
_inst
=
new
Singleton
;
}
_mtx
.
unlock
();
}
return
_inst
;
}
private
:
//1
、将构造函数设置为私有,并防拷贝
Singleton
()
{}
Singleton
(
const
Singleton
&
)
=
delete
;
Singleton
&
operator
=
(
const
Singleton
&
)
=
delete
;
//2
、提供一个指向单例对象的
static
指针
static
Singleton
*
_inst
;
static
mutex _mtx
;
//
互斥锁
};
//
在程序入口之前先将
static
指针初始化为空
Singleton
*
Singleton::_inst
=
nullptr
;
mutex Singleton::_mtx
;
//
初始化互斥锁
管道
(pipe)
:允许一个进程和另一个与它有共同祖先的进程之间进行通信
命名管道
(FIFO)
:类似于管道,但是它可以用于任何两个进程之间的通信,命名管道在文件系统中
有对应的文件名。命名管道通过命令
mkfifo
或系统调用
mkfifo
来创建
操作系统
1.
线程与进程的区别
进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位;
同一进程的线程共享进程资源,不同进程间的地址空间和资源是独立的;
1.
资源分配
:
多进程
:每个进程有独立的地址空间,一个进程崩溃不会影响其他进程,但是进程之间的切换
会消耗更多的资源和时间。
多线程
:线程共享进程的地址空间和资源,如文件句柄和内存,这使得线程之间的通信更容
易,但一个线程崩溃可能影响整个进程。
2.
数据共享、同步和通信
:
多进程
:进程间通信
(IPC)
需要特定的技术,如管道、消息队列、共享内存等,因为进程有独
立的内存空间。
多线程
:线程间可以直接读写进程数据段来进行通信,但需要使用同步操作来避免竞态条件,
如互斥锁、信号量等。
3.
创建和销毁的开销
:
多进程
:创建和销毁进程的开销较大,因为每个进程都需要独立的地址空间和资源。
多线程
:相比之下,创建和销毁线程的开销较小,因为线程间共享进程资源。
4.
并行度
:
多进程
:适合多核、多
CPU
的系统,可以实现真正的并行,即多个进程同时在不同的
CPU
上执
行。
多线程
:在单核
CPU
上也能提高效率,因为线程的切换开销小于进程的切换开销。
5.
使用场景
I/O
密集型可用多进程提高并发;计算密集型可用多线程减少切换开销
频繁修改:需要频繁创建和销毁的优先使用
多线程
计算量:需要大量计算的优先使用
多线程
因为需要消耗大量
CPU
资源且切换频繁,所以多线
程好一点
相关性:任务间相关性比较强的用
多线程
,相关性比较弱的用多进程。因为线程之间的数据共
享和同步比较简单。
多分布:可能要扩展到多机分布的用
多进程
,多核分布的用
多线程
。
但是实际中更常见的是进程加线程的结合方式,并不是非此即彼的。
2. Linux
和
windows
下的进程通信方法和线程通信方法分
别有哪些
进程间通信
管道
(pipe)
:允许一个进程和另一个与它有共同祖先的进程之间进行通信
消息队列
(MQ)
:消息队列是消息的连接表,包括
POSIX
消息对和
System V
消息队列。有足够权限的
进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号
承载信息量少,管道只能成该无格式字节流以及缓冲区大小受限等缺点;
信号量
(semaphore)
:信号量主要作为进程间以及同进程不同线程之间的同步手段;
共享内存
(shared memory)
:它使得多个进程可以访问同一块内存空间,
是最快的可用
IPC
形式。
这是针对其他通信机制运行效率较低而设计的。它往往与其他通信机制,如信号量结合使用,以达
到进程间的同步及互斥
信号
(signal)
:信号是比较复杂的通信方式,用于通知接收进程有某种事情发生,除了用于进程间通
信外,进程还可以发送信号给进程本身
内存映射
(mapped memory)
:内存映射允许任何多个进程间通信,每一个使用该机制的进程通过
把一个共享的文件映射到自己的进程地址空间来实现它
Socket
:它是更为通用的进程间通信机制,可用于不同机器之间的进程间通信
Linux
:
信号:类似进程间的信号处理
锁机制:互斥锁、读写锁和自旋锁
条件变量:使用通知的方式解锁,与互斥锁配合使用
信号量:包括无名线程信号量和命名线程信号量
Windows
:
全局变量:需要有多个线程来访问一个全局变量时,通常我们会在这个全局变量前加上
volatile
声
明,以防编译器对此变量进行优化
Message
消息机制:常用的
Message
通信的接口主要有两个:
PostMessage
和
PostThreadMessage
,
PostMessage
为线程向主窗口发送消息。而
PostThreadMessage
是任意两
个线程之间的通信接口。
CEvent
对象:
CEvent
为
MFC
中的一个对象,可以通过对
CEvent
的触发状态进行改变,从而实现线
程间的通信和同步,这个主要是实现线程直接同步的一种方法。
线程间通信
3.
讲讲死锁
死锁是指两个(多个)线程相互等待对方数据的过程,死锁的产生会导致程序卡死,不解锁程序将永远
无法进行下去。
死锁产生原因
举个例子:两个线程
A
和
B
,两个数据
1
和
2
。线程
A
在执行过程中,首先对资源
1
加锁,然后再去给资源
2
加锁,但是由于线程的切换,导致线程
A
没能给资源
2
加锁。线程切换到
B
后,线程
B
先对资源
2
加锁,然
后再去给资源
1
加锁,由于资源
1
已经被线程
A
加锁,因此线程
B
无法加锁成功,当线程切换为
A
时,
A
也
无法成功对资源
2
加锁,由此就造成了线程
AB
双方相互对一个已加锁资源的等待,死锁产生。
1.
四个必要条件:
1.
互斥条件
:资源不能被多个进程共享,只能由一个进程独占。
2.
占有和等待条件
:进程至少占有一个资源,并且正在等待获取其他进程占有的资源。
3.
不剥夺条件
:资源只能由占有它的进程主动释放,不能被其他进程强制剥夺。
4.
循环等待条件
:存在一种进程资源的循环等待链,每个进程都在等待链中的下一个进程所占有
的资源。
只有当这四个条件同时存在时,死锁才可能发生。为了防止死锁,可以通过破坏上述任何一个条件来实
现。例如,可以通过资源的预分配、请求资源的顺序排列、资源的可剥夺性等方式来预防死锁的发生
4.
介绍一下几种典型的锁
读写锁
多个读者可以同时进行读
写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
互斥锁
一次只能一个线程拥有互斥锁,其他线程只有等待
互斥锁是在抢锁失败的情况下主动放弃
CPU
进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责
线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以
互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概
100ns
左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线
程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁
条件变量
互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个
线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足
时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变
量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互
斥的机制,条件变量则是同步机制。
自旋锁
如果进线程无法取得锁,进线程不会立刻放弃
CPU
时间片,而是一直循环尝试获取锁,直到获取为止。
如果别的线程长时期占有锁那么自旋就是在浪费
CPU
做无用功,但是自旋锁一般应用于加锁时间很短的
场景,这个时候效率比较高。
5. Linux
中异常和中断的区别
相同点
最后都是由
CPU
发送给内核,由内核去处理
处理程序的流程设计上是相似的
不同点
产生源不相同,异常是由
CPU
产生的,而中断是由硬件设备产生的
内核需要根据是异常还是中断调用不同的处理程序
中断不是时钟同步的,这意味着中断可能随时到来;异常由于是
CPU
产生的,所以它是时钟同步的
当处理中断时,处于中断上下文中;处理异常时,处于进程上下文中
6.
内存交换和覆盖有什么区别
交换技术主要是在不同进程(或作业)之间进行,而覆盖则用于同一程序或进程中。
7.
用户态和内核态是什么?有什么区别?
内核态
cpu
可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,
cpu
也可以将自己从一个程序切换到另
一个程序。
用户态
只能受限的访问内存,且不允许访问外围设备,占用
cpu
的能力被剥夺,
cpu
资源可以被其他程序获取。
最大的区别就是权限不同,在运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。
为什么要有这两态
需要限制不同的程序之间的访问能力,防止他们获取别的程序的内存数据,或者获取外围设备的数据,
并发送到网络,
CPU
划分出两个权限等级
–
用户态和内核态。
8.
怎么回收线程
等待线程结束:
int pthread_join(pthread_t tid, void** retval);
主线程调用,等待子线程退出并回收其资源,类似于进程中
wait/waitpid
回收僵尸进程,调用
pthread_join
的线程会被阻塞。
tid
:创建线程时通过指针得到
tid
值。
retval
:指向返回值的指针。
结束线程:
void pthread_exit(void *retval);
子线程执行,用来结束当前线程并通过
retval
传递返回值,该返回值可通过
pthread_join
获得。
retval
:同上。
分离线程:
int pthread_detach(pthread_t tid);
主线程、子线程均可调用。主线程中
pthread_detach(tid)
,子线程中
pthread_detach(pthread_self())
,
调用后和主线程分离,子线程结束时自己立即回收资源。
tid
:同上。
9.
线程切换有哪些状态需要切换
线程切换是指在多线程程序中,当一个线程执行完毕后,操作系统需要将
CPU
分配给另一个线程执行的
过程。线程切换涉及到多个状态的转换。
以下是一些常见的状态:
1.
就绪状态
(Runnable):
线程已经准备好运行,但是还没有被分配到
CPU
上执行。
2.
运行状态
(Running):
线程已经被分配到
CPU
上执行,正在运行。
3.
阻塞状态
(Blocked):
线程因为某些原因无法继续执行,例如等待
I/O
操作完成、等待锁释放等。
4.
等待状态
(Waiting):
线程在等待其他线程或系统资源的操作完成,例如等待信号量、条件变量等。
5.
终止状态
(Terminated):
线程已经执行完毕或者被强制终止。
在进行线程切换时,操作系统需要根据当前的调度策略和线程的状态来选择合适的线程进行切换。一般
来说,操作系统会优先选择就绪状态和运行状态的线程进行切换,以提高程序的性能和响应速度。
10.
分段和分页的区别
分页和分段都是操作系统中的存储管理技术,但它们有一些区别。
分页
分页是将物理内存分成固定大小的块,称为页框,再将逻辑地址分成相同大小的页,然后将每个页映射
到一个页框中。而
分段
分段是将物理内存分成固定大小的块,称为段,再将逻辑地址分成相同大小的段,然后将每个段映射到
一个段表中。
总结
采用分页是为了消除内存碎片,提高内存利用率,仅仅是系统的行为,对用户是不可见的。而分段的目
的主要是为了更好地满足用户的需要。
11.
虚拟地址是怎么转化到物理地址的
虚拟地址到物理地址的转化是通过页表
(Page Table)
来实现的。页表是一种数据结构,用于将虚拟地址
映射到物理地址。
在分段和分页机制下,
CPU
会将虚拟地址转换为线性地址,然后再将线性地址转换为物理地址。
拓展
分段机制简单的来说是将进程的代码、数据、栈分在不同的虚拟地址段上,从而避免了不同段之间的内
存访问冲突。
分页机制则是为了解决分段机制中存在的问题而提出的。它将进程的代码、数据、栈等分成多个大小相
等的页,每个页都有一个唯一的页号,这样就可以将整个进程映射到一个连续的物理地址空间上了。
保护物理内存不受到任何进程内地址的直接访问,在虚拟地址到物理地址的转化过程中方便进行合法性
校验
12.
守护线程是什么
为了防止锁在业务没有执行完成后就释放掉了
,
开启一个线程来定期对这把锁进行延期操作。
13.
操作系统的目标和功能是什么
主要功能:进程管理、内存管理、设备管理、文件系统管理、存储器管理。
对上给用户提供良好的运行环境,对下对设备已经各种资源进行管理。
14.
内部碎片与外部碎片
内碎片
分配给某些进程的内存区域中有些部分没用上,常见于固定分配方式
内存总量相同,
100M
固定分配,将
100M
分割成
10
块,每块
10M
,一个程序需要
45M
,那么需要分配
5
块,第五块只用了
5M
,剩下的
5M
就是内部碎片;
分段式分配,按需分配,一个程序需要
45M
,就给分片
45MB
,剩下的
55M
供其它程序使用,不存在内
部碎片。
外碎片
内存中某些空闲区因为比较小,而难以利用上,一般出现在内存动态分配方式中
分段式分配:内存总量相同,
100M
,比如,内存分配依次
5M
,
15M
,
50M
,
25M
,程序运行一段时间
之后,
5M
,
15M
的程序运行完毕,释放内存,其他程序还在运行,再次分配一个
10M
的内存供其它程序
使用,只能从头开始分片,这样,就会存在
10M+5M
的外部碎片
15.
并发和并行是什么
并发
指宏观上在一段时间内能同时运行多个程序,而并行则指同一时刻能运行多个指令。
并行
需要硬件支持,如多流水线、多核处理器或者分布式计算系统。
操作系统通过引入进程和线程,使得程序能够并发运行。
16.
局部性原理
时间局部性
如果执行了程序中的某条指令,那么不久后这条指令很有可能再次执行
;
如果某个数据被访问过,不久之
后该数据很可能再次被访问。
(
因为程序中存在大量的循环
)
空间局部性
一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也很有可能被访问。
(
因为很多数据在内
存中都是连续存放的,并且程序的指令也是顺序地在内存中存放的
)

17.
守护进程、僵尸进程和孤儿进程
守护进程
指在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。
Linux
的
大多数服务器就是用守护进程的方式实现的,如
web
服务器进程
http
等
孤儿进程
如果父进程先退出,子进程还没退出,那么子进程的父进程将变为
init
进程。(注:任何一个进程都必须
有父进程)。
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被
init
进程
(
进程号为
1)
所收养,并由
init
进程对它们完成状态收集工作。
僵尸进程
如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结
束,否则这个时候子进程就成为僵尸进程。
18.
线程同步机制
线程同步机制是指在多线程编程中,为了保证线程之间的互不干扰,而采用的一种机制。常见的线程同
步机制有以下几种:
临界区:在用户模式下,不会发生用户态到内核态的切换,只能用于同进程内线程间同步。
互斥量:用于保护共享资源,防止多个线程同时访问。
事件:当某个条件满足时,通知其他等待该条件的线程。
信号量:用于控制多个线程对共享资源进行访问的工具。
19.
什么是公平锁和非公平锁
公平锁和非公平锁是针对锁的获取方式而言的。
公平锁是指多个线程按照申请锁的顺序来获取锁,即先到先得的原则。当线程
A
释放锁后,线程
B
、
C
、
D
依次获取锁,如果此时线程
E
申请锁,则它需要等待
B
、
C
、
D
依次获取到锁并释放锁后才能获取锁。
非公平锁是指多个线程获取锁的顺序是随机的,不保证公平性。当线程
A
释放锁后,线程
B
、
C
、
D
等线
程都可以通过竞争获取到锁,而此时线程
E
也可以通过竞争获取到锁。
在实际应用中,公平锁可以避免饥饿现象,但是由于需要维护线程队列,因此效率相对较低。而非公平
锁由于不需要维护线程队列,因此效率相对较高,但是可能会导致某些线程长时间无法获取锁。
20.
多线程锁是什么
多线程锁是一种用来保护共享资源的机制。在多线程编程中,如果多个线程同时访问同一个共享资源,
可能会发生竞态条件(
Race Condition
),导致程序的行为出现未定义的情况。为了避免这种情况的发
生,可以使用多线程锁来保护共享资源。
多线程锁的基本思想是,在访问共享资源之前先获取锁,访问完成之后再释放锁。这样可以保证同一时
刻只有一个线程可以访问共享资源,从而避免竞态条件的发生。
常见的多线程锁包括互斥锁、读写锁、条件变量等。其中,互斥锁用于保护共享资源的访问,读写锁用
于在读多写少的情况下提高并发性能,条件变量用于线程之间的同步和通信。
21.
大小端存储
大小端字节序存储模式
1.
大端存储:数据的低位保存在内存的高地址中,高位保存在内存的低地址中
2.
小端存储:数据的低位保存在内存的低地址中,高位保存在内存的高地址中
为什么要有大小端
在计算机系统中是以字节为单位的,每个地址都对应着一个字节,一个字节为
8bit
,但是在
C
语言
中,还有
16bit
的
short
类型,
32bit
的
long
类型等,另外,对于位数大于
8
位的处理器,由于寄存器
宽度大于一个字节,那就必然存在着如何将多字节安排的问题,因此就导致了大小端存储模式的差
异
22.
多个线程竞争怎么解决
线程竞争(
race condition
)是一个常见的问题,当多个线程尝试同时访问和修改共享数据时,就可能
发生。以下是一些解决线程竞争的常见方法:
1
、
互斥锁(
Mutex
)
: 使用互斥锁是防止多个线程同时访问共享资源的常见方法。当一个线程获取锁
时,其他线程必须等待直到锁被释放。
2
、
读写锁(
Read-Write Lock
)
: 如果共享资源的读操作远多于写操作,可以使用读写锁。它允许多
个读线程同时访问资源,但写操作是排他的。
3
、
原子操作
: 对于简单的数据类型,可以使用原子类型和原子操作来保证操作的原子性,从而避免竞
争条件。
此外,还有条件变量、信号量(
Semaphore
)以及避免共享等多种方式,解决线程竞争问题。
23.
进程状态的切换
就绪状态(
ready
):等待被调度
运行状态(
running
)
阻塞状态(
waiting
):等待资源
应该注意以下内容
只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得
CPU
时间,转为运行状态;而运行状态的进程,在分配给它的
CPU
时间片用完之后就会转为就绪
状态,等待下一次调度。
阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括
CPU
时间,缺少
CPU
时
间会从运行态转换为就绪态。
24.
进程
/
线程上下文切换过程,切换的资源有哪些
bool
judgeOrder
()
{
int
num
=
1
;
int
isOne
= *
(
char*
)
&
num
;
return
isOne
==
1
;
}
int
main
()
{
if
(
judgeOrder
())
{
std::cout
<<
"
小端
"
<<
std::endl
;
}
else
{
std::cout
<<
"
大端
"
<<
std::endl
;
}
return
0
;
}
进程
/
线程上下文切换是操作系统进行任务切换时,保存当前任务的状态并加载下一个任务的状态的过
程。在上下文切换过程中,操作系统需要保存和恢复的资源包括:
寄存器
包括通用寄存器(如
PC
、
SP
等)和特殊寄存器(如状态寄存器、控制寄存器等)。保存当前任务的寄存
器状态,并加载下一个任务的寄存器状态。
程序计数器(
PC
)
保存当前任务执行的下一条指令的地址,以便在切换回来时继续执行。
栈指针(
SP
)
保存当前任务的栈指针,以便在切换回来时继续使用该任务的栈。
内存管理单元(
MMU
)
保存当前任务的页表、段表等内存管理信息,以便在切换回来时继续使用该任务的内存映射。
文件描述符表
保存当前任务打开的文件描述符信息,以便在切换回来时继续使用。
环境变量
保存当前任务的环境变量信息,以便在切换回来时继续使用。
其他资源
如信号处理函数、定时器、硬件中断等,需要保存当前任务的相关状态,并在切换回来时继续处理。
需要注意的是,不同操作系统和架构可能会有略微不同的上下文切换过程和需要保存的资源,上述列举
的是一般情况下的常见资源。
25.
介绍一下虚拟技术
虚拟技术把一个物理实体转换为多个逻辑实体。
主要有两种虚拟技术:时(时间)分复用技术和空(空间)分复用技术。
多进程与多线程:多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处
理器,每次只执行一小个时间片并快速切换。
虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空
间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的
页时,执行页面置换算法,将该页置换到内存中。
26.
线程同步机制
线程同步机制是指在多线程编程中,为了保证线程之间的互不干扰,而采用的一种机制。常见的线程同
步机制有以下几种:
临界区:在用户模式下,不会发生用户态到内核态的切换,只能用于同进程内线程间同步。
互斥量:用于保护共享资源,防止多个线程同时访问。
事件:当某个条件满足时,通知其他等待该条件的线程。
信号量:用于控制多个线程对共享资源进行访问的工具。
27.
程序中局部变量,全局变量和动态申请的数据都存放在
哪里
局部变量存放在栈中,全局变量存放在静态数据区或全局数据区,动态申请的数据存放在堆中。
28.
信号和信号量有什么区别
信号
:一种处理异步事件的方式。信号是比较复杂的通信方式,用于通知接收进程有某种事件发生,除
了用于进程外,还可以发送信号给进程本身。
信号量
:进程间通信处理同步互斥的机制。是在多线程环境下使用的一种设施,它负责协调各个线程,
以保证它们能够正确,合理的使用公共资源。
29. Linux
指令
1.
文件相关
(mv mkdir cd ls)
2.docker
相关
(docker container ls docker ps -a )
3.
权限相关
(chmod chown useradd groupadd)
4.
网络相关
(netstat ip addr)
5.
测试相关
(
测试连通性
:ping
测试端口连通性
:telnet)
30. select
、
poll
、
epoll
多路复用
IO
多路复用是一种处理多个
IO
流的技术。
它允许单个进程同时监视多个文件描述符,当一个或多个文件描述符准备好读或写时,它就可以立即响
应。这种技术可以提高系统的并发性和响应能力,减少系统资源的浪费。
在
Linux
中,
epoll
、
select
、
poll
都是
IO
多路复用的实现方式,他们都可以监视多个描述符,一旦某个描
述符就绪
(
一般是读就绪或者写就绪
),
能够通知程序进行相应的读写操作。
select
和
poll
并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的
Socket
集合。
在使用的时候,首先需要把关注的
Socket
集合通过
select/poll
系统调用从用户态拷贝到内核态,然后
由内核检测事件,当有网络事件产生时,内核需要遍历进程关注
Socket
集合,找到对应的
Socket
,并
设置其状态为可读
/
可写,然后把整个
Socket
集合从内核态拷贝到用户态,用户态还要继续遍历整个
Socket
集合找到可读
/
可写的
Socket
,然后对其处理。
很明显发现,
select
和
poll
的缺陷在于,当客户端越多,也就是
Socket
集合越大,
Socket
集合的遍历
和拷贝会带来很大的开销,因此也很难应对
C10K
。
epoll
是解决
C10K
问题的利器,通过两个方面解决了
select/poll
的问题。
epoll
在内核里使用「红黑树」来关注进程所有待检测的
Socket
,红黑树是个高效的数据结构,增
删改一般时间复杂度是
O(logn)
,通过对这棵黑红树的管理,不需要像
select/poll
在每次操作时都
传入整个
Socket
集合,减少了内核和用户空间大量的数据拷贝和内存分配。
epoll
使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的
Socket
集合传递给应用程序,不需要像
select/poll
那样轮询扫描整个集合(包含有和无事件的
Socket
),大大提高了检测的效率。
计算机网络
1.
在浏览器地址栏输入一个
URL
后回车,背后会进行哪些
技术步骤
1. DNS
域名解析
2.
发起
TCP
的
3
次握手建立
TCP
连接
3.
发起
http
请求
4.
服务器响应
http
请求,浏览器得到
html
代码
5.
浏览器解析
html
代码,并请求
html
代码中的资源(如
js
、
css
、图片等)
6.
浏览器对页面进行渲染呈现给用户。
客户端进行
url
解析,
url
缓存检查,在
DNS
服务器上进行
dns
解析,建立
tcp
连接通道后浏览器向服务器
发送
http
请求,服务器处理请求,并通过发送
http
响应把结果返回浏览器,关闭
tcp
连接通道,最后结果
在浏览器上渲染后将页面展示给用户。
2. TCP
是如何保证可靠传输的
确认应答和超时重传
:接收方收到报文就会确认,发送方发送一段时间后没有收到确认就会重传。
数据校验
:
TCP
报文头有校验和,用于校验报文是否损坏。
数据合理分片和排序
:
tcp
会按最大传输单元
(MTU)
合理分片,接收方会缓存未按序到达的数据,重
新排序后交给应用层。而
UDP
:
IP
数据报大于
1500
字节,大于
MTU
。这个时候发送方的
IP
层就需要
分片,把数据报分成若干片,是的每一片都小于
MTU
。而接收方
IP
层则需要进行数据报的重组。由
于
UDP
的特性,某一片数据丢失时,接收方便无法重组数据报,导致丢弃整个
UDP
数据报。
流量控制
:当接收方来不及处理发送方的数据,能通过滑动窗口,提示发送方降低发送的速率,防
止包丢失。
拥塞控制
:当网络拥塞时,通过拥塞窗口,减少数据的发送,防止包丢失。
3. TCP
和
UDP
的区别
1.
连接方式
:
TCP
是面向连接的协议,传输数据前需要建立连接。
UDP
是无连接的协议,发送数据无需建立连接。
2.
可靠性
:
TCP
提供可靠的数据传输,确保数据无差错、不丢失、不重复、按序到达。
UDP
则尽最大努力交付,但不保证可靠传输。
3.
传输效率
:
TCP
由于需要确认机制,相对效率较低。
UDP
简单高效,适用于对实时性要求高的应用。
4.
拥塞控制
:
TCP
有拥塞控制和流量控制机制,可以根据网络状况调整数据发送速率。
UDP
没有拥塞控制,即使网络拥堵也不会减慢发送速率。
5.
首部开销
:
TCP
首部较长,至少
20
字节,增加了传输开销。
UDP
首部只有
8
字节,开销较小。
6.
数据传输方式
:
TCP
是流式传输,数据没有明确边界,保证数据顺序和完整性。
UDP
是面向报文的传输方式,发送方发送的是什么,接收方就接收什么。
7.
使用场景
:
TCP
适用于需要高可靠性的应用,如网页浏览、文件传输。
UDP
适用于实时应用,如在线视频会议、游戏等。
4. TCP
三次握手,为什么不能两次
第一次握手:
客户端发送
syn
包
(seq=x)
到服务器,并进入
SYN_SEND
状态,等待服务器确认
;
第二次握手:
服务器收到
syn
包,必须确认客户的
SYN(ack=x+1)
,同时自己也发送一个
SYN
包
(seq=y)
,
即
SYN+ACK
包,此时服务器进入
SYN_RECV
状态
;
第三次握手:
客户端收到服务器的
SYN+ACK
包,向服务器发送确认包
ACK(ack=y+1)
,此包发送完毕,客
户端和服务器进入
ESTABLISHED
状态,完成三次握手。
syn(
同步序列编号
) ack(
确认
) fin(
结束
)
目的
1.
避免重复的历史连接
三次握手时,客户端收到后可以根据自身的上下文,判断是否是一个历史连接(序列号过期或超
时),那么客户端就会发送
RST
报文给服务端,表示终止这一次连接
2.
同步双方序列号
3.
避免资源浪费
如客户端发出连接请求,但因连接请求报文丢失而未收到确认,于是客户端再重传一次连接请求。后来
收到了确认,建立了连接。数据传输完毕后,就释放了连接,客户端共发出了两个连接请求报文段,其
中第一个丢失,第二个到达了服务端,但是第一个丢失的报文段只是在
某些网络结点长时间滞留了,延
误到连接释放以后的某个时间才到达服务端
,此时服务端误认为客户端又发出一次新的连接请求,于是
就向客户端发出确认报文段,同意建立连接,不采用三次握手,只要服务端发出确认,就建立新的连接
了,此时客户端忽略服务端发来的确认,也不发送数据,则服务端一致等待客户端发送数据,浪费资
源。
5. TCP
四次挥手,为什么不能三次
第一次挥手
: 客户端发送
FIN
报文给服务器,表明它不再向服务器发送数据,但是仍然愿意接收数据。
第二次挥手
: 服务器接收到客户端的
FIN
报文后,发送一个
ACK
报文确认客户端的关闭请求。
第三次挥手
:服务器如果也没有数据要发送给客户端时,它会发送一个
FIN
报文给客户端,请求关闭服务
器到客户端的数据传输。
第四次挥手
:客户端收到服务器的
FIN
报文后,会发送一个
ACK
给服务器进行响应。然后客户端进入
TIME_WAIT
状态,等待足够的时间以确保服务器收到这个
ACK
,并且处理可能延迟的数据包。
TCP
的四次挥手过程是因为
TCP
是一个全双工协议,意味着数据可以在两个方向上同时传输。在关闭
TCP
连接时,每个方向都需要单独终止,其中每两次挥手就是关闭一个方向上的通信通道,这就是为什么挥
手需要四次而不是三次。
四次挥手过程的目的是确保数据在关闭过程中能够被完整传输,同时也允许延迟的数据包在关闭后仍然
能够被接收。
TIME_WAIT
状态的存在是为了处理可能的延迟数据包,以确保连接的完全关闭。
如果采用
三次挥手,可能会导致数据包的丢失或混淆,从而影响连接的可靠性和完整性。
6. HTTP
和
HTTPS
的区别
http
是明文传输,数据没有加密不安全,
https
用到
CA
申请证书是加密的安全;
http
页面响应速度比
https
快,
http
使用
tcp
三次握手建立连接,客户端和服务端需交换三个包,而
https
除了
tcp
三个包,还有
ssl
握手需
9
个包,总共
12
个包;
http
端口是
80
,
https
端口是
443
;
https
是构建在
ssl
之上的协议,比
http
更耗费资源;
7. http1.0 / 1.1 / 2 / 3
的区别
HTTP 1.0
、
1.1
、
2.0
和
3.0
的区别如下:
HTTP 1.0
是一种无状态,无连接的应用层协议。浏览器每次请求都需要与服务器建立一个
TCP
连
接,服务器处理完成以后立即断开
TCP
连接
(
无连接
),
服务器不跟踪也每个客户单,也不记录过去的
请求
(
无状态
)
。
HTTP 1.1
支持长连接和请求的流水线处理,在一个
TCP
连接上可以传送多个
HTTP
请求和响应,减
少了网络延迟 。
HTTP 2.0
是基于二进制流的,可以分解为独立的帧,交错发送,从而提高了网络传输效率。
HTTP/3
是最新的版本,它使用了
QUIC
协议来提高网络传输效率。
8.
封包和拆包你听说过吗?它是基于
TCP
还是
UDP
的
封包和拆包都是基于
TCP
的概念。因为
TCP
是无边界的流传输,所以需要对
TCP
进行封包和拆包,确保发
送和接收的数据不粘连。
封包:封包就是在发送数据报的时候为每个
TCP
数据包加上一个包头,将数据报分为包头和包体两
个部分。包头是一个固定长度的结构体,里面包含该数据包的总长度。
拆包:接收方在接收到报文后提取包头中的长度信息进行截取。
9. UDP
怎么实现可靠传输
最简单的方式是在应用层模仿传输层
TCP
的可靠性传输。下面不考虑拥塞处理,可靠
UDP
的简单设计。
1
、添加
seq/ack
机制,确保数据发送到对端
2
、添加发送和接收缓冲区,主要是用户超时重传。
3
、添加超时重传机制。
详细说明:送端发送数据时,生成一个随机
seq=x
,然后每一片按照数据大小分配
seq
。数据到达接收
端后接收端放入缓存,并发送一个
ack=x
的包,表示对方已经收到了数据。发送端收到了
ack
包后,删除
缓冲区对应的数据。时间到后,定时任务检查是否需要重传数据。
10. HTTP
长连接怎么保活
HTTP
长连接保活的方法有很多,以下是一些常见的方法:
在服务器端设置一个保活定时器,当定时器开始工作后就定时的向网络通信的另一端发出保活探测
的
TCP
报文,如果接收到了
ACK
报文,那么就证明对方存活,可以继续保有连接;否则就证明网络
存在故障。
通过在客户端发送心跳包来检测服务器是否存活。如果服务器在一定时间内没有收到客户端的心跳
包,则认为服务器已经宕机了,需要重新建立连接。
通过在服务器端设置
keep-alive
参数来实现长连接保活。
keep-alive
参数指定了客户端与服务器之
间的长连接超时时间,超过这个时间后,如果没有数据传输,则自动断开连接。如果在这个时间内
有数据传输,则重置超时时间。
11.
为什么服务器会缓存这一项功能
?
如何实现的
原因
缓解服务器压力;
降低客户端获取资源的延迟:缓存通常位于内存中,读取缓存的速度更快。并且缓存服务器在地理
位置上也有可能比源服务器来得近,例如浏览器缓存。
实现方法
让代理服务器进行缓存;
让客户端浏览器进行缓存
12.
为什么区域传送用
TCP
协议
因为
TCP
协议可靠性好!
你要从主
DNS
上复制内容啊,你用不可靠的
UDP
? 因为
TCP
协议传输的内容大啊,你用最大只能传
512
字节的
UDP
协议?万一同步的数据大于
512
字节,你怎么办?
所以用
TCP
协议比较好!
13.
流量控制是使用什么数据结构来实现的
流量控制是使用滑动窗口来实现的。接收方确认报文中的窗口字段可以用来控制发送方窗口的大小。
如果窗户的值为
0
,则发送方停止发送数据,但是发送方会定期的向接收方发送窗口探测报文以得到窗口
的大小。滑动窗口的大小是可以动态调整的,它可以根据网络状况和双方的能力来自适应地调整,从而
实现流量控制的功能。
14. IN_WAIT_2
,
CLOSE_WAIT
状态和
TIME_WAIT
状态
FIN_WAIT_2
:
半关闭状态。
发送断开请求一方还有接收数据能力,但已经没有发送数据能力。
CLOSE_WAIT
状态:
被动关闭连接一方接收到
FIN
包会立即回应
ACK
包表示已接收到断开请求。
被动关闭连接一方如果还有剩余数据要发送就会进入
CLOSE_WAIT
状态。
TIME_WAIT
状态:
又叫
2MSL
等待状态。
如果客户端直接进入
CLOSED
状态,如果服务端没有接收到最后一次
ACK
包会在超时之后重新再发
FIN
包,此时因为客户端已经
CLOSED
,所以服务端就不会收到
ACK
而是收到
RST
。所以
TIME_WAIT
状态目的是防止最后一次握手数据没有到达对方而触发重传
FIN
准备的。
在
2MSL
时间内,同一个
socket
不能再被使用,否则有可能会和旧连接数据混淆(如果新连接和旧
连接的
socket
相同的话)。
15.
路由器和交换机的区别
功能
路由器是连接因特网中各局域网和广域网的设备,用来做网间连接,也就是用来连接不同网络的。
而交换机是一个扩大网络的器材,能为子网络中提供更多的连接端口,以便连接更多的计算机。
层级
此外,路由器和交换机在工作层次上也有所不同,普通的交换机一般工作在
OSI
七层模型的第二层
·
数据
链路层,而路由器则工作在第三层
·
网络层
16.
谈一谈
HTTP
状态码
304
和缓存机制
304
HTTP
状态码
304
表示
“
未修改(
Not Modified
)
”
这个状态码是在客户端向服务器发送了一个带有条件的
请求(通常是通过发送请求头中的
If-Modified-Since
或
If-None-Match
字段),并且服务器判断请求的资
源在上次请求后没有发生变化时返回的。
缓存
缓存机制是一种优化技术,用于减少网络流量和提高网站性能。
当客户端首次请求一个资源时,服务器可以在响应中包含缓存相关的头信息,例如
ETag
和
Last-Modified
字段。客户端会将这些头信息存储在本地,下次请求同样的资源时会将这些头信息发送给服务器,以便
服务器判断资源是否发生变化。
HTTP
状态码
304
表示资源未修改,是缓存机制的一部分,通过判断资源是否发生变化来减少网络流量和
提高网站性能。
17.
介绍几个
UDP
对应的应用层协议
DNS
:用于域名解析服务,用的是
53
号端口
SNMP
:简单网络管理协议,使用
161
号端口
TFTP(Trival File Transfer Protocal)
:简单文件传输协议,
69
18.
常见
TCP
的连接状态
CLOSED
:初始状态。
LISTEN
:服务器处于监听状态。
SYN_SEND
:客户端
socket
执行
CONNECT
连接,发送
SYN
包,进入此状态。
SYN_RECV
:服务端收到
SYN
包并发送服务端
SYN
包,进入此状态。
ESTABLISH
:表示连接建立。客户端发送了最后一个
ACK
包后进入此状态,服务端接收到
ACK
包后
进入此状态。
FIN_WAIT_1
:终止连接的一方(通常是客户机)发送了
FIN
报文后进入。等待对方
FIN
。
CLOSE_WAIT
:(假设服务器)接收到客户机
FIN
包之后等待关闭的阶段。在接收到对方的
FIN
包之
后,自然是需要立即回复
ACK
包的,表示已经知道断开请求。但是本方是否立即断开连接(发送
FIN
包)取决于是否还有数据需要发送给客户端,若有,则在发送
FIN
包之前均为此状态。
FIN_WAIT_2
:此时是半连接状态,即有一方要求关闭连接,等待另一方关闭。客户端接收到服务
器的
ACK
包,但并没有立即接收到服务端的
FIN
包,进入
FIN_WAIT_2
状态。
LAST_ACK
:服务端发动最后的
FIN
包,等待最后的客户端
ACK
响应,进入此状态。
TIME_WAIT
:客户端收到服务端的
FIN
包,并立即发出
ACK
包做最后的确认,在此之后的
2MSL
时间
称为
TIME_WAIT
状态。
19. HTTPS
传输流程
序
号
方法
描述
1
GET
请求指定的页面信息,并返回实体主体。
2
HEAD
类似于
GET
请求,只不过返回的响应中没有具体的内容,用于获取报头
3
POST
向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被
包含在请求体中。
POST
请求可能会导致新的资源的建立和
/
或已有资源的修
改。

20.
一个
TCP
连接可以对应几个
HTTP
请求
如果维持连接,一个
TCP
连接是可以发送多个
HTTP
请求的
21.
子网掩码的作用
子网掩码是用来划分网络和主机的一个
32
位二进制数,它的作用是将
IP
地址分成网络号和主机号两部
分。子网掩码与
IP
地址进行逻辑与运算,可以得到网络号部分,从而确定网络范围。
子网掩码的作用是帮助划分网络和主机,并确定网络的范围,以及在网络地址转换中起到辅助作用。
22. HTTP
常见的请求请求方法
客户端发送的
请求报文
第一行为请求行,包含了方法字段。
根据
HTTP
标准,
HTTP
请求可以使用多种请求方法。
HTTP1.0
定义了三种请求方法:
GET, POST
和
HEAD
方法。
HTTP1.1
新增了六种请求方法:
OPTIONS
、
PUT
、
PATCH
、
DELETE
、
TRACE
和
CONNECT
方法。

23. HTTP
一定是基于
TCP
的吗
不一定,
HTTP/3
的
QUIC
协议就属于
UDP
24. TCP
四大拥塞控制算法
拥塞控制主要是四个算法:
1
)慢启动,
2
)拥塞避免,
3
)拥塞发生,
4
)快速恢复。
25.
网络的七层
/
五层模型主要的协议有哪些

26. OSI
的七层模型分别是?各自的功能是什么
简要概括

物理层:底层数据传输,如网线;网卡标准。
数据链路层:定义数据的基本格式,如何传输,如何标识;如网卡
MAC
地址。
网络层:定义
IP
编址,定义路由功能;如不同设备的数据转发。
传输层:端到端传输数据的基本功能;如
TCP
、
UDP
。
会话层:控制应用程序之间会话能力;如不同软件数据分发给不同软件。
表示层:数据格式标识,基本压缩加密功能。
应用层:各种应用软件,包括
Web
应用。
总结
网络七层模型是一个标准,而非实现。
网络四层模型是一个实现的应用模型。
网络四层模型由七层模型简化合并而来。
27. HTTP
长连接和短连接的区别
在
HTTP/1.0
中默认使用短连接。也就是说,客户端和服务器每进行一次
HTTP
操作,就建立一次连接,
任务结束就中断连接。
而从
HTTP/1.1
起,默认使用长连接,用以保持连接特性。
28. TCP
粘包问题
TCP
是一个一个报文过来的,按照
序号
排好序放在
缓冲区
中,但是站在
应用层
的角度,它看到的只是
一串连续
的字节数据。
应用程序
看到了这么一连串的字节数据, 就不知道从
哪个部分
开始到
哪个部分
结
束是一个完整的应用层数据包,这就是
粘包问题
。
如何避免粘包问题?明确包之间的边界
对于
定长
的包,保证每次都按
固定大小
读取即可。
例如一个
Request
结构
,
是固定大小的
,
那么就
从缓冲区从头开始按
sizeof(Request)
依次读取即可
对于
变长
的包,可以在
包头
的位置,约定一个
包总长度
的字段,从而就知道了包的
结束位置
对于
变长
的包,还可以在
包和包
之间使用明确的分隔符
(
应用层协议是程序员自己来定义的
,
只要保
证分隔符不和正文冲突即可
)
隔离级别
脏读
不可重复读
幻影读
READ-UNCOMMITTED
未提交读
√
√
√
READ-COMMITTED
提交读
×
√
√
REPEATABLE-READ
重复读
×
×
√
SERIALIZABLE
可串行化读
×
×
×
MySQL
1.
数据库的事务隔离级别、
MVCC
机制
读未提交
事务中发生了修改,即使没有提交,其他事务也是可见的,比如对于一个数
A
原来
50
修改为
100
,但是我
还没有提交修改,另一个事务看到这个修改,而这个时候原事务发生了回滚,这时候
A
还是
50
,但是另
一个事务看到的
A
是
100.
可能会导致脏读、幻读或不可重复读
读已提交
对于一个事务从开始直到提交之前,所做的任何修改是其他事务不可见的,举例就是对于一个数
A
原来
是
50
,然后提交修改成
100
,这个时候另一个事务在
A
提交修改之前,读取的
A
是
50
,刚读取完,
A
就被
修改成
100
,这个时候另一个事务再进行读取发现
A
就突然变成
100
了;
可以阻止脏读,但是幻读或不可
重复读仍有可能发生
可重复读
就是对一个记录读取多次的记录是相同的,比如对于一个数
A
读取的话一直是
A
,前后两次读取的
A
是一
致的;
可以阻止脏读和不可重复读,但幻读仍有可能发生
可串行化读
在并发情况下,和串行化的读取的结果是一致的,没有什么不同,比如不会发生脏读和幻读;
该级别可
以防止脏读、不可重复读以及幻读

MySQL InnoDB
存储引擎的默认支持的隔离级别是
REPEATABLE-READ
(可重读)
MVCC
机制简介
MVCC
,即多版本并发控制,是一种用于处理数据库并发操作的技术。它允许数据库在不加锁的情况下
进行读操作,同时写操作也不会被读操作阻塞,从而提高了数据库的并发性能。
MVCC
通过为每一行数
据维护多个版本,使得读操作可以访问到记录的早期版本,而不必等待写操作完成。
MVCC
机制适用的隔离级别
MVCC
机制主要用于
读已提交
和
可重复读
这两个隔离级别。在
读已提交
级别下,每个事务都会看到其他
事务提交后的数据,但不会看到其他事务未提交的数据。在
可重复读
级别下,事务可以看到在事务开始
时已经存在的数据的一个快照,即使后来有其他事务对数据进行了修改,原事务也不会看到这些修改,
从而保证了可重复读。
MVCC
通过为每一行数据维护多个版本,使得读操作可以访问到记录的早期版本,而不必等待写操作完
成,从而提高了并发性能。在
REPEATABLE READ
级别下,
InnoDB
存储引擎通过隐藏字段和
undo
日志来
实现
MVCC
,确保了事务的隔离性和数据的一致性
2.
事务四大特性
事务的概念
事务由一条或多条
SQL
语句组成,这些语句在逻辑上存在相关性,共同完成一个任务,要么全部成
功,要么全部失败,是一个整体
事务的四个属性
原子性: 一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事
务在执行过程中如果发生错误,则会自动回滚到事务开始前的状态,就像这个事务从来没有执行过
一样。
持久性: 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
隔离性: 数据库允许多个事务同时访问同一份数据,隔离性可以保证多个事务在并发执行时,不会
因为由于交叉执行而导致数据的不一致。
一致性: 在事务开始之前和事务结束以后,数据库的完整型没有被破坏,这表示写入的资料必须完
全符合所有的预设规则,这包含资料的精确度、串联型以及后续数据库可以自发性地完成预定的工
作。
为什么需要事务
?
本质是为了当应用程序访问数据库的时候
,
事务能够简化我们的编程模型
,
事务本质上是为了应用层
服务的
3.
脏读、幻读、丢弃更改、不可重复读的区别
数据库并发会带来脏读、幻读、丢弃更改、不可重复读这四个常见问题,其中:
脏读
:
一个事务可以读取到另一个事务尚未提交的修改
幻读
:
一个事务在读取某个范围的数据时,另一个事务在此范围内插入或删除新的数据,导致后续
的读操作读取到不同的数据行。
不可重复读:不可重复读是指在一个事务内,两次读取同一数据集合时,由于另一个事务的修改并
提交了数据,导致第一次和第二次读取到的数据不一致
4.
数据库引擎
InnoDB
与
MyISAM
的区别
区别
事务
: InnoDB
是事务型的,可以使用
Commit
和
Rollback
语句。
并发
: MyISAM
只支持表级锁,而
InnoDB
还支持行级锁。
外键
: InnoDB
支持外键。
备份
: InnoDB
支持在线热备份。
崩溃恢复
: MyISAM
崩溃后发生损坏的概率比
InnoDB
高很多,而且恢复的速度也更慢。
其它特性
: MyISAM
支持压缩表和空间数据索引。
适用场景
MyISAM
适合: 插入不频繁,查询非常频繁,如果执行大量的
SELECT
,
MyISAM
是更好的选择, 没有事
务。
InnoDB
适合: 可靠性要求比较高,或者要求事务; 表更新和查询都相当的频繁, 大量的
INSERT
或
UPDATE
5. MySQL
索引主要使用的两种数据结构是什么
索引的概念
:
定义
:索引是一种特殊的数据结构,它存储在数据库表中,可以帮助快速查询和检索数据。
作用
:通过创建索引,可以提高数据检索的效率,减少数据库的查询时间。
类比
:类似于图书的目录,可以快速找到所需的内容而不必逐页翻阅。
哈希索引
对于哈希索引来说,底层的数据结构肯定是哈希表,因此
在绝大多数需求为单条记录查询
的时候,可以
选择哈希索引,查询性能最快;其余大部分场景,建议选择
BTree
索引
BTree
索引
Mysql
的
BTree
索引使用的是
B
树中的
B+Tree
,
BTREE
索引就是一种将索引值按一定的算法,存入一个树
形的数据结构中(二叉树),每次查询都是从树的入口
root
开始,依次遍历
node
,获取
leaf
。
优势
B+tree
的磁盘读写代价更低:
B+tree
的内部结点并没有指向关键字具体信息的指针
(
红色部分
)
,因此其
内部结点相对
B
树更小。如果把所有同一内部结点的关键字存放在同一块盘中,那么盘块所能容纳的关
键字数量也越多。一次性读入内存中的需要查找的关键字也就越多,相对来说
IO
读写次数也就降低了;
B+tree
的查询效率更加稳定:由于内部结点并不是最终指向文件内容的结点,而只是叶子结点中关键字
的索引,所以,任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相
同,导致每一个数据的查询效率相当;
6.
为什么
MySQL
索引要使用
B+
树,而不是
B
树或者红黑树
或者哈希表
【
B+
树相较于
B
树的优势】
1. IO
次数更少(查询效率更高)
B+
树的非叶子节点不存放实际的数
据,仅存放索引,因此数据量相同的情况下,相比既存储索引又存储数据的
B
树,
B+
树的非叶子节点
可以存放更多的索引,所以
B+
树查询时
IO
次数更少,查询效率更高。
2.
范围查询性能高
B+
树的
叶子节点使用链表相连,有利于范围查询;而
B
树想要进行范围查询时,就只能通过树的深度遍历或广
度遍历来完成范围查询,这就会产生更多节点的磁盘
IO
,查询效率就低了。
3.
插入和删除性能更好
B+
树有大量的冗余节点(所有的非叶子节点都是冗余索引),这些冗余索引使得
B+
树在进行插入和删除
操作的时候,效率很高,不会像
B
树那样发生复杂的变化(不断调整节点位置)。 那为什么不使用红黑
树呢 ?? 连
B
树都不用,红黑树就更不用说了。 其一,它是二叉树,那么它树的高度就比
B+
树要
高; 其二,它在进行插入删除的时候,需要不断的调整树的位置,保证树的平衡性,还需要保证节点的
颜色符合红黑树的性质; 其三,它的非叶子节点也是不仅要存储索引,
利用
Hash
需要把数据全部
加载到内存中
,如果数据量大,是一件很
消耗内存
的事,而采用
B+
树,
是基于
按照节点分段加载,由此减少内存消耗
。
和业务场景有段,
对于唯一查找
(查找一个值),
Hash
确实更快,
但数据库中经常查询多条数
据
,这时候由于
B+
数据的有序性,与叶子节点又有链表相连,他的查询效率会比
Hash
快的多。
b+
树的
非叶子节点不保存数据
,
只保存子树的临界值
(最大或者最小),所以同样大小的节点,
b+
树相对于
b
树能够有更多的分支,使得这棵树更加矮胖,查询时做的
IO
操作次数也更少
。
MySQL
中的索引使用
B+
树而不是其他数据结构,主要是因为
B+
树在数据库索引的应用中提供了几个关
键优势:
1.
高效的查找和范围查询性能
:
B+
树是一种平衡多路查找树,它的结构保证了即使在大量数据中也能
保持较低的查找深度,从而实现快速的数据检索。由于所有叶子节点都位于同一层,并且通过指针
相连,这使得范围查询变得非常高效
2.
磁盘
I/O
优化
:数据库操作的性能瓶颈往往在于磁盘
I/O
。
B+
树的设计允许它存储更多的索引键在一
个节点中,这意味着在查找过程中需要读取的磁盘页数更少,从而减少了
I/O
操作的次数
3.
更好的利用缓存
:由于
B+
树的内部节点不存储数据,只存储键值和子节点指针,因此可以在同样大
小的磁盘页中存储更多的键值。这样一来,当一个节点被加载到内存中时,它可以覆盖更大范围的
索引键,提高了缓存的利用率
4.
顺序访问指针
:
B+
树的叶子节点之间通过指针相连,形成了一个有序链表。这种结构使得顺序访问
数据变得非常快速,特别是对于顺序扫描整个表的操作
5.
统一的索引节点格式
:在
B+
树中,所有的叶子节点都包含了数据指针和键值,而内部节点仅包含键
值。这种统一的结构简化了树的管理,并且使得叶子节点可以直接用于数据的查找和存储
总的来说,
B+
树为
MySQL
提供了一个既能保证高效数据访问,又能最小化磁盘
I/O
消耗的索引结构,这
对于数据库系统来说是非常重要的。这些优势使得
B+
树成为了关系型数据库索引的首选数据结构
B+
树是
B
树的一种变形结构,那为什么我们没有采用普通的
B
树作为索引结构呢?
首先,普通
B
树中的所有结点中都同时包括索引信息和数据信息,由于一个
Page
的大小是固定的,
因此非叶子结点中如果包含了数据信息,那么这些结点中能够存储的索引信息一定会变少,这时这
棵树形结构一定会变得更高更瘦,当查询数据时就可能需要与磁盘进行更多次的
IO
操作。
其次,普通
B
树中的各个叶子结点之间没有连接起来,这将不利于进行数据的范围查找,而
B+
树的
各个叶子结点之间是连接起来的,当我们进行范围查找时,直接先找到第一个数据然后继续向后遍
历找到之后的数据即可,因此将各个叶子结点连接起来更有利于进行数据的范围查找。
7.
说一下
MySQL
是如何执行一条
SQL
的
连接器
:管理连接、权限验证;
查询缓存
:命中缓存则直接返回结果;
分析器
:对
SQL
进行词法分析、语法分析;(判断查询的
SQL
字段是否存在也是在这步)
优化器
:执行计划生成、选择索引;
执行器
:操作引擎、返回结果;
存储引擎
:存储数据、提供读写接口。
8. MySQL
中有哪些索引?有什么特点
普通索引
:仅加速查询
唯一索引
:加速查询
+
列值唯一(可以有
null
)
主键索引
:加速查询
+
列值唯一(不可以有
null
)
+
表中只有一个
组合索引
:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并
全文索引
:对文本的内容进行分词,进行搜索
索引合并
:使用多个单列索引组合搜索
覆盖索引
:
select
的数据列只用从索引中就能够取得,不必读取数据行,换句话说查询列要被所建
的索引覆盖
聚簇索引
:表数据是和主键一起存储的,主键索引的叶结点存储行数据
(
包含了主键值
)
,二级索引
的叶结点存储行的主键值。使用的是
B+
树作为索引的存储结构,非叶子节点都是索引关键字,但非
叶子节点中的关键字中不存储对应记录的具体内容或内容地址。叶子节点上的数据是主键与具体记
录
(
数据内容
)
9.
说下你了解的
MVCC
机制?包括其中的原理
MVCC(Multi-Version Concurrency Control)
是一种多版本并发控制机制,它可以在数据库的读写操作
中,将数据按照时间版本进行保存,并且在读取时只读取已提交的版本,避免数据的并发访问产生的问
题。
MVCC
是通过在每行记录后面保存两个隐藏的列来实现的。
这两个列,一个保存了行的创建时间,一个
保存行的过期时间
(
或删除时间
)
。 当然存储的并不是实际的时间值,而是系统版本号
(system version
number)
。 每开始一个新的事务,系统版本号都会自动递增。
10. MySQL
的行级锁有哪些
主要有三种,记录锁、间隙锁、临建锁。
记录锁:锁住的是一条记录,记录锁分为排他锁和共享锁。
间隙锁:只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。
临键锁:是
Record Lock + Gap Lock
的组合,锁定一个范围,并且锁定记录本身。
next-key lock
即能保护该记录,又能阻止其他事务将新纪录插入到被保护记录前面的间隙中。
11.
数据库的备份和容灾
概念
备份和容灾是数据库管理员和系统管理员经常考虑的重要问题。
数据库备份
数据库备份是指定期间将数据库的副本创建到另一个位置,以便在发生数据丢失或数据库崩溃时进行恢
复。备份可以存储在磁盘、磁带或远程服务器等位置。备份的频率可以根据业务需求和数据重要性来确
定,可以是每天、每周或每月进行一次。
数据库容灾
数据库容灾是指在数据库主服务器发生故障或不可用时,确保数据库系统继续运行并提供服务的能力。
容灾解决方案通常包括冗余硬件、多个数据库实例、数据复制和故障转移。这些措施可以确保在主服务
器发生故障时,备用服务器可以接管并继续提供服务,从而减少系统中断时间。
12.
说一说
Drop
、
Delete
与
Truncate
的区别
Drop
直接删掉表
;
Truncate
删除表中数据,再插入时自增长
id
又从
1
开始
;
Delete
删除表中数据,可以加
where
字句。
在不再需要一张表的时候,用
Drop
;在想删除部分数据行时候,用
Delete
;在保留表而删除所有数据的
时候用
Truncate
。
13.
什么是非关系型数据库
非关系型数据库也叫
NOSQL
,采用键值对的形式进行存储。
它的读写性能很高,易于扩展,可分为内存性数据库以及文档型数据库,比如
Redis
,
Mongodb
,
HBase
等等。
适合使用非关系型数据库的场景:
日志系统
地理位置存储
数据量巨大
高可用
14.
数据库悲观锁和乐观锁的原理和应用场景
悲观锁
先获取锁,再进行业务操作,一般就是利用类似
SELECT … FOR UPDATE
这样的语句,对数据加锁,避
免其他事务意外修改数据。
当数据库执行
SELECT … FOR UPDATE
时会获取被
select
中的数据行的行锁,
select for update
获取的行
锁会在当前事务结束时自动释放,因此必须在事务中使用。
乐观锁
先进行业务操作,只在最后实际更新数据时进行检查数据是否被更新过。
Java
并发包中的
AtomicFieldUpdater
类似,也是利用
CAS
机制,并不会对数据加锁,而是通过对比数据的时间戳或者
版本号,来实现乐观锁需要的版本判断。
15. MySQL
中
CHAR
和
VARCHAR
的区别
char
的长度是不可变的,用空格填充到指定长度大小,而
varchar
的长度是可变的。
char
的存取数度还是要比
varchar
要快得多
char
的存储方式是:对英文字符(
ASCII
)占用
1
个字节,对一个汉字占用两个字节。
varchar
的存
储方式是:对每个英文字符占用
2
个字节,汉字也占用
2
个字节。
16.
聚集索引与非聚集索引的区别
聚集索引和非聚集索引的区别在于, 通过聚集索引可以查到需要查找的数据, 而通过非聚集索引可以查
到记录对应的主键值 , 再使用主键的值通过聚集索引查找到需要的数据。
聚集索引和非聚集索引的根本区别是表记录的排列顺序和与索引的排列顺序是否一致。
聚集索引(
Innodb
)的叶节点就是数据节点,而非聚集索引
(MyisAM)
的叶节点仍然是索引节点,只不过
其包含一个指向对应数据块的指针。
17.
增加
B+
树的路数可以降低树的高度,那么无限增加树的
路数是不是可以有最优的查找效率
不可以。
因为这样会形成一个有序数组,文件系统和数据库的索引都是存在硬盘上的,并且如果数据量大的话,
不一定能一次性加载到内存中。
有序数组没法一次性加载进内存,这时候
B+
树的多路存储威力就出来了,可以每次加载
B+
树的一个结
点,然后一步步往下找
18.
假如你的电脑内存很小,要送一个海量数据库中读取数
据,进行计算;有什么好的方法
使用游标
使用游标:游标是数据库操作的一种方法,可以逐行或逐批读取数据而不将其全部加载到内存中。通过
使用游标,可以逐行读取数据库中的数据,并进行相应的计算操作。
数据分块读取
将海量数据库中的数据划分为多个小块,每次只读取一小块数据到内存中进行计算,然后释放内存。这
样可以避免一次性读取大量数据导致内存不足的问题。
使用索引优化查询
在海量数据库中使用索引可以提高查询效率,减少需要读取的数据量。通过优化查询语句和创建适当的
索引,可以减少内存占用和计算所需的数据量。
数据压缩和存储优化
对于海量数据库中的数据,可以采用数据压缩算法进行压缩,减少存储空间占用。同时,可以对数据进
行存储优化,选择适当的存储格式,如列式存储,以减少内存占用和提高计算效率。
19.
说一下你理解的外键约束
外键约束的作用是维护表与表之间的关系,确保数据的完整性和一致性。让我们举一个简单的例子:
假设你有两个表,一个是学生表,另一个是课程表,这两个表之间有一个关系,即一个学生可以选修多
门课程,而一门课程也可以被多个学生选修。在这种情况下,我们可以在学生表中定义一个指向课程表
的外键,如下所示:
这里,
students
表中的
course_id
字段是一个外键,它指向
courses
表中的
id
字段。这个外键约束确保了
每个学生所选的课程在
courses
表中都存在,从而维护了数据的完整性和一致性。
如果没有定义外键约束,那么就有可能出现学生选了不存在的课程或者删除了一个课程而忘记从学生表
中删除选修该课程的学生的情况,这会破坏数据的完整性和一致性。因此,使用外键约束可以帮助我们
避免这些问题。
20.
说一下你理解的分库分表
分库与分表的目的在于,减小数据库的单库单表负担,提高查询性能,缩短查询时间
当数据量过大造成事务执行缓慢时,就要考虑分表,因为减少每次查询数据总量是解决数据查询缓慢的
主要原因。你可能会问:
“
查询可以通过主从分离或缓存来解决,为什么还要分表?
”
但这里的查询是指
事务中的查询和更新操作。
为了应对高并发,一个数据库实例撑不住,即单库的性能无法满足高并发的要求,就把并发请求分散到
多个实例中去,这种就是分库。
总的来说,分库分表使用的场景不一样: 分表是因为数据量比较大,导致事务执行缓慢;分库是因为单
库的性能无法满足要求。
21.
索引失效的场景
1.
模糊查询中使用通配符:
%
或
_
开头的
LIKE
查询:
2.
在索引列上进行函数操作:
3.
数据表中的数据量较小:
22.
既然索引有那么多优点,为什么不对表总的每一列创建
一个索引呢
当对表中的数据进行增加、删除和修改的时候,
索引也要动态的维护
,这样就降低了数据的维护速
度。
索引需要占物理空间
,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果要建
立簇索引,那么需要的空间就会更大。
创建索引和维护索引要耗费时间
,这种时间随着数据量的增加而增加
23.
索引有哪些好处
CREATE TABLE students (
id INT PRIMARY KEY,
name VARCHAR(50),
course_id INT,
FOREIGN KEY (course_id) REFERENCES courses(id)
);
通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。
可以大大加快数据的检索速度,这也是创建索引的最主要的原因。
帮助服务器避免排序和临时表
将随机
IO
变为顺序
IO
。
可以加速表和表之间的连接,特别是在实现数据的参考完整性方面特别有意义。
24.
数据库如何保证一致性
从数据库层面
数据库通过原子性、隔离性、持久性来保证一致性。也就是说
ACID
四大特性之中,
C(
一致性
)
是目的,
A(
原子性
)
、
I(
隔离性
)
、
D(
持久性
)
是手段,是为了保证一致性,数据库提供的手段。
数据库必须要实现
AID
三大特性,才有可能实现一致性
。例如,原子性无法保证,显然一致性也无法保证。
从应用层面
通过代码判断数据库数据是否有效,然后决定回滚还是提交数据!
25.
数据库高并发是我们经常会遇到的,你有什么好的解决
方案吗
在
web
服务框架中加入缓存。在服务器与数据库层之间加入缓存层,将高频访问的数据存入缓存
中,减少数据库的读取负担。
增加数据库索引,进而提高查询速度。(不过索引太多会导致速度变慢,并且数据库的写入会导致
索引的更新,也会导致速度变慢)
主从读写分离,让主服务器负责写,从服务器负责读。
将数据库进行拆分,使得数据库的表尽可能小,提高查询的速度。
使用分布式架构,分散计算压力。
26.
说一下从哪些方面可以做到性能优化
为搜索字段创建索引
避免使用
Select *
,列出需要查询的字段
垂直分割分表
选择正确的存储引擎
27. MySQL
的内部构造
可以分为服务层和存储引擎层两部分,其中:
服务层包括连接器、查询缓存、分析器、优化器、执行器等
,涵盖
MySQL
的大多数核心服务功能,以及
所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如
存储过程、触发器、视图等。
存储引擎层负责数据的存储和提取
。其架构模式是插件式的,支持
InnoDB
、
MyISAM
、
Memory
等多个
存储引擎。现在最常用的存储引擎是
InnoDB
,它从
MySQL 5.5.5
版本开始成为了默认的存储引擎。
28.
排它锁和共享锁
排他锁
排他锁
(Exclusive Locks
,简称
X
锁
)
,又称为写锁或独占锁,是一种基本的锁类型。如果事务
T1
对数据
对象
O1
加上了排他锁,那么在整个加锁期间,只允许事务
T1
对
O1
进行读取和更新操作,其他任何事
务都不能再对这个数据对象进行任何类型的操作
——
直到
T1
释放了排他锁。
从上面讲解的排他锁的基本概念中,我们可以看到,排他锁的核心是如何保证当前有且仅有一个事务获
得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到。
共享锁
共享锁
(Shared Locks
,简称
S
锁
)
,又称为读锁,同样是一种基本的锁类型。
如果事务
T1
对数据对象
O1
加上了共享锁,那么当前事务只能对
O1
进行读取操作,其他事务也只能对这
个数据对象加共享锁
——
直到该数据对象上的所有共享锁都被释放。
区别
共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对一个事务可见,而加上共享锁后,数
据对所有事务都可见。
29. MySQL
日志文件
redo log
重做日志,确保事务的持久性
undo log
回滚日志,确保事务的原子性,用于回滚事务,同时提供
mvcc
下的非锁定读
bin log
二进制日志,用于主从复制场景下,记录
master
做过的操作
relay log
中继日志,用于主从复制场景下,
slave
通过
io
线程拷贝
master
的
bin log
后本地生成的日
志
慢查询日志,用于记录执行时间过长的
sql
,需要设置阈值后手动开启
30.
数据库的主从应该如何做
数据库的主从复制是一种常见的数据库架构,用于提高系统的可用性和性能。
1
、选择一个数据库实例作为主服务器(
Master
),其他实例作为从服务器(
Slave
)。
2
、在主服务器上开启二进制日志(
Binary Logging
),将所有的写操作记录到二进制日志中。
3
、在从服务器上配置主从复制,将主服务器的二进制日志传输到从服务器。
4
、从服务器连接到主服务器,并执行一个特殊的命令(
CHANGE MASTER TO
)来指定主服务器的
地址、用户名、密码,以及从哪个二进制日志文件开始复制。
5
、从服务器开始复制主服务器的数据,通过读取主服务器的二进制日志,并将数据写入从服务器
的数据文件中。
6
、从服务器定期向主服务器发送心跳信号,以确保主服务器的可用性。
-7
、在主服务器上执行写操作时,会将写操作记录到二进制日志,并将写操作传输到从服务器进行执
行。
通过以上步骤,数据库的主从复制就建立起来了。主服务器负责处理写操作,并将写操作传输给从服务
器,从服务器负责接收并执行写操作,以保持主从数据的同步。
日志系统
1.
为什么做这个项目
之前了解过
spdlog
日志框架,就想着能不能自己实现一个简易版本,锻炼一下自己的编程能力,所以就
在网上查阅了一些资料,借鉴了
spdlog
的实现框架,自己用
C++
实现了一个日志库
2.
介绍一下项目流程和框架设计
本项目实现的是一个多日志器日志系统,主要实现的是能让程序员轻松的将程序运行日志信息落地到指
定位置,并且支持同步与异步两种方式的日志落地模式
项目框架主要分为这几个模块
1.
一是日志等级模块:对输出的日志等级进行划分,来控制日志的最低输出等级
2.
二是日志消息模块:来中间存储日志输出的各项要输信息
3.
三是日志格式化模块:负责设置日志的输出格式,并提供对日志消息进行格式化功能
4.
四是日志消息落地模块:控制日志的落地方向,可以是标准输出,也可以是指定文件或者滚动文件
5.
五是日志器模块:此模块就是以上几个模块的整合,用户通过日志器进行日志的输出,降低用户的
使用难度,包含日志消息落地模块对象,日志消息格式化模块对象,日志输出等级
6.
六是日志器管理模块:管理模块就是对创建的所有日志器进行统一管理,并提供一个默认日志器提
供标准输出的日志输出
7.
最后是异步线程模块:实现对日志的异步输出功能,用户只需要将输出日志任务放入任务池,异步
线程负责日志的落地输出功能
我们调⽤
Logger
对象的
log
函数来打印⼀条同步⽇志,先将这条⽇志等级和⽇志内容封装成⼀个⽇
志消息对象,在通过⽇志消息格式化模块将⽇志消息进⾏统⼀的格式化处理, 处理完成之后将消息
交给
LogSink
对象进⾏落地输出
3.
项目的最终成果?你从这个项目学到了什么?
成果:
实现了一个打印日志的日志库,该库可以支持同步打印日志和异步打印日志,并且支持多种日志落
地方向
学到:
技术上:加深了对数据结构的封装和理解,使用
C++11
新特性加深了这些特性的理解和使用,异步
线程模块加深了异步场景这种设计思想
工程实践上:从
0
到
1
开发了日志系统之后加深了对于
C++
工程实践上的应用
4.
使用了哪些技术?
1. C++11
:比较现代一些的写法,写出来的代码更加优雅
2. std::thread
线程库:异步日志用到异步线程
3.
设计模式:为了让代码的拓展性更高,可读性更好,更加的易于维护
5.
项目时间线
1.
这个项目我花费了一个多月来完成,从项目的确定,再到各个模块和具体框架的拆分,再到最后的
开发测试部署
2.
对于时间我并没有详细的规划,因为我也是抱着学习的心态来完成这个项目的。在开发中可能会遇
到一些问题,解决问题也是我学习的过程,因此时间就确定不了
6.
项目中遇到的难点?你是怎么解决这些问题的?
异步日志的性能问题:最开始设计的异步日志线程每次从缓冲区中消费一条数据,导致在性能测试的时
候异步日志性能非常低下,分析之后发现是因为异步日志线程每次从缓冲区拿数据都需要加锁来保证线
程安全,就导致效率非常低下,其实在这个时候缓冲区中可能已经存在多个日志消息了,后续引入了双
缓冲区的思路来解决了这个问题,异步日志负责交换两个缓冲区的日志,只需要在交换的时候加一次锁
即可,相当于一次批量处理了
7.
如何测试这个项目的
1.
首先是功能性测试:测试一个日志器中包含的所有落地方向,观察每个方向都能否正常落地,再就
是测试同步和异步的方式落地后数据是否正常
2.
然后是性能测试:
主要测试的方法是:每秒能打印的日志数
=
打印日志数
/
打印日志消耗的总时间
主要的测试要素:同步
/
异步日志器
&
单线程
/
多线程
100w
条指定长度的日志输出所消耗的时间 每秒可以输出多少条日志 每秒可以输出多少
MB
日
志
测试结果:
在单线程的情况下,异步的效率没有同步高,原因是现在的
IO
操作在用户态都会有缓冲区进行缓
冲,因此我们当前的测试用例看起来的同步其实大多时候也是在操作内存,只有在缓冲区满了才会
涉及到阻塞写磁盘操作,而异步单线程看起来效率低,也有个原因是因为单线程同步操作中不存在
锁冲突,而单线程异步操作中存在大量的锁冲突,因此性能也会有一定的降低
但是限制同步日志效率的最大原因还是磁盘性能,与日志线程的多少并无明显区别,线程太多了反
而会因为增加了磁盘的读写争抢导致性能降低,而对于异步日志的限制,并非磁盘的性能,而是
cpu
的性能以及内存的吞吐性能,写日志并不会因为落地而阻塞,因此在多线程写日志的情况下性
能有了显著的提高
性能优化可以考虑引入无锁队列、原子变量等来省去加锁带来的性能损失
8.
如何设计项目的扩展?如何保证代码的可维护性?
扩展
1.
丰富落地类型
按小时按天滚动文件
支持将
log
通过网络传输落地到日志服务器(
tcp/udp
)
支持在控制台通过日志等级渲染不同颜色的输出方便定位
支持落地到数据库
2.
实现日志器服务器负责存储日志并提供检索、分析、展示等功能
可维护性
引入了一些设计模式来增加工程代码的可扩展性和可维护性:工厂、单例、建造者、代理
9.
日志系统的作用?大型项目中为什么需要日志系统?
1.
线上客户端的产品出现
bug
无法发现解决的时候,可以借助日志系统打印日志并上传到服务端帮助
开发人员进行分析
2.
对于一些高频操作,如定时器这种,在少量调试次数下可能无法触发我们想要的行为,通过打断点
调试得重复很多次,导致排查问题的效率低下,可以借助打印日志的方式查问题
3.
在分布式、多线程、多进程的代码中,出现
bug
比较难定位,可以借助日志系统打印日志帮助定位
bug
4.
还可以帮助首次接触项目代码的新开发人员理解代码的运行流程
10.
同步日志和异步日志的区别以及适用场景
同步日志
同步日志是指当输出日志时,必须等日志输出语句执行完毕后,才能执行后面的业务逻辑语句,日
志输出语句和程序的业务逻辑语句将在同一个线程中运行,每调用一次打印日志
API
就对应一次系
统调用
write
写日志文件

在高并发场景下,随着日志数量的不断增加,同步日志系统容易产生瓶颈
一方面,大量的日志打印陷入等量的
write
系统调用,有一定的系统开销
另一方面,使得打印日志的进程附带了大量同步的磁盘
IO
,影响程序性能
异步日志
异步日志是指在进行日志输出时,日志输出语句与业务逻辑语句并不是在一个线程中运行,而是有
专门的线程用于进行日志输出操作。业务线程只需要将日志放到一个内存缓冲区中不用等待即可继
续执行后续的业务逻辑(作为日志的生产者),而日志的落地操作交给单独的日志线程去完成(作
为日志的消费者),这是一个典型的生产消费模型

这样做的好处是即使日志没有真正的完成输出也不会影响程序的主业务,可以提高程序的性能
主线程调用日志打印接口成为非阻塞操作
同步的磁盘
IO
从主线程中剥离出来交给单独的线程完成
11.
项目中都用到了哪些设计模式?为什么选?
1.
首先是日志落地类使用到了简单工厂模式
工厂模式是一种创建型设计模式,在工厂模式中我们创建对象不会对上层暴露创建逻辑,而是
通过使用一个共同结构来指向新创建的对象,以此实现创建
-
使用的分离
简单工厂模式就是由一个工厂对象通过类型来决定创建出指定的产品类实例
2.
然后是日志器类使用到了建造者模式
建造者模式也是一种创建型的设计模式,使用多个简单的对象来一步步构成一个复杂对象,能
够将一个复杂的对象的构建与表示分离,主要用于解决对象的构造过于复杂的问题
因为日志器模块是前面多个模块的整合,想要创建一个日志器,需要设置日志器名称、日志输
出等级、日志器类型、日志输出格式、日志落地方向,而且日志落地方向可能有多个,整个日
志器的创建过程有些复杂,为了保持良好的代码风格也为了简洁用户的使用,这里就选择了使
用建造者模式来创建
3.
还有是日志器管理类的创建使用到了单例模式
单例模式就是一个类只能创建一个对象,保证该类只有一个实例,并提供一个全局访问它的节
点,该实例被所有程序模块共享
为了突破访问区域的限制,我们创建一个日志器管理类,并且这个类是一个单例类,这样的话
我们就可以在任意位置来通过管理器单例获取到指定的日志器来进行日志输出了
4.
最后是日志宏和全局接口使用到了代理模式
代理模式指代理控制对其它对象的访问,也就是代理对象来控制对原对象的引用
使用代理模式通过全局函数或宏函数来代理日志落地类的不同等级的落地接口,以便于控制源
码文件名和行号的控制输出,简化用户操作
12.
日志消息格式化是怎么做的?
设计格式化字符串,对消息进行格式化
13.
为什么日志落地类要落地日志的时候需要加锁
保证多线程在日志打印过程在的线程安全,避免出现交叉输出的情况
14.
为什么需要滚动日志?怎么实现的?
1.
机器磁盘空间有限,不可能无限的向一个文件写日志
2.
如果一个日志文件体积太大,一是不好打开,另外如果数据多了不方便查找
3.
所以实际开发中会对单个⽇志⽂件的⼤⼩也会做⼀些控制,即当⼤⼩超过某个⼤⼩时(如
1GB
),
或者过了多长时间时,我们就重新创建⼀个新的⽇志⽂件来滚动写⽇志
15.
怎么实现异步日志?异步日志器是怎么设计的?
异步日志器继承自日志器类,在同步日志器类上拓展了异步消息处理逻辑,当我们需要异步输出日志
时,需要创建异步日志器和消息处理器,调用异步日志器的
log
,
error
,
infp
,
fatal
等函数输出不同等
级的日志
log
函数为重写
Logger
类的函数, 主要实现将⽇志数据加⼊异步队列缓冲区中
realLog
函数主要由异步线程进⾏调⽤
(
是为异步消息处理器设置的回调函数
)
,完成⽇志的实际落地
⼯作。
异步日志器的设计思想:使用者将需要完成的任务添加到任务池中,由异步线程来完成任务的实际执行
操作。我们这里的任务池使用双缓冲区阻塞的任务池,如下图所示

双
buffer
本质是⼀个批处理, ⽣产者只管往队列中
push
数据,不需要加锁,只需要在适当的时候交换两
个
buffer
, 在交换的时候加锁即可。它可以避免了空间的频繁申请释放,且尽可能的减少了⽣产者与消
费者之间锁冲突的概率,提⾼了任务处理效率。
云备份
1.
为什么要做这个项目
这是学校的一个课设,不过课设的内容相对简单不少,我在课设项目的基础上,查找了一些资料进行了
一些改进
2.
介绍一下这个项目的流程和框架设计
本项目搭建了一个云备份服务器和客户端,客户端程序运行在客户机上自动将指定目录下的文件备份到
服务器,并且能够支持浏览器查看与下载,其中下载支持断点续传功能,并且服务器会对备份的文件进
行热点管理,将长时间无访问的文件进行压缩存储,节省磁盘空间
项目模块
服务端:
数据管理模块:内存中使用哈希表存储提高访问效率,持久化使用文件存储管理备份数据
网络通信模块:搭建网络通信服务器,实现与客户端通信
业务处理模块:搭建
http
服务器与客户端进行通信处理客户端的上传、下载、查看请求,并支持断
点续传
热点管理模块:对备份文件进行热点管理,将长时间无访问的文件进行压缩存储,节省磁盘空间
客户端:
数据管理模块:内存中使用哈希表存储提高访问效率,持久化使用文件存储管理备份数据,负责客
户端备份的文件信息管理,通过这些数据可以确定一个文件是否需要备份
文件检索模块:遍历获取指定文件夹下的所有文件
网络通信模块:搭建网络通信客户端,实现将文件数据备份上传到服务器
3.
项目的最终成功如何?你从这个项目中学到了什么?
最终成果
项目实现了文件备份和支持断点续传的文件下载,用户也可以通过浏览器访问文件列表
学到了什么
C++
工程能力得到了一些实际的提升
学到了一些新技术,比如
httplib
构建
http
服务器,
bundle
库支持文件压缩、
C++17
目录迭代器遍历
目录下所有的文件等待
学到了以项⽬为主导的开发应该如何完成,⽐如从项⽬确定到功能拆解,再到开发测试部署。
学到了当项⽬中遇到问题,迫使我思考和寻找解决⽅案,这增强了我的问题解决能⼒。
学到了如何进⾏有效的测试,以确保项⽬不出现
bug
。例如功能测试,性能测试等。
4.
你在这个项目中使用了哪些技术?这些技术是怎么选型
的, 对比过那些其他的相关技术嘛?
http
客⼾端
/
服务器搭建:
httplib
, 对⽐了
httpd
、
mongoose
等其他的第三⽅库, 发现
httplib
是
only header
模式,⽤法接⼝设计的⽐较简单易⽤
json
序列化:
jsoncpp,
对⽐了
nlohmann/json
和
RapidJSON
库,发现
JsonCpp
提供了易于使⽤的
API
来解析和⽣成
JSON
数据
⽂件压缩:
bundle
, 对⽐了
Webpack
等库,发现
bundle
的压缩效率更⾼⼀些
C++17:
⽬录迭代器主要⽤来做⽬录的遍历,
linux
原⽣的⼀些系统调⽤接⼝易⽤性很低
5.
你在项目中遇到了哪些挑战?你是如何解决这些问题
的?
1. http
服务器的选型,刚开始不知道选哪个库合适,后面在网上查阅一些资料选择了
httplib
库
a.
轻量级:
httplib
库⾮常轻量级,只包含⼀个头⽂件,使得使⽤和集成变得⽅便快捷。
b.
灵活性:⽀持
HTTP
客⼾端和服务器的功能,可以轻松进⾏
HTTP
请求和响应处理。
c.
跨平台性:可以在多种操作系统上运⾏,包括
Windows
、
Linux
和
macOS
等。
d.
⾼性能:采⽤异步
IO
模型,能够提⾼
HTTP
服务器的性能。
e.
开源:
httplib
库是开源的,用户可以⾃由地使⽤和修改它。
f.
⽀持
HTTPS
:⽀持
SSL/TLS
加密,可以通过
CPPHTTPLIB_OPENSSL_SUPPORT
宏启⽤。
g.
简单易⽤:
API
设计简洁明了,易于集成到现有
C++
项⽬中。
h.
⽀持多种
HTTP
⽅法:⽀持
GET
、
POST
、
PUT
、
DELETE
、
PATCH
、
HEAD
、
OPTIONS
等
HTTP
⽅法。
2.
遍历某个目录下的所有文件,刚开始想到的是使用
linux
下的系统调用,后面发现这些接口用起来有
些复杂,还需要自己再封装一层,并且不支持跨平台,后面查资料使用了
C++17
的目录迭代器
6.
你是如何测试和部署这个项目的?
⽬前项⽬中只是通过编写简单的客⼾端对服务器进⾏功能测试和性能测试,没有进⾏部署。
功能测试主要包含:
文件上传
文件下载
文件下载断点续传
文件上传客户端失败下次遍历的时候重新上传
客户端正常访问文件列表
性能测试主要包含:
上传
/
下载速度测试
客户端并发量测试
7.
性能测试如何?有没有想过优化性能?
性能测试:
上传
/
下载速度测试
1.
测试环境:
1G
单核的虚拟机,
1G
单核
1M
带宽的云服务器
2.
上传的性能测试:在客⼾端程序中进⾏了数据统计,在开始传输前进⾏计时,传输完毕后进⾏的统
计计算,⼤概虚拟机平均在
35MB/s
,云服务器在
230KB/s
, 这个速度在模拟多客⼾端运⾏测试时
会降低
3.
下载的性能测试:编写了⼀个模拟
http
下载客⼾端程序进⾏测试,主要是在程序中进⾏计时统计,
单客⼾端的下载速度,平均⼤概虚拟机平均在
43MB/s
,云服务器在
280KB/s
, 这个速度在模拟多
客⼾端运⾏测试时会降低
客⼾端并发量测试
1.
测试环境:
1G
单核的虚拟机,
1G
单核
1M
带宽的云服务器
2.
编写了⼀个多进程程序,在⼦进程中程序替换运⾏客⼾端程序,各⾃运⾏⼀个客⼾端程序,⽽且也
使⽤了宿舍的多台电脑
....
进⾏测试
3.
根据客⼾端统计,客⼾端数量在达到差不多
40
的时候开始出现请求失败的情况。
性能优化:
1.
⾮热点⽂件压缩存储⽬前使⽤单线程,容易达到性能瓶颈,可以采⽤线程池的策略并⾏压缩⾮热点
⽂件。
8.
你是如何设计项⽬以⽀持未来的扩展?你们是如何确保
项⽬代码的可维护性的?
扩展
1.
给客⼾端开发⼀个好看的界⾯,让监控⽬录可以选择
2.
内存中的管理的数据也可以采⽤热点管理
3.
压缩模块也可以使⽤线程池实现
4.
实现⽤⼾管理,不同的⽤⼾分⽂件夹存储以及查看
5.
实现断点上传
6.
客⼾端限速,收费则放开
保证可维护性
1.
遵循编码规范
2.
模块化设计
3.
文档化
9. http
服务器是如何搭建的
项目是基于
httplib
库搭建
http
服务器,虽然我没有自己从零实现,但是我也了解了
httplib
中的实现流程
思想
httplib
搭建
http
服务器的流程:
1.
实例化⼀个
Server
类对象,创建⼀个
pair(url-path+url-method,
回调处理函数
)
的请求路由映射表
2.
将对应设计好的⽹络通信接⼝与业务回调处理函数指针等信息添加到请求路由映射表中(回调函数
指针指向的函数就是对应请求的处理函数,这个函数由⽤⼾⾃⼰实现其内部的业务功能处理)
3.
启动服务器,服务器使⽤线程池针对客⼾端的请求进⾏处理
4.
线程池中的线程收到客⼾端请求后,接收请求进⾏解析,组成
Request
对象,其中包含了所有请求
信息
5.
根据请求信息中的请求⽅法和资源路径,在请求路由映射表中查找是否存在业务回调处理函数,如
果没有则
404
,如果有则调⽤回调处理函数,将请求信息组成的
Request
对象与⼀个在业务回调处
理函数中等待业务处理完成后填充的
Response
对象传⼊回调处理函数中。
6.
业务处理回调函数在业务处理过程中根据请求进⾏业务处理,并填充
Response
对象中的数据(正
⽂,状态码,头部字段信息)
7.
回调函数执⾏完毕后,线程池继续向下运⾏,将填充了响应信息的
Response
对象中的数据组成
http
协议格式的响应发送给客⼾端
10. http
协议格式是什么
http
请求
请求首行:请求方法
URL
协议版本
头部字段:以
\r\n
间隔的多个请求关键描述信息,每个信息以
key:value
的键值对组成
空行:
\r\n
用于间隔头部与正文
正文:提交给服务器端的数据
http
响应
响应首行:协议版本 响应状态码 状态码描述
头部字段:以
\r\n
间隔的多个请求关键描述信息,每个信息以
key:value
的键值对组成
空行:
\r\n
用于间隔头部与正文
正文:响应给客户端的数据
11.
多个客户端同时上传一个同名文件如何处理
1.
当下还没有来得及实现多⽤⼾管理,所以所有客⼾端上传的⽂件都在同⼀个⽂件夹中,⽂件的同名
处理因为有可能存在重复备份同⼀个⽂件的可能,因此采⽤的是覆盖处理。
2.
服务端针对每个⽤⼾上传的⽂件名都进⾏了特殊处理,⽤ ⼾名
+
⽂件名的形式重组⽂件名,所以并
不冲突
3.
后边如果有机会可以使⽤
MD5
或者⼀些其他散列算法得到⼀个唯⼀名称。
4.
当前没有实现⽤⼾管理,实现了⽤⼾管理可以给每个⽤⼾创建⾃⼰的⽬录存储,可以进⼀步降低冲
突可能性
12.
下载的断点续传功能怎么实现的
断点续传的实现思想是客户端将接收到的文件数据存储到文件中,并记录大小,如果传输过程中断错
误,继续传输的话将需要继续传输的区间范围告诉服务器,服务器只需要返回区间范围的数据即可
在
http
协议中主要是通过一些关键字头部字段来实现
Accept-Range
:响应头部字段,⽤于告诉客⼾端⾃⼰⽀持断点续传
,
格式:
Accept-Ranges: bytes
ETag
:响应头部字段,⽤于⽂件的唯⼀标识,服务端在客⼾端第⼀次⽂件下载时,将⽂件的唯⼀标
识放在这个头部字段中发送给客⼾端,客⼾端保存起来,
格式:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
If-Rnge
:断点续传请求头部字段,放置服务端之前进⾏⽂件下载响应时的
ETag
字段中的⽂件唯⼀
标识信息,在发送断点续传时请求时将通过这个字段发送给服务器,服务器根据这个唯⼀标识判断
⽂件在上⼀次下载后是否被修改过,如果修改过则重新下载,反之则断点续传。
格式:
If-Range: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Range
:断点续传请求头部字段,⽤于告诉服务器端本次请求的数据范围区间,
格式:
Range: bytes=200-1000
Content-Range
:断点续传响应头部字段,⽤于告知客⼾端本条响应的数据区间范围
格式:
Content-Range: bytes 200-1000/67589
后边的数字是⽂件总⼤⼩
⽂件从头响应,也就是正常的⽂件下载响应状态码为
200
⽂件断点续传的区间传输响应状态码是
206
13.
你的服务器⽀持多少客⼾端?
1.
测试环境:
1G
单核的虚拟机,
1G
单核
1M
带宽的云服务器
2.
编写了⼀个多进程程序,在⼦进程中程序替换运⾏客⼾端程序,各⾃运⾏⼀个客⼾端程序,⽽且也
使⽤了宿舍的多台电脑
....
进⾏测试
3.
根据客⼾端统计,客⼾端数量在达到差不多
40
的时候开始出现请求失败的情况。
14.
热点管理功能如何实现的?
1.
服务端会对客⼾端上传的每个⽂件建⽴⼀个⽂件备份信息保存起来
2.
热点管理模块针对⽂件上传备份⽂件夹进⾏遍历检测,这个是通过
C++17
中的⽂件系统库提供的⽬
录迭代器完成的,可以获取每个⽂件信息,然后获取每个⽂件的最后⼀次访问时间,与当前系统时
间进⾏⽐对,超过⼀定时间就被认定为⾮热点⽂件,然后进⾏压缩存储,并修改⽂件备份信息,标
识⽂件已压缩
3.
当客⼾端下载⽂件时,先根据备份信息查看⽂件是否已经压缩存储,若压缩存储了则先进⾏解压然
后进⾏下载响应处理。
15.
客⼾端是怎么实现的?
1.
建⽴备份⽂件信息的数据管理,其中包含⽂件路径名以及⽂件唯⼀标识,唯⼀标识主要⽤于辨别上
⼀次上传后是否被修改过(这⾥的⽂件唯⼀标识通过⽂件名称,⼤⼩,最后⼀次修改时间组成)
2.
基于
C++17
的⽬录迭代器遍历⽂件夹获取到⽂件信息,计算⽂件⽂件唯⼀标识,然后在备份数据中
查找⽂件备份信息,若查找到则对⽐唯⼀标识是否⼀致,不⼀致在表⽰⽂件修改过需要重新备份,
若查找不到备份信息则表⽰⽂件是新增⽂件需要备份。
3.
基于
httplib
库搭建
http
客⼾端发送⽂件上传请求备份⽂件数据到服务器,这⾥其实就是给与请求的
各项信息组织
http
协议格式的请求,然后搭建
tcp
客⼾端,发送请求给服务器,然后等待响应。
4.
⽂件上传成功,则更新备份数据
16.
客⼾端上传⽂件失败如何处理?
⽬前云备份项⽬没有实现断点上传,如果⽂件备份失败则没有更新备份数据,因此在下⼀次在客⼾端
遍历⽂件夹时会再次检测到⽂件需要备份,重新备份⽂件数据
高并发内存池
1.
为什么做这个项目
之前学校⽼师提过内存池的概念,⼀直对内存池⽐较好奇到底是什么做的,后来在⽹上看到
google
的
tcmalloc
做的很⽜,甚⾄
go
语⾔的内存管理都是
tcmalloc
框架下衍⽣设计出来的,在多线程并发情况下
效率很不错,甚⾄⽐
malloc
还要好,就⽐较好奇,就搜了
tcmalloc
的很多博客了解他的实现框架,但是
总感觉还是堆这个认知模模糊糊的,之前学习
STL
的时候,看了侯捷⽼师《
STL
源码剖析》也是类似的感
觉,后来就尝试着模拟着
STL
⼀份简洁的容器,这样学习效果⾮常好,把各种容器
/
迭代器
/
算法之间的关
系和细节都捋清楚了。所以学习
tcmalloc
我也就萌⽣了这样的思路,把最核⼼的部分剥离出来,模拟实
现⼀份
mini
版
tcmalloc
,发现这样效果确实⾮常好,加强内存管理,多线程,线程本地存储
TLS(Thread
Local Storage)
、哈希桶、基数树、单例模式等知识的理解。之前学习的知识都是孤岛,现在把他们揉起
来,做成⼀个组件,让我有了学以致⽤的感觉
2.
介绍一下项目的流程和框架设计
项目架构

高并发内存池主要分为三层框架设计:
thread cache
、
central cache
、
page cache
,他们本质都是⼀个哈希桶结构,只是挂的内存对象
不⼀样。
thread cache
直接挂的是小块内存,
central cache
挂的的
Span
的⼤块内存,
Span
是⼀个
页为单位的结构,里面记录了页号页的数量和把内存切成
size
大小的小块内存的自由链表。这个
Span
会被切小为对应
thread cache
大小的小块内存挂起来,
page cache
挂的是页为单位的大块内
存。
thread cache
和
central cache
按映射规则映射
8byte-256KB
区间的内存,
page cache
按页数
量映射,每页
8byte
,按
1-128
页大小映射。
thread cache
: 线程缓存是每个线程独有的,用于小于等于
256KB
的内存分配,每个线程独享一个
thread cache
。也就意味着线程在
thread cache
申请内存时是不需要加锁的,而一次性申请大于
256KB
内存的情况是很少的,因此大部分情况下申请内存时都是无锁的,这也就是这个高并发内存池高效的地方。

central cache
: 中心缓存是所有线程所共享的,当
thread cache
需要内存时会按需从
central cache
中
获取内存,而当一个
thread cache
中的占用的内存太多时,
central cache
也会在合适的时机对其进行回
收。这就避免了出现单个线程的
thread cache
占用太多内存,而其余
thread cache
出现内存吃紧的问题。

page cache
: 页缓存中存储的内存是以页为单位进行存储及分配的,当
central cache
需要内存时,
page cache
会分配出一定数量的页分配给
central cache
,而当
central cache
中的内存满足一定条件时,
page cache
也会在合适的时机对其进行回收,并将回收的内存尽可能的进行合并,组成更大的连续内存
块,缓解内存碎片的问题。

多线程的
thread cache
可能会同时找
central cache
申请内存,此时就会涉及线程安全的问题,因此
在访问
central cache
时是需要加锁的,但
central cache
实际上是一个哈希桶的结构,只有当多个
线程同时访问同一个桶时才需要加锁,所以这里的锁竞争也不会很激烈。
各个部分的主要作用
thread cache
主要解决锁竞争的问题,每个线程独享自己的
thread cache
,当自己的
thread cache
中有内存时该线程不会去和其他线程进行竞争,每个线程只要在自己的
thread cache
申请内存就行
了。
central cache
主要起到一个居中调度的作用,每个线程的
thread cache
需要内存时从
central
cache
获取,而当
thread cache
的内存多了就会将内存还给
central cache
,其作用类似于一个中
枢,因此取名为中心缓存。
page cache
就负责提供以页为单位的大块内存,当
central cache
需要内存时就会去向
page cache
申请,而当
page cache
没有内存了就会直接去找系统,也就是直接去堆上按页申请内存块。
项目流程
内存申请:
1.
申请内存⼤⼩
size>256KB
,认为是⼤块内存,直接找
PageCache
按⻚对⻬申请⼤块内存。
2.
申请内存⼤⼩
size<=256KB
,找到现成的
TLS ThreadCache
对象,
ThreadCache
⽤
size
计算出对
ThreadCache
对应的哈希桶位置,这个桶中挂的有内存块,则直接
Pop
返回⼀个内存块;这个桶中
是空的,则需要找
CentralCache
对象申请。
3. CentralCache
需要加锁,每个哈希桶⼀把锁,
CentralCache
中会按慢启动逐步增⻓的⽅式,给⼀
批内存块给
ThreadCache
,避免
ThreadCache
频繁的找
CentralCache
要内存。
CentralCache
中每
个哈希桶位置挂的是以⻚为单位的
Span
对象,并且按映射关系切好,如果
size
映射的
CentralCache
对应的哈希桶位置
Span
中有⼩块内存,则把这⼀批对象分配给
ThreadCache
。如果
size
映射的
CentralCache
对应的哈希桶位置
Span
已经空了,则需要找
PageCache
对象申请
Span
内存块,申请
到以后,把
Span
按映射⼤⼩切⼩挂起来,按前⾯慢增⻓计算的个数,分配⼀起内存块给
ThreadCache
。
4. PageCache
需要加锁,整体哈希桶⼀把锁,根据
size
的⼤⼩计算出
x
⻚的⼤块内存,去
PageCache
的第
x
个桶取
Span
内存块,如果第
x
个桶有⼤内存块
Span
,则直接分配给
CentralCache
,如果没有
则去第
x+1, 128
号桶之间找
Span
,如果找到了,则切成
x
⻚⼤⼩
Span
返回,剩下的挂起来。如果没
有就去堆神枪
128
⻚的⼤块内存
Span
,然后成
x
⻚⼤⼩
Span
返回,剩下的挂起来。
释放内存:
1.
内存⼤⼩
size>256KB
,认为是⼤块内存,直接释放给
PageCache
。
2.
内存⼤⼩
size<=256KB
,找到现成的
TLS ThreadCache
对象,
ThreadCache
⽤
size
计算出对
ThreadCache
对应的哈希桶位置,直接
Push
到这个桶中。如果这个桶⻓度满⾜⼀定条件,则将⼀
批⼩块内存归还
CentralCache
。
3.
这些⼩块内存可能属于不同⻚号的
Span
,通过⼩块内存的地址,计算出对应的⻚号,⽤⻚号找到
对应的
Span
,归还给
Span
的⾃由链表。
Span
中有⼀个
usecount
的基数,申请⼩块内存时
++
,释
放⼩块内存时
--
,当
usecount==0
,则说明
Span
切出去的⼩块内存都还回来了,则把这个
Span
归
还给
PageCache
。
4. PageCache
并不会直接把这个
Span
挂到对应⻚数量的桶,⽽是通过⻚号查找前⾯的⻚的
Span
是否
空闲,如果空闲则合并成更⼤⻚的
Span
,再不断按⻚号前后搜索,直接前后也不空闲,再将
Span
挂到对应⻚数量的桶。
3.
项目的模块划分
?
每个模块都是干什么的
?
每个线程都有⼀个
thread cache
,它主要解决⼩块内存分配和锁竞争问题
全局只有⼀个
central cache
,他负责居中调度,分配和回收
thread cache
的⼩块内存,同时对接
page cache
,获取⼤块内存和回收⼤块内存。
全局只有⼀个
page cache
,他负责跟堆对接申请超⼤块内存,分配给
central cache
,并且回收
central cache
的的内存,如果有连续内存块回来了,合并内存,缓解内存碎⽚问题。
4.
项目最终的成果如何?你从这个项目中学到了什么?
多线程并发申请和释放的场景下,⽐
malloc
性能快很多。我的测试甚⾄快数倍,但是我测试场景相
对局限单⼀,看⽹上测试
tcmalloc
⽐
malloc
整体也快不少。
通过这个项⽬加深了我对数据结构的认识,尤其是哈希结构,他在进⾏⾼效查找时真是⼀本利器。
其次⾃由链表也设计很精巧,上⼀个内存块前⼏个字节存下⼀个内存块的地址即可,这样多⼤的内
存挂,都可以挂到⼀起。结构的设计可以千变万化,需要什么就怎么设计。
通过这个项⽬加深了我对多线程加锁的理解,对线程本地存储
TLS
的理解。
通过这个项⽬加深了我对内存管理的理解。
5.
你在项目中遇到了哪些挑战?你是如何解决这些问题
的?
1.
第⼀个挑战是,源码阅读很困难,因为
tcmalloc
是成熟项⽬已经迭代了很多版,有些结构实现会有
很多种,需要不断思考和查资料了解为什么。⽐如
tcmalloc
每个线程都通过
TLS
获取⾃⼰的
ThreadCache
对象即可,但是中间⼜把所有的
ThreadCache
⽤双向指针连接起来,搞成⼀个链表,
获取时要加锁,后来不断查阅资料和思考,才知道是因为有些系统可能不⽀持
TLS
,那么就需要⽤
这种⽅式。这就说明实践中考虑的问题还是多。
2.
第⼆个挑战是,有些部分要理解清楚他为什么这么设计很困难,也没有什么资料讲清楚了,只能⾃
⼰去感悟。如果
ThreadCache
和
CentralCache
中
size
和桶之间的映射关系,源码中设计得很复杂,
算法中也⽤了⼤量的位运算,没有注释,很难看懂,后来不断查资料和⽤各种值套进去计算观察,
才发现他是要⼀段区间映射⼀个桶,这样控制浪费率在⼀定⽐例即可。⽐如后来模拟实现我简化设
计了最⼤
10%
左右的浪费率逻辑,分为多段区间,⼀段区间⼀个对⻬数。否则给统⼀对⻬数,对⻬
数太⼩,桶太多了,切得太碎;对⻬数太⼤,申请内存⼩时,浪费太多了,⽐如你要
8byte
,按
128byte
对⻬,浪费
120byte
;所以⼀段区间,取⼀个映射值,最⼩值是浪费最多的,⽐如你要
129byte
按,
16byte
对⻬,就会给
144byte
,浪费了
15byte
,浪费率为
15/144 = 10.4%
,申请
1025
,按
128Byte
对⻬,给
1152
字节,浪费
127Byte
,浪费率为
127/1152 = 11%
,然后再选取合
适的下⼀个对⻬数和下⼀个区间的最⼩值。也就是说下⾯的这个定好能接受的内碎⽚浪费率以后,
按⼀定规律定出来了,具体区间个数和对⻬数还可以更少⼀些或者更多⼀些,这个都是很灵活的。
3.
第三个挑战是,性能测试时对⽐
malloc
,没有优势甚⾄更慢。通过使⽤查寻性能优化技巧,发现
VS
可以帮助分析程序每个部分的性能消耗,最后发现性能卡在查找⻚号对应
Span
的逻辑,⻚号和
Span
是存储在
map
中,
map
是需要多线程加锁的,后来查阅源码发现他⽤基数树查找,居然是⽆
锁的。后来仔细理解基数树发现他的设计⾮常巧妙,基数树也是⼀个哈希结构,存储
{
⻚号,
Span
}
的键值对,他提前给⻚号映射
Span
的地址分配好存储空间,申请内存时分配⼀个
Span
,将
{
⻚号,
Span
}
插⼊基数树不需要加锁,因为空间开好了且这时没有其他线程会访问这个⻚号位置且提前开
好了空间。释放内存时,
CentralCache
⼤量在基数中⽤⻚号查询
span
,不需要加锁,因为这会没
有⼈会对这个⻚号位置映射修改。⼀个
Span
空闲了归还
PageCache
时,合并
Span
后,修改
{
⻚号,
Span*}
也不需要加锁,这时
Span
已经回到
PageCache
了,
CentralCache
中不会⽤这个⻚号查询
Span
。基数树的设计不像红⿊树哈希表等,每个空间是提前开好的,⼀个位置的访问不会被其他
位置影响,红⿊树查询⼀个节点,同时在插⼊其他节点,可能会引发旋转,所以不同值映射位置会
互相影响,必须加锁。这⾥基数树,不同位置不会互相相同,同⼀个位置的插⼊、查询、修改不会
同时发⽣,相当于读写时分离的,所以不需要加锁。(这个问题不好解释,对线程安全理解较⾼,
确保⾃⼰能理解清楚再提这个点)

6.
项目扩展有哪些?
1.
我们只实现了
windows
下的版本,可以考虑去试下
Linux
版本。
2.
我们只实现了
32
位的版本,没有实现
64
位的版本。(不过要注意的是,实现
64
位的版本,两个地
⽅要注意变化,第⼀个地⽅就是
64
为堆⼤了很多,⻚⼤⼩要变⼤,其次哈希表和
size
映射规则要变
化,基数树要⽤两层甚⾄三层的基数树,这个部分需要参考源码了解或者灵活思考,对⼤家挑战是
不⼩的)
3.
实际中
tcmalloc
是直接可以替换
malloc
的,
Linux
下使⽤了
weak alias
的⽅式替换即可,
windows
下可能需要⽤
hook
钩⼦技术才可以。
4.
其次我们是简化实现的,很多地⽅还是相对粗糙的,
tcmalloc
中实现细节就很多,⽐如
ThreadCache
释放桶时,我们只⽤了是如果桶⻓度超过慢启动控制增⻓的最⼤⻓度,就释放⼀批,
tcmalloc
中还控制了⼤⼩,如果
ThreadCache
占⽤内存⼤⼩超过⼀定数值,也会释放。等等这样细
节
tcmalloc
还挺多的。
7.
不同⼤⼩的内存,对⻬映射的规则是什么?为什么这么
对⻬!
1. 1Byte-256KB
分为
5
段,每段取⼀个对⻬数,整体控制最⼤浪费率是
10%
左右。
2.
⽐如申请
129byte
,按
16byte
对⻬,就会给
144byte
,浪费了
15byte
,浪费率为
15/144 = 10.4%
。
申请
1025
,按
128Byte
对⻬,给
1152
字节,浪费
127Byte
,浪费率为
127/1152 = 11%
;
3.
只有分段映射取值才能控制浪费率,否则给统⼀对⻬数,对⻬数太⼩,桶太多了,切得太碎。对⻬
数太⼤,申请内存⼩时,浪费太多了,⽐如你要
8byte
,按
128byte
对⻬,浪费
120byte
。
4.
下⾯的分区间给的对⻬数只是⼀种⽅式,并不是必须这么给,假设你想让最⼤浪费率控制在最⼤
5%
的浪费率,那么可以把区间分更多⼀些,对⻬数放⼩⼀些,每个区间只需要看最⼩值浪费率即
可。那么就可以设计设计出⼀套新的对⻬规则。也是就说对⻬映射⽅式有很多种,分段给对⻬数的
⽅式是不变的,具体怎么给,就看不想控制最⼤浪费率是多少的问题
//
控制在最多
10%
左右的内碎
⽚
浪费
// [1,128] 8byte
对
⻬
freelist[0,16)
// [128+1,1024] 16byte
对
⻬
freelist[16,72)
// [1024+1,8*1024] 128byte
对
⻬
freelist[72,128)
// [8*1024+1,64*1024] 1024byte
对
⻬
freelist[128,184)
// [64*1024+1,256*1024] 8*1024byte
对
⻬
freelist[184,208)
8.
每个线程是如何⽆锁获取到⾃⼰的
thread cache
的
线程局部存储(
thread local storage
,简称
TLS
)是⼀种存储机制,它允许每个线程拥有⾃⼰的变量副
本。这意味着相同的变量在不同的线程中可以有不同的值,⽽互不影响。
TLS
常⽤于存储那些不需要在
线程间共享的数据,或者⽤于减少锁的使⽤以提⾼性能。
我们项⽬于
windwos
下,⽤的是
windows
下的现成局部存储语法机制。

C++11
之后,可以直接⽤
thread_local
去做声明,就可以定义线程局部存储变量了,这样可以跨平台

3.
项目中是如何解决内存碎片的?
1. Span
是⼀个管理多个⻚的⼤块内存的对象,
CentralCache
和
PageCache
中管理⼤块内存都是
Span
结构挂在哈希桶中,区别是
CentralCache
中是⼀部分正在被使⽤的
Span
,
PageCache
中的是没有
被使⽤的空闲
Span
。
2. CentralCache
中的会把
Span
切成对应⼤⼩的⼩块内存,挂到⾃由链表,
Span
中有⼀个
usecount
的
计数变量,当
CentralCache
把
Span
的⼩块内存,分配给各个
ThreadCache
时
usecount++
;当
ThreadCache
⼩块内存通过地址计算出⻚号归还到
CentralCache
的对应的
Span
时
usecount--
,也
就意味着释放逻辑中
usecount==0
时,⼀个
Span
的⼩块内存都回来了,标志着这个
Span
就空闲
了,要归还给
PageCache
。这⾥可以看到多个⻚的⼤块内存
Span
分配出去切碎打乱的分配出去
后,释放时会通过⻚号计算有序的回到对应的
Span
。⼩块内存就被合并成的⼤块内存,当合并出
完整的⼤块内存再归还给
PageCache
。
3. PageCache
申请逻辑中,要申请
x
⻚
Span
,
x
号桶没有时,会去⽐
x
⼤的桶中找⼤的
Span
切⼩,如
果后⾯的都没有,会去申请
128
⻚的
Span
,也就意味这个
PageCache
中⼩点的
Span
都是⽤⼤的
Span
切出来的,那么当内存都回来时,⼀定会被合并成⼤⻚的
Span
。那么
CentralCache
中是的
Span
还给
PageCache
以后,
PageCache
会尝试去按⻚号搜索前后的⻚,看是否空闲,如果空闲就
会合并出更⼤的⻚,不断重复,直到前后⻚不空闲,这样也就把⼩的⻚合并成了更好的⻚。
4.
最后我们要认识到,这⾥也没有彻底解决内存碎⽚问题,只是缓解内存碎⽚问题。因为如果⼀个
CentralCache
中⼀个
Span
部分内存始终在使⽤,那么
Span
就不会回到
PageCache
进⾏合并,或者
PageCache
中合并时前后⻚不空闲也没办法合并出更⼤的⻚。
9.
多线程环境下,这个内存池是如何做到⽐
malloc
更快
的?
1.
由于使⽤线程局部存储(
thread local storage
),每个线程可以⽆锁的获取⾃⼰的
ThreadCache
对象,如果
ThreadCache
的哈希桶中有对象⼤⼩的内存,直接取内存⾮常快,且⽆锁。
2. ThreadCache
中没有内存时使⽤慢增⻓⽅式批量的向
CentralCache
申请内存,那么后⾯多次申请
这个⼤⼩的内存就⽆锁的直接在
ThreadCache
取了;其次
CentralCache
中是需要加锁的,但是每
//
控制在最多
10%
左右的内碎
⽚
浪费
// [1,128] 8byte
对
⻬
freelist[0,16)
// [128+1,1024] 16byte
对
⻬
freelist[16,72)
// [1024+1,8*1024] 128byte
对
⻬
freelist[72,128)
// [8*1024+1,64*1024] 1024byte
对
⻬
freelist[128,184)
// [64*1024+1,256*1024] 8*1024byte
对
⻬
freelist[184,208)
// _declspec(thread)
声明后
pTLSThreadCache
变量就是每个线程都有
⼀
份,
pTLSThreadCache
可以理解为每个线程独
⽴
的静态全局变量
static
_declspec
(
thread
)
ThreadCache
*
pTLSThreadCache
=
nullptr
;
thread_local
int
my_variable
;
个桶⼀个锁,也就意味着如果是申请⼤⼩不同,映射到不同的桶,在
CentralCache
也是并发的。
3.
如果是相同⼤⼩,映射到
CentralCache
的同⼀个桶,那么这⾥就有锁竞争了,但是
CentralCache
中的
Span
中多个⻚的⼤块内存已经被切好成⼩块内存⽤⾃由链表挂起来,所以当
CentralCache
中
有
Span
时,在
Span
中取多个⼀批⼩块内存时很快的,也就意味着⼤部分情况这⾥锁竞争的粒度很
⼩。
4. ThreadCache
、
CentralCache
、
PageCache
层层缓存,每⼀层都有缓存池化设计的逻辑,都会储
备⼀些内存,
ThreadCache
中申请是⽆锁的,
CentralCache
和
PageCache
尽管有锁,但是因为他
们⼤部分时候有储备内存,所以申请也是很快的。综上所述,预期的是⼤部分申请内存就⽆锁的在
ThreadCache
中解决了,⼩部分时候
CentralCache
和
PageCache
中有锁,但是也是很快的。
10. thread cache central cache page cache
三层的框
架设计能否改成两层?
1.
⾸先
ThreadCache
是每个线程⼀份,控制⼤部分时候⽆锁申请内存。不能合并,要合并也是把
CentralCache
和
PageCache
合并,但是他们各有各的价值。
2. Span
是⼀个管理多个⻚的⼤块内存的对象,
CentralCache
和
PageCache
中管理⼤块内存都是
Span
为结构挂下哈希桶下⾯的,区别是
CentralCache
中
Span
是⼀部分正在被使⽤的,
PageCache
中的
Span
是没有被使⽤的。
3. CentralCache
的哈希桶是按分段取对⻬数映射的,跟
ThreadCache
的哈希桶映射规则是⼀样的,
也就意味着
CentralCache
哈希桶位置挂的
Span
是给这个⼤⼩专⽤的,
Span
中也切好成对应的⼤⼩
的⾃由链表
(
切的过程也是⽆锁的
)
,那么虽然
CentralCache
有锁,但是
ThreadCache
来申请时⼤部
分时候可以直接取⼀批⼩块内存⾛,⾮常快,并发申请时减少了锁竞争的时间。
4. PageCache
的哈希⽤是按
Span
的⻚⼤⼩映射的,意味着第
x
号桶的
Span
就是
x
⻚的⼤块内存,那么
我们申请时计算出需要多少⻚就直接去对应的桶取。
CentralCache
释放
Span
回来以后,
PageCache
中会⽤⻚号不断搜索前后空闲⻚进⾏合并,假设合并出
y
⻚的
Span
,则直接挂到
y
号桶
即可。
5.
综上所述,不能合并
CentralCache
和
PageCache
,虽然他们都是管理
Span
,但是哈希桶的映射设
计⽅式,和管理的
Span
都个各有不同,
CentralCache
居中调度,承上启下,使⽤桶锁减少锁竞
争,竞争时减少锁粒度;
PageCache
负责申请内存时把空闲⼤⻚切⼩,释放内存时把⼩的空闲⻚合
并⼤,设计结构⽅便挂对应⼤⼩的
Span
,他们各⾃结构差异很⼤,各⾃有各⾃不可替代的功能。