这篇小记的重点偏向于 Java 的代码实现同步和互斥。
进程之间为什么要实现互斥:不同进程之间可能会共享某项资源。
进程之间为什么要实现同步:不同进程之间的操作可能会存在先后关系
在 OS 的学习中,同步 和 互斥 绝对是绕不过去的大山,而在 Java 中是怎样利用 OS 的同步和互斥的?是直接复用还是大胆创新?
Java 怎么实现 同步和互斥的
锁(Lock)不是资源本身,锁是访问资源的“入场券”。
显式锁 - ReentrantLock - 互斥量
在 Java 中可以使用 Lock.lock 对一块地方上锁(其实是 JVM 向 OS 申请了一块互斥空间/临界资源),在 Java 中,变量可以被放置到临界资源区中,这样子来实现锁
例如
// 1. 【抢钥匙】
// 这一步不是申请资源,而是“申请访问资源的权限”。
// 如果拿到钥匙,就进门;拿不到,就在门口排队(阻塞)。
lock.lock();
try {
// 2. 【临界区 (Critical Section)】
// 只有拿到了钥匙的线程,才能运行这几行代码。
// 在这里,你可以安全地操作“临界资源”(比如 map.put, count++)。
// 此时,别人进不来,因为钥匙在你兜里。
map.put("key", 1);
} finally {
// 3. 【还钥匙】
// 这一步不是把资源还给 OS(厕所还在),而是“归还权限”。
// 把钥匙挂回墙上,通知门口排队的人:“我用完了,你们抢吧”。
lock.unlock();
}
其实 Java 在 lock 的底层就已经实现了阻塞了,那么这个时候就有人要问了,老师老师,那我们(Condition)呢?
一句话总结:阻塞 不等于 释放锁。只有 Condition.await() 才能做到 “在阻塞的同时释放锁”。
把话说得更日常一点:你在食堂吃饭,一般来讲就是 先抢位置(抢锁) -> 去打饭(调用资源) -> 吃饭(使用资源) -> 离开(释放资源和锁) 这一个过程
lock 相当于你抢了一个位置,然后在排打饭队伍,快到你了告诉你:小伙子不好意思啊,饭没了,你得等一下了,condition 的做法是你先把座位放开,让其他已经打了饭同学可以坐,然后你去等阿姨跟你说,饭好了,然后你再去抢座位,抢饭。
如果没有 condition,相当于阿姨跟你说没饭了,你说:那没事儿,我等等,但是座位还是你的啊,其他要吃饭的人就没座位了,那不就是占着茅坑不拉屎了吗?
隐式锁 - synchronized - 管程
在 JDK 编写过程,开发者就已经给每一个对象上了一把隐式锁
隐式锁可以通过添加 synchronized 关键字开启
生产者 - 消费者
在 OS 中,生产者-消费者问题绝对是十分经典的问题,问题描述如下:
另外,缓冲区是有空间限制的,比如缓存区只能放入 5 条数据。
从题目我们不难发现
- 如果缓冲区没有满,则生产者可以往缓冲区中写入数据
- 如果缓冲区不为空,则消费者可以往缓冲区中读出数据
- 如果多个生产者同时往缓冲区的同一地方写入数据,则会导致数据覆盖
- 如果多个消费者同时往缓冲区的同一地方读出数据,则会导致数据读取异常
所以
- 缓冲区为临界资源
- 生产者/消费者之间应当存在互斥关系,即多个生产者/消费者应当互斥地访问缓冲区(互斥)
- 生产者必须在缓冲区中存在空间时才能写入数据,消费者必须在缓冲区中存在数据时才能读出数据(同步)
接下来,我将使用 Java 代码进行模拟
import java.util.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
// 互斥信号量: 互斥操作中分发给生产者/消费者的钥匙
map.put("key", 1);
// 同步信号量: 缓存区中的数据个数
map.put("value", 0);
// 缓存区最大数据量
map.put("size", 5);
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
// 生产者队列
Thread thread = new Thread(() -> {
lock.lock();
try {
// 判断数据是否满了
// 如果生产者发现缓存区满了,则需要阻塞等待消费者将缓存区的数据读取
while (Objects.equals(map.get("size"), map.get("value"))) {
System.out.println("生产者 正在阻塞自己");
// 自己进入阻塞状态
condition.await();
}
// 向缓存区中写入数据
System.out.println("生产者 正在写入数据");
Integer key = map.get("key");
Integer value = map.get("value");
map.put("value", ++value);
// 唤醒其他所有线程
condition.signalAll();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
});
thread.start();
// 消费者队列
Thread thread1 = new Thread(() -> {
lock.lock();
try {
// 判断数据是否为空
// 如果消费者发现缓存区为空,则需要阻塞等待生产者增加缓存区的数据
while (Objects.equals(0, map.get("value"))) {
System.out.println("消费者 正在阻塞自己");
// 自己进入阻塞状态
condition.await();
}
// 向缓存区中读取数据
System.out.println("消费者 正在读取数据");
Integer key = map.get("key");
Integer value = map.get("value");
map.put("value", --value);
// 唤醒其他所有线程
condition.signalAll();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
});
thread1.start();
}
}
为什么阻塞部分的代码会使用 while 循环重复判断条件,而不是 if ?
这与互斥机制有关系,当一个线程被唤醒2后,它需要再去抢锁,只有抢到了才能够继续向下执行(condition.await(); 代码中,线程被唤醒,然后会去抢锁),不过可能会存在一个问题,如果 线程A 被唤醒期间,线程B 已经捷足先登了,当 线程A 在找锁时 线程B 也早执行完成,早 线程A 一步把锁放回去了,那在线程A的视角里: 我被唤醒了,因为当前的条件满足了,锁也在,所以我拿上锁开始执行我的操作。但是在线程B 捷足先登之后,线程A如果就那么盲目地去执行自己的操作、修改数据,可能就会导致 OOM ,所以在这里会让 线程A 再看一遍,现在的条件到底符不符合,线程A到底能不能出去干活。
多生产者 - 多消费者
多生产者 - 多消费者 问题并没有说比 生产者 - 消费者 问题难多少,无非就是增加几个判断就可以了,代码的逻辑是差不多的。
代码实现如下
import java.util.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// 注: 这里的水果类需要自己创建
public class Main {
public static void main(String[] args) {
// 临界资源 - 盘子,只能放入一个水果
Queue<Fruit> queue = new ArrayDeque<>();
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
Fruit apple = new Apple();
Fruit orange = new Orange();
// 父亲线程
new Thread(() -> {
lock.lock();
try {
// 判断盘子是不是空的
while (!queue.isEmpty()) {
condition.await();
}
// 往盘子里面放水果
queue.add(apple);
System.out.println("爸爸放苹果");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}).start();
// 母亲线程
new Thread(() -> {
lock.lock();
try {
// 判断盘子是不是空的
while (!queue.isEmpty()) {
condition.await();
}
// 往盘子里面放水果
queue.add(orange);
System.out.println("妈妈放橘子");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}).start();
// 儿子线程
new Thread(() -> {
lock.lock();
try {
// 判断盘子里是苹果吗
while(!queue.contains(apple)){
condition.await();
}
// 吃水果
queue.poll();
System.out.println("儿子吃苹果");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}).start();
// 女儿线程
new Thread(() -> {
lock.lock();
try {
// 判断盘子里是橘子吗
while(!queue.contains(orange)){
condition.await();
}
// 吃水果
queue.poll();
System.out.println("女儿吃橘子");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}).start();
}
}
运行结果如下
读者 - 写者
读写者问题和生产消费者问题最大的不同是:
读者之间不存在互斥关系,而消费者之间存在互斥关系
说到读写者就不由自主地想到 MySQL ,MySQL 在执行中也采用了“读数据的进程可以并行,但写数据进程的必须互斥”,而且在写数据的进程在执行操作时,为了保护数据的可靠性,不允许读数据进程访问,这就是读写互斥。
接下来我将使用 Java 代码进行模拟,这里有一个额外的注意点:锁可以有很多把
首先:
写者与其他任何人都是互斥关系
而读者进程与读者进程之间并不互斥
所以我们可以使用 count 来记录当前是否有读者在读,当 count 为 0 时解锁
import java.util.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
// 写者锁,写者与其他人之间都需要互斥
static Lock writer_lock = new ReentrantLock();
static Condition writer_condition = writer_lock.newCondition();
// 读者锁,锁住的并不是读者,锁住的是读者数量,读者对于修改数量是互斥的,但是对于访问文章是并发的
static Lock reader_lock = new ReentrantLock();
static Condition reader_condition = reader_lock.newCondition();
// 防写者进程饿死锁/写者进程优先锁
static Lock writer_before = new ReentrantLock();
static Condition writer_before_condition = writer_before.newCondition();
// 记录当前临时空间内是否有人
static Queue<Integer> queue = new ArrayDeque<>();
// 记录读者数量
static Integer count = 0;
public static void main(String[] args) {
// 写者进程
new Thread(() -> {
writer_before.lock();
writer_lock.lock();
try {
// 写文章。。。
queue.add(1);
System.out.println(Thread.currentThread().getName() + " 正在写书...");
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
queue.poll();
writer_lock.unlock();
writer_before.unlock();
}
}).start();
// 读者进程
new Thread(() -> {
// 如果有写者在排队,读者就进不来
writer_before.lock();
writer_before.unlock();
// 对 count 的操作只允许一个读者一个读者地来
reader_lock.lock();
try {
if (count == 0) {
// 如果是第一个读者,需要把写者的锁拿上,因为写者与所有人互斥
writer_lock.lock();
}
// 登记人数
count++;
} finally {
reader_lock.unlock();
}
System.out.println(Thread.currentThread().getName() + " 正在读书...");
try {
Thread.sleep(2000);
} catch (Exception e) {
}
// 读者完成读书操作后的 count-- 操作也需要互斥
reader_lock.lock();
try {
count--;
if (count == 0) {
// 当没有人在访问文章时,把写者进程的锁还回去
writer_lock.unlock();
}
} finally {
reader_lock.unlock();
}
}).start();
}
}
其中容易混淆的是:
读者对于 count (人数的改变是互斥的),但是对于读书(其他不改变临界资源的操作)是并发的
高并发缓存
其实在底层跟读写者问题是一样的,核心点在于:
- 读者不会更改临界资源
当缓存被获取的时候所有线程都可以并发访问缓存区的内容,
但当缓存区的内容被修改时只能有一个进程在进行操作,
这与读写者问题完全一样!
管程
管程是什么?
管程的目的是:为了实现各个进程对于共享资源的互斥或同步
管程在面向对象中可以理解成:
- 将共享资源封装在对象中
- 在对象内部增加对应的需求方法
- 在构造函数中对该共享资源进行初始化
- 对外公开方法,方法内部实现同步|互斥
管程的基本特征为
在面向对象中可以理解成:
- 对象中的临界资源只有该对象有权利访问
- 只能使用对象公开的方法访问对象的属性(临界资源)
- 第三点就是字面意思了
synchronized
Java 中的加锁通常使用的是 synchronized,而这个机制就很类似与管程,同一时间访问 synchronized 访问的线程只能有一个
哲学家问题
哲学家问题的难点在于一个线程需要同时拥有两个临界资源才能进行下一步操作,如果五位哲学家同时拿起一根筷子就会产生死锁!
哲学家问题的重点是避免死锁,什么情况下会产生死锁?
- 互斥:某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
- 占有且等待:一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。
- 不可抢占:别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。
- 循环等待:存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。
解决方案
包括但不限于:
- 最多只允许 4 名哲学家同时吃饭,
- 给每一位哲学家编号,要求奇数号哲学家必须先拿左边的筷子再拿右边的筷子,偶数相反
- 一次只允许一个哲学家进行“拿筷子”这个行为
我这里使用 Java 实现第三个方案
这里只以第一个哲学家的实现为例子
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// 为了演示代码简洁,这里模拟的是第 0 号哲学家的视角
public class Main {
// 只有拿到这个锁的哲学家才能拿放筷子
static Lock canEat_lock = new ReentrantLock();
static Condition canEat_condition = canEat_lock.newCondition();
// 记录当前的 5 根筷子,筷子如果存在,则标注为 1
static Integer[] chops = new Integer[]{1, 1, 1, 1, 1};
public static void main(String[] args) {
// 有 5 个哲学家,每个哲学家都只能拿自己左边 i 和右边 i+1==5?i=0:i+=1 的筷子
new Thread(() -> {
canEat_lock.lock();
try{
// 抢到这个锁的哲学家可以看一下自己的两边有没有筷子
// 当一位哲学家左右的筷子有其中一只不在时,进入阻塞并归还锁
while (chops[0] == 0 || chops[1] == 0) {
canEat_condition.await();
}
chops[0]--;
chops[1]--;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
canEat_lock.unlock();
}
// 哲学家在吃饭。。。
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
canEat_lock.lock();
try{
chops[0]++;
chops[1]++;
canEat_condition.signalAll();
} finally {
canEat_lock.unlock();
}
}).start();
}
}
总结
在我看来,复杂的应该是每个角色之间的联系(同步),以及与临界资源的访问(互斥)的逻辑。