一、沉默王二-并发编程
1、ThreadLocal
本地变量ThreadLocal是 Java 中提供的一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离,用于解决多线程中共享对象的线程安全问题。
通常,我们会使用 synchronzed 关键字或者 lock来控制线程对临界区资源的同步顺序,但这种加锁的方式会让未获取到锁的线程进行阻塞,很显然,这种方式的时间效率不会特别高。
线程安全问题的核心在于多个线程会对同一个临界区的共享资源进行访问,那如果每个线程都拥有自己的“共享资源”,各用各的,互不影响,这样就不会出现线程安全的问题了,对吧?
事实上,这就是一种“空间换时间”的思想,每个线程拥有自己的“共享资源”,虽然内存占用变大了,但由于不需要同步,也就减少了线程可能存在的阻塞问题,从而提高时间上的效率。
不过,ThreadLocal 并不在 java.util.concurrent 并发包下,而是在 java.lang 包下,但我更倾向于把它当作是一种并发容器。
顾名思义,ThreadLocal 就是线程的“本地变量”,即每个线程都拥有该变量的一个副本,达到人手一份的目的,这样就可以避免共享资源的竞争。
1.1 ThreadLocal 的源码分析
1.1.1 set 方法
set 方法用于设置当前线程中 ThreadLocal 的变量值,过程如下:
- 通过
Thread.currentThread()方法获取当前调用此方法的线程实例。 - 每个线程都有自己的 ThreadLocalMap,这个映射表存储了线程的局部变量,其中键是 ThreadLocal 对象,值为特定于线程的对象。
- 如果 Map 不为 null,则以当前 ThreadLocal 实例为 key,值为 value 进行存入;如果 map 为 null,则新建 ThreadLocalMap 并存入 value。
通过源码我们知道,value 是存放在 ThreadLocalMap 里的。
set 方法的重要性在于它确保了每个线程都有自己的变量副本。由于这些变量是存储在与线程关联的映射表中的,所以不同的线程之间的这些变量互不影响。
1.1.2 get 方法
get 方法用于获取当前线程中 ThreadLocal 的变量值,同样的还是来看源码:
public T get() {
//1. 获取当前线程的实例对象
Thread t = Thread.currentThread();
//2. 获取当前线程的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
//3. 获取map中当前ThreadLocal实例为key的值的entry
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
//4. 当前entitiy不为null的话,就返回相应的值value
T result = (T)e.value;
return result;
}
}
//5. 若map为null或者entry为null的话通过该方法初始化,并返回该方法返回的value
return setInitialValue();
}
setInitialValue方法是通过 protected 修饰的,也就意味着 ThreadLocal 的子类可以重写该方法给一个合适的初始值。
这里是 initialValue 方法的典型用法:
private static ThreadLocal<Integer> myThreadLocal = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return 0; // 初始值设置为0
}
};
此代码段创建了一个新的 ThreadLocal<Integer> 对象,其初始值为 0。任何尝试首次访问此 ThreadLocal 变量的线程都会看到值 0。
整个 setInitialValue 方法的目的是确保每个线程在第一次尝试访问其 ThreadLocal 变量时都有一个合适的值。这种“懒惰”初始化的方法确保了仅在实际需要特定于线程的值时才创建这些值。
1.1.3 remove 方法
public void remove() {
//1. 获取当前线程的ThreadLocalMap
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
//2. 从map中删除以当前ThreadLocal实例为key的键值对
m.remove(this);
}
remove 方法的作用是从当前线程的 ThreadLocalMap 中删除与当前 ThreadLocal 实例关联的条目。这个方法在释放线程局部变量的资源或重置线程局部变量的值时特别有用。
1.2 ThreadLocalMap 的源码分析
ThreadLocalMap 是 ThreadLocal 类的静态内部类,它是一个定制的哈希表,专门用于保存每个线程中的线程局部变量。
static class ThreadLocalMap {}
和大多数容器一样,ThreadLocalMap 内部维护了一个 Entry 类型的数组 类型的数组 table,长度为 2 的幂次方。
Entry 继承了弱引用 WeakReference<ThreadLocal<?>>,它的 value 字段用于存储与特定 ThreadLocal 对象关联的值。使用弱引用作为键允许垃圾收集器在不再需要的情况下回收 ThreadLocal 实例。
这里我们可以用一张图来理解下 Thread、ThreadLocal、ThreadLocalMap、Entry 之间的关系:
上图中的实线表示强引用,虚线表示弱引用。每个线程都可以通过 ThreadLocals 获取到 ThreadLocalMap,而 ThreadLocalMap 实际上就是一个以 ThreadLocal 实例为 key,任意对象为 value 的 Entry 数组。
当我们为 ThreadLocal 变量赋值时,实际上就是以当前 ThreadLocal 实例为 key,值为 Entry 往这个 ThreadLocalMap 中存放。
注意,Entry 的 key 为弱引用,意味着当 ThreadLocal 外部强引用被置为 null(ThreadLocalInstance=null)时,根据可达性分析,ThreadLocal 实例此时没有任何一条链路引用它,所以系统 GC 的时候 ThreadLocal 会被回收。
这样一来,ThreadLocalMap 就会出现 key 为 null 的 Entry,也就没办法访问这些 key 对应的 value,如果线程迟迟不结束的话,这些 key 为 null 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,无法回收就会造成内存泄漏。
当然,如果 thread 运行结束,ThreadLocal、ThreadLocalMap、Entry 没有引用链可达,在垃圾回收时都会被系统回收。但实际开发中,线程为了复用是不会主动结束的,比如说数据库连接池,过大的线程池可能会增加内存泄漏的风险,因此合理配置线程池的大小和线程的存活时间有助于减轻这个问题。
为了避免这个问题,在每次使用完 ThreadLocal 之后,最好明确调用 ThreadLocal 的 remove 方法来删除与当前线程关联的值。这样可以确保线程再次使用时不会存储旧的、不再需要的值。
与 ConcurrentHashMap、HashMap等容器一样,ThreadLocalMap 也是通过哈希表实现的。
1.2.1 哈希表
哈希表是基于数组的,每个数组元素被称为一个“桶”(Bucket),桶中存储了键值对(Key-Value Pair),键是通过哈希函数生成的,理想的哈希函数可以均匀分布键,从而最大限度地减少冲突。
理想的哈希函数可以均匀分布键,从而最大限度地减少冲突。当两个或多个键的哈希值相同(即映射到同一个桶)时,称之为哈希冲突。常见的解决策略有拉链法和开放地址法。
拉链法
在讲 HashMap 的时候,我们详细讲过拉链法,相信大家都还有印象,我们这里简单回顾一下:当某项关键字通过哈希后落到哈希表中的某个位置,把该条数据添加到链表中,其他同样映射到这个位置的数据项也只需要添加到链表中。下面是示意图:
开放地址法
开放地址法中,若数据不能直接存放在哈希函数计算出来的数组下标时,就需要寻找其他位置来存放。在开放地址法中有三种方式来寻找其他的位置,分别是「线性探测」、「二次探测」、「再哈希法」。
-
01、线性探测:当哈希函数计算出来的数组下标已经被占用时,就顺序往后查找,直到找到一个空闲的位置。
例如我们将数88经过哈希函数后得到的数组下标是16,但是在数组下标为16的地方已经存在元素,那么就找17,17还存在元素就找18,一直往下找,直到找到空白地方存放元素。
我们向哈希表中添加一个元素钱多多,钱多多经过哈希函数后得到的数组下标为0,但是在0的位置已经有张三了,所以下标往前移,直到下标4才为空,所以就将元素钱多多添加到数组下标为4的地方。 我们来看下面这张图:
-
02、二次探测:当哈希函数计算出来的数组下标已经被占用时,就顺序往后查找,直到找到一个空闲的位置。不同的是,二次探测是按照某种规律查找,而不是顺序查找,比如说每次查找的步长是 1,2,4,8,16……
在线性探测哈希表中,数据会发生聚集,一旦聚集形成,它就会变的越来越大,那些哈希函数后落在聚集范围内的数据项,都需要一步一步往后移动,并且插入到聚集的后面,因此聚集变的越大,聚集增长的越快。这个就像我们在逛超市一样,当某个地方人很多时,人只会越来越多,大家都只是想知道这里在干什么。
二次探测是防止聚集产生的一种尝试,思想是探测相隔较远的单元,而不是和原始位置相邻的单元。在线性探测中,如果哈希函数得到的原始下标是x,线性探测就是x+1,x+2,x+3......,以此类推,而在二次探测中,探测过程是x+1,x+4,x+9,x+16,x+25......,以此类推,到原始距离的步数平方,为了方便理解,我们来看下面这张图。
在线性探测中我们找到钱多多的存储位置需要经过4步。在二次探测中,每次是原始距离步数的平方,所以我们只需要两次就找到钱多多的存储位置。
-
03、再哈希法:当哈希函数计算出来的数组下标已经被占用时,就使用另一个哈希函数计算出来的数组下标。
二次探测消除了线性探测的聚集问题,这种聚集问题叫做原始聚集,然而,二次探测也产生了新的聚集问题,之所以会产生新的聚集问题,是因为所有映射到同一位置的关键字在寻找空位时,探测的位置都是一样的。
比如讲1、11、21、31、41依次插入到哈希表中,它们映射的位置都是1,那么11需要以一为步长探测,21需要以四为步长探测,31需要为九为步长探测,41需要以十六为步长探测,只要有一项映射到1的位置,就需要更长的步长来探测,这个现象叫做二次聚集。
再哈希法是为了消除原始聚集和二次聚集问题,不管是线性探测还是二次探测,每次的探测步长都是固定的。双哈希是除了第一个哈希函数外再增加一个哈希函数用来根据关键字生成探测步长,这样即使第一个哈希函数映射到了数组的同一下标,但是探测步长不一样,这样就能够解决聚集的问题。
第二个哈希函数必须具备如下特点:
- 和第一个哈希函数不一样
- 不能输出为 0,因为步长为 0,每次探测都是指向同一个位置,将进入死循环,经过试验得出
stepSize = constant-(key%constant);形式的哈希函数效果非常好,constant是一个质数并且小于数组容量。
示意图如下:
ThreadLocalMap 是使用开放地址法来处理哈希冲突的,和 HashMap 不同,之所以采用不同的方式主要是因为:
ThreadLocalMap 中的哈希值分散的比较均匀,很少会出现冲突。并且 ThreadLocalMap 经常需要清除无用的对象,冲突的概率就更小了。
1.2.2 set 方法
在了解哈希表的相关知识后,我们再来看一下 set 方法。set 方法的源码如下:
/**
* 设置ThreadLocal的值。
* 该方法用于将给定的ThreadLocal对象和值放入当前线程的ThreadLocalMap中。
* 如果已经存在相同的ThreadLocal对象,则替换其值;如果不存在,则创建新的Entry。
*
* @param key ThreadLocal对象,用于确定值的位置
* @param value 要设置的值
*/
private void set(ThreadLocal<?> key, Object value) {
/**
* 不使用get()中的快速路径,因为set()用于创建新条目的频率
* 至少与替换现有条目一样高,在这种情况下,快速路径会经常失败。
*/
Entry[] tab = table; // 获取ThreadLocalMap中的Entry数组
int len = tab.length; // 获取数组的长度
// 根据ThreadLocal的hashCode确定Entry应该存放的位置
int i = key.ThreadLocalHashCode & (len-1);
// 采用开放地址法解决hash冲突,当发生冲突时使用线性探测
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 如果找到相同的ThreadLocal对象,则替换其值
if (k == key) {
e.value = value;
return;
}
// 如果ThreadLocal对象为null,说明对应的ThreadLocal已经被垃圾回收,
// 这可能导致内存泄漏。这里替换掉这个“脏”entry。
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 在table的i位置新建一个Entry,并插入
tab[i] = new Entry(key, value);
int sz = ++size; // 更新map的大小
// 清除一些key为null的“脏”entry,如果清理后大小仍超过阈值,则进行rehash
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
1.2.2.1 ThreadLocal 的 hashcode
ThreadLocal 的 hashCode 是通过 nextHashCode() 方法获取的,该方法实际上是用 AtomicInteger 加上 0x61c88647 来实现的。
0x61c88647 是一个魔数,用于 ThreadLocal 的哈希码递增。这个值的选择并不是随机的,它是一个质数,可以优化性能。
1.2.2.2 怎样确定新值插入的位置?
通过这行代码:key.ThreadLocalHashCode & (len-1)。
同 HashMap 一样,通过当前 key 的 hashcode 与哈希表大小相与。
1.2.2.3 怎样解决 hash 冲突?
通过 nextIndex(i, len),该方法中的((i + 1 < len) ? i + 1 : 0); 能不断往后线性探测,当到哈希表末尾的时候再从 0 开始,成环形。
1.2.2.4 怎样解决“脏”Entry?
我们知道,使用 ThreadLocal 有可能存在内存泄漏的问题,针对这种 key 为 null 的 Entry,我们称之为“stale entry”,直译为不新鲜的 entry,我把它理解为“脏 entry”。
当然了,Josh Bloch 和 Doug Lea 已经替我们考虑了这种情况,源码中提供了这些解决方案:
在向ThreadLocalMap添加新条目时,可以检查是否有“脏”Entry(键为null的Entry),并用新的条目替换它。这就是源码中的replaceStaleEntry方法所做的事情。
1.2.2.5 如何进行扩容?
和 HashMap一样,ThreadLocalMap 也有扩容机制,那么它的 threshold 又是怎样确定的呢?
在第一次对 ThreadLocal 赋值的时候会创建初始大小为 16 的 ThreadLocalMap,并且通过 setThreshold 方法设置 threshold,其值为当前哈希数组长度乘以(2/3),也就是说加载因子为 2/3。
加载因子(Load Factor)是哈希表的一个重要概念,它表示哈希表中已经存放的条目数量与哈希表容量的比例。加载因子可以用来衡量哈希表的满载程度,影响哈希表的查找、插入和删除操作的性能。相信大家都还记得,HashMap 的加载因子都为 0.75。
这里ThreadLocalMap 初始大小为 16,加载因子为 2/3,所以哈希表可用大小为:16*2/3=10,即哈希表可用容量为 10。
当哈希表的 size 大于 threshold 的时候,会通过 resize 方法进行扩容。
新建的数组为原来数组长度的两倍,然后遍历旧数组中的 entry 并将其插入到新的数组中。注意,这段代码考虑得非常周全,在扩容的过程中,针对脏 entry 会把 value 设为 null,以便被垃圾回收,解决隐藏的内存泄漏问题。
1.2.3 getEntry 方法
getEntry 方法的源码如下:
private Entry getEntry(ThreadLocal<?> key) {
//1. 确定在哈希数组中的位置
int i = key.ThreadLocalHashCode & (table.length - 1);
//2. 根据索引i获取entry
Entry e = table[i];
//3. 满足条件则返回该entry
if (e != null && e.get() == key)
return e;
else
//4. 未查找到满足条件的entry,额外在做的处理
return getEntryAfterMiss(key, i, e);
}
方法的逻辑很简单,如果当前 entry 的 key 和查找的 key 相同就直接返回这个 entry,否则的就通过 getEntryAfterMiss 做进一步处理:如果索引处的条目为null,或者其键与给定的键不匹配,那么需要调用getEntryAfterMiss方法来处理可能的哈希冲突。
getEntryAfterMiss 方法用于在发生哈希冲突的情况下继续在ThreadLocalMap中查找条目,通过开放寻址的策略,在哈希表中的其他位置查找,并适当地处理“脏”条目
1.2.4 remove 方法
直接来看源码:
/**
* 移除指定键的条目。
*
* @param key 要移除的ThreadLocal键
*/
private void remove(ThreadLocal<?> key) {
// 获取当前哈希表
Entry[] tab = table;
// 获取哈希表长度
int len = tab.length;
// 计算键的哈希码在哈希表中的索引位置
int i = key.ThreadLocalHashCode & (len - 1);
// 遍历哈希表中指定索引及其后续的条目
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
// 如果找到与键匹配的条目
if (e.get() == key) {
// 将条目的键设置为null
e.clear();
// 清除该条目的值,并处理陈旧条目
expungeStaleEntry(i);
// 完成移除操作,返回
return;
}
}
}
01、通过局部变量tab获取ThreadLocalMap的哈希表数组,len表示其长度。
02、通过key.ThreadLocalHashCode & (len-1)计算给定ThreadLocal键的哈希索引。这将决定从哪个索引位置开始搜索。
03、使用开放寻址法遍历哈希表,通过nextIndex(i, len)计算下一个索引以处理哈希冲突。
04、如果找到与给定键匹配的条目(即e.get() == key),执行以下操作:
- 清除键:通过调用
e.clear()方法,将条目的键置为null。由于Entry是WeakReference的子类,clear方法将断开对ThreadLocal对象的引用,允许垃圾收集器在需要时回收它。 - 清除值:通过调用
expungeStaleEntry(i)方法,清除该条目的值并对哈希表进行部分清理。该方法的目的是清除哈希表中的无效条目,即那些其键已被垃圾收集的条目。
05、结束删除操作:一旦找到并删除了匹配的条目,方法返回。如果遍历整个哈希表都没有找到匹配的键,则该方法不执行任何操作并正常返回。
1.3 ThreadLocal 的使用场景
ThreadLocal 的使用场景非常多,比如说:
- 用于保存用户登录信息,这样在同一个线程中的任何地方都可以获取到登录信息。
- 用于保存数据库连接、Session 对象等,这样在同一个线程中的任何地方都可以获取到数据库连接、Session 对象等。
- 用于保存事务上下文,这样在同一个线程中的任何地方都可以获取到事务上下文。
- 用于保存线程中的变量,这样在同一个线程中的任何地方都可以获取到线程中的变量。
ThreadLocal 是一个非常有用的工具类,它可以用于保存线程中的变量,这样在同一个线程中的任何地方都可以获取到线程中的变量。但是,ThreadLocal 也是一个非常容易被误用的工具类,如果没有使用好,就可能会造成内存泄漏的问题。
ThreadLocalMap 是 ThreadLocal 的核心,它是一个以 ThreadLocal 实例为 key,任意对象为 value 的哈希表。ThreadLocalMap 使用开放地址法来处理哈希冲突,它的初始容量为 16,加载因子为 2/3,扩容时会将容量扩大为原来的两倍。
二、小林-图解系统
1、进程调度/页面置换/磁盘调度算法
1.1 进程调度算法
进程调度算法也称 CPU 调度算法,毕竟进程是由 CPU 调度的。
当 CPU 空闲时,操作系统就选择内存中的某个「就绪状态」的进程,并给其分配 CPU。
什么时候会发生 CPU 调度呢?通常有以下情况:
- 当进程从运行状态转到等待状态;
- 当进程从运行状态转到就绪状态;
- 当进程从等待状态转到就绪状态;
- 当进程从运行状态转到终止状态;
其中发生在 1 和 4 两种情况下的调度称为「非抢占式调度」,2 和 3 两种情况下发生的调度称为「抢占式调度」。
非抢占式的意思就是,当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把 CPU 让给其他进程。
而抢占式调度,顾名思义就是进程正在运行的时,可以被打断,使其把 CPU 让给其他进程。那抢占的原则一般有三种,分别是时间片原则、优先权原则、短作业优先原则。
你可能会好奇为什么第 3 种情况也会发生 CPU 调度呢?假设有一个进程是处于等待状态的,但是它的优先级比较高,如果该进程等待的事件发生了,它就会转到就绪状态,一旦它转到就绪状态,如果我们的调度算法是以优先级来进行调度的,那么它就会立马抢占正在运行的进程,所以这个时候就会发生 CPU 调度。
那第 2 种状态通常是时间片到的情况,因为时间片到了就会发生中断,于是就会抢占正在运行的进程,从而占用 CPU。
调度算法影响的是等待时间(进程在就绪队列中等待调度的时间总和),而不能影响进程真在使用 CPU 的时间和 I/O 时间。
接下来,说说常见的调度算法:
- 先来先服务调度算法
- 最短作业优先调度算法
- 高响应比优先调度算法
- 时间片轮转调度算法
- 最高优先级调度算法
- 多级反馈队列调度算法
1.1.1 先来先服务调度算法
最简单的一个调度算法,就是非抢占式的先来先服务(First Come First Severd, FCFS)算法了。
顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
1.1.2 最短作业优先调度算法
最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
1.1.3 高响应比优先调度算法
前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。
那么,高响应比优先 (Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
从上面的公式,可以发现:
- 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
- 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
1.1.4 时间片轮转调度算法
最古老、最简单、最公平且使用最广的算法就是时间片轮转(Round Robin, RR)调度算法。
每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。
- 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
- 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
另外,时间片的长度就是一个很关键的点:
- 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
- 如果设得太长又可能引起对短作业进程的响应时间变长。
通常时间片设为 20ms~50ms 通常是一个比较合理的折中值。
1.1.5 最高优先级调度算法
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(Highest Priority First,HPF)调度算法。
进程的优先级可以分为,静态优先级或动态优先级:
- 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
- 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
1.1.6 多级反馈队列调度算法
多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
顾名思义:
- 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
- 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
来看看,它是如何工作的:
- 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
1.2 内存页面置换算法
在了解内存页面置换算法前,我们得先谈一下缺页异常(缺页中断) 。
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。那它与一般中断的主要区别在于:
- 缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。
- 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。
我们来看一下缺页中断的处理流程,如下图:
- 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。
- 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。
- 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。
- 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。
- 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。
- 最后,CPU 重新执行导致缺页异常的指令。
上面所说的过程,第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢?
找不到空闲页的话,就说明此时内存已满了,这时候,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。
这里提一下,页表项通常有如下图的字段:
那其中:
- 状态位:用于表示该页是否有效,也就是说是否在物理内存中,供程序访问时参考。
- 访问字段:用于记录该页在一段时间被访问的次数,供页面置换算法选择出页面时参考。
- 修改位:表示该页在调入内存后是否有被修改过,由于内存中的每一页都在磁盘上保留一份副本,因此,如果没有修改,在置换该页时就不需要将该页写回到磁盘上,以减少系统的开销;如果已经被修改,则将该页重写到磁盘上,以保证磁盘中所保留的始终是最新的副本。
- 硬盘地址:用于指出该页在硬盘上的地址,通常是物理块号,供调入该页时使用。
虚拟内存的管理整个流程,你可以看下面这张图:
所以,页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
那其算法目标则是,尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:
- 最佳页面置换算法(OPT)
- 先进先出置换算法(FIFO)
- 最近最久未使用的置换算法(LRU)
- 时钟页面置换算法(Lock)
- 最不常用置换算法(LFU)
1.2.1 最佳页面置换算法
最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。
所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。
我们举个例子,假设一开始有 3 个空闲的物理页,然后有请求的页面序列,那它的置换过程如下图:
在这个请求的页面序列中,缺页共发生了 7 次(空闲页换入 3 次 + 最优页面置换 4 次),页面置换共发生了 4 次。
这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。
所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。
1.2.2 先进先出置换算法
既然我们无法预知页面在下一次访问前所需的等待时间,那我们可以选择在内存驻留时间很长的页面进行中置换,这个就是「先进先出置换」算法的思想。
还是以前面的请求的页面序列作为例子,假设使用先进先出置换算法,则过程如下图:
在这个请求的页面序列中,缺页共发生了 10 次,页面置换共发生了 7 次,跟最佳页面置换算法比较起来,性能明显差了很多。
1.2.3 最近最久未使用的置换算法
最近最久未使用(LRU)的置换算法的基本思路是,发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。
还是以前面的请求的页面序列作为例子,假设使用最近最久未使用的置换算法,则过程如下图:
在这个请求的页面序列中,缺页共发生了 9 次,页面置换共发生了 6 次,跟先进先出置换算法比较起来,性能提高了一些。
虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。
困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。
所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。
1.2.4 时钟页面置换算法
那有没有一种即能优化置换的次数,也能方便实现的算法呢?
时钟页面置换算法就可以两者兼得,它跟 LRU 近似,又是对 FIFO 的一种改进。
该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:
- 如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
- 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;
了解了这个算法的工作方式,就明白为什么它被称为时钟(Clock)算法了。
1.2.5 最不常用算法
最不常用(LFU)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,而是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
看起来很简单,每个页面加一个计数器就可以实现了,但是在操作系统中实现的时候,我们需要考虑效率和硬件成本的。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。
但还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。
那这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率。
1.3 磁盘调度算法
我们来看看磁盘的结构,如下图:
常见的机械磁盘是上图左边的样子,中间圆的部分是磁盘的盘片,一般会有多个盘片,每个盘面都有自己的磁头。右边的图就是一个盘片的结构,盘片中的每一层分为多个磁道,每个磁道分多个扇区,每个扇区是 512 字节。那么,多个具有相同编号的磁道形成一个圆柱,称之为磁盘的柱面,如上图里中间的样子。
磁盘调度算法的目的很简单,就是为了提高磁盘的访问性能,一般是通过优化磁盘的访问请求顺序来做到的。
寻道的时间是磁盘访问最耗时的部分,如果请求顺序优化的得当,必然可以节省一些不必要的寻道时间,从而提高磁盘的访问性能。
假设有下面一个请求序列,每个数字代表磁道的位置:
98,183,37,122,14,124,65,67
初始磁头当前的位置是在第 53 磁道。
接下来,分别对以上的序列,作为每个调度算法的例子,那常见的磁盘调度算法有:
- 先来先服务算法
- 最短寻道时间优先算法
- 扫描算法
- 循环扫描算法
- LOOK 与 C-LOOK 算法
1.3.1 先来先服务
先来先服务(First-Come,First-Served,FCFS),顾名思义,先到来的请求,先被服务。
那按照这个序列的话:
98,183,37,122,14,124,65,67
那么,磁盘的写入顺序是从左到右,如下图:
先来先服务算法总共移动了 640 个磁道的距离,这么一看这种算法,比较简单粗暴,但是如果大量进程竞争使用磁盘,请求访问的磁道可能会很分散,那先来先服务算法在性能上就会显得很差,因为寻道时间过长。
1.3.2 最短寻道时间优先
最短寻道时间优先(Shortest Seek First,SSF)算法的工作方式是,优先选择从当前磁头位置所需寻道时间最短的请求,还是以这个序列为例子:
98,183,37,122,14,124,65,67
那么,那么根据距离磁头( 53 位置)最近的请求的算法,具体的请求则会是下列从左到右的顺序:
65,67,37,14,98,122,124,183
磁头移动的总距离是 236 磁道,相比先来先服务性能提高了不少。
但这个算法可能存在某些请求的饥饿,因为本次例子我们是静态的序列,看不出问题,假设是一个动态的请求,如果后续来的请求都是小于 183 磁道的,那么 183 磁道可能永远不会被响应,于是就产生了饥饿现象,这里产生饥饿的原因是磁头在一小块区域来回移动。
1.3.3 扫描算法
最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。
为了防止这个问题,可以规定:磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(Scan)算法。
这种算法也叫做电梯算法,比如电梯保持按一个方向移动,直到在那个方向上没有请求为止,然后改变方向。
还是以这个序列为例子,磁头的初始位置是 53:
98,183,37,122,14,124,65,67
那么,假设扫描调度算先朝磁道号减少的方向移动,具体请求则会是下列从左到右的顺序:
37,14,0,65,67,98,122,124,183
磁头先响应左边的请求,直到到达最左端( 0 磁道)后,才开始反向移动,响应右边的请求。
扫描调度算法性能较好,不会产生饥饿现象,但是存在这样的问题,中间部分的磁道会比较占便宜,中间部分相比其他部分响应的频率会比较多,也就是说每个磁道的响应频率存在差异。
1.3.4 循环扫描算法
扫描算法使得每个磁道响应的频率存在差异,那么要优化这个问题的话,可以总是按相同的方向进行扫描,使得每个磁道的响应频率基本一致。
循环扫描(Circular Scan, CSCAN )规定:只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求。
还是以这个序列为例子,磁头的初始位置是 53:
98,183,37,122,14,124,65,67
那么,假设循环扫描调度算先朝磁道增加的方向移动,具体请求会是下列从左到右的顺序:
65,67,98,122,124,183,199,0,14,37
磁头先响应了右边的请求,直到碰到了最右端的磁道 199,就立即回到磁盘的开始处(磁道 0),但这个返回的途中是不响应任何请求的,直到到达最开始的磁道后,才继续顺序响应右边的请求。
循环扫描算法相比于扫描算法,对于各个位置磁道响应频率相对比较平均。
1.3.5 LOOK 与 C-LOOK算法
我们前面说到的扫描算法和循环扫描算法,都是磁头移动到磁盘「最始端或最末端」才开始调换方向。
那这其实是可以优化的,优化的思路就是磁头在移动到「最远的请求」位置,然后立即反向移动。
那针对 SCAN 算法的优化则叫 LOOK 算法,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中会响应请求。
而针对 C-SCAN 算法的优化则叫 C-LOOK,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中不会响应请求。