notify()与wait()

前言

如果每个线程能够独自完成自己的任务,就最好不过了。但是现实是很多情况下各个线程之间需要沟通和协作,通过相互配合的方式共同完成一个任务。Java中的Object类中提供了如下方法,让我们做到能够在完成某些操作时通知(唤醒)一个或多个进程,或者当判定不满足一些条件时,主动释放掉获得的锁,并将自己加入该锁的等待队里。

public final native void wait(long timeout) throws InterruptedException; 
public final native void notifyAll();  // 唤醒所有线程
public final native void notify();     // 唤醒一个线程
为什么需要notify()与wait()

结合具体场景分析这两个方法的作用,首先我们假设有一个厨房,厨房外有一把锁,每次只允许一位食客进来吃饭。厨师在厨房里做好饭后,就离开厨房等多个食客进入就餐。下面给出Kitchen类的具体实现:

  • Kitchen类
public class Kitchen {
    // 厨房的门锁
    private Object lock;
    // 食物准备好了吗?
    private volatile boolean isOk;

    public Kitchen(){
        this.lock = new Object();
        this.isOk = false;
    }
    // 现在食物准备好了吗?
    public boolean isOk() { return isOk;}
   // 厨师将该标志设为true,表示师傅已经准备好
    public void setOk(boolean ok) { isOk = ok; }
    // 该厨房大门上的锁
    public Object getLock() { return lock; }
}

我们设立了一个isOk的标志, 当其为true时,表示厨师已经把食物准备好了,食客看到这个标志为true,就明白现在可以就餐了。
我们再编写表示厨师做饭行为的Cook类,以及表示食客就餐行为的Eat类,两个类都实现了Runnable接口的run()方法,可交给线程执行。

  • Cook类
public class Cook implements Runnable {

    private Kitchen kitchen;
    public Cook(Kitchen kitchen) {
        this.kitchen = kitchen;
    }
    @Override
    public void run() {
        synchronized (kitchen.getLock()) {
            System.out.println("厨师进入厨房准备开始烹饪食物");
            // 开始做饭
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 饭做好了,设置isOk变量为true
            kitchen.setOk(true);
            System.out.println("食物已经做好了,大家可以吃了");
            // 离开同步块,释放厨房的锁
        }
    }
}

Cook类的逻辑很简单,代表厨师的线程会尝试获得厨房上的锁,当成功获得锁进入厨房后,就开始做饭,中间的sleep方法模拟这个做饭所消耗的时间。当厨师做好饭以后,就把厨房的isOk标志设置为true,表示饭做好了,大家可以吃了,然后释放厨房上的锁,离开厨房。

  • Eat类
public class Eat implements Runnable {
    private Kitchen kitchen;
    private String name;
    public Eat(Kitchen kitchen, String name) {
        this.kitchen = kitchen;
        this.name = name;
    }
    @Override
    public void run() {
        // 拿到厨房的锁,进入厨房
        synchronized (kitchen.getLock()) {
            System.out.println(name + "进入了厨房,准备开始吃饭");
            // 判断食物做好了吗?没有的话原地等待
            while (!kitchen.isOk()) { }
            // 食物做好了, 开始吃饭
            System.out.println(name + "吃完了饭, 离开了厨房");
        }
    }
}

当代表食客的线程拿到了厨房的锁,成功进入厨房后,他首先要判断,饭做好了吗?如果没做好,这个食客就开始不停判断饭做没做好,因为isOk这个条件变量是用volatile关键字修饰的,保证了可见性,那么只要有厨师把饭做好,那么当前食客线程肯定能感知到,然后从while循环跳出。但其实这里很明显会发生思索,拿到了厨房锁的食客线程一直自旋等待食物做好,厨师却无法获得锁进去烹饪食物,两者开始了无休止的相互等待。这里我们先放置不管,马上再来解决这个问题。我们先来模拟一种不会发生死锁的理想的场景。

  • 一个理想的场景
    厨师先进厨房做饭,做好后多个食客进入就餐


    理想场景

给出这个场景下具体的java实现

