一、沉默王二-并发编程
1、ConcurrentHashMap
相对于 HashMap,ConcurrentHashMap 就是线程安全的 map,其中利用了锁分段的思想大大提高了并发的效率。
ConcurrentHashMap从 JDK 1.8 开始有了较大的变化,光是代码量就足足增加了很多。
1.8 版本舍弃了 segment,并且使用了大量的 synchronized,以及 CAS 无锁操作以保证 ConcurrentHashMap 的线程安全性。
为什么不用 ReentrantLock而是 synchronzied 呢?
实际上,synchronzied 做了很多的优化,包括偏向锁、轻量级锁、重量级锁,可以依次向上升级锁状态,因此,synchronized 相较于 ReentrantLock 的性能其实差不多,甚至在某些情况更优。
1.1 ConcurrentHashMap 的变化
ConcurrentHashMap 在 JDK 1.7 和 JDK 1.8 中有一些区别。这里我们分开介绍一下。
1.1.1 JDK 1.7
ConcurrentHashMap 在 JDK 1.7 中,提供了一种粒度更细的加锁机制,这种机制叫分段锁「Lock Striping」。整个哈希表被分为多个段,每个段都独立锁定。读取操作不需要锁,写入操作仅锁定相关的段。这减小了锁冲突的几率,从而提高了并发性能。
这种机制的优点:在并发环境下将实现更高的吞吐量,而在单线程环境下只损失非常小的性能。
可以这样理解分段锁,就是将数据分段,对每一段数据分配一把锁。当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
有些方法需要跨段,比如 size()、isEmpty()、containsValue(),它们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完后,再按顺序释放所有段的锁。如下图:
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组构成的。Segment 是一种可重入的锁 ReentrantLock,HashEntry 则用于存储键值对数据。
一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。
单一的 Segment 结构如下:
像这样的 Segment 对象,在 ConcurrentHashMap 集合中有多少个呢?有 2 的 N 次方个,共同保存在一个名为 segments 的数组当中。 因此整个 ConcurrentHashMap 的结构如下:
可以说,ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。
Case1:不同 Segment 的并发写入(可以并发执行)
Case2:同一 Segment 的一写一读(可以并发执行)
Case3:同一 Segment 的并发写入
Segment 的写入是需要上锁的,因此对同一 Segment 的并发写入会被阻塞。
由此可见,ConcurrentHashMap 中每个 Segment 各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。
ConcurrentHashMap 读写过程如下:
get 方法
- 为输入的 Key 做 Hash 运算,得到 hash 值。
- 通过 hash 值,定位到对应的 Segment 对象
- 再次通过 hash 值,定位到 Segment 当中数组的具体位置。
put 方法
- 为输入的 Key 做 Hash 运算,得到 hash 值。
- 通过 hash 值,定位到对应的 Segment 对象
- 获取可重入锁
- 再次通过 hash 值,定位到 Segment 当中数组的具体位置。
- 插入或覆盖 HashEntry 对象。
- 释放锁。
1.1.2 JDK 1.8
而在 JDK 1.8 中,ConcurrentHashMap 主要做了两个优化:
- 同 HashMap一样,链表也会在长度达到 8 的时候转化为红黑树,这样可以提升大量冲突时候的查询效率;
- 以某个位置的头结点(链表的头结点或红黑树的 root 结点)为锁,配合自旋+CAS避免不必要的锁开销,进一步提升并发性能。
相比 JDK1.7 中的 ConcurrentHashMap,JDK1.8 中的 ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS + synchronized 来保证并发安全性,整个容器只分为一个 Segment,即 table 数组。
JDK1.8 中的 ConcurrentHashMap 对节点 Node 类中的共享变量,和 JDK1.7 一样,使用 volatile 关键字,保证多线程操作时,变量的可见性!
1.2 ConcurrentHashMap 的字段
1、table,volatile Node<K,V>[] table:
装载 Node 的数组,作为 ConcurrentHashMap 的底层容器,采用懒加载的方式,直到第一次插入数据的时候才会进行初始化操作,数组的大小总是为 2 的幂次方,讲 HashMap 的时候讲过。
2、nextTable,volatile Node<K,V>[] nextTable
扩容时使用,平时为 null,只有在扩容的时候才为非 null
3、sizeCtl,volatile int sizeCtl
该属性用来控制 table 数组的大小,根据是否初始化和是否正在扩容有几种情况:
- 当值为负数时: 如果为-1 表示正在初始化,如果为 -N 则表示当前正有 N-1 个线程进行扩容操作;
- 当值为正数时: 如果当前数组为 null 的话表示 table 在初始化过程中,sizeCtl 表示为需要新建数组的长度;若已经初始化了,表示当前数据容器(table 数组)可用容量,也可以理解成临界值(插入节点数超过了该临界值就需要扩容),具体指为数组的长度 n 乘以 加载因子 loadFactor;
- 当值为 0 时,即数组长度为默认初始值。
4、sun.misc.Unsafe U
在 ConcurrentHashMap 的实现中,可以看到用了大量的 U.compareAndSwapXXXX 方法去修改 ConcurrentHashMap 的一些属性。
这些方法实际上是利用了 CAS 算法用于保证线程安全性,这是一种乐观策略:假设每一次操作都不会产生冲突,当且仅当冲突发生的时候再去尝试。
CAS 操作依赖于现代处理器指令集,通过底层的CMPXCHG指令实现。CAS(V,O,N)核心思想为:若当前变量实际值 V 与期望的旧值 O 相同,则表明该变量没被其他线程进行修改,因此可以安全的将新值 N 赋值给变量;若当前变量实际值 V 与期望的旧值 O 不相同,则表明该变量已经被其他线程做了处理,此时将新值 N 赋给变量操作就是不安全的,在进行重试。
在并发容器中,CAS 是通过sun.misc.Unsafe类实现的,该类提供了一些可以直接操控内存和线程的底层操作,可以理解为 Java 中的“指针”。该成员变量的获取是在静态代码块中:
1.3 ConcurrentHashMap 的内部类
1.3.1 Node
Node 类实现了 Map.Entry 接口,主要存放 key-value 对,并且具有 next 域
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
......
}
另外可以看出很多属性都是用 volatile 关键字修饰的,也是为了保证内存可见性。
1.3.2 TreeNode
树节点,继承于承载数据的 Node 类。红黑树的操作是针对 TreeBin 类的,从该类的注释也可以看出,TreeBin 是对 TreeNode 的再一次封装,下面会提到。
/**
* TreeNode类是红黑树中使用的节点类。
* 它继承自Node类,并添加了用于红黑树操作的特殊字段。
*/
static final class TreeNode<K,V> extends Node<K,V> {
// 父节点,用于红黑树的结构维护
TreeNode<K,V> parent;
// 左子节点,红黑树中的左链接
TreeNode<K,V> left;
// 右子节点,红黑树中的右链接
TreeNode<K,V> right;
// 前一个节点,用于在删除节点时解除与后继节点的链接
TreeNode<K,V> prev;
// 节点的颜色标志,用于红黑树的平衡操作
boolean red;
// 其他代码......
}
1.3.3 TreeBin
这个类并不负责用户的 key、value 信息,而是封装了很多 TreeNode 节点。实际的 ConcurrentHashMap “数组”中,存放的都是 TreeBin 对象,而不是 TreeNode 对象。
/**
* TreeBin类是Java中的ConcurrentHashMap类的一部分,用于在ConcurrentHashMap中存储和操作树形结构的数据。
* 它继承自Node类,并包含了一些用于管理树形结构的特有属性和方法。
*/
static final class TreeBin<K,V> extends Node<K,V> {
// 根节点,树形结构的最顶层节点。
TreeNode<K,V> root;
// 第一个节点,用于快速访问树形结构中的第一个元素。
volatile TreeNode<K,V> first;
// 等待线程,当需要获取写锁时,线程会等待,这个属性用于存储等待的线程。
volatile Thread waiter;
// 锁状态,用于表示当前TreeBin的锁状态,包括写锁、等待写锁和读锁。
volatile int lockState;
// 锁状态的值
// 写锁状态,当线程持有写锁时,lockState的值会设置为1。
static final int WRITER = 1; // 设置写锁时
// 等待写锁状态,当线程正在等待获取写锁时,lockState的值会设置为2。
static final int WAITER = 2; // 等待写锁时
// 读锁状态,当线程正在读取数据时,lockState的值会递增,每次递增4。
static final int READER = 4; // 设置读锁时递增的值
// ...(此处省略其他代码)
}
1.3.4 ForwardingNode
在扩容时会出现的特殊节点,其 key、value、hash 全部为 null。并拥有 nextTable 引用的新 table 数组。
/**
* ForwardingNode类是一个特殊的节点类,用于在ConcurrentHashMap中进行扩容操作。
* 当表正在进行扩容操作时,ForwardingNode会被插入到表中,指示其他线程进行帮助扩容或访问新表。
*/
static final class ForwardingNode<K,V> extends Node<K,V> {
// 指向新表的引用,用于扩容时的新表引用
final Node<K,V>[] nextTable;
/**
* 构造函数,创建一个ForwardingNode节点。
* @param tab 新表的引用
*/
ForwardingNode(Node<K,V>[] tab) {
// 调用父类的构造函数,使用MOVED作为哈希值,其他字段设置为null。
// MOVED是一个特殊的哈希值,表示节点正在进行扩容操作。
super(MOVED, null, null, null);
// 将新表的引用赋值给nextTable字段
this.nextTable = tab;
}
// 其他代码......
}
1.4 ConcurrentHashMap 的 CAS
ConcurrentHashMap 会大量使用 CAS 来修改它的属性和进行一些操作。因此,在理解 ConcurrentHashMap 的方法前,我们需要了解几个常用的利用 CAS 算法来保障线程安全的操作。
1.4.1 tabAt
// 获取指定索引位置的Node节点
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
// 使用getObjectVolatile方法直接在数组tab中获取索引i处的Node节点
// 这里使用了位运算((long)i << ASHIFT) + ABASE来计算实际的内存地址
// U.getObjectVolatile是一个在sun.misc.Unsafe类中定义的方法,用于直接在内存中读取对象
// 这个方法确保了读取操作的原子性和可见性,适用于并发环境
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
该方法用来获取 table 数组中索引为 i 的 Node 元素。
1.4.2 casTabAt
/**
* 使用比较并交换(CAS)操作来更新数组中的节点。
* 如果当前节点(c)与预期节点相同,则将其替换为新节点(v)。
*
* @param tab 数组,包含节点。
* @param i 数组索引,指定要更新的节点位置。
* @param c 预期的当前节点。
* @param v 新节点,用于替换当前节点。
* @param <K> 节点中键的类型。
* @param <V> 节点中值的类型。
* @return 如果替换成功,返回true;否则返回false。
*/
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
// 使用U.compareAndSwapObject执行CAS操作。
// ((long)i << ASHIFT) + ABASE用于计算内存地址。
// 如果tab[i]处的节点与c相同,则将其替换为v。
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
利用 CAS 操作设置 table 数组中索引为 i 的元素
1.4.3 setTabAt
// 设置数组tab中索引为i的元素为节点v,使用volatile保证内存可见性
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
// 使用U.putObjectVolatile方法,将节点v设置到数组tab的指定位置
// 这里通过((long)i << ASHIFT) + ABASE计算得到具体的内存地址
// ASHIFT和ABASE是用于计算内存地址的常量
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
该方法用来设置 table 数组中索引为 i 的元素
1.5 ConcurrentHashMap 的方法
1.5.1 构造方法
ConcurrentHashMap 一共提供了以下 5 个构造方法:
// 1. 构造一个空的map,即table数组还未初始化,初始化放在第一次插入数据时,默认大小为16
ConcurrentHashMap()
// 2. 给定map的大小
ConcurrentHashMap(int initialCapacity)
// 3. 给定一个map
ConcurrentHashMap(Map<? extends K, ? extends V> m)
// 4. 给定map的大小以及加载因子
ConcurrentHashMap(int initialCapacity, float loadFactor)
// 5. 给定map大小,加载因子以及并发度(预计同时操作数据的线程)
ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
差别请看注释,我们来看看第 2 种构造方法,源码如下:
public ConcurrentHashMap(int initialCapacity) {
//1. 小于0直接抛异常
if (initialCapacity < 0)
throw new IllegalArgumentException();
//2. 判断是否超过了允许的最大值,超过了话则取最大值,否则再对该值进一步处理
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
//3. 赋值给sizeCtl
this.sizeCtl = cap;
}
这段代码的逻辑请看注释,很容易理解,如果小于 0 就直接抛异常,如果指定值大于所允许的最大值就取最大值,否则再对指定值做进一步处理。最后将 cap 赋值给 sizeCtl。
当调用构造方法之后,sizeCtl 的大小就代表了 ConcurrentHashMap 的大小,即 table 数组的长度。
tableSizeFor 做了哪些事情呢?
该方法会将构造方法指定的大小转换成一个 2 的幂次方数,也就是说 ConcurrentHashMap 的大小一定是 2 的幂次方,比如,当指定大小为 18 时,为了满足 2 的幂次方特性,实际上 ConcurrentHashMap 的大小为 2 的 5 次方(32)。
另外,需要注意的是,调用构造方法时并初始化 table 数组,而只算出了 table 数组的长度,当第一次向 ConcurrentHashMap 插入数据时才会真正的完成初始化,并创建 table 数组。
1.5.2 initTable 方法
直接上源码:
/**
* 初始化哈希表的方法。
* @return 初始化后的哈希表数组。
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; // 声明哈希表数组
int sc; // 控制变量,用于同步初始化操作
// 循环直到哈希表被成功初始化
while ((tab = table) == null || tab.length == 0) {
// 如果sizeCtl小于0,表示有其他线程正在进行初始化操作
if ((sc = sizeCtl) < 0) {
// 当前线程让出CPU时间片,等待其他线程完成初始化
Thread.yield();
} else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 双重检查,确保哈希表尚未被初始化
if ((tab = table) == null || tab.length == 0) {
// 确定数组的大小,如果sc大于0则使用sc,否则使用默认容量
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
// 实际初始化哈希表数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 计算数组中可用的大小,即实际大小n乘以0.75(加载因子)
sc = n - (n >>> 2);
}
} finally {
// 设置sizeCtl为计算出的值,表示初始化完成
sizeCtl = sc;
}
// 初始化成功,跳出循环
break;
}
}
// 返回初始化后的哈希表数组
return tab;
}
代码的逻辑请见注释。
可能存在这样一种情况,多个线程同时进入到这个方法,为了保证能够正确地初始化,第 1 步会先通过 if 进行判断,如果当前已经有一个线程正在初始化,这时候其他线程会调用 Thread.yield() 让出 CPU 时间片。
正在进行初始化的线程会调用 U.compareAndSwapInt 方法将 sizeCtl 改为 -1,即正在初始化的状态。
另外还需要注意,在第四步中会进一步计算数组中可用的大小,即数组的实际大小 n 乘以加载因子 0.75,0.75 就是四分之三,这里n - (n >>> 2)刚好是n-(1/4)n=(3/4)n,挺有意思的吧?
如果选择是无参的构造方法,这里在 new Node 数组的时候会使用默认大小DEFAULT_CAPACITY(16),然后乘以加载因子 0.75,结果为 12,也就是说数组当前的可用大小为 12。
1.5.3 put 方法
调用 put 方法时会调用 putVal 方法,源码分析如下:
ConcurrentHashMap 是一个哈希桶数组,如果不出现哈希冲突的时候,每个元素均匀的分布在哈希桶数组中。当出现哈希冲突的时候,采用拉链法的解决方案,将 hash 值相同的节点转换成链表的形式,另外,在 JDK 1.8 版本中,为了防止拉链过长,当链表的长度大于 8 的时候会将链表转换成红黑树。
1、确定好数组的索引 i 后,可以调用 tabAt() 方法获取该位置上的元素,如果当前 Node 为 null 的话,可以直接用 casTabAt 方法将新值插入。
2、如果当前节点不为 null,且该节点为特殊节点(forwardingNode),就说明当前 concurrentHashMap 正在进行扩容操作。怎么确定当前这个 Node 是特殊节点呢?
通过判断该节点的 hash 值是不是等于 -1(MOVED):
static final int MOVED = -1; // hash for forwarding nodes
当 table[i] 不为 null 并且不是 forwardingNode 时,以及当前 Node 的 hash 值大于0(fh >= 0)时,说明当前节点为链表的头节点,那么向 ConcurrentHashMap 插入新值就是向这个链表插入新值。通过 synchronized (f) 的方式进行加锁以实现线程安全。
3、往链表中插入节点的部分如下,这部分代码很好理解,就两种情况:
- 如果在链表中找到了与待插入的 key 相同的节点,就直接覆盖;
- 如果找到链表的末尾都还没找到的话,直接将待插入的键值对追加到链表的末尾。
4、当链表长度超过 8(默认值)时,链表就转换为红黑树,利用红黑树快速增删改查的特点可以提高 ConcurrentHashMap 的性能:
这段代码很简单,调用 putTreeVal 方法向红黑树插入新节点,同样的逻辑,如果在红黑树中存在 Key 相同(hash 值相等并且 equals 方法判断为 true)的节点,就覆盖旧值,否则向红黑树追加新节点。
当完成数据新节点插入后,会进一步对当前链表大小进行调整:
至此,put 方法就分析完了,我们来做个总结:
- 对每一个放入的值,先用 spread 方法对 key 的 hashcode 进行 hash 计算,由此来确定这个值在 table 中的位置;
- 如果当前 table 数组还未初始化,进行初始化操作;
- 如果这个位置是 null,那么使用 CAS 操作直接放入;
- 如果这个位置存在节点,说明发生了 hash 碰撞,先判断这个节点的类型,如果该节点
==MOVED的话,说明正在进行扩容; - 如果是链表节点(
fh>0),先获取头节点,再依次向后遍历确定这个新加入节点的位置。如果遇到 key 相同的节点,直接覆盖。否则在链表尾插入; - 如果这个节点的类型是 TreeBin,直接调用红黑树的插入方法插入新的节点;
- 插入完节点之后再次检查链表的长度,如果长度大于 8,就把这个链表转换成红黑树;
- 对当前容量大小进行检查,如果超过了临界值(实际大小*加载因子)就需要扩容。
1.5.4 get 方法
get 方法的源码如下:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 1. 重hash
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 2. table[i]桶节点的key与查找的key相同,则直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 3. 当前节点hash小于0说明为树节点,在红黑树中查找即可
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
//4. 从链表中查找,查找到则返回该节点的value,否则就返回null即可
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
- 哈希: 对传入的键的哈希值进行散列,这有助于减少哈希冲突的可能性。使用 spread 方法可以保证不同的键更均匀地分布在桶数组中。
- 直接查找: 查找的第一步是检查键的哈希值是否位于表的正确位置。如果在该桶的第一个元素中找到了键,则直接返回该元素的值。这里使用了 == 操作符和 equals 方法来比较键,这有助于处理可能的 null 值和确保正确的相等性比较。
- 红黑树查找: 如果第一个节点的哈希值小于 0,那么这个桶的数据结构是红黑树(Java 8 引入了树化结构来改进链表在哈希冲突时的性能)。在这种情况下,使用 find 方法在红黑树中查找键。
- 链表查找: 如果前两个条件都不满足,那么代码将遍历该桶中的链表。如果在链表中找到了具有相同哈希值和键的元素,则返回其值。如果遍历完整个链表都未找到,则返回 null。
1.5.5 transfer 方法
当 ConcurrentHashMap 容量不足的时候,需要对 table 进行扩容。这个方法的基本思想跟 HashMap 很像,但由于支持并发扩容,所以要复杂一些。
整个扩容操作分为两个部分:
第一部分是构建一个 nextTable,它的容量是原来的两倍,这个操作是单线程完成的。
第二个部分是将原来 table 中的元素复制到 nextTable 中,主要是遍历复制的过程。 得到当前遍历的数组位置 i,然后利用 tabAt 方法获得 i 位置的元素:
- 如果这个位置为空,就在原 table 中的 i 位置放入 forwardNode 节点,这个也是触发并发扩容的关键;
- 如果这个位置是 Node 节点(
fh>=0),并且是链表的头节点,就把这个链表分裂成两个链表,把它们分别放在 nextTable 的 i 和 i+n 的位置上; - 如果这个位置是 TreeBin 节点(
fh<0),也做一个反序处理,并且判断是否需要 untreefi,把处理的结果分别放在 nextTable 的 i 和 i+n 的位置上; - 遍历所有的节点,就完成复制工作,这时让 nextTable 作为新的 table,并且更新 sizeCtl 为新容量的 0.75 倍 ,完成扩容。
1.5.6 size 相关的方法
对于 ConcurrentHashMap 来说,这个 table 里到底装了多少东西是不确定的,因为不可能在调用 size() 方法的时候“stop the world”让其他线程都停下来去统计,对于这个不确定的 size,ConcurrentHashMap 仍然花费了大量的力气。
size 方法返回 Map 中的元素数量,但结果被限制在 Integer.MAX_VALUE 内。如果计算的大小超过这个值,则返回 Integer.MAX_VALUE。如果计算的大小小于 0,则返回 0。
mappingCount 方法也返回 Map 中的元素数量,但允许返回一个 long 值,因此可以表示大于 Integer.MAX_VALUE 的数量。与 size() 方法类似,该方法也会忽略负值,返回 0。
sumCount 方法计算 Map 的实际大小。ConcurrentHashMap 使用一个基础计数 baseCount 和一个 CounterCell 数组 counterCells 来跟踪大小。这种结构有助于减少多线程环境中的争用,因为不同的线程可能会更新不同的 CounterCell。
在计算总和时,sumCount() 方法将 baseCount 与 counterCells 数组中的所有非空单元的值相加。
在 put 方法结尾处调用了 addCount 方法,把当前 ConcurrentHashMap 的元素个数 +1,这个方法一共做了两件事,更新 baseCount 的值,检测是否进行扩容。
二、小林-图解系统-进程管理
1、多线程冲突了怎么办?
1.1 竞争与协作
在单核 CPU 系统里,为了实现多个程序同时运行的假象,操作系统通常以时间片调度的方式,让每个进程执行每次执行一个时间片,时间片用完了,就切换下一个进程运行,由于这个时间片的时间很短,于是就造成了「并发」的现象。
另外,操作系统也为每个进程创建巨大、私有的虚拟内存的假象,这种地址空间的抽象让每个程序好像拥有自己的内存,而实际上操作系统在背后秘密地让多个地址空间「复用」物理内存或者磁盘。
如果一个程序只有一个执行流程,也代表它是单线程的。当然一个程序可以有多个执行流程,也就是所谓的多线程程序,线程是调度的基本单位,进程则是资源分配的基本单位。
所以,线程之间是可以共享进程的资源,比如代码段、堆空间、数据段、打开的文件等资源,但每个线程都有自己独立的栈空间。
那么问题就来了,多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。
1.1.1 互斥的概念
上面展示的情况称为竞争条件(race condition) ,当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate) 。
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。
我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。
另外,说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。
1.1.2 同步的概念
互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的,只要一个进程/线程进入了临界区,其他试图想进入临界区的进程/线程都会被阻塞着,直到第一个进程/线程离开了临界区。
我们都知道在多线程里,每个线程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个线程能密切合作,以实现一个共同的任务。
例子,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。
所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
注意,同步与互斥是两种不同的概念:
- 同步就好比:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等;
- 互斥就好比:「操作 A 和操作 B 不能在同一时刻执行」;
1.2 互斥与同步的实现和使用
在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:
- 锁:加锁、解锁操作;
- 信号量:P、V 操作;
这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。
1.2.1 锁
使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。
任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。
根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。
我们先来看看「忙等待锁」的实现
在说明「忙等待锁」的实现之前,先介绍现代 CPU 体系结构提供的特殊原子操作指令 —— 测试和置位(Test-and-Set)指令。
当然,关键是这些代码是原子执行。因为既可以测试旧值,又可以设置新值,所以我们把这条指令叫作「测试并设置」。
那什么是原子操作呢?原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态
很明显,当获取不到锁时,线程就会一直 while 循环,不做任何事情,所以就被称为「忙等待锁」,也被称为自旋锁(spin lock) 。
这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
无等待锁顾明思议就是获取不到锁的时候,不用自旋。
既然不想自旋,当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。
1.2.2 信号量
信号量是操作系统提供的一种协调共享资源访问的方法。
通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。
另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:
- P 操作:将
sem减1,相减后,如果sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞; - V 操作:将
sem加1,相加后,如果sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行 PV 函数时是具有原子性的。
PV 操作如何使用的呢?
信号量不仅可以实现临界区的互斥访问控制,还可以线程间的事件同步。
我们先来说说如何使用信号量实现临界区的互斥访问。
为每类共享资源设置一个信号量 s,其初值为 1,表示该临界资源未被占用。
只要把进入临界区的操作置于 P(s) 和 V(s) 之间,即可实现进程/线程互斥:
此时,任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V 操作。由于互斥信号量的初始值为 1,故在第一个线程执行 P 操作后 s 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区。
若此时又有第二个线程想进入临界区,也应先执行 P 操作,结果使 s 变为负值,这就意味着临界资源已被占用,因此,第二个线程被阻塞。
并且,直到第一个线程执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 s 恢复到初始值 1。
对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示:
- 如果互斥信号量为 1,表示没有线程进入临界区;
- 如果互斥信号量为 0,表示有一个线程进入临界区;
- 如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。
再来,我们说说如何使用信号量实现事件同步。
同步的方式是设置一个信号量,其初值为 0。
1.2.3 生产者-消费者问题
生产者-消费者问题描述:
- 生产者在生成数据后,放在一个缓冲区中;
- 消费者从缓冲区取出数据处理;
- 任何时刻,只能有一个生产者或消费者可以访问缓冲区;
我们对问题分析可以得出:
- 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;
- 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。
那么我们需要三个信号量,分别是:
- 互斥信号量
mutex:用于互斥访问缓冲区,初始化值为 1; - 资源信号量
fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空); - 资源信号量
emptyBuffers:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n (缓冲区大小)
具体的实现代码:
如果消费者线程一开始执行 P(fullBuffers),由于信号量 fullBuffers 初始值为 0,则此时 fullBuffers 的值从 0 变为 -1,说明缓冲区里没有数据,消费者只能等待。
接着,轮到生产者执行 P(emptyBuffers),表示减少 1 个空槽,如果当前没有其他生产者线程在临界区执行代码,那么该生产者线程就可以把数据放到缓冲区,放完后,执行 V(fullBuffers) ,信号量 fullBuffers 从 -1 变成 0,表明有「消费者」线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。
消费者线程被唤醒后,如果此时没有其他消费者线程在读数据,那么就可以直接进入临界区,从缓冲区读取数据。最后,离开临界区后,把空槽的个数 + 1。
1.3 经典同步问题
1.3.1 哲学家就餐问题
先来看看哲学家就餐的问题描述:
5个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面;- 巧就巧在,这个桌子只有
5支叉子,每两个哲学家之间放一支叉子; - 哲学家围在一起先思考,思考中途饿了就会想进餐;
- 奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐;
- 吃完后,会把两支叉子放回原处,继续思考;
那么问题来了,如何保证哲 学家们的动作有序进行,而不会出现有人永远拿不到叉子呢?
我们就避免哲学家可以同时拿左边的刀叉,采用分支结构,根据哲学家的编号的不同,而采取不同的动作。
即让偶数编号的哲学家「先拿左边的叉子后拿右边的叉子」,奇数编号的哲学家「先拿右边的叉子后拿左边的叉子」。
#define N 5 // 哲学家个数
semaphore fork[5]; // 每个叉子一个信号量,初值为1
void smart_person(int i)//i为哲学家编号0-4
{
while(TRUE)
{
think(); // 哲学家思考
if(i%2==0)
{
P(fork[i]); // 去拿左边的叉子
P(fork[(i + 1)%N ]);//去拿右边的叉子
}
else
{
P(fork[(i + 1)%N ]);//去拿右边的叉子
P(fork[i]); // 去拿左边的叉子
}
eat();//哲学家进餐
V(fork[i]);//放下左边的叉子
V(fork[(i+1)%N]);//放下右边的叉子
}
}
上面的程序,在 P 操作时,根据哲学家的编号不同,拿起左右两边叉子的顺序不同。另外,V 操作是不需要分支的,因为 V 操作是不会阻塞的。
1.3.2 读者-写者问题
前面的「哲学家进餐问题」对于互斥访问有限的竞争问题(如 I/O 设备)一类的建模过程十分有用。
另外,还有个著名的问题是「读者-写者」,它为数据库访问建立了一个模型。
读者只会读取数据,不会修改数据,而写者即可以读也可以修改数据。
读者-写者的问题描述:
- 「读-读」允许:同一时刻,允许多个读者同时读
- 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
- 「写-写」互斥:没有其他写者时,写者才能写
方案一
使用信号量的方式来尝试解决:
- 信号量
wMutex:控制写操作的互斥信号量,初始值为 1 ; - 读者计数
rCount:正在进行读操作的读者个数,初始化为 0; - 信号量
rCountMutex:控制对 rCount 读者计数器的互斥修改,初始值为 1;
接下来看看代码的实现:
semaphore wMutex; // 控制写操作的互斥信号量,初始值为1
semaphore rCountMutex; // 控制对rCount 的互斥修改,初始值为1
int rCount = 0; // 正在进行读操作的读者个数,初始化为0
// 写者进程/线程执行的函数
void writer()
{
while(TRUE)
{
P(wMutex); // 进入临界区
write();
V(wMutex); // 离开临界区
}
}
// 读者进程/线程执行的函数
void reader()
{
while(TRUE)
{
P(rCountMutex); // 进入临界区
if( rCount == 0 )
{
P(wMutex); // 如果有写者,则阻塞写者
}
rCount++; // 读者计数 + 1
V(rCountMutex); // 离开临界区
read(); // 读数据
P(rCountMutex); // 进入临界区
rCount--; // 读完数据,准备离开
if( rCount == 0 )
{
V(wMutex); // 最后一个读者离开了,则唤醒写者
}
V(rCountMutex); // 离开临界区
}
}
上面的这种实现,是读者优先的策略,因为只要有读者正在读的状态,后来的读者都可以直接进入,如果读者持续不断进入,则写者会处于饥饿状态。
方案二
那既然有读者优先策略,自然也有写者优先策略:
- 只要有写者准备要写入,写者应尽快执行写操作,后来的读者就必须阻塞;
- 如果有写者持续不断写入,则读者就处于饥饿;
在方案一的基础上新增如下变量:
- 信号量
rMutex:控制读者进入的互斥信号量,初始值为 1; - 信号量
wDataMutex:控制写者写操作的互斥信号量,初始值为 1; - 写者计数
wCount:记录写者数量,初始值为 0; - 信号量
wCountMutex:控制 wCount 互斥修改,初始值为 1;
具体实现如下代码:
semaphore rCountMutex; // 控制对rCount的互斥修改,初始值为1
semaphore rMutex; // 控制读者进入的互斥信号量,初始值为1
semaphore wCountMutex; // 控制wCount互斥修改,初始值为1
semaphore wDataMutex; // 控制写者写操作的互斥信号量,初始值为1
int rCount = 0; // 正在进行读操作的读者个数,初始化为0
int wCount = 0; // 正在进行读操作的写者个数,初始化为0
// 写者进程/线程执行的函数
void writer() {
while(TRUE) {
P(wCountMutex); // 进入临界区
if (wCount == 0) {
P(rMutex); // 当第一个写者进入,如果有读者则阻塞读者
}
wCount++; // 写者计数+1
V(wCountMutex); // 离开临界区
P(wDataMutex); // 写者写操作之间互斥,进入临界区
write(); // 写数据
V(wDataMutex); // 离开临界区
P(wCountMutex); // 进入临界区
wCount--; // 写完数据,准备离开
if (wCount == 0) {
V(rMutex); // 最后一个写者离开了,则唤醒读者
}
V(wCountMutex); // 离开临界区
}
}
// 读者进程/线程执行的函数
void reader() {
while(TRUE) {
P(rMutex); // 进入临界区
P(rCountMutex); // 当第一个读者进入,如果有写者则阻塞写者写操作
if (rCount == 0) {
P(wDataMutex);
}
rCount++; // 读者计数加一
V(rCountMutex); // 离开临界区
V(rMutex); // 离开临界区
read(); // 读数据
P(rCountMutex); // 进入临界区
rCount--;
if (rCount == 0) {
V(wDataMutex); // 当没有读者了,则唤醒阻塞中写者的写操作
}
V(rCountMutex); // 离开临界区
}
}
注意,这里 rMutex 的作用,开始有多个读者读数据,它们全部进入读者队列,此时来了一个写者,执行了 P(rMutex) 之后,后续的读者由于阻塞在 rMutex 上,都不能再进入读者队列,而写者到来,则可以全部进入写者队列,因此保证了写者优先。
同时,第一个写者执行了 P(rMutex) 之后,也不能马上开始写,必须等到所有进入读者队列的读者都执行完读操作,通过 V(wDataMutex) 唤醒写者的写操作。
方案三
既然读者优先策略和写者优先策略都会造成饥饿的现象,那么我们就来实现一下公平策略。
公平策略:
- 优先级相同;
- 写者、读者互斥访问;
- 只能一个写者访问临界区;
- 可以有多个读者同时访问临界资源;
具体代码实现:
semaphore rCountMutex = 1; // 控制对rCount的互斥修改,初始值为1
semaphore wDataMutex = 1; // 控制写者写操作的互斥信号量,初始值为1
semaphore flag = 1;
int rCount = 0; // 正在进行读操作的读者个数,初始化为0
// 写者进程/线程执行的函数
void writer() {
while (TRUE) {
P(flag);
P(wDataMutex); // 写者写操作之间互斥,进入临界区
write(); // 写数据
V(wDataMutex); // 离开临界区
V(flag);
}
}
// 读者进程/线程执行的函数
void reader() {
while (TRUE) {
P(flag);
P(rCountMutex); // 进入临界区
if (rCount == 0) {
P(wDataMutex); // 当第一个读者进入,如果有写者则阻塞写者写操作
}
rCount++;
V(rCountMutex); // 离开临界区
V(flag);
read();
P(rCountMutex); // 进入临界区
rCount--;
if (rCount == 0) {
V(wDataMutex); // 当没有读者了,则唤醒阻塞中写者的写操作
}
V(rCountMutex); // 离开临界区
}
}
看完代码不知你是否有这样的疑问,为什么加了一个信号量 flag,就实现了公平竞争?
对比方案一的读者优先策略,可以发现,读者优先中只要后续有读者到达,读者就可以进入读者队列, 而写者必须等待,直到没有读者到达。
没有读者到达会导致读者队列为空,即 rCount==0,此时写者才可以进入临界区执行写操作。
而这里 flag 的作用就是阻止读者的这种特殊权限(特殊权限是只要读者到达,就可以进入读者队列)。
比如:开始来了一些读者读数据,它们全部进入读者队列,此时来了一个写者,执行 P(falg) 操作,使得后续到来的读者都阻塞在 flag 上,不能进入读者队列,这会使得读者队列逐渐为空,即 rCount 减为 0。
这个写者也不能立马开始写(因为此时读者队列不为空),会阻塞在信号量 wDataMutex 上,读者队列中的读者全部读取结束后,最后一个读者进程执行 V(wDataMutex),唤醒刚才的写者,写者则继续开始进行写操作。