From d2072a61de57c6cfa64921a0a81fd56354515e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=93=9C=E7=93=9C?= <13598081419@163.com> Date: Thu, 2 Feb 2023 16:54:49 +0800 Subject: [PATCH] Add files via upload --- ...15\344\275\234\347\263\273\347\273\237.md" | 2077 +++++++++++++++++ ...04\345\217\212\347\256\227\346\263\225.md" | 1983 ++++++++++++++++ ...76\350\256\241\346\250\241\345\274\217.md" | 389 +++ 3 files changed, 4449 insertions(+) create mode 100644 "\346\223\215\344\275\234\347\263\273\347\273\237.md" create mode 100644 "\346\225\260\346\215\256\347\273\223\346\236\204\345\217\212\347\256\227\346\263\225.md" create mode 100644 "\350\256\276\350\256\241\346\250\241\345\274\217.md" diff --git "a/\346\223\215\344\275\234\347\263\273\347\273\237.md" "b/\346\223\215\344\275\234\347\263\273\347\273\237.md" new file mode 100644 index 0000000..46deab0 --- /dev/null +++ "b/\346\223\215\344\275\234\347\263\273\347\273\237.md" @@ -0,0 +1,2077 @@ +# 进程和线程的区别和联系 + +**概念上** + +最开始就是一个进程跑完再跑另一个进程,后来有多道程序分派之后,我们可以利用进程调度算法来在一个电脑上跑多个程序,在我们用户看来这些程序是一起运行的。随着技术的发展,多核处理器的实现使得线程越来越普及,因此我们计算机就可以适配多核出现了线程。 + +**进程:**进程是系统中正在运行的程序。是计算机分配资源的单位,每一个进程都会有属于自己独立的内存空间,磁盘空间,I\O设备等等。比如说windows中的任务管理器,每一行都是一个进程。在同一进程中还是在不同进程中时系统功能划分的重要决策点。 + +> 可以把进程比做一个人。每个人都有自己的记忆(内存),人与人通过谈话(消息传递)来交流,谈话既可以面谈(同一个电脑),也可以远程谈(不同服务器,网络通信)。面谈和远程的区别在于,面谈的话可以立刻知道对方死没死(SIGCHILD信号),而远程只能通过周期性的信条来判断对方是否活着。 + +**线程:**线程是任务调度的基本单元。(现在来看这局话其实就是正在运行的一个程序有很多任务,而线程是将任务呈现出细粒度,更精确了)其实我觉得线程主要扮演的角色就是如何利用cpu处理代码,完成当前进程中的各个子任务。各个子线程之间共享父进程的代码空间和全局变量,但是每个进程都有自己独立的堆栈,即局部变量对于线程来说是私有的。因此创建多进程代价有点大,在一个进程中创建多线程代价要小很多。 + +线程大概93年出现的,有SUN Solaris操作系统使用的线程叫做**UNIX International线程**,现在一直用的POSIX线程(POSIX threads),Pthreads线程的头文件是``,Win32线程是Windows API的一部分。 + +线程的特点就是共享地址空间,从而可以高效的共享数据。多线程的价值在于更好的发挥了多核处理器的效能,在单核时代多线程没啥用,因为就一个核心,一个执行单元,按状态机的思路去写程序是最高效的。 + +**内核实现上** + +进程和线程的相同点要远远大于不同点。主要依据就是在 Linux 中,无论进程还是线程,都是抽象成了 task 任务,在源码里都是用 task_struct 结构来实现的。 + +图片 + +对于线程来讲,所有的字段都是和进程一样的(本来就是一个结构体来表示的)。包括状态、pid、task 树关系、地址空间、文件系统信息、打开的文件信息等等字段,线程也都有。在 Linux 下的线程还有另外一个名字,叫轻量级进程。 + +**进程中**pid 就是我们平时常说的进程 pid,在 Linux 中,每一个 task_struct 都需要被唯一的标识,它的 pid 就是唯一标识号。 + +**对于线程来说**,我们假如一个进程下创建了多个线程出来。那么每个线程的 pid 都是不同的。但是我们一般又需要记录线程是属于哪个进程的。这时候,tgid 就派上用场了,通过 tgid 字段来表示自己所归属的进程 ID。 + +图片 + +事实上,进程线程创建的时候,使用的函数看起来不一样。但实际在底层实现上,最终都是使用同一个函数来实现的。 + +图片 + +创建进程时使用的 fork 系统调用,创建线程的 clone 系统调用几乎和 fork 差不多,也一样使用的是内核里的 do_fork 函数,最后走到 copy_process 来完整创建。不过创建过程的区别是二者在调用 do_fork 时传入的 clone_flags 里的标记不一样!。 + +- 创建进程时的 flag:仅有一个 SIGCHLD +- 创建线程时的 flag:包括 CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGNAL、CLONE_SETTLS、CLONE_PARENT_SETTID、CLONE_CHILD_CLEARTID、CLONE_SYSVSEM。 + +> - CLONE_VM: 新 task 和父进程共享地址空间 +> - CLONE_FS:新 task 和父进程共享文件系统信息 +> - CLONE_FILES:新 task 和父进程共享文件描述符表 + +进程和线程创建都是调用内核中的 do_fork 函数来执行的。在 do_fork 的实现中,核心是一个 copy_process 函数,它以拷贝父进程(线程)的方式来生成一个新的 task_struct 出来。copy_process 先是复制了一个新的 task_struct 出来,然后调用 copy_xxx 系列的函数对 task_struct 中的各种核心对象进行拷贝处理,还申请了 pid 。 + +对于线程来讲,其地址空间 mm_struct、目录信息 fs_struct、打开文件列表 files_struct 都是和创建它的任务共享的。如图: + +图片 + +对于进程来讲,地址空间 mm_struct、挂载点 fs_struct、打开文件列表 files_struct 都要是独立拥有的,都需要去申请内存并初始化它们。如图: + +图片 + +在 Linux 内核中并没有对线程做特殊处理,还是由 task_struct 来管理。从内核的角度看,用户态的线程本质上还是一个进程。只不过和普通进程比,稍微“轻量”了那么一些。那么线程具体能轻量多少呢?我之前曾经做过一个进程和线程的上下文切换开销测试。进程的测试结果是一次上下文切换平均 2.7 - 5.48 us 之间。线程上下文切换是 3.8 us左右。总的来说,进程线程切换还是没差太多。 + +**比喻** + +做个简单的比喻:进程=火车,线程=车厢 + +1. 线程在进程下行进(单纯的车厢无法运行) +2. 一个进程可以包含多个线程(一辆火车可以有多个车厢) +3. 不同进程间数据很难共享(一辆火车上的乘客很难换到另外一辆火车,比如站点换乘) +4. 同一进程下不同线程间数据很易共享(A车厢换到B车厢很容易) +5. 进程要比线程消耗更多的计算机资源(采用多列火车相比多个车厢更耗资源) +6. 进程间不会相互影响,一个线程挂掉将导致整个进程挂掉(不一定)(一列火车不会影响到另外一列火车,但是如果一列火车上中间的一节车厢着火了,将影响到所有车厢) +7. 线程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。(比如火车上的洗手间)-"互斥锁" + +> 所谓内核中的任务调度,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。所以,对于线程和进程,我们可以这么理解: - 当进程只有一个线程时,可以认为进程就等于线程。 - 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。 - 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。 + + + +# 线程调度为什么比进程调度更少开销? + +## 进程与线程的差异 + +从概念上来讲,线程是进程的一部分,只是任务调度相关的部分,所以我们才说,“线程是调度的最小单位”。进程拥有着资源,这些资源不属于某一个特定线程,因为所有线程共享进程拥有的资源,所以我们才说,“进程是资源分配的最小单位”。 + +我们fork一个新的进程时,实际上“伴生”了一个线程,而这个唯一的线程,实际上代表了这个进程参与到任务调度。在linux中,不管进程还是线程,都用`struct task_struct`描述。`struct mm_struct *mm`,这是一个指针,指向实际的内存资源。同一个进程内的所有线程,他们都使用相同的资源,只需要把对应的资源指针指向相同的地址。 + +Linux内核就好像淡化了“线程”的概念,每一个线程描述都是`struct task_struct`,他们都是一个独立的“进程”,都有着自己的进程号,都参与任务调度,只不过指向相同的进程资源。 + +## 任务调度的开销 + +**任务调度的主要开销** + +1. CPU执行任务调度的开销,主要是进程上下文切换的开销 +2. 任务调度后,CPU Cache/TLB不命中,导致缺页中断的开销 + +对于第1点的开销,不管是进程调度还是线程调度都是必须的,所以,两者的差异体现在第2点。既然线程调度的`struct task_struct`都使用相同的资源,是不是就意味着,我即使切换到了其他的线程,CPU Cache/TLB命中的概率会高很多?相反,进程调度使用的是不同的资源,每次换了个进程,就意味着原有的Cache就不适用了,没命中,就触发更多的缺页中断,开销自然就更多。 + +linux中,线程都是独立的`struct task_struct`,都参与任务调度,那这里说的线程调度和进程调度怎么区分? + +线程调度:使用相同资源的`struct task_struct`之间的调度 + +进程调度:使用不同资源的`struct task_struct`之间的调度 + + + + + +# 线程和进程之前共享那些资源? + +同一进程的所有线程共享以下资源: + +1. 堆。堆是在进程空间内开辟的,所以被共享 +2. 全局变量。与某一函数无关,与特定线程无关 +3. 静态变量。静态变量存放位置和全局变量一样,都存在于堆中开辟的.bss和.data段,是共享的 +4. 其他一些共用资源,比如文件。 + +同一进程的所有线程独享以下资源: + +1. 栈。 +2. 寄存器。 +3. 程序计数器 + +**线程运行的本质就是函数的执行**,而函数的执行总会有一个源头,这个源头叫做入口函数,cup从入口函数开始一步一步向下执行,这个过程就叫做线程。由于函数运行时信息是保存在栈中的,比如返回值,参数,局部变量等等,所以栈是私有的。 + +cpu执行指令的信息会保存在寄存器中,这个寄存器叫做程序计数器。由于操作系统可以随时终止线程的运行,所以保存和恢复程序计数器的值就知道线程从哪里暂停的以及从哪里开始运行。 + + + +# 进程间通信方式 + +每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,内核是可以共享的。在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为**进程间通信(IPC,InterProcess Communication)** + +- **管道** + + 管道允许进程以先进先出的方式传送数据,是半双工的,意味着数据只能往一个方向流动。因此当双方通信时,必须建立两个管道。 + + 管道的实质就是在内核中创建一个缓冲区,管道一端的进程进入管道写数据,另一端的进程进入管道读取数据。 + + 管道分为pipe和FIFO两种 + + - pipe:用于相关联的进程,比如父进程和子进程之间的通信。 + - FIFO:命名管道,即任何进程可以根据管道的文件名将其打开和读写。 + + 缺点:管道本质上是通过内核交换数据的,因此通信效率很低,不适合频繁交换数据的情况。 + + 匿名管道的周期随着进程的创建而创建,销毁而销毁。 + +- **消息队列** + + 消息队列是保存在内核中的链表,由一个个独立的数据块组成,消息的接收方和发送方要约定具体的消息类型。当进程从消息队列中读取了相关数据块,则内核会将该数据块删除。跟管道相比,消息队列不一定按照先进先出的方式读取,也可以按照消息类型进行兑取。 + + 消息队列的生命周期与内核相关,如果不显示的删除消息队列,则消息队列会一直存在 + + 消息队列这种通信方式,跟收发邮件类似。两个进程你来我往的进行沟通 + + 缺点:不能实现实时通信。数据块是有大小限制的。**消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销**,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。 + +- **共享内存** + + 共享内存技术就是要解决用户态和内核态之间频繁发生拷贝过程的。现代操作系统对于内存管理普遍采用的是虚拟内存技术,每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存空间映射到不同的物理内存中。 + + 数据不需要在不同进程之间进行复制,这是最快的一种IPC + + 共享内存技术的实质就是拿出一块虚拟地址空间,映射到相同的物理内存中。这样的好处是一个进程写入数据后另一个进程可以立刻看到,不用进行拷贝。效率很高。 + +- **信号** + + 上面那几种进程间通信,都是常规状态下的。异常状态下的需要用信号来通知进程。 + + 信号和信号量就像雷锋和雷峰塔的区别。 + + 可以在任何时刻给进程发送信号,信号是进程间通信或操作的一种异步通信机制。 + + 收到信号后进程对信号的处理有三种方式 + + 1. 如果是系统定义的信号函数,执行默认操作。 + + >**SIGINT:**程序终止信号。程序运行过程中,按`Ctrl+C`键将产生该信号。 + > + >**SIGQUIT:**程序退出信号。程序运行过程中,按`Ctrl+\\`键将产生该信号。 + > + >**SIGALRM:**定时器信号。 + > + >**SIGTERM:**结束进程信号。shell下执行`kill 进程pid`发送该信号。 + + 2. 捕捉信号。用户可以给信号定义信号处理函数,表示收到信号后该进程该怎么做。 + + 3. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 `SIGKILL` 和 `SEGSTOP`,它们用于在任何时候中断或结束某一进程。 + +- **unix域套接字** + + 具体指unix域间套接字。socket API原本是为网络通讯设计的,但后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。**UNIX域套接字与TCP套接字相比较,在同一台主机的传输速度前者是后者的两倍。**这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。 + +- **信号量**(同步原语,并不叫IPC) + + **本质是一个计数器,用于为多个进程提供对共享对象的访问** + + 共享内存在效率高的同时也带来了新的问题,即如果多个进程同时对一个共享内存进行操作,会产生冲突造成不可预计的后果。 + + 为了不冲突,共享内存在一个时间段只能有一个进程访问,就出现了信号量。 + + 信号量其实是一个计数器,用于实现进程间的互斥和同步。 + + **信号量表示资源的数量。** **P操作** 会把信号量-1,-1之后如果信号量的值<0,则表示资源已经被占用,进程需要阻塞等待。如果信号量-1后>= 0,表明进程可以正常执行。**V操作**跟P操作正好相反。 + + img + + 1. 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。 + + 2. 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。 + + 3. 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。 + + > **信号量与互斥量之间的区别:** + > + > (1)互斥量用于线程的互斥,信号量用于线程的同步。这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。 + > + > **互斥:**是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。 + > + > **同步:**是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。 + > + > (2)互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。 + + + +# 进程同步 + +在多道程序环境下,当程序并发执行时候,由于资源共享和进程间相互协作的关系,同一个系统中的诸进程之间会存在一下两种相互制约的关系: + +1. 间接相互制约。主要是资源共享这种情况 +2. 直接相互制约。源于进程间的相互合作,例如A进程向B进程提供数据,当A缓存没数据的时候B就阻塞,A缓存满时A就阻塞。 + +进程同步首先要搞明白临界区 + +- 临界区 + + 许多硬件资源如打印机,磁带机等,都属于临界资源,诸进程应该采取互斥方式,实现对这种资源的共享。人们把在每个进程中访问临界资源的那段代码成为临界区,显然,若能保证诸进程互斥地进入自己的临界区,便可实现诸进程对临界资源的互斥访问。 + +- 同步机制遵循的原则 + + 1. 空闲让进,当无进程处于临界区时,表明临界资源处于空闲状态,应允许一个请求进入临界区的进程立即进入自己的临界区,以有效的利用临界资源。 + 2. 忙则等待,当已有进程进入临界区时,表明临界资源正在被访问,因而其他视图进入临界区的进程必须等待,以保证对临界资源的互斥访问。 + 3. 有限等待,对要求访问临界资源的进程 ,应保证在有限时限内能进入自己的临界区,以免陷入死等状态。 + 4. 让权等待,当进程不能进入自己的临界区时,应立即释放处理机,以免进程陷入忙等状态。 + +- 进程同步实现机制 + + 1. 提高临界区代码执行中断的优先级 + + 在传统操作系统中,打断进程对临界区代码的执行只有中断请求、中断一旦被接受,系统就有可能调用其它进程进入临界区,并修改此全局数据库。所以用提高临界区中断优先级方法就可以屏蔽了其它中断,保证了临界段的执行不被打断,从而实现了互斥。 + + 2. 自旋锁 + + 内核(处理器也如此)被保持在过渡状态“旋转”,直到它获得锁,“自旋锁”由此而得名。 + 自旋锁像它们所保护的数据结构一样,储存在共用内存中。为了速度和使用任何在处理器体系下提供的锁定机构,获取和释放自旋锁的代码是用汇编语言写的。 + + 3. 信号量机制 + + 跟进程同步信号量机制一样 + +- 经典进程同步问题 + + [具体参考](#经典的进程间通信(同步)问题) + + 1. 生产者——消费者问题 + 2. 哲学家进餐问题 + 3. 读者——写者问题 + + + +# 经典的进程间通信(同步)问题 + +> P是减小 +> +> V是增加 + +## 生产者—消费者 + +> **问题描述:**两个进程(生产者和消费者)共享一个公共的固定大小的缓冲区。生产者将数据放入缓冲区,消费者从缓冲区中取数据。也可以扩展成m个生产者和n个消费者。当缓冲区空的时候,消费者因为取不到数据就会睡眠,知道缓冲区有数据才会被唤醒。当缓冲区满的时候,生产者无法继续往缓冲区中添加数据,就会睡眠,当缓冲区不满的时候再唤醒。 +> +> **出现的问题:**为了时刻监视缓冲区大小,需要有一个变量count来映射。但是这个变量就是映射的共享内存,生产者消费者都可以修改这个变量。由于这里面对count没有加以限制会出现竞争。比如当缓冲区为空时,count=0,消费者读取到count为0,这个时候cpu的执行权限突然转移给了生产者。然后生产者发现count=0,就会马上生产一个数据放到缓冲区中,此时count=1,接着会发送一个wakeup信号给消费者,因为由于之前count=0,生产者以为消费者进入了阻塞状态。但事实上我们知道消费者还没有进入阻塞状态,因此生产者的这个wakeup信号会丢失。接着CPU执行权限有转移到消费者这里,消费者查看自己的进程表项中存储的信息发现count=0然后进入阻塞,永远不去取数据。这个时候生产者迟早会把缓冲区填满,然后生产者也会进入阻塞,然后两个进程都在阻塞下去,出现了问题。 + +这个模型涉及到了两种关系: + +1. 生产者和消费者之间的同步关系。当缓冲区满时,必须等消费者先行动。当缓冲区空时,必须等生产者先行动。 +2. 生产者和消费者之间的互斥关系。就是刚才提到的问题,对缓冲区的操作必须与其他进程互斥才行。不然很容易死锁。 + +解决方案: + +```c +pthread_mutex_t mutex; +pthread_cond_t producter; +pthread_cond_t consumer; +count = pool.size() + +producer(){ + while(1){ + pthread_mutex_lock(&mutex); + pool++; + while(pool == full){ + pthread_cond_wait(&producter, &mutex); + } + pthread_cond_signal(&consumer, &mutex); + pthread_mutex_unlock(&mutex); + } +} +consumer(){ + while(1){ + pthread_mutex_lock(&mutex); + pool--; + while(pool == 0){ + pthread_cond_wait(&consumer, &mutex); + } + pthread_cond_signal(&producter, &mutex); + pthread_mutex_unlock(&mutex); + } +} +``` + +注意不能先执行P(mutex)再执行P(empty),这样生产者阻塞了消费者也阻塞了。 + +## 哲学家就餐问题 + +> **问题描述:**如下图所示 +> +> img +> +> 哲学家只有两件事情,吃饭和思考。每个哲学家的两边都有叉子,只有当拥有左面和右面的叉子时候才能吃饭,吃完饭后放下叉子思考。 +> +> **出现问题:**一下两种情况会产生死锁。第一种情况是五位哲学家同时拿起左边的叉子,就没有人能够拿到右面的叉子造成死锁。第二种情况是拿起左边叉子,然后查看右边有没有叉子,没有就放下左边叉子,有就拿起右边叉子吃饭。这样也会造成死锁,五个人还是同时拿左边的叉子,然后放下这样会永远重复。第三种情况就是哲学家拿起左边叉子然后随机等待一段时间,有右边叉子就拿没有就放下左边叉子。但是当对于核电站中的安全系统时候,随机数不可靠。 + +整个模型中有五个进程,互相之间是互斥的关系。解决方法是: + +1. 同时拿到两个筷子 +2. 对每个哲学家的动作制定规则,避免饥饿或者死锁。 + +> 解决方法是设置一个互斥信号量组用于对进程之间的互斥访问chopstick=[1,1,1,1,1] +> +> ```c +> semaphore chopstick[5] = {1,1,1,1,1}; //定义信号量数组mutex[5],并初始化 +> Pi(){ //i号哲学家的进程 +> do{ +> P (chopstick[i] ) ; //取左边筷子 +> P (chopstick[(i+1) %5] ) ; //取右边篌子 +> eat; //进餐 +> V(chopstick[i]) ; //放回左边筷子 +> V(chopstick[(i+l)%5]); //放回右边筷子 +> think; //思考 +> } while (1); +> } +> ``` +> +> 这个算法存在以下问题:**同时就餐拿起左边的筷子会产生死锁** + +改进: + +> 对哲学家进程施加一些限制条件,比如至多允许四个哲学家同时进餐;仅当一个哲学家左右两边的筷子都可用时才允许他抓起筷子 +> +> ```c +> semaphore chopstick[5] = {1,1,1,1,1}; //初始化信号量 +> semaphore mutex=l; //设置取筷子的信号量 +> Pi(){ //i号哲学家的进程 +> do{ +> P (mutex) ; //在取筷子前获得互斥量 +> P (chopstick [i]) ; //取左边筷子 +> P (chopstick[ (i+1) %5]) ; //取右边筷子 +> V (mutex) ; //释放取筷子的信号量 +> eat; //进餐 +> V(chopstick[i] ) ; //放回左边筷子 +> V(chopstick[ (i+l)%5]) ; //放回右边筷子 +> think; // 思考 +> }while(1); +> } +> ``` + +## 读写问题 + +> **问题描述:**有读者和写者**两组**并发进程,共享一个文件,当两个或以上的读进程同时访问共享数据时不会产生副作用,但是如果有写进程在修改数据库的时候不能有其他读进程或写进程访问数据库。因此要求:①允许多个读者可以同时对文件执行读操作;②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作;④写者执行写操作前,应让已有的读者和写者全部退出。 + +问题中的三个关系: + +1. 读者和写者是互斥关系 +2. 写者和写者是互斥关系 +3. 读者和读者不存在互斥关系 + +> **分析:** +> +> 1. 写者好分析,他和任意进程互斥,用互斥信号量操作就行 +> 2. 读者问题比较复杂。读者必须实现和写者的互斥,而且要实现和其他读者的同步。 +> +> 因此用到一个计数器,判断当前有多少读者在读文件。 +> +> 当有读者的时候不能写入文件,当没有读者的时候写者才会写入文件。 +> +> 同时计数器也是公共内存,对计数器的访问也应该是互斥的 +> +> ```c +> int count=0; //用于记录当前的读者数量 +> semaphore mutex=1; //用于保护更新count变量时的互斥 +> semaphore rw=1; //用于保证读者和写者互斥地访问文件 +> +> //可以看到writer比较简单 +> writer () { //写者进程 +> while (1){ +> P(rw); // 互斥访问共享文件 +> Writing; //写入 +> V(rw) ; //释放共享文件 +> } +> } +> +> reader () { // 读者进程 +> while(1){ +> P (mutex) ; //互斥访问count变量 +> if (count==0) //当第一个读进程读共享文件时 +> P(rw); //阻止写进程写 +> count++; //读者计数器加1 +> V (mutex) ; //释放互斥变量count +> +> reading; //读取 +> +> P (mutex) ; //互斥访问count变量 +> count--; //读者计数器减1 +> if (count==0) //当最后一个读进程读完共享文件 +> V(rw) ; //允许写进程写 +> V (mutex) ; //释放互斥变量 count +> } +> } +> ``` + + + +# CPU调度算法(进程调度算法) + +进程调度算法也称 CPU 调度算法,毕竟进程是由 CPU 调度的。 + +调度分为两大类:抢占式调度和非抢占式调度。抢占式调度说明程序正在运行时可以被打断,把CPU让给其他进程。非抢占式调度表示一个进程正在运行当进程完成或者阻塞的时候把CPU让出来。 + +调度的的话有很多进程都会等待这调度,那么就有一些调度算法: + +- **先来先服务调度算法** + + 每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。 + + 这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。 + + FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。 + +- **最短作业优先调度算法** + + 优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。 + + 这显然对长作业不利,很容易造成一种极端现象。比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。 + +- **高响应比优先调度算法** + + 前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。 + + 每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式: + + 优先权=(等待时间+服务时间)/ 服务时间。 + +- **时间片轮转调度算法** + + 每个进程被分配一个时间段,称为时间片(\*Quantum\*),即允许该进程在该时间段中运行。 + + 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;通常时间片设为 `20ms~50ms` 通常是一个比较合理的折中值。 + +- **最高优先级调度算法** + + 对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(Highest Priority First,HPF)调度算法。 + + 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。 + + 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。 + +- **多级反馈队列调度算法** + + 多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。 + + 多级表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。 + + 反馈表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列; + + 多级反馈队列 + + 其工作流程如下: + + 1. 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短; + 2. 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成; + 3. 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行; + + + +# 操作系统是如何做到进程阻塞的? + +- 什么是阻塞 + + 正在运行的进程由于提出系统服务请求(如I/O操作),但因为某种原因未得到操作系统的立即响应,或者需要从其他合作进程获得的数据尚未到达等原因,该进程只能调用阻塞原语把自己阻塞,等待相应的事件出现后才被唤醒。 + + 进程并不总是可以立即运行的,一方面是 CPU 资源有限,另一方面则是进程时常需要等待外部事件的发生,例如 I/O 事件、定时器事件等。 + +- 如何做到进程阻塞 + + 操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务,这里用到了时间片轮转技术,如果在时间片结束时进程还在运行,则CPU使用权将被剥夺并分配给另一个进程。**如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。** + + 当CPU从一个进程跑到了别的进程之后,肯定还需要跑回来,因此就有工作队列和等待队列。 + + 假如现在进程 A 里跑的程序有一个对象执行了某个方法将当前进程阻塞了,内核会立刻将进程A从工作队列中移除,同时创建等待队列,并新建一个引用指向进程A。这时进程A被排在了工作队列之外,不受系统调度了,这就是我们常说的被操作系统“挂起”。这也提现了阻塞和挂起的关系。阻塞是人为安排的,让你程序走到这里阻塞。而阻塞的实现方式是系统将进程挂起。 + + 当这个对象受到某种“刺激”(某事件触发)之后, 操作系统将该对象等待队列上的进程重新放回到工作队列上就绪,等待时间片轮转到该进程。所以,操作系统不会去尝试运行被阻塞的进程,而是由对象去等待某种“刺激”,喜欢被动。 + + (要点:时间片轮转,工作队列和等待队列) + +- 进程阻塞不消耗CPU资源,但是会消耗系统资源。因此系统资源不仅包含CPU,还有内存、磁盘I/O等等 + + + +# 线程间通信的方式 + +线程间很多资源都是共享的,所以线程间没有像进程间那样有许多数据交换机制。**线程间通信主要目的是为了线程同步。** + +- **锁机制** + + - 互斥锁。确保同一时间内只有一个线程能访问共享资源。当资源被占用时其他试图加锁的线程会进入阻塞状态。当锁释放后,哪个线程能上锁取决于内核调度。 + - 读写锁。当以写模式加锁的时候,任何其他线程不论以何种方式加锁都会处以阻塞状态。当以读模式加锁时,读状态不阻塞,但是写状态阻塞。“读模式共享,写模式互斥” + - 自旋锁。上锁受阻时线程不阻塞而是在循环中轮询查看能否获得该锁,没有线程的切换因而没有切换开销,不过对CPU的霸占会导致CPU资源的浪费。 +- **posix信号量机制** + + 信号量本质上是一个计数器,可以有PV操作,来控制多个进程或者线程对共享资源的访问。 + + 信号量API有两组,第一组就是System V IPC信号量用于进程间通信的,另外一组就是POSIX信号量,信号量原理都是一样的 + +- **条件变量** + + 条件变量提供了线程间的通知机制:当某个共享数据到达某个值的时候,唤醒等待这个共享数据的线程。 + + 条件变量要结合互斥锁使用,如下图代码: + + ```c + pthread_mutex_lock(&mutex);  +   while(条件为假) +     pthread_cond_wait(&cond,&mutex);   +   执行某种操作 + pthread_mutex_unlock(&mutex); + ``` + + 也就是说,一个线程要等到临界区的共享数据达到某种状态时再进行某种操作,而这个状态的成立,则是由另外一个进程/线程来完成后发送信号来通知的。 + + + +# 线程是如何实现的? + +这个看《操作系统》总结的也有 + +实现线程主要有三种方式:(1)使用内核线程实现,(2)使用用户线程实现(3)使用用户线程加轻量级进程混合实现。 + +- 内核线程实现 + + 内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这种操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。 + + 程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口:轻量级进程(Light Weight Process ,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。 + +- 用户线程实现 + + 从广义上讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT)。而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,因此比较难。 + +- 用户线程加轻量级进程混合实现 + + 线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。 + + + +# 线程调度 + +- 协同式调度 + + 使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上。协同式多线程的最大好处是实现简单,不会有线程同步问题。缺点是线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。 + +- 抢占式调度 + + 使用抢占式调度的多线程系统,每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。 + + + +# **进程阻塞为什么不占用cpu资源?** + +因为阻塞了得不到cpu,怎么占用CPU资源 + + + +# linux内核的同步方式 + +首先要明确,同步和互斥是计算机系统中,用于控制进程对某些特定资源访问的机制。同步指的是进程按照一定的顺序和规则访问资源,互斥则是控制资源某一时刻只能由一个进程访问。这样看来互斥是同步的一种情况。 + +在现代操作系统里,同一时间可能有多个内核执行流在执行,因此内核其实象多进程多线程编程一样也需要一些同步机制来同步各执行单元对共享数据的访问。尤其是在多处理器系统上,更需要一些同步机制来同步不同处理器上的执行单元对共享的数据的访问。在主流的Linux内核中包含了几乎所有现代的操作系统具有的同步机制,**这些同步机制包括:原子操作、信号量(semaphore)、读写信号量 (rw_semaphore)、spinlock、BKL(Big Kernel Lock)、rwlock、brlock(只包含在2.4内核中)、RCU(只包含在2.6内核中)和seqlock(只包含在2.6内核中)。** + +> linux会因为三种机制而产生并发,需要进行同步。 +> +> 1. **中断。**中断就是操作系统随时可以打断正在执行的代码。当进程访问某个临界资源发生中断,会进入中断处理程序。中断处理程序也可能访问该临界资源。 +> 2. **内核抢占式调度。**内核中正在执行的任务被另外一个任务抢占。 +> 3. **多处理器并发。**每个处理器都可以调度一个进程,多个进程可能会造成并发。 + +- **禁用中断** + + 对于单处理器不可抢占系统来说,系统并发源主要是中断处理。因此在进行临界资源访问时,进行禁用/使能中断即可以达到消除异步并发源的目的。 + +- **原子操作** + + 原子操作保证指令在运行时候不会被任何事物或者事件打断,把读和写的行为包含在一步中执行,避免竞争。 + +- **内存屏障(memory barrier)** + + 程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。内存乱序访问主要发生在两个阶段: + + 1. 编译时,编译器优化导致内存乱序访问(指令重排) + 2. 运行时,多 CPU 间交互引起内存乱序访问 + +- **自旋锁(spin lock)** + + 对于复杂的操作原子操作是不能的。比如从一种数据结构中读取数据,写入到另一种数据结构中。自旋锁是linux内核中最常见的一种锁。自旋锁只能被一个可执行线程所拥有,如果有线程要抢占一个已经被占有的自旋锁,则其会一直进行循环来等待锁重新可用。当锁被释放后,请求锁的执行线程便能立刻得到它。其实就是忙等自旋锁只适用于那些在临界区停留很短时间的加锁操作。因为线程在等待锁期间会一直占据处理器,如果长时间等待锁会导致处理器效率降低。而如果线程占用锁只需要短暂的执行一下,那么使用自旋锁更优,因为不需要进行上下文的切换。 + +- **信号量** + + 可以理解为一种“睡眠锁” + + 自旋锁是“忙等”机制,在临界资源被锁定的时间很短的情况下很有效。但是在临界资源被持有时间很长或者不确定的情况下,忙等机制则会浪费很多宝贵的处理器时间。 + + 而信号量机制在进程无法获取到临界资源的情况下,立即释放处理器的使用权,并睡眠在所访问的临界资源上对应的等待队列上;在临界资源被释放时,再唤醒阻塞在该临界资源上的进程。信号量适用于长时间占用锁的情形。 + +- **读-写自旋锁** + + 具体就是在读写场景中了,为读和写提供不同的锁机制。 + + 当一个或者多个读任务时可以并发的持有读锁,但是写锁只能被一个人物所持有的。即对写者共享对读者排斥 + +- **读写信号量** + + 这个和读写自旋锁的思想是一样的。 + +- **mutex体制** + + 也是一种睡眠锁,是实现互斥的特定睡眠锁。是一种互斥信号 + + 使用mutex有很多限制,不像信号量那样想用就用 + + 1. 任何时刻只有一个任务可以持有mutex,引用计数只能是1 + 2. 给mutex上锁必须给其解锁,严格点就是必须在同一上下文中上锁和解锁 + 3. 持有mutex的进程不能退出 + 4. 等等 + +- **完成变量(completion variable)** + + 内核中一个任务需要发出信号通知另一个任务发生了某个特定事件,利用完成变量使得两个任务实现同步 + +- **BLK:大内核锁** + + 属于混沌时期的产物,是一个全局的自旋锁 + + 这个东西是早期linux不支持线程的时候用的,和自旋锁差不多的思想。 + + 现在一般不鼓励使用这个了 + +- **顺序锁** + + 这种锁用于读写共享数据。 + + 实现这个机制主要依靠序列计数器,当写数据时,会得到一个锁,序列值会增加。在读取数据前后这两个时间内,序列值都会被读取 + + 如果读取的序列号值相同,表明读操作在进行的过程中没有被写操作打断, + +- **关闭内核抢占** + + 内核是抢占性的,因此内核中的进程在任何时候都可能停下来以便让另一个具有更高优先级的进程运行。但是一个任务和被抢占的任务可能在同一个临界区运行。为了避免上述情况,当使用自旋锁时这个区域被标记为非抢占的,一个自旋锁被持有表示内核不能抢占调度。 + + 但是在一些情况下,就算不用自旋锁,也要关闭内核抢占。 + + 比如,对于只有一个处理器能够访问到数据,原理上是没有必要加自旋锁的,因为在任何时刻数据的访问者永远只有一位。但是,如果内核抢占没有关闭,则可能一个新调度的任务就可能访问同一个变量。 + + 所以这时候害怕的不是多个任务访问同问同一个变量,而是一个任务的访问还没有完成就转到了另一个任务。 + +- **RCU(Read, Copy, Update)** + + RCU(Read, Copy, Update)是一组Linux内核API,实现了一种同步机制,允许多个读者与写者并发操作而不需要任何锁,这种同步机制可以用于保护通过指针进行访问的数据。比较适合用在读操作很多而写操作极少的情况,可以用来替代读写锁。 + + 一个典型场景就是内核路由表,路由表的更新是由外部触发的,外部环境的延迟远比内核更新延迟高,在内核更新路由表前实际已经向旧路径转发了很多数据包,RCU读者按照旧路径再多转发几个数据包是完全可以接受的,而且由于RCU的无锁特性,实际上相比有锁的同步机制,内核可以更早生效新的路由表。路由表这个场景,以系统内部短时间的不一致为代价,降低了系统内部与外部世界的不一致时间,同时降低了读者成本。 + + > Q: **为什么只能保护通过指针访问的数据?** + > + > A: 任何CPU架构下的Linux都可以保证指针操作的原子性,这是无锁并发的前提。也就是说,假设CPU A在修改指针,无论何时CPU B读取该指针,都可以保证读取到的数据要么是旧的值,要么是新的值,绝不会是混合新旧值不同bit位的无意义值。因此使用RCU对更复杂的数据结构的保护都是基于对指向该数据结构的指针的保护。 + + + +# 死锁 + + > 概念:由于操作系统会产生并发,那会产生一个问题,就是多个进程因为争夺资源而互相陷入等待。 + + - **死锁产生的原因** + + 1. 资源分配不当 + 2. 进程运行的顺序不合理 + + - **产生死锁的必要条件**** + + 1. 互斥。某个资源只允许一个进程访问,如果已经有进程访问该资源,则其他进程就不能访问,直到该进程访问结束。 + 2. 占有的同时等待。一个进程占有其他资源的同时,还有资源未得到,需要其他进程释放该资源。 + 3. 不可抢占。别的进程已经占有某资源,自己不能去抢。 + 4. 循环等待。存在一个循环,每个进程都需要下一个进程的资源。 + + 以上四个条件均满足必然会造成死锁。会导致cpu吞吐量下降,死锁会浪费系统资源造成计算机性能下降。 + + - **避免死锁的方法** + + 我们要尽量避免四个条件同时产生,因此就要破坏。由于互斥条件是必须的,必须要保证的,因此从后面三条下手。 + + 1. 破坏“占有且等待条件”。 + + ①所有进程在开始运行之前,一次性申请到所有所需要的资源。 + + ②进程用完的资源释放掉,然后再去请求新的资源,提高利用率。 + + 2. 破坏“不可抢占”条件。 + + 当进程提出在得到一些资源时候不被满足的情况下,必须释放自己已经保存的资源。 + + 3. 破坏“循环等待”。 + + 实现资源有序分配策略,所有进程申请资源必须按照顺序执行。 + + 4. 银行家算法 + + [参考链接](https://www.cnblogs.com/wkfvawl/p/11929508.html) + + 银行家算法的实质就是即每当进程提出资源请求且系统的资源能够满足该请求时,系统将判断满足此次资源请求后系统状态是否安全(安全状态是非死锁状态,而不安全状态并不一定是死锁状态。即系统处于安全状态一定可以避免死锁,而系统处于不安全状态则仅仅可能进入死锁状态。),如果判断结果为安全,则给该进程分配资源,否则不分配资源,申请资源的进程将阻塞。 + + 银行家算法的执行有个前提条件,即要求进程预先提出自己的最大资源请求,并假设系统拥有固定的资源总量。 + + img + + + +# 虚拟内存 + +## 为什么需要虚拟内存?内存技术发展历程 + +- 1、无存储器抽象 + + 早期计算机没有存储器抽象,每一个程序都是直接访问物理内存。这种情况下在内存中同时运行两个程序是不可能的,因为如果第一个程序比如在内存2000的位置写入一个新的值,那么第二个程序运行的时候就会擦掉第一个程序的值。 + + 但是即使没有抽象概念,运行两个程序也是可能的。。。 + + 操作系统把当前内存中的内容保存在磁盘文件中,然后把下一个程序读入到内存中再运行就行,即某个时间内内存只有一个程序在运行就不会发生冲突。(最早期的交换的概念) + + 还有就是借助硬件来实现并发运行多个程序,比如IBM360中内存被划分为2KB的块,每个块有一个4位的保护键,保护键存储在CPU的特殊寄存器中。一个运行中的程序如果访问的保护键与操作系统中保存的不相符,则硬件会捕获到该异常,由于只有操作系统可以修改保护键,因此这样就可以防止用户进程之间、用户进程和操作系统间的干扰。 + +- 2、地址空间(存储抽象) + + > 把物理地址暴露给进程会带来几个问题: + > + > 1. 如果用户进程可以寻址内存中的每一个字节,就可以很容易的破坏操作系统,从而时电脑出故障。 + > 2. 想要同时运行多个程序比较困难 + + 在之前所说的解决多个程序在内存中互不影响的办法主要有两个:保护和重定位,也就是说并不是所有内存你都能访问,因此出现了地址空间的概念。 + + **地址空间**是一个进程可用于寻址内存的一套地址的集合。每个进程都有自己的地址空间,并且这个地址空间独立于其他进程的地址空间 + + 最开始的地址空间的解决办法是动态重定位,就是简单地把每个进程的地址空间映射到物理内存的不同部分。但是也有问题,即每个进程都有内存,如果同时运行多个进程,那所需要的内存是很庞大的,这个ram数远远超过存储器支持的。比如在Linux和Windows中,计算机完成引导后会启动50-100个进程,那你需要的空间可大了去了。 + + 处理上述内存超载问题有两个办法,就是交换技术和虚拟内存。 + + 交换技术就是把一个进程完整的调入内存,使得该进程完整运行一段时间后在调入磁盘。但是这样的话,你把一个完整的进程调入进去,可能很大程度只会使用一部分内存,这样是不划算的。 + + 所以有了虚拟内存,每个进程的一部分调入内存。 + +- 3、虚拟内存 + + 这个要重点说 + +## 什么是虚拟内存 + +根据上面所说,虚拟内存就是来解决“**程序大于内存的问题**”,即如何不把程序内存全部装进去。 + +**虚拟内存的基本思想是每个程序都拥有自己的地址空间,这些空间被分割成多个块儿。每一块儿被称作一页或者页面。每一个页面有连续的地址范围。这些页面被映射到物理内存,但是并不是一个程序的所有的页面都必须在内存中才能运行**。当程序引用到一部分在物理内存中的地址空间时,由硬件立刻执行映射。当程序引用到一部分不在物理内存中的地址空间时,由操作系统负责将缺失部分装入物理内存并重新执行指令。 + +**虚拟内存** 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如RAM)的使用也更有效率。 + +## 第一阶段:分段 + +程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段的形式 + +分段机制下的虚拟地址由两部分组成,**段选择子**和**段内偏移量**。 + +内存分段-寻址的方式 + +- 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。 +- 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。 + +虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图: + +内存分段-虚拟地址与物理地址 + +如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。 + +分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处: + +1. 第一个就是内存碎片的问题。 + - 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载; + - 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费; +2. 第二个就是内存交换的效率低的问题。 + +解决外部内存碎片的问题就是内存交换。这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 `Swap` 内存区域,这个过程会产生性能瓶颈。因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,**如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。**为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。 + +## 第二阶段:分页 + +在没有虚拟内存的计算机上,系统直接将虚拟地址送到内存总线上,读写操作使用具有同样地址的物理内存字。在使用虚拟内存的情况下,虚拟地址不是直接被送到内存总线上的,而是被送往内存管理单元MMU,然后MMU把虚拟地址映射为物理内存地址 + +> img +> +> 在这里MMU是作为CPU芯片的一部分,通常就是这样的。 + + + +**具体页表中虚拟地址与物理内存地址之间的映射关系如下:** + +> img +> +> 上图可以看到虚拟地址被划分成多个页面组成的单位,而物理内存地址中对应的是页框的概念。页面和页框的大小通常是一样的,在这里是4KB作为例子,实际系统中可能是512KB-1GB。上图虚拟地址空间有64KB,物理内存有32KB,因此由16个虚拟页面和8个页框。请记住,ram和也磁盘之间的交换总是以整个页面为单位进行的。 +> +> 比如当程序访问地址0的时候,其实是访问虚拟地址,然后将虚拟地址0送到MMU,MMU根据映射关系发现虚拟地址0在页面0-4095这个页面上,然后根据映射找到实际物理内存地址是第二个页框,即8192,然后把地址8192送到总线上。内存对MMU是一无所知的,他只看到了一个读写8192的请求并执行。 +> +> 当程序访问了一个未被映射的页面,即虚拟地址没有对应的页框索引。此时MMU注意到该页面没有映射,使CPU陷入到操作系统,即缺页中断或者缺页错误(page fault)。随后操作系统找到了一个很少使用的页框并把该页框内容写入磁盘,然后把需要访问的页面读到刚才被回收的那个页框上,修改映射关系,重新启动引起中断的指令就好。 +> +> 例如如果操作系统放弃页框1,即重新映射页框1,那么重新改变映射关系,将页面8装入物理地址4096(页框1),并且对MMU映射做两处修改:①由于页框1之前被页面1映射,因此要将页面1标记为未映射,使得以后对页面1的访问都将导致缺页中断。②把虚拟页面8的叉号改为1,表明虚拟页面8映射到页框1上去,当指令重新启动时候就会产生新的映射。 + + + +**MMU的内部操作:** + +> img +> +> 输入虚拟地址8196的二进制在最底下即0010000000000100,用MMU映射机进行映射,这16位虚拟地址被分解成4位的页号+12位的偏移量。4位页号表示16个页面,是页面的索引,12位的位偏移可以为一页内的全部4096个字节编址。 + + + +## 第二阶段:分表 + +虚拟地址到物理地址的映射可以概括如下:虚拟地址被分成虚拟页号+偏移量。虚拟页号是一个页表的索引,可以有该索引即页面号找到对应的页框号,然后将该页框号放到16位地址的前4位,替换掉虚拟页号,就形成了送往内存的地址。可以参考上面那个图中,两个二进制字符串的替换形式。如下图: + +img + +页表的目的是将虚拟页面映射为页框,从数学角度说页表是一个函数,输入参数是虚拟页号,输出结果是物理页框号。 + +页表的结构如下: + +img + +页表项中最重要的字段就是`页框号(Page frame number)`。毕竟,页表到页框最重要的一步操作就是要把此值映射过去。下一个比较重要的就是`在/不在`位,如果此位上的值是 1,那么页表项是有效的并且能够被`使用`。如果此值是 0 的话,则表示该页表项对应的虚拟页面`不在`内存中,访问该页面会引起一个`缺页异常(page fault)`。`保护位(Protection)` 告诉我们哪一种访问是允许的,啥意思呢?最简单的表示形式是这个域只有一位,**0 表示可读可写,1 表示的是只读**。`修改位(Modified)` 和 `访问位(Referenced)` 会跟踪页面的使用情况。当一个页面被写入时,硬件会自动的设置修改位。修改位在页面重新分配页框时很有用。如果一个页面已经被修改过(即它是 `脏` 的),则必须把它写回磁盘。如果一个页面没有被修改过(即它是 `干净`的),那么重新分配时这个页框会被直接丢弃,因为磁盘上的副本仍然是有效的。这个位有时也叫做 `脏位(dirty bit)`,因为它反映了页面的状态。`访问位(Referenced)` 在页面被访问时被设置,不管是读还是写。这个值能够帮助操作系统在发生缺页中断时选择要淘汰的页。不再使用的页要比正在使用的页更适合被淘汰。这个位在后面要讨论的`页面置换`算法中作用很大。最后一位用于禁止该页面被高速缓存,这个功能对于映射到设备寄存器还是内存中起到了关键作用。通过这一位可以禁用高速缓存。具有独立的 I/O 空间而不是用内存映射 I/O 的机器来说,并不需要这一位。 + +## 分页带来的问题 + +分页系统中要考虑两个问题: + +1. 虚拟地址到物理地址的映射必须非常快(速度问题) + + 由于每次访问内存都需要进行虚拟地址到物理地址的映射,所有的指令最终都必须来自内存,并且很多指令也会访问内存中的操作数。因此每条指令进行多次页表访问是必要的。如果执行一条指令需要1ns,则页表查询必须在0.2ns之内完成,以避免映射成为主要的瓶颈。 + +2. 如果虚拟地址空间很大,页表也会很大(空间问题) + + 现代计算机使用的虚拟地址至少为32位,而且越来越多的64位。假设一个页面大小为4KB,则32位的地址空间将有100万页,那么64位地址空间更多了。一个页表有100万条表项,你个存储开销就很大。而且每个进程都有自己的页表还 + +所以我们接下来主要解决的就是这两个问题。 + +## 解决速度问题 + +大多数优化技术都是从内存中的页表开始的,因为这里面会存在这有巨大影响的问题。比如一条1字节指令要把一个寄存器中的数据复制到另一个寄存器,在不分页的情况下这条指令只访问一次内存。有了分页机制之后,会因为要访问页表而引起多次的内存访问。同时,CPU的执行速度会被内存中取指令执行和取数据的速度拉低,所以会使性能下降。解决方案如下: + +由于大多数程序总是对少量页面进行多次访问,因此只有很少的页表项会被反复读取,而其他页表项很少会被访问。针对这个问题,为计算机设置一个小型的硬件设备,将虚拟地址直接映射到物理地址,而不必再去访问页表通过MMU得到物理地址,这个设备叫做**转换检测缓冲区**又叫做**快表(TLB)**。通常在MMU中,包含少量的表项,实际中应该有256个,每一个表项都记录了一个页面相关信息,即虚拟页号、修改为、保护码、对应的物理页框号,如下表所示: + +| 有效位 | 虚拟页面号 | 修改位 | 保护位 | 页框号 | +| :----: | :--------: | :----: | :----: | :----: | +| 1 | 140 | 1 | RW | 31 | +| 1 | 20 | 0 | R X | 38 | +| 1 | 130 | 1 | RW | 29 | +| 1 | 129 | 1 | RW | 62 | +| 1 | 19 | 0 | R X | 50 | +| 1 | 21 | 0 | R X | 45 | +| 1 | 860 | 1 | RW | 14 | +| 1 | 861 | 1 | RW | 75 | + +TLB 其实就是一种内存缓存,用于减少访问内存所需要的时间,它就是 MMU 的一部分,TLB 会将虚拟地址到物理地址的转换存储起来,通常可以称为`地址翻译缓存(address-translation cache)`。TLB 通常位于 CPU 和 CPU 缓存之间,它与 CPU 缓存是不同的缓存级别。下面我们来看一下 TLB 是如何工作的。 + +当一个 MMU 中的虚拟地址需要进行转换时,硬件首先检查虚拟页号与 TLB 中所有表项进行并行匹配,判断虚拟页是否在 TLB 中。如果找到了有效匹配项,并且要进行的访问操作没有违反保护位的话,则将页框号直接从 TLB 中取出而不用再直接访问页表。如果虚拟页在 TLB 中但是违反了保护位的权限的话(比如只允许读但是是一个写指令),则会生成一个`保护错误(protection fault)` 返回。上面探讨的是虚拟地址在 TLB 中的情况,那么如果虚拟地址不再 TLB 中该怎么办?如果 MMU 检测到没有有效的匹配项,就会进行正常的页表查找,然后从 TLB 中逐出一个表项然后把从页表中找到的项放在 TLB 中。当一个表项被从 TLB 中清除出,将修改位复制到内存中页表项,除了访问位之外,其他位保持不变。当页表项从页表装入 TLB 中时,所有的值都来自于内存。 + +下面给出流程图: + +img + +**软件 TLB 管理** + +直到现在,我们假设每台电脑都有可以被硬件识别的页表,外加一个 TLB。在这个设计中,TLB 管理和处理 TLB 错误完全由硬件来完成。仅仅当页面不在内存中时,才会发生操作系统的`陷入(trap)`。 + +但是有些机器几乎所有的页面管理都是在软件中完成的。 + +在这些计算机上,TLB 条目由操作系统显示加载。当发生 TLB 访问丢失时,**不再是由 MMU 到页表中查找并取出需要的页表项,而是生成一个 TLB 失效并将问题交给操作系统解决**。操作系统必须找到该页,把它从 TLB 中移除(移除页表中的一项),然后把新找到的页放在 TLB 中,最后再执行先前出错的指令。然而,所有这些操作都必须通过少量指令完成,因为 TLB 丢失的发生率要比出错率高很多。 + +无论是用硬件还是用软件来处理 TLB 失效,常见的方式都是找到页表并执行索引操作以定位到将要访问的页面,在软件中进行搜索的问题是保存页表的页可能不在 TLB 中,这将在处理过程中导致其他 TLB 错误。改善方法是可以在内存中的固定位置维护一个大的 TLB 表项的高速缓存来减少 TLB 失效。通过首先检查软件的高速缓存,`操作系统` 能够有效的减少 TLB 失效问题。 + +TLB 软件管理会有两种 TLB 失效问题,当一个页访问在内存中而不在 TLB 中时,将产生 `软失效(soft miss)`,那么此时要做的就是把页表更新到 TLB 中(我们上面探讨的过程),而不会产生磁盘 I/O,处理仅仅需要一些机器指令在几纳秒的时间内完成。然而,当页本身不在内存中时,将会产生`硬失效(hard miss)`,那么此时就需要从磁盘中进行页表提取,硬失效的处理时间通常是软失效的百万倍。在页表结构中查找映射的过程称为 `页表遍历(page table walk)`。如图: + +img + +上面的这两种情况都是理想情况下出现的现象,但是在实际应用过程中情况会更加复杂,未命中的情况可能既不是硬失效又不是软失效。一些未命中可能更`软`或更`硬`。比如,如果页表遍历的过程中没有找到所需要的页,那么此时会出现三种情况: + +1. 所需的页面就在内存中,但是却没有记录在进程的页表中,这种情况可能是由其他进程从磁盘掉入内存,这种情况只需要把页正确映射就可以了,而不需要在从硬盘调入,这是一种软失效,称为 `次要缺页错误(minor page fault)`。 +2. 基于上述情况,如果需要从硬盘直接调入页面,这就是`严重缺页错误(major page falut)`。 +3. 还有一种情况是,程序可能访问了一个非法地址,根本无需向 TLB 中增加映射。此时,操作系统会报告一个 `段错误(segmentation fault)` 来终止程序。只有第三种缺页属于程序错误,其他缺页情况都会被硬件或操作系统以降低程序性能为代价来修复 + +## 解决内存太大问题 + +上面引入TLB加快虚拟地址到物理地址的转换,另一个要解决的问题就是处理巨大的虚拟空间,有两种解决方法:多级页表和倒排页表。 + +#### 多级页表 + +> 从整体来过一遍虚拟地址的概念,虚拟存储器的基本思想是:程序、数据和堆栈的总大小可能超过可用的物理内存的大小。由操作系统把程序当前使用的那些部分保留在主存中,而把其他部分保存在磁盘上。例如,对于一个16MB的程序,通过仔细地选择在每个时刻将哪4MB内容保留在内存中,并在需要时在内存和磁盘间交换程序的片段,这样这个程序就可以在一个4MB的机器上运行。 +> +> 由程序产生的地址被称为虚拟地址,它们构成了一个虚拟地址空间。在使用虚拟存储器的情况下,虚拟地址不是被直接送到内存总线上,而且是被送到内存管理单元(Memory Management Unt,MMU),MMU把虚拟地址映射为物理内存地址。 +> +> 虚拟地址空间以页面为单位划分。在物理内存中对应的单位称为页帧。页面和页帧的大小总是一样的。 + +- **页表在哪儿** + + 任何进程的切换都会更换活动页表集,Linux中为每一个进程维护了一个tast_struct结构体(进程描述符PCB),其中tast_struct->mm_struct结构体成员用来保存该进程页表。在进程切换的过程中,内核把新的页表的地址写入CR3控制寄存器。CR3中含有页目录表的物理内存基地址,如下图: + + img + +- **为什么省空间?** + + 假如每个进程都有4GB的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到4GB,何必去映射不可能用到的空间呢?一级页表覆盖了整个4GB虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。 + + 如果每个页表项都存在对应的映射地址那也就算了,但是,绝大部分程序仅仅使用了几个页,也就是说,只需要几个页的映射就可以了,如下图(左),进程1的页表,只用到了0,1,1024三个页,剩下的1048573页表项是空的,这就造成了巨大的浪费,为了避免内存浪费,计算机系统开发人员想出了一个方案,多级页表。 + + 页表是啥以及为啥多级页表能够节省空间_页表项02 + + 下面计算一下上图(右)的内存占用情况,对于一级页表来说,假设一个表项是4B,则 + + 一级页表占用:1024 * 4 B= 4K + + 2级页表占用 = (1024 * 4 B) * 2 = 8K。 + + 总共的占用情况是 12K,相比只有一个页表 4M,节省了99.7%的内存占用。 + + **因此引入多级页表的原因就是避免把全部页表一直保存在内存中。** + +- **《深入理解计算机操作系统》 中的解释** + + img + + 假设32位的虚拟地址被划分为10位的PT1域、10位的PT2域和12位的偏移量域,工作过程如下:当一个虚拟地址被送到MMU时,MMU首先提取PT1域并把该值作为访问顶级页表的索引。由于虚拟空间为32G,顶级页表有2^10(PT1)^=1024个表项,则二级页表有2^10(PT2)^=1024个表项,每一个二级页表都含有虚拟地址对应物理地址的页框号,该页框号与偏移量结合便形成物理地址,放到地址总线送入内存中。 + + 如果没有多级页表,则32位虚拟内存应该有100万个页面,这个存储开销是很大的,而有了多级页表,则实际只需要4个页表,顶级页表、0、1、1024这四个,顶级页表中的其他表项的“在/不在”为被设置为0,访问他们是强制产生一个缺页中断。 + +#### 倒排页表 + +针对不断分级的页表的替代方案是**倒排页表**,实际内存中的每个页框对应一个表项,而不是每个虚拟页面对应一个表项。 + +对于64位虚拟地址,4KB的页,4GB的RAM,一个倒排页表仅需要1048576个表项。 + +由于4KB的页面,因此需要2^64^/2^12^=2^52^个页面,但是1GB物理内存只能有2^18^个4KB页框 + +> 虽然倒排页表节省了大量的空间,但是它也有自己的缺陷:那就是从虚拟地址到物理地址的转换会变得很困难。当进程 n 访问虚拟页面 p 时,硬件不能再通过把 p 当作指向页表的一个索引来查找物理页。而是必须搜索整个倒排表来查找某个表项。另外,搜索必须对每一个内存访问操作都执行一次,而不是在发生缺页中断时执行。解决这一问题的方式时使用 TLB。当发生 TLB 失效时,需要用软件搜索整个倒排页表。一个可行的方式是建立一个散列表,用虚拟地址来散列。当前所有内存中的具有相同散列值的虚拟页面被链接在一起。如下图所示 +> +> 如果散列表中的槽数与机器中物理页面数一样多,那么散列表的冲突链的长度将会是 1 个表项的长度,这将会大大提高映射速度。一旦页框被找到,新的(虚拟页号,物理页框号)就会被装在到 TLB 中。 +> +> img +> +> +> +> + + + +# 逻辑地址到物理地址的转换 + +页式存储管理的逻辑地址分为两部分:页号和页内地址。物理地址分为两部分:块号+页内地址; + +逻辑地址= 页号+页内地址 + +物理地址= 块号+页内地址; + +**举例子** + +某虚拟存储器的用户编程空间共32个页面,每页为1KB,内存为16KB。假定某时刻一用户页表中已调入内存的页面的页号和物理块号的对照表如下,则逻辑地址0A5C(H)所对应的物理地址是什么?要求:写出主要计算过程。 + +| 页号 | 物理块号 | +| ---- | -------- | +| 0 | 3 | +| 1 | 7 | +| 2 | 11 | +| 3 | 8 | + +**解答** + +用户编程空间共32个页面,$2^5 = 32$得知页号部分占5位,由“每页为1KB”,$1K=2^{10}$,可知内页地址占10位。 + +由“内存为16KB”,$2^4 = 16$得知块号占4位。 + +然后找页号部分,转换成二进制查表,得到物理块号,然后转为二进制拼接页内地址就ok了。 + +逻辑地址0A5C(H)所对应的二进制表示形式是:0000101001011100,后十位1001011100是页内地址, + +00010为为页号,页号化为十进制是2,在对照表中找到2对应的物理块号是11,11转换二进制是1011,即可求出物理地址为10111001011100,化成十六进制为2E5C; + +即则逻辑地址0A5C(H)所对应的物理地址是2E5C; + + + +# 页面置换算法 + +**缺页中断:**一个进程所有地址空间里的页面不必全部常驻内存,在执行一条指令时,如果发现他要访问的页没有在内存中(即存在位为0),那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从物理内存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。 + +- 最佳页面置换算法(*OPT*)(理想算法) + + 最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。 + +- 先进先出置换算法(*FIFO*) + + 选择在内存驻留时间很长的页面进行中置换 + +- 最近最久未使用的置换算法(*LRU*) + + 发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。 + + 虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。 + +- 时钟页面置换算法(*Lock*) + + 把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。 + + 当发生缺页中断时,算法首先检查表针指向的页面: + + - 如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置; + - 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止; + +- 最不常用置换算法(*LFU*) + + 当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。 + + 要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。 + + + +# 对缺页中断的处理 + +1. 硬件陷入内核,在堆栈中保存程序计数器。大多数机器将当前的指令,各种状态信息保存在特殊的CPU寄存器中。 + +2. 启动一个汇编代码保存通用寄存器和其他易失信息,防止被操作系统破坏 + +3. 当操作系统收到缺页中断信号后,定位到需要的虚拟页面。 + +4. 找到发生缺页中断的虚拟地址,操作系统检查这个地址是否有效,并检查存取与保护是否一致。 + + 如果不一致则杀掉该进程 + + 如果地址有效且没有保护错误发生,系统会检查是否有空闲页框。如果没有空闲页框就执行页面置换算法淘汰一个页面。 + +5. 如果选择的页框对应的页面发生了修改,即为“脏页面”,需要写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至全部把内容写到磁盘。 + +6. 一旦页框是干净的,则OS会查找要发生置换的页面对应磁盘上的地址,通过磁盘操作将其装入。在装入该页面的时候,产生缺页中断的进程仍然被挂起,运行其他可运行的进程 + +7. 当发生磁盘中断时表明该页面已经被装入,页表已经更新可以反映其位置,页框也被标记为正常状态。 + +8. 恢复发生缺页中断指令以前的状态,程序计数器重新指向引起缺页中断的指令 + +9. 调度引发缺页中断的进程 + +10. 该进程恢复寄存器和其他状态信息,返回用户空间继续执行。 + + + +# Linux可执行文件如何装载进虚拟内存 + +源代码通过预处理,编译,汇编,链接后形成可执行文件,因为计算机的操作系统的启动程序是写死在硬件上的,每次计算机上电时,都将自动加载启动程序,之后的每一个程序,每一个应用,都是不断的 fork 出来的新进程。那么我们的可执行文件,以linux 系统为例,也是由shell 进程 fork 出一个新进程,在新进程中调用exec函数装载我们的可执行文件并执行。 + +1. execve()。当shell中敲入执行程序的指令之后,shell进程获取到敲入的指令,并执行execve()函数,该函数的参数是敲入的可执行文件名和形参,还有就是环境变量信息。execve()函数对进程栈进行初始化,即压栈环境变量值,并压栈传入的参数值,最后压栈可执行文件名。初始化完成后调用 sys_execve() + +2. sys_execve()。该函数进行一些参数的检查与复制,而后调用 do_execve() + +3. do_execve()。该函数在当前路径与环境变量的路径中寻找给定的可执行文件名,找到文件后读取该文件的前128字节。读取这128个字节的目的是为了判断文件的格式,每个文件的开头几个字节都是魔数,可以用来判断文件类型。读取了前128字节的文件头部后,将调用 search_binary_handle() + +4. search_binary_handle()。该函数将去搜索和匹配合适的可执行文件装载处理程序。Linux 中所有被支持的可执行文件格式都有相应的装在处理程序。以Linux 中的ELF 文件为例,接下来将会调用elf 文件的处理程序:load_elf_binary() + +5. load_elf_binary()。 + + 该函数执行以下三个步骤: + + a)创建虚拟地址空间:实际上指的是建立从虚拟地址空间到物理内存的映射函数所需要的相应的数据结构。(即创建一个空的页表) + + b)读取可执行文件的文件头,建立可执行文件到虚拟地址空间之间的映射关系 + + c)将CPU指令寄存器设置为可执行文件入口(虚拟空间中的一个地址) + + load_elf_binary()函数执行完毕,事实上装载函数执行完毕后,可执行文件真正的指令和数据都没有被装入内存中,只是建立了可执行文件与虚拟内存之间的映射关系,以及分配了一个空的页表,用来存储虚拟内存与物理内存之间的映射关系。 + +6. 程序返回到execve()中。此时从内核态返回到用户态,且寄存器的地址被设置为了ELF 的入口地址,于是新的程序开始启动,发现程序入口对应的页面并没有加载(因为初始时是空页面),则此时引发一个缺页错误,操作系统根据可执行文件和虚拟内存之间的映射关系,在磁盘上找到缺的页,并申请物理内存,将其加载到物理内存中,并在页表中填入该虚拟内存页与物理内存页之间的映射关系。之后程序正常运行,直至结束后回到shell 父进程中,结束回到 shell。 + + + +# 说一说内存对齐 + +- 由一个例子引出内存对齐 + + ```c + //32位系统 + #include + struct{ + int x; + char y; + }s; + + int main() + { + printf("%d\n",sizeof(s); // 输出8 + return 0; + } + ``` + + 上述代码实际得到的值是8byte,这就是内存对齐所导致的。 + + > 从理论上来讲,任何类型的变量可以从任意地址开始存放。但是实际上计算机对基本数据类型在内存中的存放位置有限制,会要求数据首地址的值是某个数(通常是4或8)的整数倍,这就是内存对齐。 + +- 为什么要内存对齐呢? + + 计算机的内存是以字节为单位的,但是大部分处理器并不是按照字节块来存取内存的。计算机有个内存存取粒度的概念,就是说一般以2字节,4字节,8字节这样的单位来存取内存。 + + 性能原因:加入没有内存对齐机制,数据可以任意存放。地址排列为01234567现在读取一个int型变量,这个变量存放地址从1开始,那我们从0字节开始找,找到1后读取第一个4字节,把0扔掉,然后从地址4开始读下4个字节,扔掉567地址,int变量就存储在1234这里,可以看到这样很浪费开销,因为访问了两次内存。 + +- 内存对齐的规则 + + 对其规则如下: + + 1. 基本类型的对齐值就是sizeof值。如果该成员是c++自带类型如int、char、double等,那么其对齐字节数=该类型在内存中所占的字节数;如果该成员是自定义类型如某个class或者struct,那个它的对齐字节数 = 该类型内最大的成员对齐字节数 + 2. 结构体。结构体本身也要对齐,按照最大成员长度来参照的。 + 3. 编译器可以设置最大对齐值,gcc中默认是#pragma pack(4)。但是类型的实际对齐值与默认对齐值取最小值来 + 3. 如果设置了对齐字节数,就另说。①如果定义的字节数为1,就是所有默认字节数直接相加。②定义的字节数大于任何一个成员大小时候,不产生任何效果。如果定义的对齐字节数大于结构体内最小的,小于结构体内最大的话,就按照定义的字节数来计算 + + + +# 内存空间的堆和栈的区别是什么? + +- 程序内存布局场景下,堆与栈表示两种内存管理方式; + + - 栈是由操作系统自动分配的,用于存放函数参数值,局部变量。存储在栈中的数据的生命周期随着函数的执行结束而结束。栈的内存生长方向与堆相反,由高到低,按照变量定义的先后顺序入栈。 + + - 堆是由用户自己分配的。如果用户不回收,程序结束后由操作系统自动回收。堆的内存地址生长方向与栈相反,由低到高。 + + > **堆上分配内存的过程:** + > + > 操作系统有一个记录空闲内存地址的链表,当系统收到程序的开辟内存申请时候,会遍历该链表,寻找第一个空间大于所申请内存空间的节点。接着把该节点从空闲链表中删除,同时将该空间分配出去给程序使用。同时大多数系统会在内存空间中的首地址记录此次分配的大小,这样delete才能正确释放内存空间。由于找到的节点所对应的内存大小不一定正好等于申请内存的大小,OS会自动的将多余的部分放入空闲链表。 + + - 堆与栈区别的总结 + + 1. 管理方式不同 + 2. 空间大小不同 + 3. 分配方式不同。堆都是动态分配。栈的静态分配由操作系统完成,回收也是自动的。栈的动态分配有alloca函数分配,由操作系统自动回收。 + 4. 分配效率不同。栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。 + +- 数据结构场景下,堆与栈表示两种常用的数据结构。 + + 栈是线性结构 + + 堆是一种特殊的完全二叉树 + +**内存为什么要分堆栈在编程里,要是全部只用堆或者全部只用栈可以吗** + +程序的结构是前部是栈,中部是程序体,后部是堆。前部和中部是链接的时候由编译器算好了的,执行过程中是固定不变的。而后部则可以随着程序的执行而改变长度。 + +栈是用来存储程序初期设定的变量的,这些变量在程序执行之前就要准备好。因此栈要放在程序的前部并且固定位置,否则程序就不知道该到那里去找这些变量。而程序的运行结果往往是不能预先确定的,所以把堆放在后部以便可以提供足够的内存保存运算结果。 + + + +# 进程虚拟空间是怎么布局的?进程内存模型 + +linux进程在32位处理器下的虚拟空间内存布局,从高地址到低地址 + +img + +- 内核空间,从C000000-FFFFFFFF + +- 栈区。有以下用途 + + 1. 存储函数局部变量 + 2. 记录函数调用过程的相关信息,成为栈帧,主要包括函数返回地址,一些不适合放在寄存器中的函数参数。 + 3. 临时存储区,暂存算术表达式的计算结果和allocation函数分配的栈内存。 + +- 内存映射段(mmap)。内核将硬盘文件的内容直接映射到内存,是一种方便高校的文件I/O方式,用于装在动态共享库。 + +- 堆。分配的堆内存是经过字节对齐的空间,以适合原子操作。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统可能会自动回收。 + +- BSS段。通常存放以下内容: + + 1. 未初始化的全局变量和静态局部变量 + 2. 初始化值为0的全局变量和静态局部变量 + 3. 未定义且初值不为0的符号 + +- 数据段。通常存放程序中已经初始化且初值不为0的全局变量。数据段属于静态存储区,可读可写。 + + 数据段和BSS段的区别如下: + + 1. BSS段不占用物理文件尺寸,但占用内存空间(不在可执行文件中)。数据段在可执行文件中,也占用内存空间。 + 2. 当程序读取数据段的数据时候,系统会发生缺页故障,从而分配物理内存。当程序读取BSS段数据的时候,内核会将其转到一个全零页面,不会发生缺页故障,也不会为期分配物理内存。 + 3. bss是不占用.exe文件(可执行文件)空间的,其内容由**操作系统初始化**(清零);而data却需要占用,其内容由**程序初始化**,因此造成了上述情况。 + +- 代码段。代码段也称正文段或文本段,通常用于存放程序执行代码(即CPU执行的机器指令)。一般C语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。 + +- 保留区。位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。 + + + +# 系统调用和进程上下文切换的区别 + +系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的: + +进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行。 + +# 上下文切换 + +> **CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境。** +> +> - CPU 寄存器是 CPU 内置的容量小、但速度极快的内存。 +> - 程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。 + +## 什么是CPU上下文切换 + +就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。 + +## CPU 上下文切换的类型 + +- **进程上下文切换** + + 1. .切换页目录以使用新的地址空间 + 2. 切换内核栈 + 3. 切换硬件上下文 + 4. 刷新TLB + 5. 系统调度器的代码执行 + + + +- **线程上下文切换** + + **两个线程属于不同进程** + + 前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样。 + + **两个线程属于相同进程** + + 线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的, + 但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。 + 内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。 + + 另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。 + 简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。 + +- **中断上下文切换** + + 为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。 + + **中断上下文切换并不涉及到进程的用户态**。即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。只需要关注内核资源就行,CPU寄存器,内核堆栈,硬件中断参数啥的。 + +- **进程上下文切换的场景:** + + 1. 时间片轮转技术下,该进程分配到的时间片耗尽,就会被系统挂起,切换到其他进程 + 2. 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。 + 3. 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。 + 4. 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行 + 5. 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。 + + + +# 什么是大端字节,什么是小端字节?如何转换字节序? + +大端序,小端序是计算机存储数据的两种方式。 + +大端字节序:高位字节在前,低位字节在后,符合人类读写数值的习惯(参考普通的十进制数) + +小端字节序:低位字节在前,高位字节在后。 + +- 为什么要有大小端字节序?统一不行吗? + + 答:①内存的低地址处存放低字节,所以在强制转换数据时不需要调整字节的内容(注解:比如把int的4字节强制转换成short的2字节时,就直接把int数据存储的前两个字节给short就行,因为其前两个字节刚好就是最低的两个字节,符合转换逻辑);②CPU做数值运算时从内存中依顺序依次从低位到高位取数据进行运算,直到最后刷新最高位的符号位,这样的运算方式会更高效。但是大端序更符合人类的习惯,主要用在网络传输和文件存储方面,符号位在所表示的数据的内存的第一个字节中,便于快速判断数据的正负和大小。 + + 其各自的优点就是对方的缺点,正因为两者彼此不分伯仲,再加上一些硬件厂商的坚持,因此在多字节存储顺序上始终没有一个统一的标准 + +- 转换字节序 + + 主要是针对主机字节序和网络字节序来说的。我们用的x86架构的处理器一般都是小端序存储数据,但是网络字节序是TCP/IP中规定好的数据表示格式,RFC1700规定使用“大端”字节序为网络字节序,独立于处理器操作系统。 + + 在Linux网络编程中,会使用下列C标准库函数进行字节之间的转换 + + ```c + #include + + uint32_t htonl(uint32_t hostlong); //把uint32_t类型从主机序转换到网络序 + uint16_t htons(uint16_t hostshort); //把uint16_t类型从主机序转换到网络序 + uint32_t ntohl(uint32_t netlong); //把uint32_t类型从网络序转换到主机序 + uint16_t ntohs(uint16_t netshort); //把uint16_t类型从网络序转换到主机序 + ``` + +- 如何判断本机是大端序还是小端序? + + ```c + int i=1; + char *p=(char *)&i; + if(*p == 1) + printf("小端模式"); //小端序是低位字节在前,高位字节在后 + else // (*p == 0) + printf("大端模式"); + ``` + + + +# 操作系统是怎么进行进程管理的? + +![img](https://cdn.jsdelivr.net/gh/luogou/cloudimg/data/20210901111845.jpeg) + +如果问到进程管理,就从进程通信,同步和死锁三大块来说! + +- [进程间通信](# 进程间通信方式) +- [进程同步](#进程同步) +- [死锁](#死锁) +- [进程调度](#CPU调度算法(进程调度算法)) + + + +# mmap(内存映射) + +## 什么是mmap? + +mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。 + +img + +## mmap的过程—原理 + +1. 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域 +2. 调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系 +3. 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝 + +## mmap和常规文件操作的区别 + +常规文件的操作如下: + +1. 进程发起读文件请求。 +2. 内核查找文件描述符,定位到内核已打开的文件信息,找到文件的inode。 +3. 查看文件页是否在缓存中,如果存在则直接返回这片页面 +4. 如果不存在,缺页中断,需要定位到该文件的磁盘地址处,将数据从磁盘复制到页缓存中,然后发起页面读写过程,将页缓存中的数据发送给用户 + +常规文件需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。 + +而使用mmap操作文件中,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。 + + + + + +# 共享内存的原理 + +共享内存可以说是最有用的进程间通信方式,也是最快的IPC形式。两个不同进程A、B共享内存的意思是,同一块物理内存被映射到进程A、B各自的进程地址空间。进程A可以即时看到进程B对共享内存中数据的更新,反之亦然。由于多个进程共享同一块内存区域,必然需要某种同步机制,互斥锁和信号量都可以。 + +这种 IPC 机制无需内核介入! + +采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝(用户空间buf到内核,内核把数据拷贝到内存,内存拷贝到内核,内核到用户空间),而共享内存则只拷贝两次数据(一次从输入文件到共享内存区,另一次从共享内存区到输出文件。) + +实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。 + +**共享内存位于进程空间的什么位置?** + +不同进程之间共享的内存通常为同一段物理内存。进程可以将同一段物理内存连接到他们自己的地址空间中,所有的进程都可以访问共享内存中的地址。 + +**共享内存段最大限制是多少?** + +单个共享内存段最大字节数,一般为32M + +共享内存段最大个数,一般为4096 + +系统中共享内存页总数默认值:2097152*4096=8GB + +**共享内存不保证同步,可以使用信号量来保证共享内存同步** + +> 注意共享内存和内存映射是不同的 + +[参考](https://www.colourso.top/linux-mmapshm/) + + + +# 共享内存和内存映射的区别 + +1. 共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外) + +2. 共享内存效率更高 + +3. 内存。 + + 共享内存,所有的进程操作的是同一块共享内存。 + + 内存映射,每个进程在自己的虚拟地址空间中有一个独立的内存。 + +4. 数据安全 + + 进程突然退出时,共享内存还存在,内存映射区消失 + + 运行进程的电脑死机,宕机时。在共享内存中的数据会消失。内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在。 + +5. 生命周期 + + 内存映射区:进程退出,内存映射区销毁 + + 共享内存:进程退出,共享内存还在,标记删除(所有的关联的进程数为0),或者关机 + 如果一个进程退出,会自动和共享内存进行取消关联。 + + + +# malloc是如何实现内存管理的 + +[参考链接](https://jacktang816.github.io/post/mallocandfree/) + +C语言中使用malloc可以分配一段连续的内存空间。在c/c++开发中,因为malloc属于C标准库函数,经常会使用其分配内存。malloc是在堆中分配一块可用内存给用户。作为一个使用频繁的基础函数,理解清楚其实现原理很有必要,因此本文主要探讨malloc的具体实现原理,以及在linux系统中这该函数的实现方式。 + +## 内存分配涉及到的系统调用 + +**跟brk/sbrk/mmap这三个系统调用函数有关** + + + +上图是一个堆内存的分布,其实在堆内存有三段空间,第一段是映射好的,指针可以访问的;第二段是未映射的地址空间,访问这段空间会报错;第三段是无法使用的空间。其中映射好的空间和未映射的空间由一个brk指针分割。所以如果要增加实际堆的可用大小,就可以移动brk指针。 + +**brk()和sbrk()** + +要增加一个进程实际的可用堆大小,就需要将break指针向高地址移动。Linux通过**brk和sbrk系统调用**操作break指针。两个系统调用的原型如下: + +```c+ +#include +int brk(void *addr); +void *sbrk(intptr_t increment); +``` + +brk函数将break指针直接设置为某个地址,而sbrk将break指针从当前位置移动increment所指定的增量。所以我们使用sbrk获得brk的地址。brk在执行成功时返回0,否则返回-1并设置errno为ENOMEM;sbrk成功时返回break指针移动之前所指向的地址,否则返回(void *)-1。 + +**mmap函数:**mmap函数第一种用法是映射磁盘文件到内存中;而malloc使用的mmap函数的第二种用法,即匿名映射,匿名映射不映射磁盘文件,而是向映射区申请一块内存。当申请小内存的时,malloc使用sbrk分配内存;当申请大内存时,使用mmap函数申请内存;但是这只是分配了虚拟内存,还没有映射到物理内存,当访问申请的内存时,才会因为缺页异常,内核分配物理内存。 + +## malloc实现方案 + +由于brk/sbrk/mmap属于系统调用,如果每次申请内存,都调用这三个函数中的一个,那么每次都要产生系统调用开销(即cpu从用户态切换到内核态的上下文切换,这里要保存用户态数据,等会还要切换回用户态),这是非常影响性能的;其次,这样申请的内存容易产生碎片,因为堆是从低地址到高地址,如果低地址的内存没有被释放,高地址的内存就不能被回收。鉴于此,**malloc采用的是内存池的实现方式**,malloc内存池实现方式更类似于STL分配器和memcached的内存池,先申请一大块内存,然后将内存分成不同大小的内存块,然后用户申请内存时,直接从内存池中选择一块相近的内存块即可。 + +img + +如上图,内存池保存在bins这个长128的数组中,每个元素都是一双向个链表。 + +malloc将内存分成了大小不同的chunk,然后通过bins来组织起来。malloc将相似大小的chunk(图中可以看出同一链表上的chunk大小差不多)用双向链表链接起来,这样一个链表被称为一个bin。malloc一共维护了128个bin,并使用一个数组来存储这些bin。数组中第一个为**unsorted bin**,数组编号前2到前64的bin为**small bins**,同一个small bin中的chunk具有相同的大小,两个相邻的small bin中的chunk大小相差8bytes。small bins后面的bin被称作**large bins**。large bins中的每一个bin分别包含了一个给定范围内的chunk,其中的chunk按大小序排列。large bin的每个bin相差64字节。 + +malloc除了有unsorted bin,small bin,large bin三个bin之外,还有一个**fast bin**。一般的情况是,程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,malloc 中在分配过程中引入了 fast bins,不大于 max_fast(默认值为 64B)的 chunk 被释放后,首先会被放到 fast bins中,fast bins 中的 chunk 并不改变它的使用标志 P。这样也就无法将它们合并,当需要给用户分配的 chunk 小于或等于 max_fast 时,malloc 首先会在 fast bins 中查找相应的空闲块,然后才会去查找 bins 中的空闲 chunk。在某个特定的时候,malloc 会遍历 fast bins 中的 chunk,将相邻的空闲 chunk 进行合并,并将合并后的 chunk 加入 unsorted bin 中,然后再将 unsorted bin 里的 chunk 加入 bins 中。 + +unsorted bin 的队列使用 bins 数组的第一个,如果被用户释放的 chunk 大于 max_fast,或者 fast bins 中的空闲 chunk 合并后,这些 chunk 首先会被放到 unsorted bin 队列中,在进行 malloc 操作的时候,如果在 fast bins 中没有找到合适的 chunk,则malloc 会先在 unsorted bin 中查找合适的空闲 chunk,然后才查找 bins。如果 unsorted bin 不能满足分配要求。 malloc便会将 unsorted bin 中的 chunk 加入 bins 中。然后再从 bins 中继续进行查找和分配过程。从这个过程可以看出来,**unsorted bin 可以看做是 bins 的一个缓冲区,增加它只是为了加快分配的速度。**(其实感觉在这里还利用了局部性原理,常用的内存块大小差不多,从unsorted bin这里取就行了,这个和TLB之类的都是异曲同工之妙啊!) + +除了上述四种bins之外,malloc还有三种内存区。 + +- 当fast bin和bins都不能满足内存需求时,malloc会设法在**top chunk**中分配一块内存给用户;top chunk为在mmap区域分配一块较大的空闲内存模拟sub-heap。(比较大的时候) >top chunk是堆顶的chunk,堆顶指针brk位于top chunk的顶部。移动brk指针,即可扩充top chunk的大小。当top chunk大小超过128k(可配置)时,会触发malloc_trim操作,调用sbrk(-size)将内存归还操作系统。 +- 当chunk足够大,fast bin和bins都不能满足要求,甚至top chunk都不能满足时,malloc会从mmap来直接使用内存映射来将页映射到进程空间,这样的chunk释放时,直接解除映射,归还给操作系统。(极限大的时候) +- Last remainder是另外一种特殊的chunk,就像top chunk和mmaped chunk一样,不会在任何bins中找到这种chunk。当需要分配一个small chunk,但在small bins中找不到合适的chunk,如果last remainder chunk的大小大于所需要的small chunk大小,last remainder chunk被分裂成两个chunk,其中一个chunk返回给用户,另一个chunk变成新的last remainder chunk。(这个应该是fast bins中也找不到合适的时候,用于极限小的) + +由之前的分析可知malloc利用chunk结构来管理内存块,malloc就是由不同大小的chunk链表组成的。malloc会给用户分配的空间的前后加上一些控制信息,用这样的方法来记录分配的信息,以便完成分配和释放工作。chunk指针指向chunk开始的地方,图中的mem指针才是真正返回给用户的内存指针。 + +## malloc 内存分配流程 + +1. 如果分配内存<512字节,则通过内存大小定位到smallbins对应的index上(floor(size/8)) + - 如果smallbins[index]为空,进入步骤3 + - 如果smallbins[index]非空,直接返回第一个chunk +2. 如果分配内存>512字节,则定位到largebins对应的index上 + - 如果largebins[index]为空,进入步骤3 + - 如果largebins[index]非空,扫描链表,找到第一个大小最合适的chunk,如size=12.5K,则使用chunk B,剩下的0.5k放入unsorted_list中 +3. 遍历unsorted_list,查找合适size的chunk,如果找到则返回;否则,将这些chunk都归类放到smallbins和largebins里面 +4. index++从更大的链表中查找,直到找到合适大小的chunk为止,找到后将chunk拆分,并将剩余的加入到unsorted_list中 +5. 如果还没有找到,那么使用top chunk +6. 或者,内存<128k,使用brk;内存>128k,使用mmap获取新内存 + +此外,调用free函数时,它将用户释放的内存块连接到空闲链上。到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段,那么空闲链上可能没有可以满足用户要求的片段了。于是,malloc函数请求延时,并开始在空闲链上翻箱倒柜地检查各内存片段,对它们进行整理,将相邻的小空闲块合并成较大的内存块。 + +## 内存碎片 + +free释放内存时,有两种情况: + +1. chunk和top chunk相邻,则和top chunk合并 +2. chunk和top chunk不相邻,则直接插入到unsorted_list中 + +img + +如上图示: top chunk是堆顶的chunk,堆顶指针brk位于top chunk的顶部。移动brk指针,即可扩充top chunk的大小。当top chunk大小超过128k(可配置)时,会触发malloc_trim操作,调用sbrk(-size)将内存归还操作系统。 + +以上图chunk分布图为例,按照glibc的内存分配策略,我们考虑下如下场景(假设brk其实地址是512k): + +malloc 40k内存,即chunkA,brk = 512k + 40k = 552k malloc 50k内存,即chunkB,brk = 552k + 50k = 602k malloc 60k内存,即chunkC,brk = 602k + 60k = 662k free chunkA。 + +此时,由于brk = 662k,而释放的内存是位于[512k, 552k]之间,无法通过移动brk指针,将区域内内存交还操作系统,因此,在[512k, 552k]的区域内便形成了一个内存空洞即内存碎片。 按照glibc的策略,free后的chunkA区域由于不和top chunk相邻,因此,无法和top chunk 合并,应该挂在unsorted_list链表上。 + + + +# 信号量和互斥量的区别? + +我觉得主要区别在两方面:**第一方面是所有权的概念,第二个方面是用途。** + +- 先说第一个所有权。 + + 解铃还须系铃人,一个锁住临界区的锁必须由上锁的线程解开,因此mutex的功能也就限制在了构造临界区上。 + + 对于信号量来说,任意多线程都可以对信号量执行PV操作。 + +- 第二个是用途 + + 也就是同步和互斥的用处。 + + 互斥很好说了,当我占有使用权的时候别人不能进入,独占式访问某段程序和内存。 + + 同步就是**调度线程**,即一些线程生产一些线程消费,让生产和消费线程保持合理执行顺序。因为semaphore本意是信号灯,含量了通知的意味,只要是我的信号量值大于等于1,那么就可以有线程或者进程来使用。 + + 『同步』这个词也可以拆开看,一侧是等待数据的『事件』或者『通知』,一侧是保护数据的 『临界区』,所以同步也即**同步+互斥**。信号量可以满足这两个功能,但是可以注意到两个功能的应用场景还是蛮大的,有 **do one thing and do it best** 的空间。linux 内核曾将 semaphore 作为同步原语,后面代码变得较难维护,刷了一把 mutex 变简单了不少还变快了,需要『通知』 的场景则替换为了 completion variable。 + +- 举个例子 + + 公司的咖啡机,互斥就是我一个人独占咖啡机,做完了咖啡然后撤走,咖啡机可以被别人使用。 + + 信号量是同步+互斥,有一个人不停地生产咖啡,每个人排队拿,如果有了就拿,没有了就阻塞等待。等有咖啡了唤醒阻塞的线程拿咖啡。 + + 所以更像是mutex+condition_variable + +- 英文的解释 + + > A [mutex](https://link.zhihu.com/?target=https%3A//en.wikipedia.org/wiki/Mutex) + > is essentially the same thing as a binary semaphore and sometimes uses + > the same basic implementation. The differences between them are in how + > they are used. While a binary semaphore may be used as a mutex, a mutex + > is a more specific use-case, in that only the thread that locked the + > mutex is supposed to unlock it. This constraint makes it possible to + > implement some additional features in mutexes: + > + > 1. Since only the thread that locked the mutex is supposed to unlock + > it, a mutex may store the id of thread that locked it and verify the + > same thread unlocks it. + > 2. Mutexes may provide [priority inversion](https://link.zhihu.com/?target=https%3A//en.wikipedia.org/wiki/Priority_inversion) + > safety. If the mutex knows who locked it and is supposed to unlock it, + > it is possible to promote the priority of that thread whenever a + > higher-priority task starts waiting on the mutex. + > 3. Mutexes may also provide deletion safety, where the thread holding the mutex cannot be accidentally deleted. + > 4. Alternately, if the thread holding the mutex is deleted (perhaps due + > to an unrecoverable error), the mutex can be automatically released. + > 5. A mutex may be recursive: a thread is allowed to lock it multiple times without causing a deadlock. + + + +# 写时拷贝底层原理 + +在 Linux 系统中,调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制 机制。如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。 + +传统的fork系统调用直接把所有资源复制给新创建的进程,这种实现过于简单并且效率低下,如果父进程中有很多数据的话依次复制个子进程,那么fork函数肯定是非常慢的。因此使用写时拷贝会快很多。 + +**原理** + +首先要了解一下内存共享机制。不同进程的 虚拟内存地址 映射到相同的 物理内存地址,那么就实现了共享内存的机制。我们可以用用这种思想来实现写时拷贝。fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。这样父进程和子进程都有了属于自己独立的页。 + +子进程可以执行exec()来做自己想要的功能。 + + + +# 栈区分配内存快还是堆区分配内存快 ? + +[参考链接](https://mp.weixin.qq.com/s/6I5TTh7zJ4NAn8YALUEtuw) + +毫无疑问,显然从栈上分配内存更快,因为从栈上分配内存仅仅就是栈指针的移动而已 + +在堆区上申请与释放内存是一个相对复杂的过程,因为堆本身是需要程序员(内存分配器实现者)自己管理的,而栈是编译器来维护的,堆区的维护同样涉及内存的分配与释放,但这里的内存分配与释放显然不会像栈区那样简单,一句话,这里是**按需进行内存的分配与释放**,**本质在于堆区中每一块被分配出去的内存其生命周期都不一样**,这是由程序员决定的,我倾向于把内存动态分配释放想象成去停车场找停车位。 + + + +# 申请内存时底层发生了什么? + +就是malloc如何实现内存管理的 + + + +# Cache + +[参考链接](https://blog.csdn.net/wyttRain/article/details/110925923) + +## 什么是cache + +Cache存储器,是位于CPU和主存储器DRAM之间的一块高速缓冲存储器,规模较小,但是速度很快,通常由SRAM(静态存储器)组成。Cache的功能是提高CPU数据输入输出的速率。Cache容量小但速度快,内存速度较低但容量大,通过优化调度算法,可以让系统的性能大大改善,感觉就像是有了主存储器的内存,又有了Cache的访问速度。 + +## 工作方式 + +CPU在访问存储器的时候,会同时把虚拟地发送给MMU中的TLB以及Cache,CPU会在TLB中查找最终的RPN(Real Page Number),也就是真实的物理页面,如果找到了,就会返回相应的物理地址。同时,CPU通过cache编码地址中的Index,也可以很快找到相应的Cache line组,但是这个cache line 中存储的数据不一定是CPU所需要的,需要进一步检查,前面我们说了,如果TLB命中后,会返回一个真实的物理地址,将cache line中存放的地址和这个转换出来的物理地址进行比较,如果相同并且状态位匹配,那么就会发生cache命中。如果cache miss,那么CPU就需要重新从存储器中获取数据,然后再将其存放在cache line中。 + +## 多级cache存储结构 + +cache的速度在一定程度上同样影响着系统的性能。一般情况下cache的速度可以达到1ns,几乎可以和CPU寄存器速度媲美。但是,这就满足了人们对性能的追求了吗?并没有。当cache中没有缓存我们想要的数据的时候,依然需要漫长的等待从主存中load数据。为了进一步提升性能,引入多级cache。前面提到的cache,称为L1 cache(第一级cache)。我们在L1 cache后面连接L2 cache,在L2 cache和主存之间连接L3 cache。等级越高,速度越慢,容量越大。 但是速度和主存相比而言,依然很快。 + + + +# 内存屏障 + +[参考链接](https://blog.csdn.net/wyttRain/article/details/114520547?spm=1001.2101.3001.6650.1&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1.pc_relevant_antiscanv2&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1.pc_relevant_antiscanv2&utm_relevant_index=2) + +现在大多数现代计算机为了提高性能而采取乱序执行,这可能会导致程序运行不符合我们预期,内存屏障就是一类同步屏障指令,是CPU或者编译器在对内存随机访问的操作中的一个同步点,只有在此点之前的所有读写操作都执行后才可以执行此点之后的操作。 + + + +# Fork函数相关知识 + +fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。 + + 一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。 + +**返回值** + +fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值: + +1. 在父进程中,fork返回新创建子进程的进程ID; +2. 在子进程中,fork返回0; +3. 如果出现错误,fork返回一个负值; + +**包含内容** + +fork时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。 + +子进程与父进程之间除了代码是共享的之外,堆栈数据和全局数据均是独立的。 + +**写时复制** + +fork子进程完全复制父进程的栈空间,也复制了页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”(类似mmap的private的方式),如果父子进程一直对这个页面是同一个页面,知道其中任何一个进程要对共享的页面“写操作”,这时内核会复制一个物理页面给这个进程使用,同时修改页表。而把原来的只读页面标记为“可写”,留给另外一个进程使用。 + +**执行顺序** + +fork之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec系统调用,因无意义的复制而造成效率的下降。 + +。正因为fork采用了这种写时复制的机制,所以fork出来子进程之后,父子进程哪个先调度呢?内核一般会先调度子进程,因为很多情况下子进程是要马上执行exec,会清空栈、堆。。这些和父进程共享的空间,加载新的代码段。。。,这就避免了“写时复制”拷贝共享页面的机会。如果父进程先调度很可能写共享页面,会产生“写时复制”的无用功。所以,一般是子进程先调度滴。 + + + + + +# fork复制内部,为什么fork返回0? + +[参考链接](https://juejin.cn/post/6909074860761169933) + +fork宏函数展开后代码 + +```c +int fork(void) \ +{ \ + long __res; \ + __asm__ volatile ("int $0x80" \ + : "=a" (__res) \ + : "0" (__NR_fork)); \ + if (__res >= 0) \ + return (type) __res; \ + errno = -__res; \ + return -1; \ +} +``` + +fork是一个宏,内部关键指令就是0x80中断,调用中断就会执行相应的中断服务程序 `kernel/sched.c`中的`sched_init`方法中就对0x80号中断进行了配置,就是说发生该中断就会调用`system_call`方法。会进入system_cal这个函数,然后有一个`sys_cal_table`。这个表就是一个系统调用函数表,存的都是函数指针。 + +```c +//sys_call_table位于linux/sys.h +fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, ... }; +``` + +根据索引找到sys_fork这个函数,能找到这个函数就是因为由寄存器eax传过来个值2,__NR_fork = 2,, 索引为2的函数指针就是sys_fork。 + +```c +sys_fork: + call find_empty_process + testl %eax,%eax # 在eax中返回进程号pid。若返回负数则退出。 + js 1f + push %gs + pushl %esi + pushl %edi + pushl %ebp + pushl %eax + call copy_process + addl $20,%esp # 丢弃这里所有压栈内容。 +1: ret +``` + + 首先`find_empty_process`找到一个可用的进程id,这个可用的进程id在`%eax`里面,接者调用`copy_process` + +```c +int copy_process(int nr,long ebp,long edi,long esi,long gs,long none, + long ebx,long ecx,long edx, + long fs,long es,long ds, + long eip,long cs,long eflags,long esp,long ss) +{ + struct task_struct *p; + int i; + struct file *f; + + // 首先为新任务数据结构分配内存。如果内存分配出错,则返回出错码并退出。 + // 然后将新任务结构指针放入任务数组的nr项中。其中nr为任务号,由前面 + // find_empty_process()返回。接着把当前进程任务结构内容复制到刚申请到 + // 的内存页面p开始处。 + p = (struct task_struct *) get_free_page(); + if (!p) + return -EAGAIN; + task[nr] = p; + *p = *current; /* NOTE! this doesn't copy the supervisor stack */ + // 随后对复制来的进程结构内容进行一些修改,作为新进程的任务结构。先将 + // 进程的状态置为不可中断等待状态,以防止内核调度其执行。然后设置新进程 + // 的进程号pid和父进程号father,并初始化进程运行时间片值等于其priority值 + // 接着复位新进程的信号位图、报警定时值、会话(session)领导标志leader、进程 + // 及其子进程在内核和用户态运行时间统计值,还设置进程开始运行的系统时间start_time. + p->state = TASK_UNINTERRUPTIBLE; + p->pid = last_pid; // 新进程号。也由find_empty_process()得到。 + p->father = current->pid; // 设置父进程 + p->counter = p->priority; // 运行时间片值 + p->signal = 0; // 信号位图置0 + p->alarm = 0; // 报警定时值(滴答数) + p->leader = 0; /* process leadership doesn't inherit */ + p->utime = p->stime = 0; // 用户态时间和和心态运行时间 + p->cutime = p->cstime = 0; // 子进程用户态和和心态运行时间 + p->start_time = jiffies; // 进程开始运行时间(当前时间滴答数) + // 再修改任务状态段TSS数据,由于系统给任务结构p分配了1页新内存,所以(PAGE_SIZE+ + // (long)p)让esp0正好指向该页顶端。ss0:esp0用作程序在内核态执行时的栈。另外, + // 每个任务在GDT表中都有两个段描述符,一个是任务的TSS段描述符,另一个是任务的LDT + // 表描述符。下面语句就是把GDT中本任务LDT段描述符和选择符保存在本任务的TSS段中。 + // 当CPU执行切换任务时,会自动从TSS中把LDT段描述符的选择符加载到ldtr寄存器中。 + p->tss.back_link = 0; + p->tss.esp0 = PAGE_SIZE + (long) p; // 任务内核态栈指针。 + p->tss.ss0 = 0x10; // 内核态栈的段选择符(与内核数据段相同) + p->tss.eip = eip; // 指令代码指针 + p->tss.eflags = eflags; // 标志寄存器 + p->tss.eax = 0; // 这是当fork()返回时新进程会返回0的原因所在 + p->tss.ecx = ecx; + p->tss.edx = edx; + p->tss.ebx = ebx; + p->tss.esp = esp; + p->tss.ebp = ebp; + p->tss.esi = esi; + p->tss.edi = edi; + p->tss.es = es & 0xffff; // 段寄存器仅16位有效 + p->tss.cs = cs & 0xffff; + p->tss.ss = ss & 0xffff; + p->tss.ds = ds & 0xffff; + p->tss.fs = fs & 0xffff; + p->tss.gs = gs & 0xffff; + p->tss.ldt = _LDT(nr); // 任务局部表描述符的选择符(LDT描述符在GDT中) + p->tss.trace_bitmap = 0x80000000; // 高16位有效 + // 如果当前任务使用了协处理器,就保存其上下文。汇编指令clts用于清除控制寄存器CRO中 + // 的任务已交换(TS)标志。每当发生任务切换,CPU都会设置该标志。该标志用于管理数学协 + // 处理器:如果该标志置位,那么每个ESC指令都会被捕获(异常7)。如果协处理器存在标志MP + // 也同时置位的话,那么WAIT指令也会捕获。因此,如果任务切换发生在一个ESC指令开始执行 + // 之后,则协处理器中的内容就可能需要在执行新的ESC指令之前保存起来。捕获处理句柄会 + // 保存协处理器的内容并复位TS标志。指令fnsave用于把协处理器的所有状态保存到目的操作数 + // 指定的内存区域中。 + if (last_task_used_math == current) + __asm__("clts ; fnsave %0"::"m" (p->tss.i387)); + // 接下来复制进程页表。即在线性地址空间中设置新任务代码段和数据段描述符中的基址和限长, + // 并复制页表。如果出错(返回值不是0),则复位任务数组中相应项并释放为该新任务分配的用于 + // 任务结构的内存页。 + if (copy_mem(nr,p)) { + task[nr] = NULL; + free_page((long) p); + return -EAGAIN; + } + // 如果父进程中有文件是打开的,则将对应文件的打开次数增1,因为这里创建的子进程会与父 + // 进程共享这些打开的文件。将当前进程(父进程)的pwd,root和executable引用次数均增1. + // 与上面同样的道理,子进程也引用了这些i节点。 + for (i=0; ifilp[i])) + f->f_count++; + if (current->pwd) + current->pwd->i_count++; + if (current->root) + current->root->i_count++; + if (current->executable) + current->executable->i_count++; + // 随后GDT表中设置新任务TSS段和LDT段描述符项。这两个段的限长均被设置成104字节。 + // set_tss_desc()和set_ldt_desc()在system.h中定义。"gdt+(nr<<1)+FIRST_TSS_ENTRY"是 + // 任务nr的TSS描述符项在全局表中的地址。因为每个任务占用GDT表中2项,因此上式中 + // 要包括'(nr<<1)'.程序然后把新进程设置成就绪态。另外在任务切换时,任务寄存器tr由 + // CPU自动加载。最后返回新进程号。 + set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss)); + set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt)); + p->state = TASK_RUNNING; /* do this last, just in case */ + return last_pid; +} +``` + +`copy_process`定义在`kernel/fork.c`里面,就是把父进程资源复制到子进程中,设置子进程的内存页等信息,如果父进程调用fork结束,就会返回`__res`,因为`__res`与`%eax`寄存器是绑定的,寄存器里面存的就是进程的pid,因为`copy_process`的返回值就是`last_pid`,也就是子进程pid,注意这里是父进程返回,子进程还没执行呢,但是子进程也会返回`__res`,`__res`的值就是寄存器`%eax`的值。 睁大眼睛看代码力里有这么一段`p->tss.eax = 0;`,父进程已经把子进程`%eax`寄存器中的值设置为0了。明白为什么使用fork时,返回0就代表是子进程了吧。 + +通俗的解释,可以这样看待:“其实就相当于链表,进程形成了链表,父进程的fork函数返回的值指向子进程的进程id, 因为子进程没有子进程,所以其fork函数返回的值为0. + +**为什么子进程先运行?** + +[参考链接](https://www.zhihu.com/question/59296096)该连接中说在多核是可以同时执行的,单核的话不确定,但是从cow来说确实应该子进程先执行。 + +复制进程的函数是`copy_process`,如果`copy_process`调用成功的话,那么系统会有意让新开辟的进程运行,这是因为子进程一般都会马上调用exec()函数来执行其他的任务,这样就可以避免写是复制造成的开销,或者从另一个角度说,如果其首先执行父进程,而父进程在执行的过程中,可能会向地址空间中写入数据,那么这个时候,系统就会为子进程拷贝父进程原来的数据,而当子进程调用的时候,其紧接着执行拉exec()操作,那么此时,系统又会为子进程拷贝新的数据,这样的话,相比优先执行子程序,就进行了一次“多余”的拷贝。 + + + +# 锁是什么? + +**本质思想** + +并发编程中,为了保证数据操作的一致性,操作系统引入了锁机制,为了保证临界区代码的安全。锁其实是一种思想 + +锁的本质就是计算机中的一块内存。当这块内存空间被赋值为1的时候表示加锁了,当被赋值为0的时候表示解锁了,当然这块内存空间在哪里并不重要。多个线程抢占一个锁,就是要把这块内存赋为1。 + +在单核的情况下,关闭 CPU 中断,使其不能暂停当前请求而处理其他请求,从而达到赋值“锁”对应的内存空间的目的。 + +在多核的情况下,使用锁总线和缓存一致性技术(详情看这里),可以实现在单一时刻,只有某个CPU里面某一个核能够赋值“锁”对应的内存空间,从而达到锁的目的。 + +> 锁总线和缓存一致性 +> +> 随着多核时代的到来,并发操作已经成了很正常的现象,操作系统必须要有一些机制和原语,以保证某些基本操作的原子性,比如处理器需要保证读一个字节或写一个字节是原子的,那么它是如何实现的呢?有两种机制:**总线锁定、缓存一致性**。 +> +> CPU 和物理内存之间的通信速度远慢于 CPU 的处理速度,所以 CPU 有自己的内部缓存,根据一些规则将内存中的数据读取到内部缓存中来,以加快频繁读取的速度。那么这样就会造成cpu寄存器中的值和内存中的值出现不匹配的现象。(比如两核,i=0,第一个核i+1,第二个核取i的时候i还是0) +> +> **总线锁定机制:**在 CPU1 要做 i++ 操作的时候,其在总线上发出一个 LOCK 信号,其他处理器就不能操作缓存了该变量内存地址的缓存,也就是阻塞了其他CPU,使该处理器可以独享此共享内存。 +> +> **缓存一致性:**当某块 CPU 对缓存中的数据进行操作了之后,就通知其他 CPU 放弃储存在它们内部的缓存,或者从主内存中重新读取 + + + +# 锁的开销 + +[参考](https://www.cnblogs.com/cposture/p/10761396.html) + +**所有锁的本质:** + +我们针对的是多线程环境下的锁机制,基于linux做测试。每种编程语言提供的锁机制都不太一样,不过无论如何,最终都会落实到两种机制上,**一是处理器提供的原子操作指令(现在一般是CAS—compare and swap),处理器会用轮询的方式试图获得锁,在处理器(包括多核)架构里这是必不可少的机制;二是内核提供的锁系统调用,在被锁住的时候会把当前线程置于睡眠(阻塞)状态。** + +实际上我们在编程的时候并不会直接调用这两种机制,而是使用编程语言所带函数库里的锁方法,锁方法内部混合使用这两种机制。以pthread库(NPTL)的pthread_mutex来举例,一把锁本质上只是一个int类型的变量,占用4个字节内存并且内存边界按4字节对齐。加锁的时候先用trylock方法(内部使用的是CAS指令)来尝试获得锁,如果无法获得锁,则调用系统调用sys_futex来试图获得锁,这时候如果还不能获得锁,当前线程就会被阻塞。(**futex的知识**) + +**所以很容易得到一个结论,如果锁不存在冲突,每次获得锁和释放锁的处理器开销仅仅是CAS指令的开销,在x86-64处理器上,这个开销只比一次内存访问(无cache)高一点(大概是1.3倍)。一般的电脑上一次没有缓存的内存访问大概是十几纳秒** + +**测试:** + +无冲突的时候:运行了 10 亿次,平摊到每次加锁/解锁操作大概是 14ns + +锁冲突的情况:运行的结果是双核机器上消耗大约3400ns,所以锁冲突的开销大概是不冲突开销的两百倍了,相差出乎意料的大。 + +**锁的开销** + +总结:锁的开销有好几部分,分别是:线程上下文切换的开销,调度器开销(把线程从睡眠改成就绪或者把就运行态改成阻塞),还有后续上下文切换带来的缓存不命中开销,跨处理器调度的开销等等。 + +**锁的优化:** + +从上面可以知道,真正消耗时间的不是上锁的次数,而是锁冲突的次数。减少锁冲突的次数才是提升性能的关键。使用更细粒度的锁,可以减少锁冲突。这里说的粒度包括时间和空间,比如哈希表包含一系列哈希桶,为每个桶设置一把锁,空间粒度就会小很多--哈希值相互不冲突的访问不会导致锁冲突,这比为整个哈希表维护一把锁的冲突机率低很多。减少时间粒度也很容易理解,加锁的范围只包含必要的代码段,尽量缩短获得锁到释放锁之间的时间,最重要的是,绝对不要在锁中进行任何可能会阻塞的操作。使用读写锁也是一个很好的减少冲突的方式,读操作之间不互斥,大大减少了冲突。 + +假设单向链表中的插入/删除操作很少,主要操作是搜索,那么基于单一锁的方法性能会很差。在这种情况下,应该考虑使用读写锁,即 pthread_rwlock_t,这么做就允许多个线程同时搜索链表。插入和删除操作仍然会锁住整个链表。假设执行的插入和搜索操作数量差不多相同,但是删除操作很少,那么在插入期间锁住整个链表是不合适的,在这种情况下,最好允许在链表中的分离点(disjoint point)上执行并发插入,同样使用基于读写锁的方式。在两个级别上执行锁定,链表有一个读写锁,各个节点包含一个互斥锁,在插入期间,写线程在链表上建立读锁,然后继续处理。在插入数据之前,锁住要在其后添加新数据的节点,插入之后释放此节点,然后释放读写锁。删除操作在链表上建立写锁。不需要获得与节点相关的锁;互斥锁只建立在某一个操作节点之上,大大减少锁冲突的次数。 + +锁本身的行为也存在进一步优化的可能性,sys_futex系统调用的作用在于让被锁住的当前线程睡眠,让出处理器供其它线程使用,既然这个过程的消耗很高,也就是说如果被锁定的时间不超过这个数值的话,根本没有必要进内核加锁——释放的处理器时间还不够消耗的。sys_futex的时间消耗够跑很多次 CAS 的,也就是说,对于一个锁冲突比较频繁而且平均锁定时间比较短的系统,一个值得考虑的优化方式是先循环调用 CAS 来尝试获得锁(这个操作也被称作自旋锁),在若干次失败后再进入内核真正加锁。当然这个优化只能在多处理器的系统里起作用(得有另一个处理器来解锁,否则自旋锁无意义)。在glibc的pthread实现里,通过对pthread_mutex设置PTHREAD_MUTEX_ADAPTIVE_NP属性就可以使用这个机制。 + +读多写一的情况用double buffer + +> 注:CAS指令是线程数据同步的原子指令。 + + + +# 什么是futex + +linux的pthreads mutex采用futex实现 + +Futex 是 Fast Userspace Mutexes 的缩写,现在锁的机制一般使用这个,内核态和用户态的混合机制。其设计思想其实不难理解。 + +在传统的 Unix 系统中,System V IPC(inter process communication),如 semaphores,msgqueues,sockets 等进程间同步机制都是对一个**内核对象**操作来完成的,这个内核对象对要同步的进程都是可见的,其提供了共享的状态信息和原子操作,用来管理互斥锁并且通知阻塞的进程。当进程间要同步的时候必须要通过系统调用(如semop())在内核中完成。比如进程A要进入临界区,先去内核查看这个对象,有没有别的进程在占用这个临界区,出临界区的时候,也去内核查看这个对象,有没有别的进程在等待进入临界区,然后根据一定的策略唤醒等待的进程。同时经研究发现,很多同步是无竞争的,即某个进程进入互斥区,到再从某个互斥区出来这段时间,常常是没有进程也要进这个互斥区或者请求同一同步变量的。但是在这种情况下,这个进程也要陷入内核去看看有没有人和它竞争,退出的时侯还要陷入内核去看看有没有进程等待在同一同步变量上,有的话需要唤醒等待的进程。这些不必要的系统调用(或者说内核陷入)造成了大量的性能开销。为了解决这个问题,Futex就应运而生。 + +为了解决上述这个问题,Futex 就应运而生,Futex 是一种用户态和内核态混合的同步机制。首先,同步的进程间通过 mmap 共享一段内存,futex 变量就位于这段共享的内存中且操作是原子的,当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的 futex 变量,如果没有竞争发生,就不用再执行系统调用了。当通过访问 futex 变量后进程发现有竞争发生,则还是得执行系统调用去完成相应的处理(wait 或者 wake up)。简单的说,futex 就是通过在用户态的检查,(motivation)如果了解到没有竞争就不用陷入内核了,大大提高了 low-contention 时候的效率。 + +mutex 是在 futex 的基础上用的内存共享变量来实现的,如果共享变量建立在进程内,它就是一个线程锁,如果它建立在进程间共享内存上,那么它是一个进程锁。pthread_mutex_t 中的 `_lock` 字段用于标记占用情况,先使用CAS判断`_lock`是否占用,若未占用,直接返回。否则,通过`__lll_lock_wait_private` 调用`SYS_futex `系统调用迫使线程进入沉睡。 CAS是用户态的 CPU 指令,若无竞争,简单修改锁状态即返回,非常高效,只有发现竞争,才通过系统调用陷入内核态。所以,FUTEX是一种用户态和内核态混合的同步机制,它保证了低竞争情况下的锁获取效率。 + + + +# 中断 + +[参考链接](https://www.zhihu.com/question/21440586/answer/2623632047) + +## 中断基本原理 + +### 中断定义 + +中断机制:CPU在执行指令时,收到某个中断信号转而去执行预先设定好的代码,然后再返回到原指令流中继续执行,这就是中断机制。 + +img + +### 中断作用 + +**外设异步通知CPU:**外设发生了什么事情或者完成了什么任务或者有什么消息要告诉CPU,都可以异步给CPU发通知。例如,网卡收到了网络包,磁盘完成了IO任务,定时器的间隔时间到了,都可以给CPU发中断信号。 + +**CPU之间发送消息:**在SMP系统中,一个CPU想要给另一个CPU发送消息,可以给其发送IPI(处理器间中断)。 + +**处理CPU异常:**CPU在执行指令的过程中遇到了异常会给自己发送中断信号来处理异常。例如,做整数除法运算的时候发现被除数是0,访问虚拟内存的时候发现虚拟内存没有映射到物理内存上。 + +**实现系统调用:**早期的系统调用就是靠中断指令来实现的,后期虽然开发了专用的系统调用指令,但是其基本原理还是相似的。 + +### 中断产生 + +中断信号的产生有以下4个来源: + +**1.外设。**外设产生的中断信号是异步的,一般也叫做硬件中断(注意硬中断是另外一个概念)。硬件中断按照是否可以屏蔽分为可屏蔽中断和不可屏蔽中断。例如,网卡、磁盘、定时器都可以产生硬件中断。 + +**2.CPU。**这里指的是一个CPU向另一个CPU发送中断,这种中断叫做IPI(处理器间中断)。IPI也可以看出是一种特殊的硬件中断,因为它和硬件中断的模式差不多,都是异步的 + +**3.CPU异常。**CPU在执行指令的过程中发现异常会向自己发送中断信号,这种中断是同步的,一般也叫做软件中断(注意软中断是另外一个概念)。CPU异常按照是否需要修复以及是否能修复分为3类:1.陷阱(trap),不需要修复,中断处理完成后继续执行下一条指令,2.故障(fault),需要修复也有可能修复,中断处理完成后重新执行之前的指令,3.中止(abort),需要修复但是无法修复,中断处理完成后,进程或者内核将会崩溃。例如,缺页异常是一种故障,所以也叫缺页故障,缺页异常处理完成后会重新执行刚才的指令。 + +**4.中断指令。**直接用CPU指令来产生中断信号,这种中断和CPU异常一样是同步的,也可以叫做软件中断。例如,中断指令int 0x80可以用来实现系统调用。 + +中断信号的4个来源正好对应着中断的4个作用。前两种中断都可以叫做硬件中断,都是异步的;后两种中断都可以叫做软件中断,都是同步的。很多书上也把硬件中断叫做中断(异步中断),把软件中断叫做异常(同步中断)。 + +> **硬件中断、软件中断,硬中断、软中断是不同的概念,分别指的是中断的来源和中断的处理方式。** + +### 中断处理 + +有了中断之后,CPU就分为两个执行场景了,进程执行场景(process context)和中断执行场景(interrupt context)。进程的执行是进程执行场景,同步中断的处理也是进程执行场景,异步中断的处理是中断执行场景。可能有的人会对同步中断的处理是进程执行场景感到疑惑,但是这也很好理解,因为同步中断处理是和当前指令相关的,可以看做是进程执行的一部分。而异步中断的处理和当前指令没有关系,所以不是进程执行场景。 + +进程执行场景和中断执行场景有两个区别:一是进程执行场景是可以调度、可以休眠的,而中断执行场景是不可以调度不可用休眠的;二是在进程执行场景中是可以接受中断信号的,而在中断执行场景中是屏蔽中断信号的。所以如果中断执行场景的执行时间太长的话,就会影响我们对新的中断信号的响应性,所以我们需要尽量缩短中断执行场景的时间。 + +由于同步中断是软件产生的因此可以看做是进程执行的一部分,但是硬件中断在执行中断处理程序的时候会屏蔽其他中断序号,因此需要①立即快速处理。②如果比较耗时可以先预处理然后再完全处理。 + +### 中断向量号 + +不同的中断信号需要有不同的处理方式,那么系统是怎么区分不同的中断信号呢?是靠中断向量号。每一个中断信号都有一个中断向量号,中断向量号是一个整数。CPU收到一个中断信号会根据这个信号的中断的向量号去查询中断向量表,根据向量表里面的指示去调用相应的处理函数。 + +中断信号和中断向量号是如何对应的呢?对于CPU异常来说,其向量号是由CPU架构标准规定的。对于外设来说,其向量号是由设备驱动动态申请的。对于IPI中断和指令中断来说,其向量号是由内核规定的。 + +### 中断框架结构 + +img + + + + + +## 中断流程 + +### 保存现场 + +CPU收到中断信号后会首先把一些数据push到内核栈上,保存的数据是和当前执行点相关的,这样中断完成后就可以返回到原执行点。如果CPU当前处于用户态,则会先切换到内核态,把用户栈切换为内核栈再去保存数据 + +### 查找向量表 + +保存完被中断程序的信息之后,就要去执行中断处理程序了。CPU会根据当前中断信号的向量号去查询中断向量表找到中断处理程序。CPU是如何获得当前中断信号的向量号的呢,如果是CPU异常可以在CPU内部获取,如果是指令中断,在指令中就有向量号,如果是硬件中断,则可以从中断控制器中获取中断向量号。那CPU又是怎么找到中断向量表呢,是通过IDTR寄存器。如下 + +img + +CPU现在已经把被中断的程序现场保存到内核栈上了,又得到了中断向量号,然后就根据中断向量号从中断向量表中找到对应的门描述符,对描述符做一番安全检查之后,CPU就开始执行中断处理函数。 + + + +## 中断处理 + +### 硬中断(hardirq) + +硬件中断的中断处理和软件中断有一部分是相同的,有一部分却有很大的不同。对于IPI中断和per CPU中断,其设置是和软件中断相同的,都是一步到位设置到具体的处理函数。但是对于余下的外设中断,只是设置了入口函数,并没有设置具体的处理函数,而且是所有的外设中断的处理函数都统一到了同一个入口函数。然后在这个入口函数处会调用相应的irq描述符的handler函数,这个handler函数是中断控制器设置的。中断控制器设置的这个handler函数会处理与这个中断控制器相关的一些事物,然后再调用具体设备注册的irqaction的handler函数进行具体的中断处理。 + +对于外设中断为什么要采取这样的处理方式呢?有两个原因,1是因为外设中断和中断控制器相关联,这样可以统一处理与中断控制器相关的事物,2是因为外设中断的驱动执行比较晚,有些设备还是可以热插拔的,直接把它们放到中断向量表上比较麻烦。有个irq_desc这个中间层,设备驱动后面只需要调用函数request_irq来注册ISR,只处理与设备相关的业务就可以了,而不用考虑和中断控制器硬件相关的处理。 + +### 软中断(softirq) + +软中断是把中断处理程序分成了两段:前一段叫做硬中断,执行驱动的ISR,处理与硬件密切相关的事,在此期间是禁止中断的;后一段叫做软中断,软中断中处理和硬件不太密切的事物,在此期间是开中断的,可以继续接受硬件中断。软中断的设计提高了系统对中断的响应性。下面我们先说软中断的执行时机,然后再说软中断的使用接口。 + +软中断也是中断处理程序的一部分,是在ISR执行完成之后运行的,在ISR中可以向软中断中添加任务,然后软中断有事要做就会运行了。有些时候当软中断过多,处理不过来的时候,也会唤醒ksoftirqd/x线程来执行软中断。 + +所有软中断的处理函数都是在系统启动的初始化函数里面用open_softirq接口设置的。raise_softirq一般是在硬中断或者软中断中用来往软中断上push work使得软中断可以被触发执行或者继续执行。 + +# Linux零拷贝技术 + +splice( )函数 + +tee( )函数 + +## **概述:** + +零拷贝(Zero-Copy)是一种 `I/O` 操作优化技术,可以快速高效地将数据从文件系统移动到网络接口,而不需要将其从内核空间复制到用户空间。其在 `FTP` 或者 `HTTP` 等协议中可以显著地提升性能。 + +## **由来:** + +如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间的复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。其过程如下图所示: + +img + +可以想想一下这个过程。服务器读从磁盘读取文件的时候,发生一次系统调用,产生用户态到内核态的转换,将磁盘文件拷贝到内核的内存中。然后将位于内核内存中的文件数据拷贝到用户的缓冲区中。用户应用缓冲区需要将这些数据发送到socket缓冲区中,进行一次用户态到内核态的转换,复制这些数据。此时这些数据在内核的socket的缓冲区中,在进行一次拷贝放到网卡上发送出去。 + +所以整个过程一共进行了四次拷贝,四次内核和用户态的切换。这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。 + +## 零拷贝原理 + +零拷贝主要是用来解决操作系统在处理 I/O 操作时,频繁复制数据的问题。关于零拷贝主要技术有 `mmap+write`、`sendfile`和`splice`等几种方式。 + +> DMA 技术很容易理解,本质上,DMA 技术就是我们在主板上放一块独立的芯片。在进行内存和 I/O 设备的数据传输的时候,我们不再通过 CPU 来控制数据传输,而直接通过 DMA 控制器(DMA Controller,简称 DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor)。DMAC 的价值在如下情况中尤其明显:当我们要传输的数据特别大、速度特别快,或者传输的数据特别小、速度特别慢的时候。 + +看完下图会发现其实零拷贝就是少了CPU拷贝这一步,磁盘拷贝还是要有的 + +- mmap/write 方式 + + image + + 把数据读取到内核缓冲区后,应用程序进行写入操作时,直接把内核的`Read Buffer`的数据复制到`Socket Buffer`以便写入,这次内核之间的复制也是需要CPU的参与的。 + +- sendfile 方式 + + image + + 可以看到使用sendfile后,没有用户空间的参与,一切操作都在内核中进行。但是还是需要1次拷贝 + +- 带有 scatter/gather 的 sendfile方式 + + Linux 2.4 内核进行了优化,提供了带有 `scatter/gather` 的 sendfile 操作,这个操作可以把最后一次 `CPU COPY` 去除。其原理就是在内核空间 Read BUffer 和 Socket Buffer 不做数据复制,而是将 Read Buffer 的内存地址、偏移量记录到相应的 Socket Buffer 中,这样就不需要复制。其本质和虚拟内存的解决方法思路一致,就是内存地址的记录。 + + **注意: sendfile适用于文件数据到网卡的传输过程,并且用户程序对数据没有修改的场景;** + +- splice 方式 + + + + 其实就是CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline)直接把数据传过去了,不去要CPU复制了 + +## 常见的零拷贝实现 + +### mmap + write + +mmap 将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。 + +### sendfile() + +通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝,sendfile 调用中 I/O 数据对用户空间是完全不可见的,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。 + +### splice() + +在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作,2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。 + +### 写时复制 + +通过尽量延迟产生私有对象中的副本,写时复制最充分地利用了稀有的物理资源。 + +### 写时拷贝底层原理 + +在 Linux 系统中,调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制 机制。如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。 + +传统的fork系统调用直接把所有资源复制给新创建的进程,这种实现过于简单并且效率低下,如果父进程中有很多数据的话依次复制个子进程,那么fork函数肯定是非常慢的。因此使用写时拷贝会快很多。 + +**原理** + +首先要了解一下内存共享机制。不同进程的 虚拟内存地址 映射到相同的 物理内存地址,那么就实现了共享内存的机制。我们可以用用这种思想来实现写时拷贝。fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。这样父进程和子进程都有了属于自己独立的页。 + +子进程可以执行exec()来做自己想要的功能。 + +# 什么是DMA? + +# 冯.诺依曼结构 + +冯.诺依曼提出了计算机“存储程序”的计算机设计理念,即将计算机指令进行编码后存储在计算机的存储器中,需要的时候可以顺序地执行程序代码,从而控制计算机运行,这就是冯.诺依曼计算机体系的开端。 + +核心设计思想主要体现在如下三个方面: + +- 程序、数据的最终形态都是二进制编码,程序和数据都是以二进制方式存储在存储器中的,二进制编码也是计算机能够所识别和执行的编码。(可执行二进制文件:.bin文件) +- 程序、数据和指令序列,都是事先存在主(内)存储器中,以便于计算机在工作时能够高速地从存储器中提取指令并加以分析和执行。 +- 确定了计算机的五个基本组成部分:运算器、控制器、存储器、输入设备、输出设备 + +# Linux内存管理 + +[参考链接](https://mp.weixin.qq.com/s/nlMGEhuaDUYqV6r8A4cRlA) + +## CPU访问内存的过程 + +img + + + + + + + +# Linux任务调度 + +https://ty-chen.github.io/linux-kernel-schedule/ + +# DMA等 diff --git "a/\346\225\260\346\215\256\347\273\223\346\236\204\345\217\212\347\256\227\346\263\225.md" "b/\346\225\260\346\215\256\347\273\223\346\236\204\345\217\212\347\256\227\346\263\225.md" new file mode 100644 index 0000000..7968c55 --- /dev/null +++ "b/\346\225\260\346\215\256\347\273\223\346\236\204\345\217\212\347\256\227\346\263\225.md" @@ -0,0 +1,1983 @@ +# **手撕memcpy** + +> memcpy函数是c和c++使用的内存拷贝函数,函数原型是: +> +> ```c++ +> void *memcpy(void*dest, const void *src, size_t n); +> ``` +> +> 表示由src指向地址为起始地址的连续n个字节的数据复制到以dest指向地址为起始地址的空间内。 +> +> 与strcpy相比,memcpy并不是遇到'\0'就结束,而是一定会拷贝完n个字节。 + +注意事项:对于地址重叠的情况,上述函数的行为是未定义的,因此要考虑到此问题 + +要点:void*类型的指针不能运算,必须强转 + +​ 必须要考虑内存重叠问题(画个图自然就明白了!) + +```c++ +void * my_memcpy(void *dst, const void *src, size_t count){ + if(dst==nullptr||src==nullptr){ + return nullptr; + } + char* temp_dst=(char*)dst; + char* temp_src=(char*)src; + if(temp_dst>temp_src&&temp_dst 排序算法的稳定性:在具有多个相同关键字的记录中,若经过排序这些记录的次序保持不变,说排序算法是稳定的。 + +## **插入排序O(n^2^)** + +- 直接插入排序 + + 动画演示如图: + + 在这里插入图片描述 + + 代码如下: + + ```c++ + void Straight_Insertion_Sort(int a[],int length){ + for (int i = 1;i < length;i++) + { + if (a[i]= 0;j--) { + a[j + 1] = a[j]; + if (a[j] < temp) { + a[j + 1] = temp; + break; + } + if (j == 0 && a[j] > temp) { + a[j] = temp; + } + } + } + } + } + ``` + +- 折半插入排序 + + 主要分为查找和插入两个部分 + + 图片演示: + + 在这里插入图片描述 + + 代码如下: + + ```c++ + void Binary_Insert_Sort(int a[],int length) { + int low, high, mid; + int i, j,temp; + for ( i = 1;i < length;i++) { + low = 0; + high = i - 1; + temp = a[i]; + while (low<=high) { + mid = (low + high) / 2; + if (temp high;j--) { + a[j + 1] = a[j]; + } + a[j + 1] = temp; + }//for(i) + ``` + + + +## **冒泡排序O(n^2^)** + +思路:两两比较元素,顺序不同就交换 + +图解: + +在这里插入图片描述 + +代码: + +```c++ +void Bubble_Sort(int a[],int length) { + for (int i = 0;i < length - 1;i++) { + for (int j = 0;j a[j+1]) { + int temp = a[j]; + a[j] = a[j +1]; + a[j+1] = temp; + } + } + } +} +``` + +## **选择排序O(n^2^)** + +思路:每一次从待排序的数据元素中选择最小(最大)的一个元素作为有序的元素。如果是升序排序,则每次选择最小值就行,这样已经排好序的部分都是生序排序选择排序是不稳定的,比如说(55231这种情况,两个5的相对顺序会变) + +图解: + +在这里插入图片描述 + +代码: + +``` +void Select_Sort(int a[],int length) { + for (int i = 0;i < length - 1;i++) { + int min_index = i; + for (int j = i+1;j < length;j++) { + if (a[min_index]>a[j]) { + min_index = j; + } + }//for j + if (i!=min_index) { + int temp = a[i]; + a[i] = a[min_index]; + a[min_index] = temp; + } + }//for i +} +``` + + + +## **希尔排序——缩小增量排序O(nlogn)** + +**思路:** + +希尔排序又叫缩小增量排序,使得待排序列从局部有序随着增量的缩小编程全局有序。当增量为1的时候就是插入排序,增量的选择最好是531这样间隔着的。 + +其实这个跟选择排序一样的道理,都是不稳定的比如下图7变成9的话,就是不稳定的 + +**图解:** + +在这里插入图片描述 + +代码: + +```c++ +void Shell_Sort1(int a[], int length) { + //首先定义一个初始增量,一般就是数组长度除以2或者3 + int gap = length / 2; + while (gap >= 1) { + for (int i = gap;i < length;i++) { + int temp = a[i]; + int j = i; + while (j >= gap&&temp 0; gap /= 2) + for (i = gap; i < n; i++) + /*j>gap之后,j前面的可以重新比较依次保证j前面间隔gap的也是有序的*/ + for (j = i - gap; j >= 0 && a[j] > a[j + gap]; j -= gap) + Swap(a[j], a[j + gap]); +} +``` + + + +## **快速排序O(nlogn)** + +思路:快排的核心叫做“基准值“,小于基准值的放在左边,大于基准值的放在右边。然后依次递归。基准值的选取随机的,一般选择数组的第一个或者数组的最后一个,然后又两个指针low和high + +图解:“基准值就是第一个元素” + +在这里插入图片描述 + +在这里插入图片描述 + +在这里插入图片描述 + +在这里插入图片描述 + + + +1)设置两个变量I、J,排序开始的时候:I=0,J=N-1; + +2)以第一个数组元素作为关键数据,赋值给 key ,即 key =A[0]; +   + +3)从J开始向前搜索,即由后开始向前搜索(J=J-1即J--),找到第一个小于 key 的值A[j],A[j]与A[i]交换; +   + +4)从I开始向后搜索,即由前开始向后搜索(I=I+1即I++),找到第一个大于 key 的A[i],A[i]与A[j]交换; +   + +5)重复第3、4、5步,直到 I=J; (3,4步是在程序中没找到时候j=j-1,i=i+1,直至找到为止。找到并交换的时候i, j指针位置不变。另外当i=j这过程一定正好是i+或j-完成的最后另循环结束。) + +代码: + +```c++ +//快速排序 需要两个函数配合 +int Quick_Sort_GetKey(int a[],int low,int high) { + int temp = a[low]; + while (low=temp) { + high--; + } + //当a[high] 当数组元素映射成为堆时: +> +> 1. 父结点索引:(*i*-1)/2 +> 2. +左孩子索引:2**i*+1 +> 3. 左孩子索引:2**i*+2 + +图解: + +在这里插入图片描述 + +基本思想: + +1. 首先将待排序的数组构造成一个大根堆,此时,整个数组的最大值就是堆结构的顶端 +2. 将顶端的数与末尾的数交换,此时,末尾的数为最大值,剩余待排序数组个数为n-1 +3. 将剩余的n-1个数再构造成大根堆,再将顶端数与n-1位置的数交换,如此反复执行,便能得到有序数组 + +代码: + +```c++ +//index是第一个非叶子节点的下标(根节点) +//递归的方式构建 +void Build_Heap(int a[],int length,int index){ + int left = 2 * index + 1; //index的左子节点 + int right = 2 * index + 2;//index的右子节点 + int maxNode = index; //默认当前节点是最大值,当前节点index + if (lefta[maxNode]) { + maxNode = left; + } + if (righta[maxNode]) { + maxNode = right; + } + if (maxNode!=index) { + int temp = a[maxNode]; + a[maxNode] = a[index]; + a[index] = temp; + Build_Heap(a,length,maxNode); + } + +} +void Heap_Sort(int a[],int length) { + // 构建大根堆(从最后一个非叶子节点向上) + //注意,最后一个非叶子节点为(length / 2) - 1 + for (int i = (length / 2) - 1;i >= 0;i--) { + Build_Heap(a, length, i); + } + for (int i = length - 1;i >= 1;i--) { + //交换刚建好的大顶堆的堆顶和堆末尾节点的元素值, + int temp = a[i]; + a[i] = a[0]; + a[0] = temp; + //交换过得值不变,剩下的重新排序成大顶堆 + Build_Heap(a,i,0); + } +} +``` + +## **归并排序(nlogn)** + +思路:分治思想,将若干个已经排好序的子序合成有序的序列。 + +图解: + +在这里插入图片描述 + +代码: + +```c++ +//分治思想,先逐步分解成最小(递归)再合并 +//归并 +void Merge(int a[],int low,int mid,int high) { + int i = low; + int j = mid + 1; + int k = 0; + int *temp = new int[high - low + 1]; + while (i<=mid&&j<=high) { + if (a[i]<=a[j]) { + temp[k++] = a[i++]; + } + else { + temp[k++] = a[j++]; + } + }//while (i + +代码: + +```c++ +void Count_Sort(int a[],int length) { + //得到待排序的最大值 + int max = a[0]; + int i=0; + while ( i a[i + 1]) ? a[i] : a[i + 1]; + i++; + } + int *countArray = new int[max + 1]{0}; + int *temp = new int[length]; + + for (int i = 0;i < length;i++) { + countArray[a[i]]++; + } + //!!!这一步的思想特别重要,在非比较排序中 + for (int i = 1;i < max+1;i++) { + countArray[i] += countArray[i - 1]; + } + //反向遍历 + for (int i = length - 1;i >= 0;i--) { + temp[countArray[a[i]]-1] = a[i]; + countArray[a[i]]--; + } + for (int i = 0;i < length;i++) { + a[i] = temp[i]; + } + delete[] countArray; + delete[] temp; +} +``` + +## **基数排序O(n*k)** + +**思路:**基数也就表明桶的个数是定死的,就是10个。基数排序的思想是,从个位依次开始排序,首先按照个位的大小排序,将改变的序列按照十位开始排序,然后一次往后…… + +**图解:** + +在这里插入图片描述 + +代码: + +```c++ +int Get_Max_Digits(int a[],int length) { + int max = a[0]; + int i = 0; + while (i a[i + 1]) ? a[i] : a[i + 1]; + i++; + } + int b = 0;//最大值的位数 + while (max>0) { + max = max / 10; + b++; + } + return b; +} +//切记!桶子只能是十个,是定死的 +void Radix_Sort(int b[],int length) { + int d = Get_Max_Digits(b, length);//得到最大值的位数 + int * temp = new int[length];//开辟一个和原数组相同的临时数组 + //根据最大值的位数进行排序次数循环 + int radix = 1; + for (int i = 0;i < d;i++) { + //每次把数据装入桶子前先清空count + int count [10] = { 0 }; //计数器 每次循环都清零 + for (int j = 0;j < length;j++) { + //统计尾数为0-9的个数,一次是个十百千....位 + int tail_number = (b[j]/radix)%10; + count[tail_number]++;//每个桶子里面的个数 + } + //桶中的每一个数的位置一次分配到temp数组中,先找到位置 + for (int j = 1;j < 10;j++) { + count[j] += count[j - 1]; + } + //分配到temp中排序后的位置 + for (int j = length - 1;j >= 0;j--) { + int tail_number = (b[j] / radix) % 10; + temp[count[tail_number] - 1] = b[j]; + count[tail_number]--; + } + //赋值 + for (int j = 0;j < length;j++) { + b[j] = temp[j]; + } + radix *= 10; + }//for(int i) + delete[] temp; +} +``` + + + +## **桶排序O(n+k)** + +**思路:**基数排序和计数排序都是桶思想的应用。桶排序是最基本的 + +​ 首先要得到整个待排序数组的最大值和最小值,然后设置桶的个数k,这样可以得到每个桶可以放的数的区间。 + +​ 然后遍历待排序的数组,将相关区间内的数放到对应的桶中,这样桶内在排序,就使得整个序列相对有序 + +**图解:** + +image-20210829144129297 + +代码: + +```c++ +void bucketSort(int arr[], int len) { + // 确定最大值和最小值 + int max = INT_MIN; int min = INT_MAX; + for (int i = 0; i < len; i++) { + if (arr[i] > max) max = arr[i]; + if (arr[i] < min) min = arr[i]; + } + + // 生成桶数组 + // 设置最小的值为索引0,每个桶间隔为1 + int bucketLen = max - min + 1; + // 初始化桶 + int bucket[bucketLen]; + for (int i = 0; i < bucketLen; i++) bucket[i] = 0; + + // 放入桶中 + int index = 0; + for (int i = 0; i < len; i++) { + index = arr[i] - min; + bucket[index] += 1; + } + + // 替换原序列 + int start = 0; + for (int i = 0; i < bucketLen; i++) { + for (int j = start; j < start + bucket[i]; j++) { + arr[j] = min + i; + } + start += bucket[i]; + } +} +``` + + + +# 查找算法(七大查找算法) + +## **顺序查找** + +> 顺序查找适合线性表,比如说数组和链表这种线性存储结构的。 +> +> **时间复杂度为O(n)** + +## **二分查找** + +> 要查找的元素必须是有序的 +> +> **时间复杂度为O(logn)** + +## **插值查找** + +> 对于差值查找来说,二分查找更近似于一种傻瓜式的查找方式。因为二分查找每次都从mid开始的,这样比较呆,没有很大的自主性。 +> +> 因此对于差值查找:mid=low+(key-a[low])/(a[high]-a[low])*(high-low),这种方式来自适应选择,减少比较次数 + +## **二叉查找树(BST)** + +> 二叉查找树的性质: +> +> 1. 任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值; +> 2. 任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值; +> 3. 任意节点的左、右子树也分别为二叉查找树。 +> +> **它和二分查找一样,插入和查找的时间复杂度均为O(logn),但是在最坏的情况下仍然会有O(n)的时间复杂度。** + +## **红黑树** + +## **B树和B+树** + +## **分块查找** + +> 分块查找是有序查找的一种改进吧,即把元素放到不同的块儿中。每一块儿中的节点不必有序,但是块与块之间必须有序 +> +> 即第一块任意元素的关键字都必须小于第二块任意元素的关键字。 +> +> 先用二分查找到在那一块,然后再进行顺序查找。 + +## **哈希查找** + +> key - value键值对,是一种时间换空间的算法。 + + + +# 非递归遍历二叉树 + +- 先序遍历· + + ```c++ + /*对于任一结点cur: + 1)访问结点cur,并将结点cur入栈; + 2)判断结点cur的左孩子是否为空,若为空,则取栈顶结点并进行出栈操作,并将栈顶结点的右孩子置为当前的结点cur,循环至1); + 若不为空,则将cur的左孩子置为当前的结点cur; + 3)直到cur为NULL并且栈为空,则遍历结束。*/ + void NonRecursive::PreTraverse(BinaryTree *T) { + stack s; + BinaryTree *cur=T; + while (cur!=nullptr||!s.empty()) { + while (cur!=nullptr) { + cout << cur->val <<" "; + s.push(cur); + cur = cur->leftchild; + } + //涉及到pop的操作所以判定条件是栈不为空 + if (!s.empty()) { + cur = s.top(); + s.pop(); + cur = cur->rightchild; + } + } + } + ``` + +- 中序遍历 + + ```c++ + /*cur的左孩子置为当前的cur,然后对当前结点cur再进行相同的处理; + 2)若其左孩子为空,则取栈顶元素并进行出栈操作,访问该栈顶结点,然后将当前的cur置为栈顶结点的右孩子; + 3)直到cur为NULL并且栈为空则遍历结束 + */ + void NonRecursive::MidTraverse(BinaryTree *T) { + stacks; + BinaryTree *cur = T; + while (cur!=nullptr||!s.empty()) { + while (cur!=nullptr) { + s.push(cur); + cur = cur->leftchild; + } + if (cur==nullptr) { + cur = s.top(); + cout << cur->val << " "; + s.pop(); + } + cur = cur->rightchild; + + } + cout << endl;//回车换行 + } + ``` + +- 后序遍历 + + ```c++ + //思想: 一共有两种情况 + //①节点同时没有左孩子和右孩子,说明是根节点,可以遍历然后出栈; + //②或者不是根节点但同时左孩子和右孩子被访问过,也可以遍历后出栈 + void NonRecursive::EndTraverse(BinaryTree *T) { + stack s; + BinaryTree *cur ; + BinaryTree *pre = nullptr; + s.push(T); + while (!s.empty()) { + cur = s.top(); + if ((cur->leftchild==nullptr&&cur->leftchild==nullptr) //没有左孩子和右孩子了说明可以访问 + || (pre!=nullptr&& (pre==cur->leftchild||pre==cur->rightchild)) //存在子树同时左孩子和右孩子都被访问过了 + ) { + cout << cur->val<<" "; + s.pop(); + pre = cur; + } + else { + //右孩子先入栈 左孩子后入栈 + if (cur->rightchild!=nullptr) { + s.push(cur->rightchild); + } + if (cur->leftchild!=nullptr) { + s.push(cur->leftchild); + } + } + } + } + ``` + + + +# KMP算法 + +**字符串匹配问题:**字符串 P 是否为字符串 S 的子串?如果是,它出现在 S 的哪些位置?” 其中 S 称为主串;P 称为模式串。如下图: + +img + +**最容易想到的方法:Brute-Force** + + + +暴力法的时间复杂度是$O(|P|*(|S|-|P|+1)) = O(|P|*|S|) = O(mn)$ + +**KMP算法:** + +kmp算法就是尽量利用残余信息,不用逐个比字符串。其思路下面这个图就解释清楚了: + +img + +即我们可以不用一个一个往后挪这比较,而是往后挪好几个身位去比较。这个时候挪多少个位置呢?需要next数组去帮我们。 + +next数组中的值是字符串的前缀集合与后缀集合的交集中最长元素的长度。例如,对于”aba”,它的前缀集合为{”a”, ”ab”},后缀 集合为{”ba”, ”a”}。两个集合的交集为{”a”},那么长度最长的元素就是字符串”a”了,长 度为1,所以对于”aba”而言,它在PMT表中对应的值就是1。再比如,对于字符串”ababa”,它的前缀集合为{”a”, ”ab”, ”aba”, ”abab”},它的后缀集合为{”baba”, ”aba”, ”ba”, ”a”}, 两个集合的交集为{”a”, ”aba”},其中最长的元素为”aba”,长度为3。 + + + +# 最小生成树——Prim算法和Kruskal算法 + +这两种算法都是求最小生成树的,都是贪心算法的典型应用 + +[这个算法讲的详细](https://www.cnblogs.com/biyeymyhjob/archive/2012/07/30/2615542.html) + +- Prim算法 + + 图论中的一种算法,可以在加权连通图中搜索最小生成树。包括图中所有顶点之且所有边的权值之和最小 + +- Kruskal算法 + + 也是用来寻找最小生成树的算法 + + 思路是将所有边的长度排序,然后 + + + +# 二叉查找树,二叉搜索树 + +它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;它的左、右子树也分别为二叉排序树。 + + + +# AVL树(平衡二叉树) + +> AVL是一种高度平衡的二叉树,本质也是一颗二叉查找树,有以下两个特点: +> +> 1. 它是一棵空树或它的左右两个子树的高度差的绝对值不超过1, +> 2. 左右两个子树也都是一棵平衡二叉树。 +> +> 这个方案很好的解决了二叉查找树退化成链表的问题,把插入,查找,删除的时间复杂度最好情况和最坏情况都维持在O(logN)。但是频繁旋转会使插入和删除牺牲掉O(logN)左右的时间,不过相对二叉查找树来说,时间上稳定了很多 + +AVL树主要的难点在于插入和删除 + +- 插入 + +| 插入位置 | 状态 | 操作 | +| ----------------------------------------------------- | ------ | ------ | +| 在结点T的左结点(L)的 **左子树(L)** 上做了插入元素 | 左左型 | 右旋 | +| 在结点T的左结点(L)的 **右子树(R)** 上做了插入元素 | 左右型 | 左右旋 | +| 在结点T的右结点(R)的 **右子树(R)** 上做了插入元素 | 右右型 | 左旋 | +| 在结点T的右结点(R)的 **左子树(L)** 上做了插入元素 | 右左型 | 右左旋 | + +[参考链接](https://blog.csdn.net/xiaojin21cen/article/details/97602146) + +又回看了以便,其实插入位置为左左型和右右型的其实是一个东西,本质上都是一次旋转就行了,注意有个节点需要断开原先的连接 + +像左右型和右左型这种都需要两次旋转,但是这两次旋转本质上和一次旋转一模一样,所以说这四种插入位置其实本质上都差不多的。 + + + +# 字典树 + +- 什么是字典树? + + 字典树我愿意给他叫做单词查找树,利用字符串的公共前缀来降低查询时间进而提高效率。有以下三个性质: + + 1. 根节点不包含字符,除根节点外每一个节点都只包含一个字符。 + 2. 总根节点到某一个节点,路径上的字符连接起来为该节点对应的字符串。 + 3. 每个节点所有子节点包含的字符都不同。 + +- 字典树的使用范围 + + 1. 词频统计 + + 如果内存有限,hash表所占的空间很大,我们就可以用trie树来压缩下空间,因为公共前缀都是用一个节点保存的。 + + 2. 前缀匹配 + + ​ + +# 2-3查找树 + +- 定义 + + 相比于AVL树对平衡性的严格要求,2-3查找树相对灵活一些。这里面2-3查找树允许一个节点保存多个键。 + + 通常我们将一棵标准的二叉查找树成为2-节点,因为每个节点都只有一个键(关键字)和两个链接 + + 但是2-3查找树引入了3节点,表示一个节点中有两个关键字和三个节点。 + + img + +- 性质 + + 1. 任意一个节点都有1个或者2个关键码 + 2. 当节点有1个关键码的时候,节点有2个子树 + 3. 当节点有两个关键码的时候,节点有3个子树 + 4. 所有叶子节点都在树的同一层 + + + +# 红黑树 + +> + +线性查找 —性能低—>二分查找— 二查叉树会出现退化成链表的问题—>出现AVL平衡二叉树—数据变化有频繁更新节点问题(即AVL变态的平衡)—>出现红黑树 + +AVL树和红黑树都是由二叉树繁衍而来,这两个数有不同的适用条件: + +1. 如果有频繁的插入和删除的话,不要用AVL数,因为其变态的平衡要求 +2. 如果频繁的查找,但是插入和删除不多的话用AVL树的话是可以的 + +[参考链接](https://www.cnblogs.com/skywang12345/p/3245399.html) + +红黑树本质上也是一种二叉查找树,但是他是自平衡的,在插入和删除可能会破坏树的结构的情况下需要自行处理以达到平衡状态。 + +红黑树有以下特性: + +1. 每个节点不是黑色就是红色 +2. 根节点是黑色 +3. 如果一个节点为红色,则子节点必为黑色 +4. 任意一个节点到每个叶子节点的路径上都包含数量相同的黑节点(这个时候null节点不算进去) +5. 每个叶子节点(NULL)都是黑色的。(红黑树吧null节点当做了叶子节点) + +img + +- 左旋和右旋 + + 左旋和右旋是一个相对的概念,但是只要记住:对一个节点左旋的时候,意味着将这个节点变成左节点,如果这个节点是根节点的话,那么也变成左节点,他的右节点就是这个节点的根节点 + + ```c++ + z + x / + / \ --(左旋)--> x + y z / + y + ``` + + 右旋也是一样的,x节点变成右孩子节点,左旋就是变成左孩子节点。然后x节点的左孩子节点变成x节点的根节点 + + ```c++ + y + x \ + / \ --(右旋)--> x + y z \ + z + ``` + +- 插入 + +- 删除 + + + +# B-tree + +对于树结构的查找来说,有以下几种:二叉查找树,平衡二叉查找树,红黑树,B-tree等等。对于二叉树来说,查找的为log2N,很明显和树的深度有关,因此在数据量特别大的时候,我们需要尽可能降低树的深度来提高查询效率。但是如果减少树的深度,那么每棵树上的节点数就会很多,这样还是退化到了对于节点的线性查找,可能时间复杂度还会增加,怼到线性的。因此我们想要降低树的深度就要采用多叉树结构。因此B树就是一个多叉树,为了降低磁盘频繁的查找操作而设计的。 + + + +B树也称B-树,它是一颗多路平衡查找树。二叉树我想大家都不陌生,其实,B树和后面讲到的B+树也是从最简单的二叉树变换而来的 + +B树有用度定义的,有用阶定义的。在这里我写的话就用阶的概念来定义,阶就是说这个树最多有多少个子节点数,用m表示。m取2的时候就表示我们的二叉树 + +> **度数:**每个节点子节点的个数称为该树的度。 +> +> **阶数:**一个节点的子节点数目最大值。 + + + +用阶定义B数如下图: + +1. 根节点不是叶子节点至少要有两个子节点,非根节点至少有m/2个子节点,取上限。 +2. 有n个子节点的节点恰好包含n-1个关键字 +3. 每个节点中的关键字都是按照从小到大的顺序排列的,同时每个关键字的左子树中的关键字均小于它,右子树中的关键字均大于它。 +4. 所有叶子节点都在同一层中 + +下图所示就是一个阶为4的B树: + +clip_image002 + +B+树的叶子节点内部结构如下图: + +img + +本质上说叶子节点存储的也不是数据,而是数据指针,指向磁盘中的数据。 + +> 通常在实际应用中B树的阶都很大,通常大于100。因此即使存储了大量的数据,B树的高度仍然比较小。**每个结点中存储了关键字(key)和关键字对应的数据(data),以及孩子结点的指针。** + +- **查找** + + img + + 如上图所示,根据根节点004和008可得,有小于004,大于004小于008和大于008三种情况,分别对应三个子树,就这样查找如果树结构里面没有包含所要查找的节点则返回null,总体来说,B树的搜索流程跟普通二叉搜索树的搜索流程大体类似。 + + 所以B树的查询并不稳定,根节点中关键字可能只需要1次IO操作,但是如果在叶子节点中可能需要树高度次磁盘IO操作 + +- **插入操作** + + 1. 树中已经存在当前的key,则更新value + + 2. 若当前节点的关键字个数小于m-1,则正常插入 + + 3. 若加入key后当前节点的个数大于m-1, 则以当前节点中所有关键字的中间为基准点开始分裂,将基准点的key值放入到父节点(因为B数要求左边小于父节点,右边大于父节点),当前基准值的左子树都比他小,右子树都比他大。 + + > 举一个5阶树的列子: + > + > 1. 未插入元素前: + > + > img + > + > 2. 插入22后,图如下,就不满足5阶B树的定义了 + > + > img + > + > 3. 分裂中间基准点40 + > + > img + > + > 4. 接着插入23,25,39,然后继续分裂,如下: + > + > img + > + > img + > + > 以上就是B树的插入过程。 + +- **删除操作** + + 1. 如果不存在对应的key,则删除失败 + 2. 如果删除的是叶子节点中的关键字,则删除就行 + 3. 若当前删除的key位于非叶子节点,则用key的后继节点替换当前要删除的节点,然后把这个后继节点删除(后继节点一定位于叶子节点上)。. + +- **复杂度分析** + + 具体分析看B+树中,我把B+和B树一起给分析了,这俩时间复杂度是一样的。 + + + + + +# B+ Tree + +[参考](https://zhuanlan.zhihu.com/p/149287061) + +B+树是b树的一种变体,广泛的用在存储引擎中。 + +**和B树的相同点:** + +根节点不是叶子节点至少要有两个子节点,非根节点至少有m/2个子节点,取上限。 + +**和B树的不同点:** + +1. B+树有两种类型的节点:**索引结点**和叶子结点。索引节点就是非叶子节点,内部节点不存储数据,只存储索引(即key),数据都存储在叶子节点。 +2. 非叶子节点中,有n颗子树就包含n个key +2. 非叶子节点中包含的是子树中最大或最小的key +2. 所有叶子节点中包含了全部关键字的信息, 及指向含这些关键字记录的指针,且叶子节点本身依关键字的大小自小而大顺序链接; + +如下图所示: + +image-20220327112534330 + +> + +- **查找** + + **查询单个元素** + + img + + 比如说查询元素59。第一次磁盘IO,访问根节点。然后发现59<=[59,97],访问当前节点的第一个孩子节点。第二次磁盘IO,发现59>=[15,44,59],访问当前节点的第三个孩子节点。第三次磁盘IO,访问叶子节点[51,59],顺序遍历内部节点找到59 + + 可以看到,B+树查找每个元素所用的磁盘IO数量一样 + + **区间查询元素** + + 区间查询的关键点在于叶子节点中,每个叶子节点都有一个指向下一个叶子节点的$P_{next}$指针。 + + 为了更好地对比B树和B+树他们区间查询的优势,我先说一下B树的,查询21<=key<=63 + + img + + 首先找到21所在的节点,上图中在叶子节点。然后从关键字21开始进行中序遍历,关键字依次为37、44、51、59、72、63。不考虑中序遍历过程的压栈和入栈操作,磁盘IO就多了两次(72、63) + + 再来看B+树的区间查找: + + img + + 根据每个节点的索引找到叶子节点[21、37、44]后找到左端点21,接着不需要中序遍历,而是链表形式的遍历,从21找到63,没有任何额外的磁盘IO操作 + +- **插入** + + 要注意一下几点: + + 1. 插入的操作全部都在叶子结点上进行,且不能破坏关键字自小而大的顺序; + 2. 由于 B+树中各结点中存储的关键字的个数有明确的范围,做插入操作可能会出现结点中关键字个数超过阶数的情况,此时需要将该结点进行 “分裂”; + + 插入有三种情况: + + 1. 若被插入关键字所在的结点,其含有关键字数目小于阶数 M,则直接插入; + + img + + 2. 若被插入关键字所在的结点,其含有关键字数目等于阶数 M,则需要将该结点分裂为两个结点,一个结点包含 `⌊M/2⌋` ,另一个结点包含 `⌈M/2⌉` 。同时,将`⌈M/2⌉`的关键字上移至其双亲结点。假设其双亲结点中包含的关键字个数小于 M,则插入操作完成。 + + img + + 3. 在第 2 情况中,如果上移操作导致其双亲结点中关键字个数大于 M,则应继续分裂其双亲结点。 + + img + +- **删除** + + 1. 删除该关键字,如果不破坏 B+树本身的性质,直接完成删除操作; + 2. 如果删除操作导致其该结点中最大(或最小)值改变,则应相应改动其父结点中的索引值; + 3. 在删除关键字后,如果导致其结点中关键字个数不足,有两种方法:一种是向兄弟结点去借,另外一种是同兄弟结点合并(情况 3、4 和 5)。(注意这两种方式有时需要更改其父结点中的索引值。) + +- **复杂度分析** + + B+树 是 B-树的一个升级版本,在存储结构上的变化,由于磁盘页的大小限制,只能读取少量的B-树结点到内存中(因为B-树结点就带有数据,占用更多空间,所以说是 **少量**);而B+树就不一样了。因为非叶子结点不带数据,能够一次性读取更多结点进去处理,所以对于同样的数据量, B+树更加 "矮胖", 性能更好。但是两者在查找、插入和删除等操作的时间复杂度的量级是一致的,均为$O(log_mN)$,其中m是阶数,N是关键字个数 + + 求查找时间复杂度也就是树的高度。对于B树和B+树来说,树的高度为h,一个节点存储m个关键字,有N个关键字,则满足$N=m^h$,所以$h=log_mN$。同时一个节点中包含的key数量是m,需要$O(m)$的时间复杂度,但是对这些节点key的遍历是放在内存中的,时间可以忽略,我们更加关注的是磁盘IO次数,即树的高度,所以B树和B+树两者在查找、插入和删除等操作的时间复杂度的量级是一致的,均为$O(log_mN)$,其中m是阶数,N是关键字个数 + + + +> **B树比B+树的优势在于:** +> +> 1. 如果经常访问的数据离根节点很近,而B树的非叶子节点存储关键字数据的地址,所以这种数据检索的时候会要比B+树快。 +> +> **B+树比B树的优势在于:** +> +> 1. B+树查询速度更稳定:B+所有关键字数据地址都存在叶子节点上,所以每次查找的次数都相同所以查询速度要比B树更稳定;(logn) +> 2. B+树天然具备排序功能:B+树所有的叶子节点数据构成了一个有序链表,查询区间数据时更方便,数据紧密性很高,缓存的命中率也会比B树高。 +> 3. B+树全节点遍历更快:B+树遍历整棵树只需要遍历所有的叶子节点即可,而不需要像B树一样需要对每一层进行遍历,这有利于数据库做全表扫描。 +> 4. 适合区间查询,需要的磁盘IO数少 + +**一般在数据库系统或文件系统中使用的B+Tree结构都在经典B+Tree的基础上进行了优化,增加了顺序访问指针。** + + + +# **手撕LRU算法** + +LRU(Least Recently Used) 即最近最少使用,属于典型的内存淘汰机制。 + +根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”,其思路如下图所示: + +img + +该算法需要达到两个目的:①可以轻易的更新最新的访问数据。②轻易的找出最近最少未使用的数据。所以要用到哈希表+双向链表实现。利用map,获取key对应的value是O(1),利用双向链表,实现新增和删除都是O(1)。 + +> 传统意义的LRU算法是为每一个Cache对象设置一个计数器,每次Cache命中则给计数器+1,而Cache用完,需要淘汰旧内容,放置新内容时,就查看所有的计数器,并将最少使用的内容替换掉。它的弊端很明显,如果Cache的数量少,问题不会很大, 但是如果Cache的空间过大,达到10W或者100W以上,一旦需要淘汰,则需要遍历所有计数器,其性能与资源消耗是巨大的。效率也就非常的慢了。双链表LRU的原理: 将Cache的所有位置都用双链表连接起来,当一个位置被命中之后,就将通过调整链表的指向,将该位置调整到链表头的位置,新加入的Cache直接加到链表头中。 这样,在多次进行Cache操作后,最近被命中的,就会被向链表头方向移动,而没有命中的,则向链表后面移动,链表尾则表示最近最少使用的Cache。当需要替换内容时候,链表的最后位置就是最少被命中的位置,我们只需要淘汰链表最后的部分即可。 + + + +LRU数据结构如下图: + +HashLinkedList + +根据上图我们可以分析一下: + +1. 如果我们每次默认从链表尾部添加元素,那么显然越靠尾部的元素就是最近使用的,越靠头部的元素就是最久未使用的。 +2. 对于某一个 `key`,我们可以通过哈希表快速定位到链表中的节点,从而取得对应 `val`。 +3. 链表显然是支持在任意位置快速插入和删除的,改改指针就行。只不过传统的链表无法按照索引快速访问某一个位置的元素,而这里借助哈希表,可以通过 `key` 快速映射到任意一个链表节点,然后进行插入和删除。 + +- 版本1:自己实现循环链表存储,没有用API + + ```c++ + /********************不用API的版本*************************/ + /********************简单说一下思路*************************/ + //1.首先hash表用的是unordered_map来实现,用来查找key对应的node节点,所以hash表应该是[key,node]形式存储 + //2.LRUCache这个类实现双向链表的添加,删除,更新和遍历 + //3.同时这个类还要实现get和put两个功能 + //4.我这里用的是循环双向链表,因此查找链表尾端的元素为O(1),正常的双向链表是O(n) + //总结:最重要的就是hash表中的key对应的不是int而是一个node节点,这个要记住 + #include + #include + struct Node{ + int key; + int value; + Node* pre; + Node* next; + Node(){} + Node(int k, int v):key(k), value(v), pre(nullptr), next(nullptr){} + }; + + class LRUCache{ + private: + //通过key可以找到位于链表中的节点 + std::unordered_map hash; + int capacity; + Node* head_node; + public: + LRUCache(int cap){ + capacity = cap; + head_node = new Node(); + //初始化dummy_Node,next和pre都指向自己 + head_node->next = head_node->pre = head_node; + } + //将新来的插入双向链表头部 + void add_Node(Node* n); + //将某个节点拿出来重新插入头部 + void update_Node(Node* n); + //移除链表中最后一个(最久未使用) + void pop_back(); + //输出LRU结构 + void show(); + int get(int key); + void put(int key, int value); + }; + + //注意,该节点可能是新节点,也可能是已经存在的有重新入链表的节点 + void LRUCache::add_Node(Node* n){ + //表示当前节点n就是dummy的next节点,不用加入 + if(n->pre == head_node){ + return; + } + //将节点n插入head_node后面 + n->pre = head_node; + n->next = head_node->next; + head_node->next->pre = n; + head_node->next = n; + } + + void LRUCache::update_Node(Node* n){ + //表示当前节点n就是dummy的next节点,不用断掉 + if(n->pre == head_node){ + return; + } + n->next->pre = n->pre; + n->pre->next = n->next; + add_Node(n); + } + + //弹出链表的最后一个,由于是循环链表,就是head_node->pre + void LRUCache::pop_back(){ + Node* tmp = head_node->pre; + head_node->pre = tmp->pre; + tmp->pre->next = head_node; + //删除unordered_map中的key + hash.erase(tmp->key); + } + + void LRUCache::show(){ + //链表中没有节点,退出 + if(head_node->next = head_node){ + return; + } + Node* tmp = head_node->next; + while(tmp->next != head_node){ + std::cout<<"key:"<key<<",vlaue:"<value<second; + update_Node(node); + return node->value; + + } + void LRUCache::put(int key, int value){ + auto it = hash.find(key); + if(it == hash.end()){ + Node* node = new Node(key, value); + add_Node(node); + hash.insert({key, node}); + if(hash.size() > capacity){ + + pop_back(); + } + }else{ + it->second->value = value; + update_Node(it->second); + } + } + ``` + + + +- 版本2:使用deque,为什么使用deque说的很清楚 + + ```c++ + /****************注意unordered_map的插入************/ + + #include + #include + #include + #include + + class LRUCache{ + private: + int capacity; + //1.之所以用deque不用list是因为移除尾部元素的时候,deque方便 + //2.deque里面可以存储自定的node类型,也可以用pair表示,这里我用pair了 + std::deque> my_deque; + //通过key找到对应key在deque中的位置 + std::unordered_map>::iterator> hash; + public: + LRUCache(int cap):capacity(cap){} + int get(int key); + void put(int key, int value); + }; + + int LRUCache::get(int key){ + if(hash.find(key) == hash.end()){ + std::cout<<"there is no key"< tmp = *hash[key]; + my_deque.erase(hash[key]); + my_deque.push_front(tmp); + //更新hash表中对应key位于deque的位置 + hash[key] = my_deque.begin(); + return tmp.second; + } + + void LRUCache::put(int key, int value){ + if(hash.find(key) == hash.end()){ + if(my_deque.size() >= capacity){ + //把hash表中的抹除,然后删除deque中的 + auto it = my_deque.back(); + hash.erase(it.first); + my_deque.pop_back(); + my_deque.push_front({key, value}); + hash.insert({key, my_deque.begin()}); + }else{ + my_deque.push_front({key, value}); + hash.insert({key, my_deque.begin()}); + } + }else{ + //更新就行 + my_deque.erase(hash[key]); + my_deque.push_front({key, value}); + //更新hash表中key的位置 + hash[key] = my_deque.begin(); + } + } + ``` + + + + + +# 线性对数级排序算法详解(重要!) + +## 快速排序 + +- ##### 快排基本概念 + + 快速排序用的是分治的思想,即通过选取一个pivot将数组分成两个子数组A和B,子数组A中的元素均小于等于pivot,子数组B中的元素均大于等于pivot中的元素。这个时候对于两个子数组A和B而言,每一个子数组又是一个数组需要选取一个pivot进行排序,这就是递归的思想。 + + 因此对于快速排序来说主要由两部分组成:第一部分是获取pivot的`partition`函数和进行快排递归的`quicksort`函数 + + ```c++ + //这个函数主要是求pivot的 + int partition(int *a, int left, int right){ + //首先要随机选取一个pivot,这样做是为了根据pivot分成两个子数组数组。一下三种选择方式均可以 + int pivot = a[left]; + int pivot = a[right]; + int pivot = a[left + (right - left) / 2];//这样做是为了防止数组越界 + int pivot_index = left/right/left + (right - left) / 2 + //两个指针移动,当同时指到一个元素时候就退出循环 + //切记,必须从右边开始找!!!! + while(left < right){ + while(a[right] >= pivot && left < right){ + right--; + } + while(a[left] <= pivot && left < right){ + left++; + } + if(left < right){ + swap(a[left], a[right]); + } + } + //这一步是为了将选取的pivot值放在中间,保证左边的子数组均比pivot小,右边的子数组均比pivot大 + //这一步也是交换元素,交换选取pivot的索引和left的索引所对应的值 + a[povit_index] = a[left]; + a[left] = povit; + return left; + /*优化的写法:: + int pivot = a[left]; + while(left=pivot)left++; + a[right] = a[left]; + } + a[left] = pivot; + return left; + */ + } + void quicksort(int *a, int left, int right){ + //边界条件判断 + if(left >= right){ + return; + } + int pivot = partition(); + quicksort(a, left, pivot - 1); + quicksort(a, pivot +1, right); + } + ``` + + **总结:**对上面代码,写的时候有几个地方需要注意: + + 1. 在写内循环的时候,一定要先从右边开始写!!切记!! + 2. 当子数组中有小于pivot的时候和有大于pivot的时候,这个时候交换是正常的,将大小对调位置。但是,,我们最后退出大循环也要交换一次,这次交换是为了划分两个子数组,一个比pivot小,一个比pivot大。 + +- ##### 性能分析 + + 对于每一个数组,数值中的每一个元素都和一个定值进行比较,这样表明内循环很简洁。 + + - 时间复杂度 + + | 平均情况 | 最坏情况 | 最好情况 | + | :----------: | :------: | :----------: | + | $O(nlog_2n)$ | $O(n^2)$ | $O(nlog_2n)$ | + + 对于快速排序算法来说最理想的情况时partition函数能做到最平衡的划分,即每次选取的pivot值都能将数组对半分,这样划分到最后使得每个部分只包含一个或者零个元素。但是缺点也很明显,即在划分函数partition极度不平衡的时候效率会很低。比如说第一次选取的pivot是最小元素,第二次选取的pivot是第二小的元素等等,这样会导致一个大数组切分n-1次。 + + - 空间复杂度 + + | 平均情况 | 最坏情况 | 最好情况 | + | :---------: | :------: | :-------------: | + | $O(log_2n)$ | $O(n)$ | $O(log_2(n+1))$ | + + 快速排序用到了递归,可以发现最好情况或者平均情况,将待排序数组每次对半分是最好的,这样的话就是logn,但是最坏的情况就是12345这种类型,基本上每次都要调栈,调大概n-1次。 + + - 稳定性 + + 想一种情况,比如递增排序,右边有两个相同元素均小于pivot,这个时候调换到左边后相对位置会发生变化。所以是不稳定的。 + +- ##### 改进算法 + + - 切换到插入排序 + + 对于小数组来说,插入排序要比快速排序效率高,因为当数组比较小的时候还是会递归。所以当递归划分的子数组较小的时候可以使用插入排序来进行。或者直接用宏排序来写。 + + - 选取pivot的时候更优。一种选取方法是取前三个值的平均值,这样保证pivot比较均匀从统计学概率上来讲。或者选取中位数。 + +- ##### 快速排序题目 + + 1. #### + + +## 归并排序 + +- **归并排序基本概念** + + 刚才讲了快速排序,这次讲解归并排序,主要思考他们两个思想有什么不同。 + + 归并排序也是采用分治的思想实现的,分即将大问题分成一些小问题递归或者迭代求解,治则是指将分阶段得到的解决答案缝缝补补的合并在一起,这样就将大问题小问题话。 + + > 其实分治算法的思想用一张图就可以概括清楚: + > + > img + > + > 可以看到分的过程是均分,这样的话分和治的过程都像是一个二叉树形状,那么求最底下一层即叶子节点,就是logn。 + + 接下来主要分析治的阶段: + + > **治的阶段本质上要解决的问题是将两个已经有序的子序列合并成一个有序序列** + > + > img + > + > 从上图中可以看到治的思想是对于两个子数组而言的,用两个指针的思想。看图就行了,不想细说了。 + +- **代码实现** + + 代码分为两部分,merge和mergesort + + [参考链接.](https://www.delftstack.com/zh/howto/cpp/merge-sort-in-cpp/#%E5%9C%A8-c-%E4%B8%AD%E4%B8%BA-std-vector-%E5%AE%B9%E5%99%A8%E5%AE%9E%E7%8E%B0%E5%90%88%E5%B9%B6%E6%8E%92%E5%BA%8F) + + ```c++ + void merge(vector& vec, vector& tmp1, vector& tmp2){ + int len1 = tmp1.size(); + int len2 = tmp2.size(); + int p1 = 0; + int p2 = 0; + while(p1 < len1 && p2 < len2){ + if(tmp1[p1] & vec){ + if(vec.size() <= 1){ + return; + } + auto mid = vec.begin() + vec.size()/2; + vector tmp1(vec.begin(), mid); + vector tmp2(mid, vec.end()); + mergesort(tmp1); + mergesort(tmp2); + vec.clear(); + merge(vec, tmp1, tmp2); + } + ``` + + + +- **性能分析** + + 从上述算法示意图可以看出,在分的过程中类似于完全二叉树的,因此可以利用二叉树的特性的思想性能都不会太差。在分的时候算法的时间复杂度是lgN,而在治的时候由于需要指针一次比对,因此时间复杂度是O(N),因此总体时间复杂度是O(NlgN)。 + + 归并排序最吸引人的性质是他能够将任意长度为N的数组排序所需要的时间和NlgN成正比(注:任何基于比较的算法的时间复杂度都是lg(N!)到NlgN的),但缺点也很明显,在治的过程中需要额外的空间和N成正比。 + + 归并排序分为自顶向下和自底向上两种模式。上图中我们看到的是最容易理解的即自顶向下的方式。当然,自底向上形式的归并排序比较适合**链表**这种数据结构,因为将链表按照长度为1的子链进行排序的时候,然后按照长度为2的子链进行排序的时候,按照长度为4的子链进行排序的时候,这样做不用创建新的链表节点就能将链表进行排序。 + + **归并排序是一种渐进最优的基于比较排序的算法**。因为归并排序在最坏的情况下的比较次数和任意基于比较的排序算法所需的最少比较次数都是NlgN。 + + > 下列表格是快排和归并排序在时间复杂度上的比较: + > + > ![这里写图片描述](https://cdn.jsdelivr.net/gh/luogou/cloudimg/data/20211207204923.png) + + + +- **稳定性** + + 归并排序是稳定排序。 + +## 堆排序 + +- 堆排序的基本概念 + + 首先要明确的是堆是一种树形结构,这种树形结构有两个重要特征: + + 1. 堆是一个完全二叉树 + 2. 堆中每一个节点的值都大于或等于其子树中每个节点的值 + + > 其实堆排序是由**优先队列**这个概念而来的,将优先队列变成一种排序方法也很简单,即将所有元素插入一个查找最小元素或最大元素的优先队列中,然后在重复调用删除最小元素或最大元素的操作将他们排序即可 + + 堆排序可以分为两个阶段: + + 1. 堆的构造阶段。不借助额外空间,将数组原地建成一个堆。 + + > 如果用指针来表示堆有序的二叉树,需要三个指针,两个子节点和他的父节点 + > + > 如果用指针来表示堆有序的完全二叉树,则需要两个指针就行,因为根节点固定,其子节点也就固定了 + > + > 但一般用数组表示也很方便,因为堆有序的完全二叉树在数组中时层级表示的:(第一个元素索引为0的时候)索引为k的节点,其父节点为`floor[(k-1)/2]`,两个子节点的索引分别为`2k+1`和`2k+2` + + > 建堆的时候有两种思路: + > + > 1. 第一种是一次插入一个元素然后组成一个堆。这里我们都用最大堆来表示。可以想一下,假如堆中有一个元素了,那么第二个元素就会在第一个元素的下面,从下往上依次排序,这就叫做从下往上堆化。这样的话不高效,需要从左往右把数组遍历一遍才行。(这种也是最基本的构建堆的方案吧,但是肯定不用,效率太低了) + > + > 2. 第二种是这样的。对于一个n长度数组准备构建堆,我们从j=(n-1)/2的地方开始组建我们只需要扫描数组中一半的元素,因此**第一半的那个索引正好对应两个根节点**。[这个链接说的很详细](https://pdai.tech/md/algorithm/alg-sort-x-heap.html) + > + > 比如下图i=4开始构建,正常的思路是i=0开始,但是这样我们得遍历整个数组,这是没必要的。因为堆是一种完全二叉树,所以我们只需要遍历一半就能知道所有的数组值,所以i=4是从i=n/2-1得来的 + > + > img + > + > img + > + > img + > + > img + > + > img + + 2. 下沉排序阶段或上升排序阶段。从下往上的堆化叫做下沉又叫做sink(),而从下往上的堆化叫做上升又叫做swim()。 + + 到这个阶段说明大顶堆或者小顶堆已经构建好了,可以排序了。我们把堆顶元素删除(这里模拟大顶堆),然后从数组的尾部补一个数,重新sink()排序就OK了。这样每次删除一个值就会从新排一次堆,知道按照从小到到的顺序让数组有序。 + +- 堆排序的分析 + + 首先要知道几个命题: + + > 创建堆的过程中N个元素至少需要2N次比较以及少于N次的交换。 + > + > 将N个元素排序,堆排序只需要少于2NlgN+2N次比较。其中2N来自于N的构造。 + + - 时间复杂度 + + 如果待排序中有N个数,遍历一趟的时间复杂度是O(N)。由于完全二叉树的提醒,遍历的次数就是树的深度,在lg(N+1)到lg(2N)之间,因此时间复杂度为O(NlgN) + + - 稳定性 + + 堆排序是不稳定算法。在交换数据的时候比较的是父节点和子节点之间的数据,如果存在两个值相等的兄弟节点,他们之间的顺序也可能在排序之后发生变化。 + +- 堆排序的一些额外的拓展 + + **要知道,堆排序是唯一同时能够最优的利用时间和空间的排序方法,最坏的情况下也能保证线性对数的时间复杂性和恒定的空间** + + 但是实际开发中,快排要比堆排序的性能好,有以下几点: + + 1. 堆排序数据访问的方式没有快排合并排序那么友好。对于快排和合并排序来说我们的数据时顺序访问的,而对于堆排序来说我们是跳着访问的,无法实现局部顺序访问,因此无法利用缓存。 + 2. 对于同样的数据,在堆排序过程中数据交换的次数要多于快排。对于基于比较的算法来说,排序的过程基本由两部分组成即比较和交换。堆排序第一步是建堆,因此可能会打乱数据原有的顺序造成数据有序度降低,进而增加很多比较次数。 + + 但是,堆排序在空间利用十分紧张的地方比如嵌入式系统或者低成本的移动设备中很流行。 + +- 代码实现 + + 代码有如下几部分: + + 1. `heap_build`建堆 + 2. `heap_sort`堆排序,其中堆排序中调用建堆的API + 3. 在堆排序函数中,每次要交换堆顶值和最后一个值,也就是数组的第一个和数组的最后一个,这样就可以升序排序 + + **在构建堆的时候我们可以有递归和非递归的方式** + + ``` + void heap_bulid(vector& vec, int root, int len) + int left_child = root*2 + 1; + int righ_child = root*2 + 2; + int max_root = root; + if(left_child < len && vec[left_child] > vec[max_root]){ + max_root = left; + } + if(right_child < len && vec[right_child] > vec[max_root]){ + max_root = right; + } + //如果最大值的节点不是原先的父节点,表示需要 + if(max_root != root){ + swap(vec[root], vec[max_root]); + heap_bulid(vec, max_root, len); + } + } + void heap_sort(vector& vec){ + //从右到左sink()方式构造堆,右指的不是最右边,而是从中间向左逼近 + int len = vec.size(); + //从最后一个节点的父节点开始调整 + for(int i = len / 2 - 1; i >=0; i++){ + heap_bulid(vec, i, len); + } + //构建完后开始排序 + for(int j = len - 1; j > 0; j--){ + swap(vec[0], vec[j]); + //交换完后从新建堆,这个堆的长度就要减去被移除(堆顶的那个)的最大元素之后的长度,所以长度不断变化,必须参数要带上长度 + heap_build(vec, 0, j); + } + } + ``` + +- 了解优先队列。 + +- 跟堆有关的题目 + + 问:编写算法,从10亿个浮点数当中,选出其中最大的10000个。 + + 答:典型的Top K问题,用堆是最典型的思路。建10000个数的小顶堆,然后将10亿个数依次读取,大于堆顶,则替换堆顶,做一次堆调整。结束之后,小顶堆中存放的数即为所求。 + + 问:设计一个数据结构,其中包含两个函数,1.插入一个数字,2.获得中数。并估计时间复杂度。 + 使用大顶堆和小顶堆存储。 + + 答:使用大顶堆存储较小的一半数字,使用小顶堆存储较大的一半数字。插入数字时,在O(logn)时间内将该数字插入到对应的堆当中,并适当移动根节点以保持两个堆数字相等(或相差1)。获取中数时,在O(1)时间内找到中数。 + + +## 为什么都在用快排而不是归并,堆? + +**问:我们知道,快排平均复杂度为nlogn,最坏时间复杂度为$n^2$,而归并排序最坏才是nlogn。那么为什么还是用快排多,用归并少呢?** + +我觉得原因有以下几个: + +1. 首先了解O的含义,即O(nlgon)的O。大O符号又称为渐进符号,在数学上表示一个函数的渐进行为的符号,描述一个函数数量级的渐进下界,不考虑首项系数和低阶项。比如说有一个规模n的算法花费时间$T(n)=n^2+2n+c$,可以看到当n增大的时候$n^2$开始占据主导地位,因此$O(n)=n^2$。在实际的生产生活中我们所用的n并没有达到那么大的规模,因此这个2n+c这个点是不可以忽略的。 + +2. 快速排序一般是原地排序,不需要创建任何的辅助数组来保存临时变量。而归并排序需要用到两个额外的数组进行存储,同时将数组合并成为一个也会花费点时间。 + +3. 就常数项来说,快排< 归并 < 堆 + + image-20211210114012869 + +**问:什么场景下用归并比用快排好?** + +当对连链表结构进行排序的时候,归并排序比快排高效。因为链表的存储空间时分散的,必须到每个节点的地址再连接起来,这就导致快排依靠数组的优势不存在了。同时归并使用链表结构的话,直接连接就行,不需要额外的辅助空间。 + +## 插入排序 + +147,148 + +## 优先队列 + +- 概念 + + 在做堆排序的时候才知道这个堆排序的思想是从优先队列里面来的。 + + 优先队列是一种抽象类型的数据结构,他的特征是: + + 1. 队列中的每个元素都有各自的优先级,优先级高的先得到服务或者先处理 + + 2. 支持删除优先级高的元素,插入新元素 + + > 来看一看线性结构和堆来取优先级最高的元素的时间复杂度比较: + > + > | 数据结构 | 入队 | 出队 | + > | :----------: | :----: | :----: | + > | 普通线性结构 | O(1) | O(n) | + > | 顺序线性结构 | O(n) | O(1) | + > | 堆 | O(lgn) | O(lgn) | + > + > 可以看到当时用堆这种数据结构的时候是比较高效的。 + +- 优先队列和堆的区别 + + 这里面堆特指二叉堆。 + + 二叉堆只是有点队列实现的一种方式。除此之外优先队列还有二项堆,配对堆,左偏树,斐波那契堆,平衡树,线段树,甚至是二进制分组的vector来实现一个优先队列等等。 + +- 代码实现 + + [参考链接](https://www.jb51.net/article/122293.htm) + + ```c++ + class Priority_queue { + public: + Priority_queue(int max_num) { + int *a = new int[max_num]; + a = { 0 }; + } + ~Priority_queue() + { + delete[] a; + } + void Insert(int num) ; + bool delete_max(); + void build_max_heap(int *a, int length); + int max_num(); + void sort(); + private: + int capacity; + int len; + int *a; + }; + + void Priority_queue::build_max_heap(int *a, int length) { + + } + int Priority_queue::max_num() { + + } + bool Priority_queue::delete_max() { + + } + void Priority_queue::Insert(int num) { + + } + void Priority_queue::sort() { + + } + ``` + + +# 二分查找 + +二分查找算法的前提是数组时有序的,同时不能有重复元素。 + +二分查找难点就在于对区间的定义。区间的定义一般分为两种:左闭右闭[left, right]和左闭右开[left, right) + +最重要的一点是:二分查找一般不仅仅用来查找某一个位置上的值,同时更多的是用来查找某一个范围。比如一个有序的重复数组,查找第k个等于target的值,其实本质上都是借用了二分查找的思想,只是变体不同而已,所以把二分查找好好学就行。 + +> **在知乎上看到的非常有启发的一句话:** +> +> **二分查找的过程就是一个 维护 low 的过程:low指针从0开始,只在中位数遇到确定小于目标数时才前进,并且永不后退。low一直在朝着第一个目标数的位置在逼近。直到最终到达。** + +## 基本的二分搜索 + +#### 左闭右闭[left, right] + +> - 左闭右闭[left, right]即 right = nums.length - 1 +> - while(left <= right)要使用小于等于,因为left==right是有意义的。 +> - if(nums[middle] > target)这个时候right=middle-1,因为当前这个nums[middle]一定不是target,所以肯定往左边查。 + +```c++ +int binary_search(vector& nums, int target){ + int left = 0; + int right = nums.size() - 1; + while(left <= right){ + int middle = left + (right - left) /2; + if(nums[middle] > target){ + right = middle -1; + } + else if(nums[middle] < target){ + left = middle + 1; + }else{ + return middle; + } + } + return -1; +} +``` + +#### 左闭右开[left, right) + +> - 左闭右开[left, right)即right = nums.length +> - while(left < right) 因为left=right是没有意义的 +> - if(nums[middle] > target 的时候应该去左边找,由于是左闭右开的,因此right=middle + +``` + int search(vector& nums, int target) { + int left = 0; + int right = nums.size(); // 定义target在左闭右开的区间里,即:[left, right) + while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间,所以使用 < + int middle = left + ((right - left) >> 1); + if (nums[middle] > target) { + right = middle; // target 在左区间,在[left, middle)中 + } else if (nums[middle] < target) { + left = middle + 1; // target 在右区间,在[middle + 1, right)中 + } else { // nums[middle] == target + return middle; // 数组中找到目标值,直接返回下标 + } + } + // 未找到目标值 + return -1; + } +``` + +## 边界的二分搜索 + +**之前的都是在数组中找到一个数要与目标相等,如果不存在则返回-1。我们也可以用二分查找法找寻边界值,也就是说在**有序数组**中找到“正好大于(小于)目标数”的那个数。** + +比如说给有序数组 `nums = [1,2,2,2,3]`,`target` 为 2,此算法返回的索引是 2,没错。但是如果我想得到 `target` 的左侧边界,即索引 1,或者我想得到 `target` 的右侧边界,即索引 3,这样的话此算法是无法处理的。 + +最直观的方法就是找到target然后线性向左向右搜索,但是当数据量很大的时候,很难保证二分查找的复杂度, + +因此总结了左侧和右侧边界的二分搜索 + +**对于边界搜索的话使用左闭右开比较普遍** + +### 左侧边界 + +左侧边界又可以理解为小于某个数有几个 + +- 左闭右开写法 + + ```c++ + int left_bound(vector& nums, int target) { + if (nums.length == 0) return -1; + int left = 0; + int right = nums.length(); // 注意 + + while (left < right) { // 注意 + int mid = left + (right -left) / 2; + if (nums[mid] < target) { + left = mid + 1; //没问题 + } else{ + right = mid; //这个有点意思,找到target不返回而是继续缩小边界,不断锁定左侧,最终会出现left=right + } + } + //检查出界情况 + if (left >= nums.length || nums[left] != target) + return -1; + return left; + } + ``` + +- 左闭右闭写法 + + ```c++ + int left_bound(int[] nums, int target) { + int left = 0, right = nums.length - 1; + // 搜索区间为 [left, right] + while (left <= right) { + int mid = left + (right - left) / 2; + if (nums[mid] < target) { + // 搜索区间变为 [mid+1, right] + left = mid + 1; + } else if (nums[mid] > target) { + // 搜索区间变为 [left, mid-1] + right = mid - 1; + } else if (nums[mid] == target) { + // 收缩右侧边界 + right = mid - 1; + } + } + // 检查出界情况 + if (left >= nums.length || nums[left] != target) + return -1; + return left; + } + ``` + +可以看到当nums[mid] == target的时候,让mid这个位置变成了right重新开始查找。 + +数组越界的判断条件是指当left大于数组长度时候,肯定是错的。或者逼近的left位置上的值不等于target,说明就没有。 + +### 右侧边界 + +同理右侧边界可以理解为大于某个数有几个 + +- 左闭右开 + + ```c++ + int right_bound(vector& nums, int target) { + if (nums.length == 0) return -1; + int left = 0; + int right = nums.length(); // 注意 + + while (left < right) { // 注意 + int mid = left + (right -left) / 2; + if (nums[mid] < target) { + left = mid ; //没问题 + } else{ + right = mid - 1; + } + } + //检查出界情况 + if (right >= nums.length || nums[left] != target) + return -1; + return right; + } + ``` + +### c++二分查找API + +> 二分查找的函数有3个: +> +> int lower_bound(起始地址,结束地址,要查找的数值) 返回的是数值 第一个等于某元素 的位置。 +> +> int upper_bound(起始地址,结束地址,要查找的数值) 返回的是数值 第一个大于某个元素 的位置。 +> +> bool binary_search(起始地址,结束地址,要查找的数值) 返回的是 是否存在 这么一个数,是一个bool值。 + +- **函数lower_bound()** + + 功能:函数lower_bound()在first和last中的前闭后开区间进行二分查找,返回大于或等于val的第一个元素位置。如果所有元素都小于val,则返回last的位置,因为是前闭后开因此这个时候的last会越界,要注意。 + +- **函数upper_bound()** + + 功能:函数upper_bound()返回的在前闭后开区间查找的关键字的上界,返回大于val的第一个元素位置。注意:返回查找元素的最后一个可安插位置,也就是“元素值>查找值”的第一个元素的位置。同样,如果val大于数组中全部元素,返回的是last。(注意:数组下标越界) + +- **函数binary_search()** + + 功能: 在数组中以二分法检索的方式查找,若在数组(要求数组元素非递减)中查找到indx元素则真,若查找不到则返回值为假。 + + + +# 跳表 + + + +# 求根号2 + +二分法 + + + +# Topk两种方法 + +**快排** + +快排的思路:首先看是求前k个最大的还是前k个最小的值,然后去找flag的左边或者右边。然后呢前k个,则数组的下标和k对比,如果不相等就看下标和k哪个大哪个小。如果index k? quickSearch(nums, lo, j - 1, k): quickSearch(nums, j + 1, hi, k); +} + +// 快排切分,返回下标j,使得比nums[j]小的数都在j的左边,比nums[j]大的数都在j的右边。 +private int partition(int[] nums, int lo, int hi) { + int v = nums[lo]; + int i = lo, j = hi + 1; + while (true) { + while (++i <= hi && nums[i] < v); + while (--j >= lo && nums[j] > v); + if (i >= j) { + break; + } + int t = nums[j]; + nums[j] = nums[i]; + nums[i] = t; + } + nums[lo] = nums[j]; + nums[j] = v; + return j; +} +``` + +**堆** + +小顶堆,最上面是最小的,求前k个最大的 + +大顶堆,最上面是最大的,求前k个最小的 + +构建思路:先随机取出N个数中的K个数,将这N个数构造为小顶堆,那么堆顶的数肯定就是这K个数中最小的数了。然后再将剩下的N-K个数与堆顶进行比较,如果大于堆顶,那么说明该数有机会成为TopK,就更新堆顶为该数,此时由于小顶堆的性质可能被破坏,就还需要调整堆 + +复杂度分析: 首先需要对K个元素进行建堆,时间复杂度为O(k),然后要遍历数组,最坏的情况是,每个元素都与堆顶比较并排序,需要堆化n次 所以是O(nlog(k)),因此总复杂度是O(nlog(k)); + +堆排序的优势:通过对比可以发现,堆排的优势是只需读入K个数据即可,可以实现来一个数据更新一次,能够很好的实现数据动态读入并找出 + +**海量数据情况下** + +比如:10亿个数中找出最大的10000个数,因为数据太大没办法全部装入内存,所以需要别的办法。 + +基本:最小堆,10000个数建堆,然后一次添加剩余元素,如果大于堆顶的数(10000中最小的),将这个数替换堆顶,并调整结构使之仍然是一个最小堆,这样,遍历完后,堆中的10000个数就是所需的最大的10000个。建堆时间复杂度是O(nlogn),算法的时间复杂度为O(nklogn)(n为10亿,k为10000)。 + + 优化的方法:可以把所有10亿个数据分组存放,比如分别放在1000个文件中。这样处理就可以分别在每个文件的10^6个数据中找出最大的10000个数,合并到一起在再找出最终的结果。 + + + +# 快排改成稳定排序 + + + + + + + +# 单向链表和双向链表的区别,分别的场景 + + + +# 哈希表 + +散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度(以时间换空间的思想)。这个映射函数叫做散列函数,存放记录的数组叫做散列表。 散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快地定位。 + +## 常见的常见的散列函数 + +- 直接寻址法 + + 取关键字或关键字的某个线性函数值为散列地址 + +- 平方取中法 + + 当无法确定关键字中哪几位分布较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址 + +- 随机数法 + + 择一随机函数,取关键字的随机值作为散列地址,通常用于关键字长度不等的场合 + +- 除留余数法 + + 取余,取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key % p, p <= m + +## **怎么解决哈希冲突** + +- 开放寻址法 + + 当发生哈希冲突后,就去寻找下一个空的散列地址,只要散列表足够大,这个空的位置一定能找到 + +- 再散列法 + + 当散列表元素太多(即装填因子 α 太大)时,查找效率会下降;当装填因子过大时,解决的方法是加倍扩大散列表,这个过程叫做“再散列(Rehashing)”。Hash 表中每次发现 loadFactor==1 时,就开辟一个原来桶数组的两倍空间(称为新桶数组),然后把原来的桶数组中元素全部转移过来到新的桶数组中。注意这里转移是需要元素一个个重新哈希到新桶中的。实用最大装填因子一般取 0.5 <= α<= 0.85 + +- 链地址法 + + 当发生冲突时,该位置上的数据会用链表链起来,当表中的某些位置没有结点时,该位置就为 NULL。但是当数据特别大时候,可能链表也很长,查找变成O(n) + + 优化:使用红黑树等树形结构来存储,但是要注意红黑树有排序,需要自定义排序算法。 + +- 公共溢出区 + + 为所有冲突的关键字记录建立一个公共的溢出区来存放。在查找时,对给定关键字通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表进行顺序查找。如果相对于基本表而言,在有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。 + +## Hash表的扩容 + +**什么时候扩容?** + +当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值(即当前数组的长度乘以加载因子的值的时候),就要自动扩容了。 + +**扩容** + +在hash表的实现中,我们一般会控制一个装载因子,当装载因子过大的时候,会考虑扩容(resize) + + 如果一个hash表中桶的个数为 size , 存储的元素个数为used .则我们称 used / size 为负载因子loadFactor . 一般的情况下,当loadFactor<=1时,hash表查找的期望复杂度为O(1). 因此。每次往hash表中加入元素时。我们必须保证是在loadFactor <1的情况下,才可以加入。 + +当我们加入一个新元素时。一旦loadFactor大于等于1了,我们不能单纯的往hash表里边加入元素。Hash表中每次发现loadFactor==1时,就开辟一个原来桶数组的两倍空间(称为新桶数组),然后把原来的桶数组中元素所有转移过来到新的桶数组中。注意这里转移是须要元素一个个又一次哈希到新桶中的。 + +缺点:容量扩张是一次完毕的,期间要花非常长时间一次转移hash表中的全部元素。这样在hash表中loadFactor==1时。往里边插入一个元素将会等候非常长的时间。 + +> redis中的dict.c中的设计思路是用两个hash表来进行进行扩容和转移的工作:当从第一个hash表的loadFactor=1时,假设要往字典里插入一个元素。首先为第二个hash表开辟2倍第一个hash表的容量。同一时候将第一个hash表的一个非空桶中元素所有转移到第二个hash表中。然后把待插入元素存储到第二个hash表里。继续往字典里插入第二个元素,又会将第一个hash表的一个非空桶中元素所有转移到第二个hash表中,然后把元素存储到第二个hash表里……直到第一个hash表为空。 diff --git "a/\350\256\276\350\256\241\346\250\241\345\274\217.md" "b/\350\256\276\350\256\241\346\250\241\345\274\217.md" new file mode 100644 index 0000000..ebca2ea --- /dev/null +++ "b/\350\256\276\350\256\241\346\250\241\345\274\217.md" @@ -0,0 +1,389 @@ +# c++单例模式 + +> 定义:单例模式是创建型设计模式,指的是在系统的生命周期中只能产生一个实例(对象),确保该类的唯一性。 +> +> 一般遇到的写进程池类、日志类、内存池(用来缓存数据的结构,在一处写多出读或者多处写多处读)的话都会用到单例模式 + +**实现方法:**全局只有一个实例也就意味着不能用new调用构造函数来创建对象,因此构造函数必须是虚有的。但是由于不能new出对象,所以类的内部必须提供一个函数来获取对象,而且由于不能外部构造对象,因此这个函数不能是通过对象调出来,换句话说这个函数应该是属于对象的,很自然我们就想到了用static。由于静态成员函数属于整个类,在类实例化对象之前就已经分配了空间,而类的非静态成员函数必须在类实例化后才能有内存空间。 + +单例模式的要点总结: + +1. 全局只有一个实例,用static特性实现,构造函数设为私有 +2. 通过公有接口获得实例 +3. 线程安全 +4. 禁止拷贝和赋值 + +单例模式可以**分为懒汉式和饿汉式**,两者之间的区别在于创建实例的时间不同:懒汉式指系统运行中,实例并不存在,只有当需要使用该实例时,才会去创建并使用实例(这种方式要考虑线程安全)。饿汉式指系统一运行,就初始化创建实例,当需要时,直接调用即可。(本身就线程安全,没有多线程的问题) + +## 懒汉式 + +- 普通懒汉式会让线程不安全 + + 因为不加锁的话当线程并发时会产生多个实例,导致线程不安全 + + ```c++ + /// 普通懒汉式实现 -- 线程不安全 // + #include // std::cout + #include // std::mutex + #include // pthread_create + + class SingleInstance + { + public: + // 获取单例对象 + static SingleInstance *GetInstance(); + // 释放单例,进程退出时调用 + static void deleteInstance(); + // 打印单例地址 + void Print(); + private: + // 将其构造和析构成为私有的, 禁止外部构造和析构 + SingleInstance(); + ~SingleInstance(); + // 将其拷贝构造和赋值构造成为私有函数, 禁止外部拷贝和赋值 + SingleInstance(const SingleInstance &signal); + const SingleInstance &operator=(const SingleInstance &signal); + private: + // 唯一单例对象指针 + static SingleInstance *m_SingleInstance; + }; + + //初始化静态成员变量 + SingleInstance *SingleInstance::m_SingleInstance = NULL; + + SingleInstance* SingleInstance::GetInstance() + { + if (m_SingleInstance == NULL) + { + m_SingleInstance = new (std::nothrow) SingleInstance; // 没有加锁是线程不安全的,当线程并发时会创建多个实例 + } + return m_SingleInstance; + } + + void SingleInstance::deleteInstance() + { + if (m_SingleInstance) + { + delete m_SingleInstance; + m_SingleInstance = NULL; + } + } + + void SingleInstance::Print() + { + std::cout << "我的实例内存地址是:" << this << std::endl; + } + + SingleInstance::SingleInstance() + { + std::cout << "构造函数" << std::endl; + } + + SingleInstance::~SingleInstance() + { + std::cout << "析构函数" << std::endl; + } + /// 普通懒汉式实现 -- 线程不安全 // + ``` + +- 线程安全、内存安全的懒汉式 + + 上述代码出现的问题: + + 1. GetInstance()可能会引发竞态条件,第一个线程在if中判断 `m_instance_ptr`是空的,于是开始实例化单例;同时第2个线程也尝试获取单例,这个时候判断`m_instance_ptr`还是空的,于是也开始实例化单例;这样就会实例化出两个对象,这就是线程安全问题的由来 + + 解决办法:①加锁。②局部变量实例 + + 2. 类中只负责new出对象,却没有负责delete对象,因此只有构造函数被调用,析构函数却没有被调用;因此会导致内存泄漏。 + + 解决办法:使用共享指针 + + > c++11标准中有一个特性:如果当变量在初始化的时候,并发同时进入声明语句,并发线程将会阻塞等待初始化结束。这样保证了并发线程在获取静态局部变量的时候一定是初始化过的,所以具有线程安全性。因此这种懒汉式是最推荐的,因为: + > + > 1. 通过局部静态变量的特性保证了线程安全 (C++11, GCC > 4.3, VS2015支持该特性); + > 2. 不需要使用共享指针和锁 + > 3. get_instance()函数要返回引用而尽量不要返回指针, + + ```c++ + /// 内部静态变量的懒汉实现 // + class Singleton + { + public: + ~Singleton(){ + std::cout<<"destructor called!"< 使用锁、共享指针实现的懒汉式单例模式 + > + > - 基于 shared_ptr, 用了C++比较倡导的 RAII思想,用对象管理资源,当 shared_ptr 析构的时候,new 出来的对象也会被 delete掉。以此避免内存泄漏。 + > - 加了锁,使用互斥量来达到线程安全。这里使用了两个 if判断语句的技术称为**双检锁**;好处是,只有判断指针为空的时候才加锁,避免每次调用 get_instance的方法都加锁,锁的开销毕竟还是有点大的。 + > + > 不足之处在于: 使用智能指针会要求用户也得使用智能指针,非必要不应该提出这种约束; 使用锁也有开销; 同时代码量也增多了,实现上我们希望越简单越好。 + + ```c++ + #include + #include // shared_ptr + #include // mutex + + // version 2: + // with problems below fixed: + // 1. thread is safe now + // 2. memory doesn't leak + + class Singleton { + public: + typedef std::shared_ptr Ptr; + ~Singleton() { + std::cout << "destructor called!" << std::endl; + } + Singleton(Singleton&) = delete; + Singleton& operator=(const Singleton&) = delete; + static Ptr get_instance() { + + // "double checked lock" + if (m_instance_ptr == nullptr) { + std::lock_guard lk(m_mutex); + if (m_instance_ptr == nullptr) { + m_instance_ptr = std::shared_ptr(new Singleton); + } + } + return m_instance_ptr; + } + + + private: + Singleton() { + std::cout << "constructor called!" << std::endl; + } + static Ptr m_instance_ptr; + static std::mutex m_mutex; + }; + + // initialization static variables out of class + Singleton::Ptr Singleton::m_instance_ptr = nullptr; + std::mutex Singleton::m_mutex; + ``` + + + +## 饿汉式 + +```c++ + +// 饿汉实现 / +class Singleton +{ + +public: + // 获取单实例 + static Singleton* GetInstance(); + // 释放单实例,进程退出时调用 + static void deleteInstance(); + // 打印实例地址 + void Print(); + +private: + // 将其构造和析构成为私有的, 禁止外部构造和析构 + Singleton(); + ~Singleton(); + + // 将其拷贝构造和赋值构造成为私有函数, 禁止外部拷贝和赋值 + Singleton(const Singleton &signal); + const Singleton &operator=(const Singleton &signal); + +private: + // 唯一单实例对象指针 + static Singleton *g_pSingleton; +}; + +// 代码一运行就初始化创建实例 ,本身就线程安全 +Singleton* Singleton::g_pSingleton = new (std::nothrow) Singleton; + +Singleton* Singleton::GetInstance() +{ + return g_pSingleton; +} + +void Singleton::deleteInstance() +{ + if (g_pSingleton) + { + + delete g_pSingleton; + g_pSingleton = NULL; + } +} + +void Singleton::Print() +{ + std::cout << "我的实例内存地址是:" << this << std::endl; +} + +Singleton::Singleton() +{ + std::cout << "构造函数" << std::endl; +} + +Singleton::~Singleton() +{ + std::cout << "析构函数" << std::endl; +} +// 饿汉实现 / +``` + +## 面试题 + +- 懒汉模式和恶汉模式的实现(判空!!!加锁!!!),并且要能说明原因(为什么判空两次?) +- 构造函数的设计(为什么私有?除了私有还可以怎么实现(进阶)?) +- 对外接口的设计(为什么这么设计?) +- 单例对象的设计(为什么是static?如何初始化?如何销毁?(进阶)) +- 对于C++编码者,需尤其注意C++11以后的单例模式的实现(为什么这么简化?怎么保证的(进阶)) + + + +# Observe模式 + +## 定义 + +又叫做观察者模式,被观察者叫做subjec,观察者叫做observer + +**观察者模式(Observer Pattern)**: 定义对象间一种一对多的依赖关系,使得当每一个对象改变状态,则所有依赖于它的对象都会得到通知并自动更新。 + +观察者模式所做的工作其实就是在解耦,让耦合的双方都依赖于抽象而不是具体,从而使得各自的变化都不会影响另一边的变化。当一个对象的改变需要改变其他对象的时候,而且它不知道具体有多少对象有待改变的时候,应该考虑使用观察者模式。一旦观察目标的状态发生改变,所有的观察者都将得到通知。具体来说就是被观察者需要用一个容器比如vector存放所有观察者对象,以便状态发生变化时给观察着发通知。观察者内部需要实例化被观察者对象的实例(需要前向声明) + +img + +**观察者模式中主要角色--2个接口,2个类** + +1. 抽象主题(Subject)角色(接口):主题角色将所有对观察者对象的引用保存在一个集合中,每个主题可以有任意多个观察者。 抽象主题提供了增加和删除观察者对象的接口。 +2. 抽象观察者(Observer)角色(接口):为所有的具体观察者定义一个接口,在观察的主题发生改变时更新自己。 +3. 具体主题(ConcreteSubject)角色(1个):存储相关状态到具体观察者对象,当具体主题的内部状态改变时,给所有登记过的观察者发出通知。具体主题角色通常用一个具体子类实现。 +4. 具体观察者(ConcretedObserver)角色(多个):存储一个具体主题对象,存储相关状态,实现抽象观察者角色所要求的更新接口,以使得其自身状态和主题的状态保持一致。 + +```java +//观察者 +interface Observer { + public void update(); +} +//被观察者 +abstract class Subject { + private Vector obs = new Vector(); + + public void addObserver(Observer obs){ + this.obs.add(obs); + } + public void delObserver(Observer obs){ + this.obs.remove(obs); + } + protected void notifyObserver(){ + for(Observer o: obs){ + o.update(); + } + } + public abstract void doSomething(); +} +//具体被观察者 +class ConcreteObserver1 implements Observer { + public void update() { + System.out.println("观察者1收到信息,并进行处理"); + } +} +class ConcreteObserver2 implements Observer { + public void update() { + System.out.println("观察者2收到信息,并进行处理"); + } +} +//客户端 +public class Client { + public static void main(String[] args){ + Subject sub = new ConcreteSubject(); + sub.addObserver(new ConcreteObserver1()); //添加观察者1 + sub.addObserver(new ConcreteObserver2()); //添加观察者2 + sub.doSomething(); + } +} +//输出 +被观察者事件发生改变 +观察者1收到信息,并进行处理 +观察者2收到信息,并进行处理 +可以看到当被观察者发生改变过后,观察者都收到了通知 +``` + +## 优点 + +- 观察者模式在被观察者和观察者之间建立一个抽象的耦合。被观察者角色所知道的只是一个具体观察者列表,每一个具体观察者都符合一个抽象观察者的接口。被观察者并不认识任何一个具体观察者,它只知道它们都有一个共同的接口。 +- 观察者模式支持广播通讯。被观察者会向所有的登记过的观察者发出通知 + +## 缺点 + +- 当观察者对象很多的时候,通知的发布会产生很多时间,影响程序的效率。一个被观察者,多个观察者时,开发代码和调试会比较复杂,java中消息的通知是默认顺序执行的,若其中一个观察者卡壳,会影响到此观察者后面的观察者执行,影响整体的执行, 多级触发时的效率更让人担忧。 +- 虽然观察者模式可以随时使观察者知道所观察的对象发生了变化,但是观察者模式没有相应的机制使观察者知道所观察的对象是怎么发生变化的。 +- 如果在被观察者之间有循环依赖的话,被观察者会触发它们之间进行循环调用,导致系统崩溃。在使用观察者模式是要特别注意这一点。 + +# 工厂模式 + + + + + +# MVC模式 + +MVC 模式的目的是实现一种动态的程序设计,简化后续对程序的修改和扩展,并且使程序某一部分的重复利用成为可能。除此之外,MVC 模式通过对复杂度的简化,使程序的结构更加直观。软件系统在分离了自身的基本部分的同时,也赋予了各个基本部分应有的功能。专业人员可以通过自身的专长进行相关的分组: + +软件系统分为三个基本部分:模型(Model)、视图(View)和控制器(Controller)。 + +- 模型(Model):程序员编写程序应有的功能(实现算法等)、数据库专家进行数据管理和数据库设计(可以实现具体的功能); +- 控制器(Controller):负责转发请求,对请求进行处理; +- 视图(View):界面设计人员进行图形界面设计。 + +## MVC模式的优点 + +**低耦合** + +通过将视图层和业务层分离,允许更改视图层代码而不必重新编译模型和控制器代码,同样,一个应用的业务流程或者业务规则的改变,只需要改动 MVC 的模型层(及控制器)即可。因为模型与控制器和视图相分离,所以很容易改变应用程序的数据层和业务规则。 + +模型层是自包含的,并且与控制器和视图层相分离,所以很容易改变应用程序的数据层和业务规则。如果想把数据库从 MySQL 移植到 Oracle,或者改变基于 RDBMS 的数据源到 LDAP,只需改变模型层即可。一旦正确的实现了模型层,不管数据来自数据库或是 LDAP 服务器,视图层都将会正确的显示它们。由于运用 MVC 的应用程序的三个部件是相互独立,改变其中一个部件并不会影响其它两个,所以依据这种设计思想能构造出良好的松耦合的构件。 + +**重用性高** + +随着技术的不断进步,当前需要使用越来越多的方式来访问应用程序了。MVC 模式允许使用各种不同样式的视图来访问同一个服务端的代码,这得益于多个视图(如 WEB(HTTP)浏览器或者无线浏览器(WAP))能共享一个模型。 + +比如,用户可以通过电脑或通过手机来订购某样产品,虽然订购的方式不一样,但处理订购产品的方式(流程)是一样的。由于模型返回的数据没有进行格式化,所以同样的构件能被不同的界面(视图)使用。例如,很多数据可能用 HTML 来表示,但是也有可能用 WAP 来表示,而这些表示的变化所需要的是仅仅是改变视图层的实现方式,而控制层和模型层无需做任何改变。 + +由于已经将数据和业务规则从表示层分开,所以可以最大化的进行代码重用了。另外,模型层也有状态管理和数据持久性处理的功能,所以,基于会话的购物车和电子商务过程,也能被 Flash 网站或者无线联网的应用程序所重用。 + +**生命周期成本低** + +MVC 模式使开发和维护用户接口的技术含量降低。 + +**部署快** + +使用 MVC 模式进行软件开发,使得软件开发时间得到相当大的缩减,它使后台程序员集中精力于业务逻辑,界面(前端)程序员集中精力于表现形式上。 + +**可维护性高** + +分离视图层和业务逻辑层使得 WEB 应用更易于维护和修改。 + +**有利软件工程化管理** + +由于不同的组件(层)各司其职,每一层不同的应用会具有某些相同的特征,这样就有利于通过工程化、工具化的方式管理程序代码。控制器同时还提供了一个好处,就是可以使用控制器来联接不同的模型和视图,来实现用户的需求,这样控制器可以为构造应用程序提供强有力的手段。给定一些**可重用的**模型和视图,控制器可以根据用户的需求选择模型进行处理,然后选择视图将处理结果显示给用户。