8.死锁的必要条件、解决死锁的策略,数据库管理系统中和 Java 中如何解决死锁
10.页面置换算法原理,特别是 LRU 的实现原理,结合 Redis 中淘汰策略中的 LRU
问题概览
- 进程与线程的本质区别、以及各自的使用场景
- 进程状态
- 进程调度算法的特点以及使用场景
- 线程实现的方式
- 协程的作用
- 常见进程同步问题
- 进程通信方法的特点以及使用场景
- 死锁的必要条件、解决死锁的策略,数据库管理系统中和 Java 中如何解决死锁
- 虚拟内存的作用,分页系统实现虚拟内存的原理
- 页面置换算法原理,特别是 LRU 的实现原理,结合 Redis 中淘汰策略中的 LRU
- 比较分段与分页的区别
- 分析静态链接的不足,以及动态链接的特点
进程与线程的本质区别、以及各自的使用场景
-
进程
1.1 进程是操作系统 资源分配 的基本单位
1.2 进程控制块(PCB)描述了进程的 基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。 -
线程
2.1 线程独立 调度 的基本单位
2.2 一个进程中可以有多个线程,它们共享进程的资源 -
区别
3.1 拥有资源:进程是资源分配的基本单位,而一个进程中的线程共享进程的资源
3.2 调度:线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程的切换。
3.3 系统开销:创建进程或者撤销进程时,系统都要为之分配和回收资源,如内存空间,I/O 设备等,而且在进程切换时,涉及当前执行进程的 CPU 环境的保存及新调度进程 CPU 环境的设置。而在线程之间进行切换时,只需要保存和设置少量寄存器的内容,开销很小
3.4 通信方面:线程可以通过读写同一进程中的数据进行通信,但是进程通信需要借助 IPC
进程状态![alt](https://i-blog.csdnimg.cn/blog_migrate/0f3e05e894bf923a3d5e0402fa1bc5d7.png)
- 就绪状态(ready):等待被调度
- 运行状态(running):运行状态
- 阻塞状态(waiting):等待资源
应该注意:
- 只有 就绪态 和 运行态 可以相互转换,其它的都是相互转换。就绪态的进程通过调度算法从而获得 CPU 时间片转为运行态;而运行态的进程,再分配给它的时间片用完之后就会转换为就绪态,等待下一次调度
- 阻塞态 是缺少需要的资源从而有 运行态 转换而来。
进程调度算法的特点以及使用场景
1. 批处理系统
1.1 先来先服务 first-come first-serverd(FCFS)
- 按照请求的顺序进行调度
- 有利于长作业,但不利于短作业,因为短作业必须等待前面的长作业执行完才能执行,而长作业执行又需要执行很长的时间,造成短作业等待时间过长
1.2 短作业优先 shortest job first(SJF)
- 按估计运行时间最短的顺序进行调度
- 长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为一直有短作业到来,那么长作业永远得不到调度
1.3 最短剩余时间优先 shortest remaining time next(SRTN)
- 按估计的剩余时间最短的顺序进行调度
2. 交互式系统
2.1 时间片理轮转
- 将所有就绪队列按 FCFS 的原则拍成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。
- 当时间片用完时,有计数器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程
- 时间片轮转算法的效率和时间片的大小有很大的关系
- 因为进程切换需要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多的时间
- 且如果时间片过长,那么实时性就得不到保证
2.2 优先级调度
- 为进程分配一个优先级,按优先级进行调度
- 为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级
2.3 多级反馈队列
- 一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次
- 多级队列是为这种需要连续执行多个时间片的进程考虑的,它设置了多个队列,每个队列的大小都不同,例如 1,2,4,8·····。进程在第一个队列没有执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次
- 每个队列的优先级也不同,最上面的优先级最高。因此只有上一个没有进程在排队,才能调度当前队列上的进程。
- 可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
线程实现的方式
三种实现线程的方法
- 实现 Runnable 接口
- 实现 Callable 接口
- 继承 Thread 类
1. 实现 Runnable 接口
- 需要实现 run 方法
- 通过 Thread 的 strat() 来启动线程
public class MyRunnable implements Runnable {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}
2. 实现 Callable 接口
- 与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。
- 实现的是 call 方法
- 实现 Callable 接口的时候,还需要指明返回值的类型
public class MyCallable implements Callable<Integer> {
public Integer call() {
return 123;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
3. 继承 Thread 类
- 同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。
- 当调用 start() 方法启动一个线程时,虚拟机会将该线程放入 就绪队列 中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。
public class MyThread extends Thread {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}
协程的作用
- 协程又称为微线程
- 子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。
- 协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。
- 优势
- 协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。(单核系统下)
- 不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
常见进程同步问题
1. 生产者消费者问题
- 问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。
- 因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。
- 为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。
- 注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
void producer() {
// 生产者
while(TRUE) {
int item = produce_item();
down(&empty);
down(&mutex);
insert_item(item);
up(&mutex);
up(&full);
}
}
void consumer() {
// 消费者
while(TRUE) {
down(&full);
down(&mutex);
int item = remove_item();
consume_item(item);
up(&mutex);
up(&empty);
}
}
2.读者—写者 问题
- 允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。‘
- 一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 data_mutex 用于对读写的数据加锁。
typedef int semaphore;
semaphore count_mutex = 1;
semaphore data_mutex = 1;
int count = 0;
void reader() {
while(TRUE) {
down(&count_mutex);
count++;
if(count == 1) down(&data_mutex); // 第一个读者需要对数据进行加锁,防止写进程访问
up(&count_mutex);
read();
down(&count_mutex);
count--;
if(count == 0) up(&data_mutex);
up(&count_mutex);
}
}
void writer() {
while(TRUE) {
down(&data_mutex);
write();
up(&data_mutex);
}
}
3.哲学家就餐 问题
- 五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。
- 为了防止死锁的发生,可以设置两个条件:
- 必须同时拿起左右两根筷子;
- 只有在两个邻居都没有进餐的情况下才允许进餐。
#define N 5
#define LEFT (i + N - 1) % N // 左邻居
#define RIGHT (i + 1) % N // 右邻居
#define THINKING 0
#define HUNGRY 1
#define EATING 2
typedef int semaphore;
int state[N]; // 跟踪每个哲学家的状态
semaphore mutex = 1; // 临界区的互斥
semaphore s[N]; // 每个哲学家一个信号量
void philosopher(int i) {
while(TRUE) {
think();
take_two(i);
eat();
put_two(i);
}
}
void take_two(int i) {
down(&mutex);
state[i] = HUNGRY;
test(i);
up(&mutex);
down(&s[i]);
}
void put_two(i) {
down(&mutex);
state[i] = THINKING;
test(LEFT);
test(RIGHT);
up(&mutex);
}
void test(i) { // 尝试拿起两把筷子
if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] !=EATING) {
state[i] = EATING;
up(&s[i]);
}
}
进程通信方法的特点以及使用场景
1. 无名管道
管道是通过调用 pipe 函数创建的,fd[0] 用于读,fd[1] 用于写。
#include <unistd.h>
int pipe(int fd[2]);
管道的限制:
- 只能在父子进程中使用
- 只支持半双工通信(读进程和写进程同时只有一个能使用)
[外链图片转存失败(img-6ToLsDJE-1564908701744)(https://camo.githubusercontent.com/d48f665fb5a94ebd2e255064cbedb1e73cb8d7e7/68747470733a2f2f67697465652e636f6d2f437943323031382f43532d4e6f7465732f7261772f6d61737465722f646f63732f706963732f35336364396164652d623061362d343339392d623464652d3766316662643036636466622e706e67)]
2. 有名管道 / FIFO
也称为命名管道,去除了管道只能在父子进程中使用的限制。
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);
FIFO 常用于客户 - 服务器应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程之间传递数据。
3. 信号量
它是一个计数器,用于为多个进程提供对共享数据对象的访问
4. 消息队列
相比于 FIFO,消息队列具有以下优点:
- 消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难
- 避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法;
- 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。
5. 共享内存
允许多个进程共享一个给定的存储区。因为数据不需要在进程之间复制,所以这是最快的一种 IPC
需要使用信号量用来同步对共享存储的访问。
多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外 XSI 共享内存不是使用文件,而是使用使用内存的匿名段。
6. 套接字
与其它通信机制不同的是,它可用于不同机器间的进程通信。
死锁
1. 死锁的必要条件
- 互斥:每个资源同时只有一个进程能访问
- 保持与等待:已经得到了某个资源的进程可以再请求新的资源。
- 不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放。
- 环路等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源。
2. 死锁的解决策略
- 鸵鸟策略(不采取任何措施)
- 杀死进程
- 进程回滚
- 进行资源抢占
3. 死锁预防
- 破坏互斥访问条件
- 破坏保持与等待
- 破坏不可抢占
- 破坏环路条件
4.银行家算法
[外链图片转存失败(img-QXuWOoi5-1564908701745)(https://camo.githubusercontent.com/0df3737fa82afe401fbe5276ae7a5ac6fa8d1e49/68747470733a2f2f67697465652e636f6d2f437943323031382f43532d4e6f7465732f7261772f6d61737465722f646f63732f706963732f36326530646434662d343463332d343365652d626236652d6665646239653036383531392e706e67)]
上图中有五个进程,四个资源。左边的图表示已经分配的资源,右边的图表示还需要分配的资源。最右边的 E、P 以及 A 分别表示:总资源、已分配资源以及可用资源,注意这三个为向量,而不是具体数值,例如 A=(1020),表示 4 个资源分别还剩下 1/0/2/0。
检查一个状态是否安全的算法如下:
- 查找右边的矩阵是否存在一行小于等于向量 A。如果不存在这样的行,那么系统将会发生死锁,状态是不安全的。
- 假若找到这样一行,将该进程标记为终止,并将其已分配资源加到 A 中。
- 重复以上两步,直到所有进程都标记为终止,则状态时安全的。
如果一个状态不是安全的,需要拒绝进入这个状态
虚拟内存的作用,分页系统实现虚拟内存的原理
-
虚拟内存的作用:
- 虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。
- 为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。
- 从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。例如有一台计算机可以产生 16 位地址,那么一个程序的地址空间范围是 0~64K。该计算机只有 32KB 的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序。
-
分页地址映射
- 内存管理单元(MMU)管理着地址空间和物理内存的转换,其中的页表(Page table)存储着页(程序地址空间)和页框(物理内存空间)的映射表。
- 一个虚拟地址分成两个部分,一部分存储页面号,一部分存储偏移量。
- 下图的页表存放着 16 个页,这 16 个页需要用 4 个比特位来进行索引定位。例如对于虚拟地址(0010 000000000100),前 4 位是存储页面号 2,读取表项内容为(110 1),页表项最后一位表示是否存在于内存中,1 表示存在。后 12 位存储偏移量。这个页对应的页框的地址为 (110 000000000100)。
[外链图片转存失败(img-PzM6N25l-1564908701745)(https://camo.githubusercontent.com/61fa3296a583384ce090530c870201e88cfab441/68747470733a2f2f67697465652e636f6d2f437943323031382f43532d4e6f7465732f7261772f6d61737465722f646f63732f706963732f63663433383661312d353863392d346563612d613137662d6531326231653937373065622e706e67)]
页面置换算法原理
1. 最佳(OPT, Optimal replacement algorithm)
- 所选择的被换出的页面将是最长时间内不再被访问,通常可以保证获得最低的缺页率。
- 是一种理论上的算法,因为无法知道一个页面多长时间不再被访问。
2. 最近最久未使用(LRU, Least Recently Used)
- LRU 将最近最久未使用的页面换出。
- 为了实现 LRU,需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到 链表表头 。这样就能保证链表表尾的页面是最近最久未访问的
- 因为每次访问都需要更新链表,因此这种方式实现的 LRU 代价很高
[外链图片转存失败(img-G4tRHwYc-1564908701745)(https://camo.githubusercontent.com/70597fc41288a2712cede53152a44079884fa0bc/68747470733a2f2f67697465652e636f6d2f437943323031382f43532d4e6f7465732f7261772f6d61737465722f646f63732f706963732f65623835393232382d633066322d346263652d393130642d6439663736393239333532622e706e67)]
3. 最久未使用 (NRU, Not Recently Used)
每个页面都有两个状态位:R 与 M,当页面被访问时设置页面的 R=1,当页面被修改时设置 M=1。其中 R 位会定时被清零。可以将页面分成以下四类:
- R=0,M=0
- R=0,M=1
- R=1,M=0
- R=1,M=1
当发生缺页中断时,NRU 算法随机地从 类编号最小 的非空类中挑选一个页面将它换出。
NRU 优先换出已经被修改的脏页面(R=0,M=1),而不是被频繁使用的干净页面(R=1,M=0)
4. 先进先出(FIFO, First In First Out)
- 选择换出的页面是最先进入的页面。
- 该算法会将那些经常被访问的页面也被换出,从而使缺页率升高。
5. 第二次生命
- FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问题,对该算法做一个简单的修改:
- 当页面被访问 (读或写) 时设置该页面的 R 位为 1。需要替换的时候,检查最老页面的 R 位。如果 R 位是 0,那么这个页面既老又没有被使用,可以立刻置换掉;如果是 1,就将 R 位清 0,并把该页面放到链表的尾端,修改它的装入时间使它就像刚装入的一样,然后继续从链表的头部开始搜索。
6. 时钟(Clock)
- 第二次机会算法需要在链表中移动页面,降低了效率。时钟算法使用环形链表将页面连接起来,再使用一个指针指向最老的页面。
比较分段与分页的区别
- 对程序员的透明性:分页透明,但是分段需要程序员显示划分每个段
- 地址空间维度:分页是一维地址空间,而分段是二维地址空间
- 大小是否可以改变:分页的大小是固定的,分段的大小可以动态改变
- 出现的原因:分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。
分析静态链接的不足,以及动态链接的特点
- 静态链接就是在编译时将多个目标文件组合在一起形成一个可执行文件的过程。
- 动态链接的引入是为了解决静态链接过程中的空间浪费以及程序库升级不方便的问题,它本质上是一种加载时链接的技术,把程序的符号解析和重定位推迟到加载的时候再进行。