1.C语言中分为下面几个存储区
- 栈(stack): 由编译器自动分配释放
- 堆(heap): 一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收
- 全局区(静态区): 全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域,程序结束释放。
- 常量区: 专门放常量的地方,程序结束释放。
2.注意的地方
- 在函数体中定义的变量通常是在栈上,用malloc, calloc, realloc等分配内存的函数分配得到的就是在堆上。
- 在所有函数体外定义的是全局变量,加了static修饰符后不管在哪里都存放都在全局区(静态区);
- 在所有函数体外定义的static变量表示在该文件中有效,不能extern到别的文件用,在函数体内定义的static表示只在该函数体内有效。
- 另外,函数中的”adgfdf”这样的字符串存放在常量区。比如:
int a = 0; // 全局初始化区char *p1; // 全局未初始化区void main(){int b; // 栈区char s[]="abc"; // 栈区char *p2; // 栈区char *p3 = "123456"; // p3在栈区; "123456\0" 在常量区static int c = 0; // 全局(静态)初始化区p1 = (char*)malloc(10); // 分配得来的10和20字节的区域就在堆区 p2 = (char*)malloc(20);strcpy(p1, "123456"); // "123456\0" 放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。}
3. new和delete的实现原理
1 内置类型
如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。
2 自定义类型
new的原理:
- 调用
operator new
函数申请空间 - 在申请的空间上执行构造函数,完成对象的构造
delete的原理:
在空间上执行析构函数,完成对象中资源的清理工作
new []的原理:
- 调用
operator new[]
函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请 - 在申请的空间上执行N次构造函数
delete[]的原理:
- 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
- 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间
4. 内存分布
![[images/Pasted image 20240717103439.png]]
代码段(.text):也称文本段,存放着程序的机器码和只读数据,可执行命令就是从这里取得。这个段在内存中一般被标记为只读,任何对该区的写操作都会导致段错误(Segmentation Fault)
数据段:包括已初始化的数据段(.data
)和未初始化的数据(.bss
),前者用来存放保存全局和静态的已初始化变量,后者用来保存全局和静态的未初始化变量。数据段在编译时分配。
堆栈段:包括堆和栈空间
堆(Heap):用来存储程序运行时分配的变量【堆的大小并不固定,可动态扩展或缩减。其分配由malloc()、new()等这类实时内存分配函数来实现。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减) 堆的内存释放由应用程序去控制,通常一个new()就要对应一个delete(),如果程序员没有释放掉,那么在程序结束后操作系统会自动回收。】
栈(Stack):是一种用来存储函数调用时的临时信息的结构,如函数调用所传递的参数、函数的返回地址、函数的局部变量等。在程序运行时由编译器在需要的时候分配。在不需要的时候自动清除。【栈的特性:最后一个放入栈中的数据总是最先被拿出来,即FILO队列】
堆与栈的区别
- 分配和管理方式不同
- 堆是动态分配的,其空间的分配和释放都是由程序员控制
- 栈由编译器自动管理。栈有两种分配方式:静态分配和动态分配:静态分配由编译器完成,比如局部变量的分配。动态分配由alloca()函数完成,但是栈的动态分配合堆是不同的,它的动态分配是由编译器进行释放,无须手动控制
- 产生碎片不同
- 对堆来说,频繁的new/delete或者malloc/free势必会造成内存空间的不连续,造成大量的碎片,使程序效率降低
- 对栈而言,则不存在碎片问题,因为栈是先进后出的队列,永远不可能有一个内存块从栈中间弹出
- 生长方向不同
- 堆是向着内存地址增加的方向增长的,从内存的低地址向搞地质方向增长
- 栈的生长方向与之相反,是向着内存地址减小的方向增长,由内存的高地址向低地址方向增长。
5. 函数运行时在内存中是什么样子?
我们知道当函数A调用函数B的时候,控制从A转移到了B,所谓控制其实就是指CPU执行属于哪个函数的机器指令,CPU从开始执行属于函数A的指令切换到执行属于函数B的指令,我们就说控制从函数A转移到了函数B。控制从函数A转移到函数B,那么我们需要有这样两个信息:
- 我从哪里来 (返回)
- 要到去哪里 (跳转)
是不是很简单,就好比你出去旅游,你需要知道去哪里,还需要记住回家的路。函数调用也是同样的道理。当函数A调用函数B时,我们只要知道:
- 函数A对于的机器指令执行到了哪里 (我从哪里来,返回)
- 函数B第一条机器指令所在的地址 (要到哪里去,跳转)
有这两条信息就足以让CPU开始执行函数B对应的机器指令,当函数B执行完毕后跳转回函数A。那么这些信息是怎么获取并保持的呢?现在我们就可以打开这个小盒子,看看是怎么使用的了。假设函数A调用函数B,如图所示:
![[images/Pasted image 20240717111139.png]]当前,CPU执行函数A的机器指令,该指令的地址为0x400564,接下来CPU将执行下一条机器指令也就是:
call 0x400540
这条机器指令是什么意思呢?
这条机器指令对应的就是我们在代码中所写的函数调用,注意call后有一条机器指令地址,注意观察上图你会看到,该地址就是函数B的第一条机器指令,从这条机器指令后CPU将跳转到函数B。
现在我们已经解决了控制跳转的“要到哪里去”问题,当函数B执行完毕后怎么跳转回来呢?
原来,call指令除了给出跳转地址之外还有这样一个作用,也就是把call指令的下一条指令的地址,也就是0x40056a push到函数A的栈帧中,如图所示:
![[images/Pasted image 20240717111555.png]]函数A的小盒子变大了一些,因为装入了返回地址:
![[images/Pasted image 20240717111646.png]]
现在CPU开始执行函数B对应的机器指令,注意观察,函数B也有一个属于自己的小盒子(栈帧),可以往里面扔一些必要的信息。
![[images/Pasted image 20240717111701.png]]
如果函数B中又调用了其它函数呢?道理和函数A调用函数B是一样的。让我们来看一下函数B最后一条机器指令ret,这条机器指令的作用是告诉CPU跳转到函数A保存在栈帧上的返回地址,这样当函数B执行完毕后就可以跳转到函数A继续执行了。至此,我们解决了控制转移中“我从哪里来”的问题。
传递参数与获取返回值
函数调用与返回使得我们可以编写函数,进行函数调用。但调用函数除了提供函数名称之外还需要传递参数以及获取返回值,那么这又是怎样实现的呢?
在x86-64中,多数情况下参数的传递与获取返回值是通过寄存器来实现的。假设函数A调用了函数B,函数A将一些参数写入相应的寄存器,当CPU执行函数B时就可以从这些寄存器中获取参数了。同样的,函数B也可以将返回值写入寄存器,当函数B执行结束后函数A从该寄存器中就可以读取到返回值了。我们知道寄存器的数量是有限的,当传递的参数个数多于寄存器的数量该怎么办呢?这时那个属于函数的小盒子也就是栈帧又能发挥作用了。原来,当参数个数多于寄存器数量时剩下的参数直接放到栈帧中,这样被调函数就可以从前一个函数的栈帧中获取到参数了。现在栈帧的样子又可以进一步丰富了,如图所示:
![[images/Pasted image 20240717124814.png]]
从图中我们可以看到,调用函数B时有部分参数放到了函数A的栈帧中,同时函数A栈帧的顶部依然保存的是返回地址。
局部变量
我们知道在函数内部定义的变量被称为局部变量,这些变量在函数运行时被放在了哪里呢?原来,这些变量同样可以放在寄存器中,但是当局部变量的数量超过寄存器的时候这些变量就必须放到栈帧中了。因此,我们的栈帧内容又一步丰富了。
![[images/Pasted image 20240717125005.png]]
细心的同学可能会有这样的疑问,我们知道寄存器是共享资源可以被所有函数使用,既然可以将函数A的局部变量写入寄存器,那么当函数A调用函数B时,函数B的局部变量也可以写到寄存器,这样的话当函数B执行完毕回到函数A时寄存器的值已经被函数B修改过了,这样会有问题吧。这样的确会有问题,因此我们在向寄存器中写入局部变量之前,一定要先将寄存器中开始的值保存起来,当寄存器使用完毕后再恢复原值就可以了。那么我们要将寄存器中的原始值保存在哪里呢?有的同学可能已经猜到了,没错,依然是函数的栈帧中。
![[images/Pasted image 20240717125103.png]]最终,我们的小盒子就变成了如图所示的样子,当寄存器使用完毕后根据栈帧中保存的初始值恢复其内容就可以了。
Big Picture
需要再次强调的一点就是,上述讨论的栈帧就位于我们常说的栈区。栈区,属于进程地址空间的一部分,如图所示,我们将栈区放大就是图左边的样子。
![[images/Pasted image 20240717125253.png]]
6. c++中map与unordered_map的区别
内部实现机理:
map
: map内部实现了一个红黑树,该结构具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素,因此,对于map进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行这样的操作,故红黑树的效率决定了map的效率。unordered_map
: unordered_map内部实现了一个哈希表,因此其元素的排列顺序是杂乱的,无序的。
优缺点及其适用处:
map
- 优点:
- 有序性,这是map结构最大的优点,其元素的有序性在很多应用中都会简化很多的操作
- 红黑树,内部实现一个红黑书使得map的很多操作在lgn的时间复杂度下就可以实现,因此效率非常的高
- 缺点:
- 空间占用率高,因为map内部实现了红黑树,虽然提高了运行效率,但是因为每一个节点都需要额外保存父节点,孩子节点以及红/黑性质,使得每一个节点都占用大量的空间
- 适用处:
- 对于那些有顺序要求的问题,用map会更高效一些
unordered_map
- 对于那些有顺序要求的问题,用map会更高效一些
- 优点:
- 因为内部实现了哈希表,因此其查找速度非常的快
- 缺点:
- 哈希表的建立比较耗费时间
- 适用处,对于查找问题,unordered_map会更加高效一些,因此遇到查找问题,常会考虑一下用unordered_map
note:
- 对于unordered_map或者unordered_set容器,其遍历顺序与创建该容器时输入元素的顺序是不一定一致的,遍历是按照哈希表从前往后依次遍历的
7. c++中map与set的区别
1. 为啥map不能自定义比较器,set可以?
答:map和set的不同之处在于他们的数据结构和用途。
map
:是一个关联容器,它是一个键-值对集合,其中键是唯一的,且键的排序是固定的。键是用于查找值的,因此对于map来说,键的排序非常重要。map使用红黑树作为底层数据结构,这是一种自平衡二叉搜索树,可以保持键的有序性。因此,map内置了自然的键排序,不需要提供自定义的比较器。set
:也是一个关联容器,他存储唯一元素的集合,但他不是键-值对集合,只包含键(元素)。与map类似,set也使用红黑树来维护元素的有序性。在set中,元素的值就是用于排序的键,因此可以提供自定义比较器来改变元素的排序规则。
总之,std::map 和 std::set 的不同在于它们的用途和数据结构。std::map 是键-值对的关联容器,它的排序是基于键的。而 std::set 是一个元素集合的关联容器,可以通过自定义比较器来改变元素的排序规则。如果您需要自定义排序规则并且不需要键-值对的关联性,那么 std::set 或 std::multiset 可能更适合您的需求。如果需要键-值对的关联性并且希望按键进行排序,那么 std::map 或 std::multimap 是更合适的选择。
2. map、set是怎么实现的?红黑树是怎么能够同时实现这两种容器的?为什么使用红黑树?
答:
- 他们的底层都是使用红黑树的结构实现,因此插入删除等操作都在O(logn)时间内完成,因此可以完成高效的插入删除。
- 在这里我们定义了一个模板参数,如果他是key那么他就是set,如果他是键值对那他就是map。底层是红黑树,实现map的红黑树的节点数据类型是key+value,而实现set的节点数据类型是value。
- 因为map和set要求是自动排序的,红黑树能够实现这一功能,而且时间复杂度比较低。
注:
对有序map中的value排序
这里就需要上文提到的pair
将map注入vector,如下
vector<pair<string, int>> v(myMap.begin(), myMap.end());
重写比较器 comparator 如下
//升序
bool myCmp2( pair<string, int> &a, pair<string, int> &b) {return a.second < b.second;
}
//降序
bool myCmp3(const pair<string, int> &a, const pair<string, int> &b) {return a.second > b.second;
}
3. map的插入方式有哪几种?
答:
- 用insert函数插入pair数据
mapStudent.insert(pair<int, string>(1, "student_one"));
- 用insert函数插入value-type数据
mapStudent.insert(map<int, string>::value_type (1, "student_one"));
- 在insert中使用make_pair()函数
mapStudent.insert(make_pair(1, "student_one"));
- 用数组方式插入数据
mapStudent[1] = "student_one";
4. map为什么没有reserve?
std::map 是 C++ 标准模板库(STL)中的关联容器之一,它使用红黑树来实现键值对的有序存储。在 std::map 中,元素会按照键的自然顺序进行排序,并且不能重复。
std::map 没有提供 reserve() 方法的原因是,与其它容器(如 std::vector、std::unordered_map 等)不同,std::map 的内部数据结构是基于红黑树实现的,它并没有连续分配内存的需求。因此,在创建一个空的 std::map 时,并不需要保留一定的容量。
reserve() 方法通常用于容器预先分配一定的存储空间。而对于 std::map 来说,由于其内部的红黑树结构不需要连续的存储空间,因此不存在重新分配和拷贝的问题,也就没有必要提供 reserve() 方法。
5. map什么时候迭代器失效?
答:
- 插入元素:如果在迭代过程中向
std::map
中插入新的键值对,则所有之前的迭代器都会失效,因为插入操作可能导致内部数据结构重新组织,改变树的结构。
std::map<int, std::string> myMap;
std::map<int, std::string>::iterator it = myMap.begin();myMap[1] = "One"; // 这里插入新元素
// 现在迭代器 it 可能已经失效
- 删除元素:如果在迭代过程中删除了元素,则指向被删除元素的迭代器也会失效。
std::map<int, std::string> myMap;
myMap[1] = "One";
myMap[2] = "Two";
std::map<int, std::string>::iterator it = myMap.begin();myMap.erase(1); // 删除元素
// 现在迭代器 it 可能已经失效
- 改变键的值:如果在迭代过程中修改了
std::map
中已存在的键的值,迭代器通常会保持有效,因为它们仍然指向相同的键,但是请小心,这可能会改变键的排序位置。
std::map<int, std::string> myMap;
myMap[1] = "One";
std::map<int, std::string>::iterator it = myMap.begin();it->second = "NewOne"; // 修改键值
// 迭代器 it 仍然有效,但排序位置可能已经改变
8. Linux内核的五个子系统
首先一张熟悉的图来说明GNU/linux的基本体系结构:
体系的上部分是用户(或应用程序)空间,这是用户应用程序执行的地方。
用户空间之外是内核空间,Linux 内核正是位于这里。
Linux 内核可以进一步划分成 3 层:
-
最上面是系统调用接口,用户程序通过软件中断后,调用系统内核提供的功能,这个在用户空间和内核提供的服务之间的接口称为系统调用,它实现了一些基本的功能,例如 read 和 write;
-
系统调用接口之下是内核代码,可以更精确地定义为独立于体系结构的内核代码,这些代码是 Linux 所支持的所有处理器体系结构所通用的;
-
内核代码之下是依赖于体系结构的代码,构成了通常称为 BSP(Board Support Package)的部分,这些代码用作给定体系结构的处理器和特定于平台的代码。
Linux内核主要由进程调度(SCHED
)、内存管理(MM
)、虚拟文件系统(VFS
)、网络接口(NET
)和进程间通信(IPC
)5个子系统组成,如图所示。
子系统之间的依赖关系:
-
进程调度与内存管理之间的关系:这两个子系统互相依赖。在多道程序环境下,程序要运行必须为之创建进程,而创建进程的第一件事情,就是将程序和数据装入内存。
-
进程间通信与内存管理的关系:进程间通信子系统要依赖内存管理支持共享内存通信机制,这种机制允许两个进程除了拥有自己的私有空间,还可以存取共同的内存区域。
-
虚拟文件系统与网络接口之间的关系:虚拟文件系统利用网络接口支持网络文件系统(NFS),也利用内存管理支持RAMDISK设备。
-
内存管理与虚拟文件系统之间的关系:内存管理利用虚拟文件系统支持交换,交换进程(swapd)定期由调度程序调度,这也是内存管理依赖于进程调度的惟一原因。当一个进程存取的内存映射被换出时,内存管理向文件系统发出请求,同时,挂起当前正在运行的进程。
除了这些依赖关系外,内核中的所有子系统还要依赖于一些共同的资源。这些资源包括所有子系统都用到的例程,如分配和释放内存空间的函数、打印警告或错误信息的函数及系统提供的调试例程等。
进程调度
进程调度控制系统中的多个进程对CPU的访问,使得多个进程能在CPU中“微观串行,宏观并行”地执行。进程调度处于系统的中心位置,内核中其他的子系统都依赖它,因为每个子系统都需要挂起或恢复进程。
如上图所示,Linux的进程在几个状态间进行切换:就绪/运行状态、等待状态(可以被中断打断)、等待状态(不可以被中断打断)、停止状态和僵死状态。
-
TASK_RUNNING:正在运行或处于就绪状态:就绪状态是指进程申请到了CPU以外的其他所有资源,正所谓:万事俱备,只欠东风.提醒:一般的操作系统教科书将正在CPU上执行的进程定义为RUNNING状态、而将可执行但是尚未被调度执行的进程定义为READY状态,这两种状态在Linux下统一为 TASK_RUNNING状态.
-
TASK_INTERRUPTIBLE:处于等待队伍中,等待资源有效时唤醒(比如等待键盘输入、socket连接、信号等等),但可以被中断唤醒.一般情况下,进程列表中的绝大多数进程都处于 TASK_INTERRUPTIBLE状态.毕竟皇帝只有一个(单个CPU时),后宫佳丽几千;如果不是绝大多数进程都在睡眠,CPU又怎么响应得过来.
-
TASK_UNINTERRUPTIBLE:处于等待队伍中,等待资源有效时唤醒(比如等待键盘输入、socket连接、信号等等),但不可以被中断唤醒.
-
TASK_ZOMBIE:僵死状态,进程资源用户空间被释放,但内核中的进程PCB并没有释放,等待父进程回收.
-
TASK_STOPPED:进程被外部程序暂停(如收到SIGSTOP信号,进程会进入到TASK_STOPPED状态),当再次允许时继续执行(进程收到SIGCONT信号,进入TASK_RUNNING状态),因此处于这一状态的进程可以被唤醒.
在设备驱动编程中,当请求的资源不能得到满足时,驱动一般会调度其他进程执行,并使本进程进入睡眠状态,直到它请求的资源被释放,才会被唤醒而进入就绪态。睡眠分成可被打断的睡眠和不可被打断的睡眠,两者的区别在于可被打断的睡眠在收到信号的时候会醒。
在设备驱动编程中,当请求的资源不能得到满足时,驱动一般会调度其他进程执行,其对应进程进入睡眠状态,直到它请求的资源被释放,才会被唤醒而进入就绪态。设备驱动中,如果需要几个并发执行的任务,可以启动内核线程,启动内核线程的函数为:
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags);
当用户使用系统提供的库函数进行进程编程,用户可以动态地创建进程,进程之间还有等待,互斥等操作,这些操作都是由linux内核来实现的。linux内核通过进程管理子系统实现了进程有关的操作,在linux系统上,所有的计算工作都是通过进程表现的,进程可以是短期的(执行一个命令),也可以是长期的(一种网络服务)。linux系统是一种动态系统,通过进程管理能够适应不断变化的计算需求。
在用户空间,进程是由进程标示符(PID)表示的。从用户角度看,一个PID是一个数字值,可以唯一标识一个进程,一个PID值在进程的整个生命周期中不会更改,但是PID可以在进程销毁后被重新使用。创建进程可以使用几种方式,可以创建一个新的进程,也可以创建当前进程的子进程。
在linux内核空间,每个进程都有一个独立的数据结构,用来保存该进程的ID、优先级、地址的空间等信息,这个结构也被称做进程控制块(Process Control Block)。所谓的进程管理就是对进程控制块的管理。
Linux的进程是通过fork()函数系统调用产生的。调用fork()的进程叫做父进程,生成的进程叫做子进程。子进程被创建的时候,除了进程ID外,其它数据结构与父进程完全一致。在fork()系统调用创建内存之后,子进程马上被加入内核的进程调试队列,然后使用exec()系统调用,把程序的代码加入到子进程的地址空间,之后子进程就开始执行自己的代码。
在一个系统上可以有多个进程,但是一般情况下只有一个CPU,在同一个时刻只能有一个进程在工作,即使有多个CPU,也不可能和进程的数量一样多。如果让若干的进程都能在CPU上工作,这就是进程管理子系统的工作。linux内核设计了存放进程队列的结构,在一个系统上会有若干队列,分别存放不同状态的进程。一个进程可以有若干状态,具体是由操作系统来定义的,但是至少包含运行态、就绪态和等待3种状态,内核设计了对应的队列存放对应状态的进程控制块。
当一个用户进程被加载后,会进入就绪态,被加入到就绪态队列,CPU时间被轮转到就绪态队列后,切换到进程的代码,进程被执行,当进程的时间片到了以后被换出。如果进程发生I/O操作也会被提前被换出,并且存放到等待队列,当I/O请求返回后,进程又被放入就绪队列。linux系统对进程队列的管理设计了若干不同的方法,主要的目的是提高进程调试的稳定性。
内存管理
内存管理的主要作用是控制多个进程安全地共享主内存区域。当CPU提供内存管理单元(MMU)时,Linux内存管理完成为每个进程进行虚拟内存到物理内存的转换。Linux 2.6引入了对无MMU CPU的支持。
如下图所示,一般而言,Linux的每个进程享有4GB的内存空间,0~3GB属于用户空间,3~4GB属于内核空间,内核空间对常规内存、I/O设备内存以及高端内存存在不同的处理方式。
地址0G-3G:用户空间,运行应用程序 (每一个应用程序都认为自己独占这0~3G 的空间)
地址3G-4G:内核空间
内核空间包括:
1、3G – 3G+896MB 直接映射区
2、vmalloc区
3、永久映射区
4、固定映射区
使用虚拟内存技术的计算机,内存管理的硬件按照分页方式管理内存。分页方式是把计算机系统的物理内存按照相同大小等分,每个内存分片称作内存页,通常内存页大小是4KB。Linux内核的内存管理子系统管理虚拟内存与物理内存之间的映射关系,以及系统可用内存空间。
内存管理要管理的不仅是4KB缓冲区。Linux提供了对4KB缓冲区的抽象,例如slab分配器。这种内存管理模式使用4KB缓冲区为基数,然后从中分配结构,并跟踪内存页使用情况,比如哪些内存页是满的,哪些页面没有完全使用,哪些页面为空。这样就允许该模式根据系统需要来动态调整内存使用。
在支持多用户的系统上,由于内存占用的增大,容易出现物理内存被消耗尽的情况。为了解决物理内存被耗尽的问题,内存管理子系统规定页面可以移出内存并放入磁盘中,这个过程称为交换。内存管理的源代码可以在./linux/mm中找到。
虚拟文件系统
如下图4所示,Linux虚拟文件系统(VFS)隐藏各种了硬件的具体细节,为所有的设备提供了统一的接口。而且,它独立于各个具体的文件系统,是对各种文件系统的一个抽象,它使用超级块super block存放文件系统相关信息,使用索引节点inode存放文件的物理信息,使用目录项dentry存放文件的逻辑信息。
在不同格式的文件分区上,程序都可以正确地读写文件,并且结果是一样的。有时在使用linux系统的时候发现,可以在不同类型的文件分区内直接复制文件,对应用程序来说,并不知道文件系统的类型,甚至不知道文件的类型,这就是虚拟文件系统在背后做的工作。虚拟文件系统屏蔽了不同文件系统间的差异,向用户提供了统一的接口。
虚拟文件系统,即VFS(Virtual File System)是Linux内核中的一个软件抽象层。它通过一些数据结构及其方法向实际的文件系统如ext2,vfat等提供接口机制。通过使用同一套文件 I/O 系统调用即可对Linux中的任意文件进行操作而无需考虑其所在的具体文件系统格式;更进一步,文件操作可以在不同文件系统之间进行。在linux系统中,一切都可以被看做是文件。不仅普通的文本文件、目录可以当做文件进行处理,而且字符设备、块设备、套接字等都可以被当做文件进行处理。这些文件虽然类型不同,但是却使用同一种操作方法。这也是UNIX/Linux设计的基本哲学之一。
虚拟文件系统(简称VFS)是实现“一切都是文件”特性的关键,是Linux内核的一个软件层,向用户空间的程序提供文件系统接口;同时提供了内核中的一个抽象功能,允许不同类型的文件系统存在。VFS可以被理解为一种抽象的接口标准,系统中所有的文件系统不仅依靠VFS共存,也依靠VFS协同工作。为了能够支持不同的文件系统,VFS定义了所有文件系统都支持的、最基本的一个概念上的接口和数据结构,在实现一个具体的文件系统的时候,需要向VFS提供符合VFS标准的接口和数据结构,不同的文件系统可能在实体概念上有差别,但是使用VFS接口时需要和VFS定义的概念保持一致,只有这样,才能实现对用户的文件系统无关性。VFS隐藏了具体文件系统的操作细节,所以,在VFS这一层以及内核其他部分看来,所有的文件系统都是相同的。对文件系统访问的系统调用通过VFS软件层处理,VFS根据访问的请求调用不同的文件系统驱动的函数处理用户的请求。文件系统的代码在访问物理设备的时候,需要使用物理设备驱动访问真正的硬件。
网络接口
网络接口提供了对各种网络标准的存取和各种网络硬件的支持。如下图所示,在Linux中网络接口可分为网络协议和网络驱动程序,网络协议部分负责实现每一种可能的网络传输协议,网络设备驱动程序负责与硬件设备通信,每一种可能的硬件设备都有相应的设备驱动程序。
(1)网络协议接口层向网络层协议提供统一的数据包收发接口,不论上层协议是ARP还是IP,都通过dev_queue_xmit()函数发送数据,并通过netif_rx()函数接收数据。这一层的存在使得上层协议独立于具体的设备。
(2)网络设备接口层向协议接口层提供的用于描述具体网络设备属性和操作的结构体net_device,该结构体是设备驱动功能层各函数的容器。
(3)设备驱动功能层的各函数是网络设备接口层net_device数据结构的具体成员,是驱使网络设备硬件完成相应动作的程序,它通过nto_start_xmit()函数启动发送操作,并通过网络设备上的中断触发接收操作。
(4)网络设备与媒介层是完成数据包发送和接收的物理实体,包括网络适配器和具体的传输媒介,网络适配器被设备驱动功能层中的函数在物理上驱动。
驱动工程师的工作:在设计具体的网络设备驱动程序时,需要完成的主要工作是编写设备驱动功能层的相关函数以填充net_device数据结构的内容并将net_device注册入内核。
写网络应用程序,使用socket通过TCP/IP协议与其他机器通信,和前面介绍的内核子系统相似,socket相关的函数也是通过内核的子系统完成的,担当这部分任务的是内核的网络子系统,有时也把这部分代码称为“网络堆栈”。Linux内核提供了优秀的网络处理能力和功能,这与网络堆栈代码的设计思想是分不开的,Linux的网络堆栈部分沿袭了传统的层次结构,网络数据从用户进程到达实际的网络设备需要四个层次:用户进程,套接字,网络协议,网络设备。
实际上,在每层里面还可以分为好多层次,数据传输的路径是按照层次来的,不能跨越某个层次。linux网络子系统对网络层次采用了类似面向对象的设计思路,把需要处理的层次抽象为不同的实体,并且定义了实体之间的关系和数据处理流程:
(1)网络协议:网络协议可以理解为一种语言,用于网络中不同设备之间的通信,是一种通信的规范。
(2)套接字:套接字是内核与用户程序的接口,一个套接字对应一个数据连接,并且向用户提供了文件I/O,用户可以像操作文件一样在数据连接上收发数据,具体的协议处理由网络协议部分处理。套接字是用户使用网络的接口。
(3)设备接口:设备接口是网络子系统中软件和硬件的接口,用户的数据最终是需要通过网络硬件设备发送和接收的,网络设备千差万别,设备驱动也不尽相同,通过设备接口屏蔽了具体设备驱动的差异。
(4)网络缓冲区:网络缓冲区也称为套接字缓冲区(sk_buff),是网络子系统中的一个重要结构。网络传输数据存在许多不定因素,除了物理设备对传输数据的限制(例如MMU),网络受到干扰、丢包、重传等,都会造成数据的不稳定,网络缓冲区通过对网络数据的重新整理,使业务处理的数据包是完整的。网络缓冲区是内存中的一块缓冲区,是网络系统与内存管理的接口。
进程通信
进程通信支持提供进程之间的通信,Linux支持进程间的多种通信机制,包含信号量、共享内存、管道等,这些机制可协助多个进程、多资源的互斥访问、进程间的同步和消息传递。
一、管道
管道,通常指无名管道,是 UNIX 系统IPC最古老的形式。
1、特点:
它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。
它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。
它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
2、原型:
#include <stdio.h>
当一个管道建立时,它会创建两个文件描述符:fd[0]为读而打开,fd[1]为写而打开。如下图:
要关闭管道只需将这两个文件描述符关闭即可。
3、For Example
单个进程中的管道几乎没有任何用处。所以,通常调用 pipe 的进程接着调用 fork,这样就创建了父进程与子进程之间的 IPC 通道。如下图所示:
若要数据流从父进程流向子进程,则关闭父进程的读端(fd[0])与子进程的写端(fd[1]);反之,则可以使数据流从子进程流向父进程。
#include <stdio.h>
二、FIFO
FIFO,也称为命名管道,是一种文件类型。
1、特点
-
FIFO可以在无关的进程之间交换数据,与无名管道不同。
-
FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。
2、原型
#include <stdio.h>
其中的 mode 参数与open函数中的 mode 相同。一旦创建了一个 FIFO,就可以用一般的文件I/O函数操作它。
当 open 一个FIFO时,是否设置非阻塞标志(O_NONBLOCK)的区别:
若没有指定O_NONBLOCK(默认),只读 open 要阻塞到某个其他进程为写而打开此 FIFO。类似的,只写 open 要阻塞到某个其他进程为读而打开它。
若指定了O_NONBLOCK,则只读 open 立即返回。而只写 open 将出错返回 -1 如果没有进程已经为读而打开该 FIFO,其errno置ENXIO。
3、For Example
FIFO的通信方式类似于在进程中使用文件来传输数据,只不过FIFO类型文件同时具有管道的特性。在数据读出时,FIFO管道中同时清除数据,并且“先进先出”。下面的例子演示了使用 FIFO 进行 IPC 的过程:
write_fifo.c
#include<stdio.h>
read_fifo.c
#include <stdio.h>
在两个终端里用 gcc 分别编译运行上面两个文件,可以看到输出结果如下:
[xq@localhost]$ ./write_fifo
[xq@localhost]$ ./write_fifo
上面的例子可以扩展成 客户端进程—服务端进程通信的实例,write_fifo的作用类似于客户端,可以打开多个客户端向一个服务器发送请求信息,read_fifo类似于服务器,它适时监控着FIFO的读端,当有数据时,读出并进行处理,但是有一个关键的问题是,每一个客户端必须预先知道服务器提供的FIFO接口,下图显示了这样的操作:
三、消息队列
消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。
1、特点
-
消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。
-
消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。
-
消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
2、原型
#include <stdio.h>
在以下两种情况下,msgget将创建一个新的消息队列:
-
如果没有与键值key相对应的消息队列,并且flag中包含了
IPC_CREAT
标志位。 -
key参数为IPC_PRIVATE。
函数msgrcv在读取消息队列时,type参数有下面几种情况:
-
type == 0,返回队列中的第一个消息;
-
type > 0,返回队列中消息类型为 type 的第一个消息;
-
type < 0,返回队列中消息类型值小于或等于 type 绝对值的消息,如果有多个,则取类型值最小的消息。
可以看出,type值非 0 时用于以非先进先出次序读消息。也可以把 type 看做优先级的权值。(其他的参数解释,请自行Google之)
四、信号量
信号量(semaphore)与已经介绍过的 IPC 结构不同,它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
1、特点
-
信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。
-
信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作。
-
每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数。
-
支持信号量组。
2、原型
最简单的信号量是只能取 0 和 1 的变量,这也是信号量最常见的一种形式,叫做二值信号量(Binary Semaphore)。而可以取多个正整数的信号量被称为通用信号量。
Linux 下的信号量函数都是在通用的信号量数组上进行操作,而不是在一个单一的二值信号量上进行操作。
#include <stdio.h>
当semget创建新的信号量集合时,必须指定集合中信号量的个数(即num_sems),通常为1;如果是引用一个现有的集合,则将num_sems指定为 0 。
在semop函数中,sembuf结构的定义如下:
struct sembuf
其中 sem_op 是一次操作中的信号量的改变量:
-
若sem_op > 0,表示进程释放相应的资源数,将 sem_op 的值加到信号量的值上。如果有进程正在休眠等待此信号量,则换行它们。
-
若sem_op < 0,请求 sem_op 的绝对值的资源。
-
sem_flg 指定IPC_NOWAIT,则semop函数出错返回
EAGAIN
。 -
sem_flg 没有指定IPC_NOWAIT,则将该信号量的semncnt值加1,然后进程挂起直到下述情况发生:
-
当相应的资源数可以满足请求,此信号量的semncnt值减1,该信号量的值减去sem_op的绝对值。成功返回;
-
此信号量被删除,函数smeop出错返回EIDRM;
-
进程捕捉到信号,并从信号处理函数返回,此情况下将此信号量的semncnt值减1,函数semop出错返回EINTR
-
如果相应的资源数可以满足请求,则将该信号量的值减去sem_op的绝对值,函数成功返回。
-
当相应的资源数不能满足请求时,这个操作与sem_flg有关。
-
若sem_op == 0,进程阻塞直到信号量的相应值为0:
-
sem_flg指定IPC_NOWAIT,则出错返回EAGAIN。
-
sem_flg没有指定IPC_NOWAIT,则将该信号量的semncnt值加1,然后进程挂起直到下述情况发生:
-
信号量值为0,将信号量的semzcnt的值减1,函数semop成功返回;
-
此信号量被删除,函数smeop出错返回EIDRM;
-
进程捕捉到信号,并从信号处理函数返回,在此情况将此信号量的semncnt值减1,函数semop出错返回EINTR
-
当信号量已经为0,函数立即返回。
-
如果信号量的值不为0,则依据sem_flg决定函数动作:
在semctl函数中的命令有多种,这里就说两个常用的:
-
SETVAL:用于初始化信号量为一个已知的值。所需要的值作为联合semun的val成员来传递。在信号量第一次使用之前需要设置信号量。
-
IPC_RMID:删除一个信号量集合。如果不删除信号量,它将继续在系统中存在,即使程序已经退出,它可能在你下次运行此程序时引发问题,而且信号量是一种有限的资源。
五、共享内存
共享内存(Shared Memory),指两个或多个进程共享一个给定的存储区。
1、特点
-
共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。
-
因为多个进程可以同时操作,所以需要进行同步。
-
信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。
2、原型
#include <stdio.h>
当用shmget函数创建一段共享内存时,必须指定其size;而如果引用一个已存在的共享内存,则将size指定为0 。
当一段共享内存被创建以后,它并不能被任何进程访问。必须使用shmat函数连接该共享内存到当前进程的地址空间,连接成功后把共享内存区对象映射到调用进程的地址空间,随后可像本地空间一样访问。
shmdt函数是用来断开shmat建立的连接的。注意,这并不是从系统中删除该共享内存,只是当前进程不能再访问该共享内存而已。
shmctl函数可以对共享内存执行多种操作,根据参数 cmd 执行相应的操作。常用的是IPC_RMID(从系统中删除该共享内存)。
9. C++网络协议完成端口IOCP
IOCP(I/O Completion Ports)是Windows操作系统提供的一种高效的异步I/O模型,主要用于处理大量并发I/O请求的应用程序。
IOCP通过以下几个关键概念来实现高效的异步I/O:
-
Completion Port:这是一个内核对象,多个I/O操作可以与其关联。每个完成端口可以关联多个线程,用于处理I/O完成事件。
-
I/O Request Packet (IRP):当一个异步I/O请求被发出时,操作系统会创建一个IRP来跟踪这个I/O操作。完成端口会在I/O操作完成时接收到一个包含I/O结果的消息。
-
Worker Threads:在使用IOCP的应用程序中,通常会创建一个线程池。这些工作线程会等待从完成端口获取I/O完成的通知,然后处理相应的I/O操作。
-
Asynchronous I/O:使用IOCP的应用程序通常会发出异步I/O请求,而不是阻塞的同步I/O请求。这允许应用程序在等待I/O操作完成的同时继续处理其他任务,从而提高了并发性能。
IOCP工作流程
-
创建完成端口:应用程序首先创建一个完成端口。
-
创建工作线程:应用程序创建多个工作线程,这些线程会等待从完成端口获取I/O完成的通知。
-
发出异步I/O请求:应用程序发出异步I/O请求,并将这些请求与完成端口关联。
-
处理I/O完成通知:当I/O操作完成时,操作系统会将I/O完成消息投递到完成端口。工作线程从完成端口获取这些消息并处理相应的I/O操作。
IOCP的优点
-
高性能:由于IOCP允许多个I/O操作并行进行,并且使用线程池处理I/O完成通知,从而大大提高了系统的并发性能。
-
可扩展性:IOCP可以高效地处理大量并发连接,因此非常适合于高负载的服务器应用。
-
资源节省:IOCP通过复用线程池中的线程来处理I/O操作,避免了频繁创建和销毁线程的开销。
10. 重叠I/O
重叠I/O是Windows操作系统提供的一种异步I/O机制,允许应用程序在发出I/O请求后继续执行其他任务,而无需等待I/O操作完成。重叠I/O通常与I/O完成端口(IOCP)结合使用,以提高系统的并发性能和资源利用效率。
重叠I/O的基本概念
OVERLAPPED结构:重叠I/O操作依赖于一个称为OVERLAPPED
的结构。这个结构包含了I/O操作的状态信息,并且可以包含一个事件句柄,用于通知I/O操作的完成。
typedef struct _OVERLAPPED {
异步I/O函数:支持重叠I/O的函数通常以Ex
结尾,如ReadFileEx
、WriteFileEx
等。这些函数会立即返回,并通过OVERLAPPED
结构跟踪I/O操作的状态。
事件通知:当I/O操作完成时,系统会通过以下方式之一通知应用程序:
-
事件对象:如果在
OVERLAPPED
结构中指定了事件句柄,操作系统会在I/O操作完成时设置该事件。 -
回调函数:某些异步I/O函数(如
ReadFileEx
)支持在I/O操作完成时调用一个回调函数。 -
I/O完成端口:当与I/O完成端口结合使用时,完成的I/O操作会投递到完成端口,由工作线程处理。
使用重叠I/O的步骤
创建文件句柄:以重叠模式打开文件或设备。可以使用CreateFile
函数,并在dwFlagsAndAttributes
参数中指定FILE_FLAG_OVERLAPPED
标志。
HANDLE hFile = CreateFile(
准备OVERLAPPED结构:初始化一个OVERLAPPED
结构,并根据需要设置偏移量和事件句柄。
OVERLAPPED ol = {0};
发出异步I/O请求:使用异步I/O函数发出I/O请求,并传递OVERLAPPED
结构。
char buffer[1024];
等待I/O完成:根据应用程序的需求,可以选择以下一种或多种方式等待I/O操作完成:
- 等待事件对象:使用
WaitForSingleObject
或WaitForMultipleObjects
等待事件被设置。
WaitForSingleObject(ol.hEvent, INFINITE);
- 轮询I/O状态:使用
GetOverlappedResult
函数检查I/O操作是否完成。
DWORD bytesTransferred;
- 使用I/O完成端口:当I/O操作完成时,系统会将I/O完成包投递到与文件句柄关联的完成端口,工作线程可以通过
GetQueuedCompletionStatus
函数获取并处理完成的I/O操作。
11.IOCP的工作原理
1. 创建完成端口
完成端口是一个内核对象,用于管理和协调多个异步I/O操作。可以使用CreateIoCompletionPort
函数创建一个新的完成端口。
HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
2. 关联文件句柄到完成端口
将文件句柄(如套接字或文件)与完成端口关联。此操作允许完成端口接收与这些句柄相关的I/O完成通知。
HANDLE hFile = CreateFile(
3. 发出异步I/O请求
发出异步I/O请求,并使用OVERLAPPED
结构来描述I/O操作的状态。常见的异步I/O函数包括ReadFile
、WriteFile
等。
OVERLAPPED ol = {0};
4. 工作线程等待和处理I/O完成
工作线程从完成端口中获取I/O完成通知,并处理这些通知。可以使用GetQueuedCompletionStatus
函数等待和获取I/O完成状态。
DWORD bytesTransferred;
IOCP的工作流程
-
创建完成端口:使用
CreateIoCompletionPort
创建一个完成端口。 -
关联文件句柄:使用
CreateIoCompletionPort
将文件句柄(如套接字或文件)与完成端口关联。 -
发出异步I/O请求:使用异步I/O函数(如
ReadFile
、WriteFile
)发出I/O请求,并提供OVERLAPPED
结构。 -
等待I/O完成:工作线程使用
GetQueuedCompletionStatus
等待I/O完成通知。该函数会阻塞,直到一个I/O操作完成或超时。 -
处理I/O完成:工作线程获取I/O完成通知后,处理完成的I/O操作(如读取数据、写入数据或处理错误)。
12.同步I/O和异步I/O
同步I/O
同步I/O操作会阻塞调用线程,直到I/O操作完成。换句话说,线程在发出I/O请求后会等待操作完成,然后再继续执行后续的代码。
特点
-
阻塞:调用线程在I/O操作完成之前会一直等待,无法执行其他任务。
-
简单:编程模型简单,容易理解和实现。
-
适用场景:适用于I/O操作相对较少或可以接受阻塞等待的情况。
异步I/O
异步I/O操作不会阻塞调用线程。调用线程在发出I/O请求后可以立即继续执行其他任务,而操作系统会在I/O操作完成时通知应用程序。
特点
-
非阻塞:调用线程不必等待I/O操作完成,可以并行处理其他任务,提高了并发性能。
-
复杂:编程模型相对复杂,需要处理I/O完成的通知和结果。
-
适用场景:适用于高并发、高吞吐量的应用场景,如服务器和网络编程。
windows异步IO接口
1. CreateIoCompletionPort
CreateIoCompletionPort
用于创建一个新的完成端口或将一个文件句柄(如套接字或文件)关联到现有的完成端口。
HANDLE CreateIoCompletionPort(
-
FileHandle
:要关联的文件句柄。如果为INVALID_HANDLE_VALUE
,则创建一个新的完成端口。 -
ExistingCompletionPort
:现有的完成端口句柄。如果创建新完成端口,此参数应为NULL
。 -
CompletionKey
:与文件句柄关联的完成键,完成端口通知时将返回此键。 -
NumberOfConcurrentThreads
:建议并发线程数。通常设置为系统的CPU核心数。
2. ReadFile
和 WriteFile
ReadFile
和WriteFile
是常用的异步读写函数。它们的异步操作需要结合OVERLAPPED
结构。
BOOL ReadFile(
3. GetQueuedCompletionStatus
GetQueuedCompletionStatus
用于从完成端口获取I/O完成通知。调用此函数的线程将被阻塞,直到一个I/O操作完成或超时。
BOOL GetQueuedCompletionStatus(
-
CompletionPort
:完成端口句柄。 -
lpNumberOfBytesTransferred
:接收传输的字节数。 -
lpCompletionKey
:接收完成键。 -
lpOverlapped
:接收指向OVERLAPPED
结构的指针。 -
dwMilliseconds
:等待的超时时间,以毫秒为单位。如果为INFINITE
,则无限等待。
4. PostQueuedCompletionStatus
PostQueuedCompletionStatus
用于将一个完成包投递到完成端口。这在模拟I/O操作或控制工作线程方面很有用。
BOOL PostQueuedCompletionStatus(
-
CompletionPort
:完成端口句柄。 -
dwNumberOfBytesTransferred
:传输的字节数。 -
dwCompletionKey
:完成键。 -
lpOverlapped
:指向OVERLAPPED
结构的指针。
5. ReadFileEx
和 WriteFileEx
ReadFileEx
和WriteFileEx
提供了另一种异步I/O接口,它们通过回调函数通知I/O操作的完成。
BOOL ReadFileEx(
13. Redis
Redis介绍
本项目主要想仿照Redis的交互方式,实现一个基本的“内存型数据库”,所以首先来介绍一下Redis。随着互联网的普及,只要是上网的APP基本上都需要和相应的服务器请求数据,通常来说,这些数据被服务器保存在“磁盘”上的文件中,称之为“磁盘型数据库”。但是面对海量用户时(比如秒杀活动),磁盘IO的读写速率不够快从而导致用户体验下降,并且服务器数据库的压力也非常大。鉴于很多请求只是读取数据,这就启发我们将一些热点数据存放在内存中,以便快速响应请求、并且减轻磁盘的读写压力。
为什么要使用Redis?
- 从高并发上来说:直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去。这样用户的一部分请求会直接到缓存,而不用经过数据库。
- 从高性能上来说:用户第一次访问数据库中的某些数据,因为是从硬盘上读取的,所以这个过程会比较慢。将该用户访问的数据存在缓存中,下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。
为什么要使用Redis而不是其他的,例如Java自带的map或者guava?
缓存分为本地缓存和分布式缓存,像map或者guava就是本地缓存。本地缓存最主要的特点是轻量以及快速,生命周期随着jvm的销毁而结束。在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性。redis或memcached之类的称为分布式缓存,在多实例的情况下,各实例共用一份缓存数据,缓存具有一致性。
解释:
使用Redis而不是Java自带的Map或Guava缓存,主要有以下几点原因:
-
分布式缓存:
- 一致性:在多实例的情况下,本地缓存(如Java Map或Guava Cache)需要每个实例各自保存一份缓存数据,无法保证数据的一致性。Redis作为分布式缓存,可以保证多实例共享同一份缓存数据,从而保证数据一致性。
- 可扩展性:Redis可以方便地扩展和缩减缓存节点,以应对不同的负载需求。
-
持久化:
- 本地缓存的生命周期随着JVM的销毁而结束,无法实现持久化。而Redis提供了多种持久化机制(如RDB快照和AOF日志),可以在服务器重启后恢复数据。
-
功能丰富:
- Redis不仅仅是一个简单的键值存储,还支持多种数据结构,如字符串、哈希、列表、集合、有序集合等,可以满足更复杂的缓存需求。
- Redis提供了丰富的功能,如发布/订阅、事务、脚本、TTL(时间到期)等,可以方便地实现各种缓存策略。
-
性能:
- 虽然本地缓存的访问速度很快,但在分布式环境下,Redis的性能也是非常出色的。Redis通过内存存储数据,并采用高效的I/O多路复用模型,能够处理高并发请求。
-
高可用性:
- Redis支持主从复制、哨兵模式和Redis Cluster,可以实现高可用性和自动故障转移,保证服务的稳定性和可靠性。
-
运维监控:
- Redis提供了丰富的运维和监控工具,可以方便地监控缓存的运行状态、性能指标、内存使用等,便于及时发现和解决问题。
综上所述,虽然本地缓存(如Java Map或Guava Cache)在某些场景下非常有用,但在分布式环境下,Redis具有明显的优势,能够提供一致性、高可用性和丰富的功能支持。
Redis应用场景有哪些?
- 缓存热点数据,缓解数据库的压力。
- 利用Redis原子性的自增操作,可以实现计数器的功能。比如统计用户点赞数、用户访问数等。
- 分布式锁。在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。可以使用Redis自带的SETNX命令实现分布式锁,除此之外,还可以使用官方提供的RedLock分布式锁实现。
- 简单的消息队列。可以使用Redis自身的发布/订阅模式或者List来实现简单的消息队列,实现异步操作。
- 限速器。可用于限制某个用户访问某个接口的频率,比如秒杀场景用于防止用户快速点击带来不必要的压力。
- 好友关系。利用集合的一些命令,比如交集、并集、差集等,实现共同好友、共同爱好之类的功能。
为什么Redis这么快?
- Redis是基于内存进行数据操作的Redis使用内存存储,没有磁盘IO上的开销,数据存在内存中,读写速度快。
- 采用IO多路复用技术。Redis使用单线程来轮询描述符,将数据库的操作都转换成了事件,不在网络I/O上浪费过多的时间。
- 高效的数据结构。Redis每种数据类型底层都做了优化,目的就是为了追求更快的速度。
14. 项目敏感词脱敏是如何实现的?
简而言之,数据隐私保护越来越重要,不管是用户的手机号、身份证号还是信用卡号,这些敏感信息都必须得到保护。
在将这些数据暴露给前端或第三方系统时,我们需要进行脱敏处理,以防数据泄露和滥用。
关于敏感词脱敏,有很多种方式,下面我们来列举几种常见的做法:
-
替换法:这个是最普遍的做法,把敏感信息中的一部分字符替换成星号( * )。比如,身份证号51343620000320711X,脱敏后变成5***************1X。
-
删除法:随机删除部分字符来进行脱敏,比如删除电话号码中的随机3位数字。
-
重排法:将原始数据中的某些字符的顺序打乱,来模糊原始数据,比如把身份证号的一些位互换顺序。
-
加噪法:在数据中注入一些随机生成的字符,使原始数据难以被识别。
以上方法各有优缺点,选择哪种取决于项目的具体需求。作为一个常年与代码打交道的老程序员,我个人比较倾向于使用替换法,简单直接,效率也高。
15. MySQL
mysql 索引有哪些?
MySQL可以按照四个角度来分类索引。
-
按「数据结构」分类:B+tree索引、Hash索引、Full-text索引。
-
按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)。
-
按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引。
-
按「字段个数」分类:单列索引、联合索引。
从数据结构的角度来看,MySQL 常见索引有 B+Tree 索引、HASH 索引、Full-Text 索引,每一种存储引擎支持的索引类型不一定相同,我在表中总结了 MySQL 常见的存储引擎 InnoDB、MyISAM 和 Memory 分别支持的索引类型。
InnoDB 是在 MySQL 5.5 之后成为默认的 MySQL 存储引擎,B+Tree 索引类型也是 MySQL 存储引擎采用最多的索引类型。
B+Tree 是一种多叉树,叶子节点才存放数据,非叶子节点只存放索引,而且每个节点里的数据是按主键顺序存放的。每一层父节点的索引值都会出现在下层子节点的索引值中,因此在叶子节点中,包括了所有的索引值信息,并且每一个叶子节点都有两个指针,分别指向下一个叶子节点和上一个叶子节点,形成一个双向链表。主键索引的 B+Tree 如图所示:
索引分为聚簇索引(主键索引)、二级索引(辅助索引)。它们的主要区别如下:
-
主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里;
-
二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。
所以,在查询时使用了二级索引,如果查询的数据能在二级索引里查询的到,那么就不需要回表,这个过程就是覆盖索引。
如果查询的数据不在二级索引里,就会先检索二级索引,找到对应的叶子节点,获取到主键值后,然后再检索主键索引,就能查询到数据了,这个过程就是回表。
创建索引需要注意什么?
什么时候适用索引?
-
字段有唯一性限制的,比如商品编码;
-
经常用于
WHERE
查询条件的字段,这样能够提高整个表的查询速度,如果查询条件不是一个字段,可以建立联合索引。 -
经常用于
GROUP BY
和ORDER BY
的字段,这样在查询的时候就不需要再去做一次排序了,因为我们都已经知道了建立索引之后在 B+Tree 中的记录都是排序好的。
什么时候不需要创建索引?
-
WHERE
条件,GROUP BY
,ORDER BY
里用不到的字段,索引的价值是快速定位,如果起不到定位的字段通常是不需要创建索引的,因为索引是会占用物理空间的。 -
字段中存在大量重复数据,不需要创建索引,比如性别字段,只有男女,如果数据库表中,男女的记录分布均匀,那么无论搜索哪个值都可能得到一半的数据。在这些情况下,还不如不要索引,因为 MySQL 还有一个查询优化器,查询优化器发现某个值出现在表的数据行中的百分比很高的时候,它一般会忽略索引,进行全表扫描。
-
表数据太少的时候,不需要创建索引;
-
经常更新的字段不用创建索引,比如不要对电商项目的用户余额建立索引,因为索引字段频繁修改,由于会带来索引结构的变动,会影响写入性能。
聚簇与非聚簇索引区别是什么?
-
数据存储:在聚簇索引中,数据行按照索引键值的顺序存储,也就是说,索引的叶子节点包含了实际的数据行。这意味着索引结构本身就是数据的物理存储结构。非聚簇索引的叶子节点不包含完整的数据行,而是包含指向数据行的指针或主键值。数据行本身存储在聚簇索引中。
-
索引与数据关系:由于数据与索引紧密相连,当通过聚簇索引查找数据时,可以直接从索引中获得数据行,而不需要额外的步骤去查找数据所在的位置。当通过非聚簇索引查找数据时,首先在非聚簇索引中找到对应的主键值,然后通过这个主键值回溯到聚簇索引中查找实际的数据行,这个过程称为“回表”。
-
唯一性:聚簇索引通常是基于主键构建的,因此每个表只能有一个聚簇索引,因为数据只能有一种物理排序方式。一个表可以有多个非聚簇索引,因为它们不直接影响数据的物理存储位置。
-
效率:对于范围查询和排序查询,聚簇索引通常更有效率,因为它避免了额外的寻址开销。非聚簇索引在使用覆盖索引进行查询时效率更高,因为它不需要读取完整的数据行。但是需要进行回表的操作,使用非聚簇索引效率比较低,因为需要进行额外的回表操作。
16. 操作系统
进程间通讯有哪些方式?
Linux 内核提供了不少进程间通信的方式:
-
管道:管道是一种单向的通信机制,允许一个进程的输出作为另一个进程的输入。管道分为「匿名管道」和「命名管道」。
-
匿名管道顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。
-
命名管道突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
-
消息队列:消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
-
共享内存:共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。
-
信号:与信号量名字很相似的叫信号,它俩名字虽然相似,但功能一点儿都不一样。信号是异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SIGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。
-
信号量:信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。
-
socket:前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
缓存回收机制了解哪些?
-
**LRU:**最近最少使用算法,当缓存满时,会回收那些在最近一段时间内最少被访问的缓存项。这是一种广泛使用的回收策略,能有效利用缓存空间。
-
**LFU:**最近最少使用频率算法,回收使用频率最低的缓存项。与LRU不同的是,LFU是根据访问次数来决定哪些数据被回收。
栈内存和堆内存有什么区别?
-
分配方式:堆是动态分配内存,由程序员手动申请和释放内存,通常用于存储动态数据结构和对象。栈是静态分配内存,由编译器自动分配和释放内存,用于存储函数的局部变量和函数调用信息。
-
内存管理:堆需要程序员手动管理内存的分配和释放,如果管理不当可能会导致内存泄漏或内存溢出。栈由编译器自动管理内存,遵循后进先出的原则,变量的生命周期由其作用域决定,函数调用时分配内存,函数返回时释放内存。
-
大小和速度:堆通常比栈大,内存空间较大,动态分配和释放内存需要时间开销。栈大小有限,通常比较小,内存分配和释放速度较快,因为是编译器自动管理。
17. 网络
输入url到页面渲染发生了什么?
-
解析URL:分析 URL 所需要使用的传输协议和请求的资源路径。如果输入的 URL 中的协议或者主机名不合法,将会把地址栏中输入的内容传递给搜索引擎。如果没有问题,浏览器会检查 URL 中是否出现了非法字符,则对非法字符进行转义后在进行下一过程。
-
缓存判断:浏览器会判断所请求的资源是否在缓存里,如果请求的资源在缓存里且没有失效,那么就直接使用,否则向服务器发起新的请求。
-
DNS解析:如果资源不在本地缓存,首先需要进行DNS解析。浏览器会向本地DNS服务器发送域名解析请求,本地DNS服务器会逐级查询,最终找到对应的IP地址。
-
获取MAC地址:当浏览器得到 IP 地址后,数据传输还需要知道目的主机 MAC 地址,因为应用层下发数据给传输层,TCP 协议会指定源端口号和目的端口号,然后下发给网络层。网络层会将本机地址作为源地址,获取的 IP 地址作为目的地址。然后将下发给数据链路层,数据链路层的发送需要加入通信双方的 MAC 地址,本机的 MAC 地址作为源 MAC 地址,目的 MAC 地址需要分情况处理。通过将 IP 地址与本机的子网掩码相结合,可以判断是否与请求主机在同一个子网里,如果在同一个子网里,可以使用 APR 协议获取到目的主机的 MAC 地址,如果不在一个子网里,那么请求应该转发给网关,由它代为转发,此时同样可以通过 ARP 协议来获取网关的 MAC 地址,此时目的主机的 MAC 地址应该为网关的地址。
-
建立TCP连接:主机将使用目标 IP地址和目标MAC地址发送一个TCP SYN包,请求建立一个TCP连接,然后交给路由器转发,等路由器转到目标服务器后,服务器回复一个SYN-ACK包,确认连接请求。然后,主机发送一个ACK包,确认已收到服务器的确认,然后 TCP 连接建立完成。
-
HTTPS 的 TLS 四次握手:如果使用的是 HTTPS 协议,在通信前还存在 TLS 的四次握手。
-
发送HTTP请求:连接建立后,浏览器会向服务器发送HTTP请求。请求中包含了用户需要获取的资源的信息,例如网页的URL、请求方法(GET、POST等)等。
-
服务器处理请求并返回响应:服务器收到请求后,会根据请求的内容进行相应的处理。例如,如果是请求网页,服务器会读取相应的网页文件,并生成HTTP响应。
TCP三次握手和四次挥手过程说一下?
TCP 三次握手过程
image.png
-
一开始,客户端和服务端都处于 CLOSE 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
-
客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
-
服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
-
客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 ESTABLISHED 状态。
-
服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态。
TCP 四次挥手过程
具体过程:
-
客户端主动调用关闭连接的函数,于是就会发送 FIN 报文,这个 FIN 报文代表客户端不会再发送数据了,进入 FIN_WAIT_1 状态;
-
服务端收到了 FIN 报文,然后马上回复一个 ACK 确认报文,此时服务端进入 CLOSE_WAIT 状态。在收到 FIN 报文的时候,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,服务端应用程序可以通过 read 调用来感知这个 FIN 包,这个 EOF 会被放在已排队等候的其他已接收的数据之后,所以必须要得继续 read 接收缓冲区已接收的数据;
-
接着,当服务端在 read 数据的时候,最后自然就会读到 EOF,接着 read() 就会返回 0,这时服务端应用程序如果有数据要发送的话,就发完数据后才调用关闭连接的函数,如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,这时服务端就会发一个 FIN 包,这个 FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态;
-
客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
-
服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
-
客户端经过 2MSL 时间之后,也进入 CLOSE 状态;