你可能会有点疑惑,这不是讲哲学家用餐问题的吗,为什么一上来你先甩一张毫无相干的图,这里又提条件语句的原子性?
别着急,一会儿你就会看到原因了。
我们先来看看上面的图,右上角是java语言写的语句;左下角是经javap得到的汇编代码。使用箭头将对应部分连接了起来。
可以看到一个最简单的if
条件语句(图中蓝色框)都至少要两条指令,一条用于取操作数(在i > 0
这个比较中0是个常量,不需要取操作),一条用于比较转移(即如果不成功跳转到哪一步执行,因为如果成功直接顺序执行即可)。如果复杂一点的(如图中红色框),需要的指令更多了(当然了由于&&
和 ||
具有短路性质,因此指令多少不是只看条件句中&&
和 ||
的个数)。
总之 条件语句在java中不是原子操作。其实还可以看到图中的i = j + 1;
需要四条指令:1. 取j; 2. 取常量1; 3. add操作; 4. 保存结果到i。因此这个也不是原子操作。再说一下,对于return
操作,也不是原子操作,就算最简单的return 0;
都是这样的:
因此,我想说的是,如果遇到多线程问题,该加锁的一定要加锁,该互斥的一定要互斥。
好了,接下来言归正传:
由Dijkstra提出并解决的哲学家就餐问题是典型的同步问题。该问题描述的是五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个盘子和五只叉子,他们的生活方式是交替的进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左手边和右手边的叉子,只有在他能够拿到左右手的两只叉子时才能进餐。进餐完毕,放下筷子继续思考。不能让哲学家饿死,即不能发生线程的死锁。
死锁产生的条件,只有当以下四个条件同时满足时,才有可能会发生死锁。这四个条件是必要不充分条件:
要发生死锁的话,上面四个条件必须同时满足,因此只要破除其中任何一个,就能避免死锁的发生。
思路: 伪代码见上图。每个人都先去拿自己左手边的叉子,然后再去拿自己右手边的叉子,如果能拿到两把叉子就可以吃饭,吃完饭后先放下左手边的叉子,然后放下右手边的叉子;否则就一直等待直到能拿起右手的叉子。
分析: 假如每个人都拿到了左手边的叉子,然后再尝试拿右手边的时候,就会因为得不到右手的叉子而陷入死等状态。由于每个人都不想让,因此大家都只能饿死。
思路: 上面可以发现,陷入死锁是因为每个人在拿到左手边的叉子后不放手,导致每个人都得不到右手边的叉子。我们进行一下改进,那就是如果得不到右手边的叉子,就把左手的放下后再进入等待状态,等一等然后再重复上述操作直到能拿起两个叉子。
分析: 毕竟是哲学家,受过教育的人高素质人才,怎么能不懂得谦让呢。但是如果每个人都拿起了左手的叉子,然后发现拿不到右手的叉子,又都放下了左手的叉子,然后等了一会儿,又都开始拿起左手的叉子然后去尝试拿右手的,又拿不到了,继续等,然后。。。这样下去大家又都要饿死了。看来过分谦让也不是一件好事。这种事情其实生活中经常碰到,一个小路你和一个人迎面走来,眼看要碰到,于是你给他让路他也给你让路,然后你俩尴尬的一笑,你又重新走回原来那边,他也是,于是你俩就这么一直互相让着让着终于碰到了一起。
思路: 我们发现哲学家的动作太整齐划一了,左手画彩虹,右手一条龙。为了使他们动作不再那么整齐,当放下左手的叉子的时候,每个人等待的时间随机,然后再重复操作。
分析: 这样的话,动作快的就能先吃到饭把叉子放下,然后动作慢的最终也能吃到饭。但是这个等待的时间是随机的,属于不可控因素。并且吃饭的效率也有问题,因为有5个叉子,所以最多可以同时满足两个人(任意不相邻的两个)吃饭,例如0号哲学家和2号哲学家同时吃饭是可以保证的。
思路: 经过上面的分析我们发现,饿死的情形中都是由于他们都是先拿的左手叉子后拿的右手叉子,因此发生了死锁。我们换一种方式,规定偶数号哲学家(即0号、2号、4号)先拿左手边叉子,而奇数号哲学家(1号和3号)先拿右手边叉子。然后再尝试拿另一只叉子,如果能拿到就吃饭吃完放下双手的叉子,如果拿不到就放下手中的叉子,等待一会儿再重新去拿叉子。。。
分析: 如上图所示,0-4五个哲学家围在一个桌子周围,偶数号哲学家先拿左手边(红色箭头)的叉子,奇数号哲学家先拿右手边(蓝色箭头)的叉子。这时4号哲学家一定可以得到两个叉子,从而先吃到饭后将叉子放下,其他的哲学家也能够吃到饭。
首先0号拿到一个叉子,1号没拿到就等待,2号拿到,则3号此时拿不到叉子等待,4号拿到叉子;然后0号拿另一个发现被4号占了于是放下叉子等待,1号在等待中,2号顺利拿到另一个叉子,3号拿不到叉子继续等待,4号可以拿到另一个叉子。这时候同时有2号和4号都能够吃饭。当他俩吃完放下叉子,3号一定可以拿到两个叉子(因为2号和4号都放下了叉子),剩下的0号和1号也一定有一个可以拿到两个叉子(假设是1号哲学家),这时又可以同时有两个哲学家能够吃饭,吃完饭放下叉子,还是可以满足两个同时吃饭的条件(2号一定可以拿到两个叉子,0号和4号其中有一个可以拿到两把叉子)。。。
这个方案是好的,不仅不会导致有人饿死,而且还提高了吃饭的效率,同时可以有两个人吃饭。
它是通过破除循环等待条件,避免死锁的。
思路: 另一种思路是限制拿叉子的人数。观察上面的死锁是因为五个人同时拥有了一支叉子导致每个人都得不到另一个。假如我们规定每次最多只有四个哲学家能同时拿起左手的叉子,然后再去拿右手的叉子的时候,必然有一个人能够得到右手边的叉子从而吃完饭。
分析: 假如0、1、2、3都先拿到了一个左叉子,这时候4就只能等待了;然后0、1、2、3再去拿右叉子的时候因为4没有拿左叉子,所以0可以拿到右叉子,吃完饭放下叉子。然后1可以吃饭,接着是2,2吃的时候0也可以吃,2吃完3吃,3吃完4吃。
可以看到这个吃饭的效率降低了一些,并且吃饭的顺序有了一定的约束。根据这个思路,我们还可以想到另一个方法。
思路: 规定哲学家的吃饭顺序,按照他们的序号从小到大。
分析: 这样效率更低了,退化成了完全的顺序执行方式。
思路: 哲学家在拿叉子的时候不再每次只拿一个了,而是一次拿两个能成功就吃饭,不成功就等待。如果哲学家的左邻和右邻都没有在用餐,则他可以同时拿起两个叉子吃饭,否则他就等待。
分析: 因为死锁的条件是每一个哲学家都正好拿到了一只叉子等待另一只。但是这里哲学家要么拿到的是两个叉子要么是一个叉子都拿不到,不会出现死锁的情况。
实现: 由于要实现同时去拿两把叉子的操作,因此需要一个互斥的信号量mutex保证这个过程的原子性。第一节条件语句的原子性就是为了这里而写的。如下:
//伪代码
P(mutex);
//如果不是在临界区里面,可能会发生这种情况:当我观察到左邻居没有在吃饭后,
//我的执行权限给了左邻居,然后他发现他的左邻居没吃饭而我也还没有开始吃饭,
//他就去吃饭了,这时执行权重新给了我,然后我继续观察发现右邻居没有吃饭,
//于是我也去吃饭了。诡异的事情来了,我和我的邻居居然在同时吃饭。
//显然这是不允许的。问题就出现在我在判断我能否同时拿起两把叉子的时候被
//另一个人打断了,导致我不能及时发现邻居的变化,从而产生了错误的判断。
//因此我的判断操作要用互斥信号量包起来,保证它的原子性,
//即在执行的时候不要被打断。
if (self == hungry && self.left != eating && self.right != eating) {
self = eating;
//这里不是叫醒自己的意思,而是将自己的资源数加一,而使自己接下来不会去等待
V(self);
}
V(mutex);
放下叉子的时候,要去观察他的邻居是否能够拿起两把叉子,如果能就通知一下啊,让邻居别傻等着了。这个操作和上面的一样,只是换了判断对象:
//伪代码
//上面已经说过,这里需要原子操作,因此使用互斥信号量包起来。
P(mutex);
//判断邻居是否能够拿到两个叉子
if (neighbour == hungry &&
neighbour.left != eating &&
neighbour.right != eating
) {
neighbour = eating;
//这里要叫醒邻居
V(neighbour);
}
V(mutex);
放大招了:
class Philosopher {
//哲学家类应该有一个静态变量(或者叫类变量)的mutex, 是所有哲学家对象所共享的,用于互斥操作
private static Semaphore mutex = new Semaphore(1);
//一个哲学家对象应该有他的左邻右舍
//他的状态,姓名
//以及他的信号量用于同步
public String name;
public PhilosopherState state;
public Philosopher left;
public Philosopher right;
public Semaphore semaphore;
Philosopher(String name) {
this.name = name;
this.state = PhilosopherState.THINKING;
this.left = null;
this.right = null;
this.semaphore = new Semaphore(0);
}
public void setLeft(Philosopher left) {
this.left = left;
}
public void setRight(Philosopher right) {
this.right = right;
}
public Philosopher getLeft() {
return left;
}
public Philosopher getRight() {
return right;
}
public void setState(PhilosopherState state) {
this.state = state;
}
public void thinking() {
state = PhilosopherState.THINKING;
System.out.println(name + " is thinking...");
}
public void takeForks() throws InterruptedException {
mutex.acquire();
//饥饿状态
this.state = PhilosopherState.HUNGRY;
//尝试拿起叉子,如果能拿到就会唤醒自己一次。
//为什么这里要用互斥信号量包起来呢,这是因为if()条件语句不是原子操作语句,如果不设为临界区
//就可能会在中途被他的邻居打断。
testTakeFork();
mutex.release();
//如果能够拿到叉子,就会唤醒自己一次(也就是将自己的信号量加一),因此这里就不会阻塞,否则
//会被挂起,阻塞,等待邻居吃完将自己唤醒
semaphore.acquire();
}
//尝试拿起叉子,如果能拿到就唤醒自己
private void testTakeFork() {
//拿叉子不是看的叉子是否可拿,因为对象中没有叉子这个属性。
// 而是看左邻右舍是否在用餐,如果没有在用餐,并且自己处于饥饿状态,
//就可以拿起叉子吃饭
//这里实际上是临界区,是互斥访问的,在调用它的函数里可以现出来。
//因为里面的条件语句不是原子操作,这样的话假如判断了左邻居没在吃饭,然后被左邻居打断了左邻居开始吃饭,
// 然后再转回来接着执行判断右邻居的操作,如果右邻居没在吃饭,他就可以开始吃饭,
// 但是实际上他的左邻居正在吃饭,任务的执行就会发生异常。
if (state == PhilosopherState.HUNGRY && left.state != PhilosopherState.EATING &&
right.state != PhilosopherState.EATING
){
state = PhilosopherState.EATING;
//如果能够拿到两把叉子,就唤醒自己
semaphore.release();
}
}
public void eating() {
state = PhilosopherState.EATING;
System.out.println(name + " is eating...");
}
//放下叉子
//并且要查看左邻右舍是否具备吃饭的条件,即能够拿到两把叉子。如果能,就唤醒
public void putForks() throws InterruptedException {
mutex.acquire();
//吃完饭后,就继续思考问题,思考问题的状态不是临界资源
//不用互斥也不会发生错误,因为思考的状态不会对其他的哲学家
//产生丝毫的影响。会对其他的哲学家产生影响的只有吃饭状态。因此吃饭
//这个状态需要是临界资源。
state = PhilosopherState.THINKING;
//查看自己的邻居是否能吃饭
left.testTakeFork();
//其实这里的放叉子操作可以分别使用互斥信号量包起来,也就是说,
//可以不用同时放下两个叉子
right.testTakeFork();
mutex.release();
}
//哲学家的一生就处于思考饥饿吃饭的循环中,直至死亡
public void life() throws InterruptedException {
while (!Thread.interrupted()) {
//思考中
// thinking();
//饥饿开始拿叉子,如果拿不到就会阻塞
takeForks();
//如果能拿到就能吃饭
eating();
//吃完饭放下叉子,再看看自己的左邻右舍
putForks();
}
}
}
哲学家任务:
class PhilosopherTask implements Runnable {
private Philosopher philosopher;
PhilosopherTask(Philosopher philosopher) {
this.philosopher = philosopher;
}
@Override
public void run() {
try {
System.out.println("哲学家" + philosopher.name + " 的任务开始");
philosopher.life();
} catch (InterruptedException e) {
// e.printStackTrace();
System.out.println("哲学家" + philosopher.name + " 的任务结束");
}
}
}
哲学家状态枚举类:
enum PhilosopherState {
THINKING, HUNGRY, EATING
}
测试:
public class PhilosophersEat {
public static void main(String[] args) throws InterruptedException {
test(10);
}
//测试n个哲学家吃饭问题
public static void test(int n) throws InterruptedException {
//小于两个人是没办法吃饭的,因为至少要两把叉子。
if (n < 2) return;
//n个哲学家围成一个圈
String name = "philosopher_";
Philosopher philosopher = new Philosopher(name + 0);
Philosopher tail = philosopher;
//双链表表示n个哲学家围成一圈
for (int i = 1; i < n; i++) {
Philosopher ph = new Philosopher(name + i);
tail.right = ph;
ph.left = tail;
tail = ph;
}
tail.right = philosopher;
philosopher.left = tail;
ExecutorService executorService = Executors.newCachedThreadPool();
Philosopher p = philosopher;
while (p != tail) {
PhilosopherTask task = new PhilosopherTask(p);
executorService.submit(task);
p = p.right;
}
executorService.submit(new PhilosopherTask(tail));
Thread.sleep(100);
executorService.shutdownNow();
}
}
哲学家用餐问题的解决方案有很多种,我个人觉得最后一种最好,因为他的执行效率高,通用性强,不仅仅是5个哲学家,还可以更多。