public class notifyDemo {
    public static void main(String[] args) {
        Kitchen kitchen = new Kitchen();

        Thread cooker = new Thread(new Cook(kitchen));
        Thread tom = new Thread(new Eat(kitchen,"tom"));
        Thread ben = new Thread(new Eat(kitchen,"ben"));

        cooker.start();
        // 主线程休眠,让厨师线程先把饭做好
        try {
            Thread.sleep(10000L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        tom.start();
        ben.start();
    }
}

cooker线程先执行,然后主线程休眠等待cooker线程把饭做好之后启动tom和ben两个食客线程进去用餐,运行结果如下一切正常。

厨师进入厨房准备开始烹饪食物
食物已经做好了,大家可以吃了
tom进入了厨房,准备开始吃饭
tom吃完了饭, 离开了厨房
ben进入了厨房,准备开始吃饭
ben吃完了饭, 离开了厨房

Process finished with exit code 0

现在让我们颠倒一下顺序,让食客tom线程先运行。

public class notifyDemo {
   public static void main(String[] args) {
       Kitchen kitchen = new Kitchen();

       Thread cooker = new Thread(new Cook(kitchen));
       Thread tom = new Thread(new Eat(kitchen,"tom"));
       Thread ben = new Thread(new Eat(kitchen,"ben"));
       // 先让食客进入,此时发生死锁
       tom.start();
       // 主线程休眠,让厨师线程先把饭做好
       try {
           Thread.sleep(10000L);
       } catch (InterruptedException e) {
           throw new RuntimeException(e);
       }

       cooker.start();
       ben.start();
   }
}

和我们预期的一致,发生了死锁。

tom进入了厨房,准备开始吃饭
//发生死锁,开始无限等待
Process finished with exit code 130 (interrupted by signal 2: SIGINT)

解决这个死锁问题的关键是要在食客进入厨房后,如果发现厨师没有把饭做好,就主动释放厨房的锁,并主动让出cpu供别的线程使用,过一段时间再试,而不是原地空转。

 while (!kitchen.isOk()) {
      释放锁,让出cpu,等待饭做好
 }

怎么实现呢?让出cpu似乎很简单,我们只要让食客线程sleep一段时间就好了,让我们试一下。

 while (!kitchen.isOk()) {
    try {
        Thread.sleep(1000L);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
  }
public class notifyDemo {
    public static void main(String[] args) {
        Kitchen kitchen = new Kitchen();

        Thread cooker = new Thread(new Cook(kitchen));
        Thread tom = new Thread(new Eat(kitchen,"tom"));
        Thread ben = new Thread(new Eat(kitchen,"ben"));
        // 先让食客进入,此时发生死锁
        tom.start();
        // 主线程休眠,让厨师线程先把饭做好
        try {
            Thread.sleep(500L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        cooker.start();
        //ben.start();
    }
}

tom拿到锁进入厨房后,发现食物还没做好,于是sleep让出cpu,随后cooker线程启动开始做饭。观察一下运行结果。

tom进入了厨房,准备开始吃饭
// 依然发生死锁,因为tom线程sleep的时候没有释放锁
Process finished with exit code 130 (interrupted by signal 2: SIGINT)

最后和之前一样发生了死锁,原因在于当tom这个线程sleep的时候,他没有释放掉厨房的锁,后来启动的cooker线程,依然无法拿到锁进去做饭。
为了解决这个问题,就需要借助notify()和wait()方法了。

  1. 首先要明确这两个方法都是和锁对象进行绑定的,比如上文的Object lock = new Object()。使用的时候是在lock这个锁对象上调用wait()notify()方法。
  2. 当我们在一个锁对象上调用wait()的方法时,该线程会被加入和这个锁关联的等待队列中,同时时候放掉锁,让出cpu
  3. 当在一个锁对象上调用notify()方法时,会随机通知等待队列中的一个线程,“我这边准备好了,你可以准备开始争用我要释放的锁了”。
  4. 执行了notify()方法的线程继续执行,直到退出同步代码块,释放占用的锁。被通知的线程抢占到锁后继续从之前调用lock.wait()方法的下一行语句开始继续执行。

整体的运行流程如下:

执行流程

按照这个逻辑修改代码

  • Eat类里添加等待逻辑
public void run() {
        // 拿到厨房的锁,进入厨房
        synchronized (kitchen.getLock()) {
            System.out.println(name + "进入了厨房,准备开始吃饭");
            // 判断食物做好了吗?没有的话原地等待
            while (!kitchen.isOk()) {
                try {
                    // 饭还没好,把自己加入这个锁的等待队列中去
                    kitchen.getLock().wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            // 食物做好了, 通知等待的食客准备开始吃饭
            System.out.println(name + "吃完了饭, 离开了厨房");
        }
    }
  • Cook类里添加通知逻辑
public void run() {
        synchronized (kitchen.getLock()) {
            System.out.println("厨师进入厨房准备开始烹饪食物");
            // 开始做饭
            try {
                Thread.sleep(5000L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 饭做好了,设置isOk变量为true
            kitchen.setOk(true);
            // 通知一个线程,饭已经做好了。
            kitchen.getLock().notify();
            System.out.println("食物已经做好了,大家可以吃了");
            // 离开同步块,释放厨房的锁
        }
    }
  • 测试结果,利用线程间通知机制,死锁问题被解决了。
tom进入了厨房,准备开始吃饭
厨师进入厨房准备开始烹饪食物
食物已经做好了,大家可以吃了
tom吃完了饭, 离开了厨房

Process finished with exit code 0
不要在循环之外调用wait方法!

现在我们来关注Eat实现里的一个细节

while (!kitchen.isOk()) {
    try {
        // 饭还没好,把自己加入这个锁的等待队列中去
        kitchen.getLock().wait();
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

当这个线程从wait()方法中醒来,说明此时厨师已经把饭做好了,isOk肯定是等于true的,可是在这里却在while循环里再重新判断了一次isOk的值,这是不是多此一举呢?现在我们考虑这样一种情况,我们厨师线程不采用notify()的方法,而采用notifyAll()方法,通知多个食客进程准备用餐,同时,这回厨师只做了一份食物,因此当一个食客吃完后就把isOk标志位设为false,表示饭吃完了。

补充:notifyAll()方法与notify()的区别是,notify()随机通知一个线程,而notifyAll()通知所有等待的线程。

我们修改Eat类和Cook类的执行逻辑,来模拟上述过程。

  • Eat类
public void run() {
        // 拿到厨房的锁,进入厨房
        synchronized (kitchen.getLock()) {
            System.out.println(name + "进入了厨房,准备开始吃饭");
            // 判断食物做好了吗?没有的话原地等待
            if (!kitchen.isOk()) {
                try {
                    // 饭还没好,把自己加入这个锁的等待队列中去
                    kitchen.getLock().wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            // 模拟吃饭所花的时间
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 把饭吃完,设置标志位为false
            kitchen.setOk(false);
            System.out.println(name + "吃完了饭, 离开了厨房");
        }
    }
  • Cook类
  public void run() {
        synchronized (kitchen.getLock()) {
            System.out.println("厨师进入厨房准备开始烹饪食物");
            // 开始做饭
            try {
                Thread.sleep(5000L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 饭做好了,设置isOk变量为true
            kitchen.setOk(true);
            // 通知一个线程,饭已经做好了, 通知所有线程。
            kitchen.getLock().notifyAll();
            System.out.println("食物已经做好了,大家可以吃了");
            // 离开同步块,释放厨房的锁
        }
    }
  • Test类
 public static void main(String[] args) {
        Kitchen kitchen = new Kitchen();

        Thread cooker = new Thread(new Cook(kitchen));
        Thread tom = new Thread(new Eat(kitchen,"tom"));
        Thread ben = new Thread(new Eat(kitchen,"ben"));
        // 先让食客进入,此时发生死锁

        tom.start();

        try {
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        cooker.start();
        ben.start();
    }
  • 实验结果
tom进入了厨房,准备开始吃饭
ben进入了厨房,准备开始吃饭
厨师进入厨房准备开始烹饪食物
食物已经做好了,大家可以吃了
ben吃完了饭, 离开了厨房
tom吃完了饭, 离开了厨房

Process finished with exit code 0

可以发现,即使食物只有一份,tomben却都吃上了饭,这显然是不可能的。问题的关键在于我们没有把wait()放在循环里。我们之前提到过,位于某个锁等待队列的线程,被通知并抢占到对应锁后,从wait()语句的下一行开始执行,我们看看如果用if语句会发生什么。

不正确的执行流程

根据上述的执行流程我们发现,同时被通知到的食客线程Ben把isOk设置为false,而Tom线程却不再去判断isOk是否为true,吃到了不存在的饭。
wait()中恢复的线程,其当初的条件变量很可能又被其他线程修改过,需要重新判断,因此需要被放在while循环里

条件判断和notify()/wait()方法要在一个同步代码块内部!

其实就是要求我们把条件判断和执行notify()/wait()方法,合并成一个原子方法。否则很可能会发生饥饿现象,考虑如下的执行流程。

发生饥饿

Tom线程发现isOk=false,准备执行wait()的方法等待厨师把饭做完,但是由于两者并非原子操作,在执行wait()方法之前,厨师线程把饭做好,并设置条件变量为isOk=true,可惜Tom线程将永远无法发现饭做好了,一直到饿死。

你可能感兴趣的:(notify()与wait())