wait 和 notify

2,183 阅读13分钟

1. wait 和 notify的作用及特点

1.1 两者都是Object类中的方法,都是 native 方法

两者都是Object类中的方法,所以Java中所有的对象都可以调用这两个方法

public final native void notify();
public final native void wait(long timeout) throws InterruptedException;

并且从Java的源码中,我们可以知道:wait() notify() 都是native方法

1.2 wait 都必须在 try catch中进行调用

Java规定**wait() 调用时,必须被 try catch 包围,而 notify() 不需要**,

之所以 wait() 需要 try catc 的原因:

  • 从上面的源码中,我们可以看到,在 wait() 中抛出了InterruptedException异常,所以我们必须在使用 wait() 时对该异常进行处理

  • notify()中没有抛出异常,所以我们不需要进行处理

1.3 是否释放锁

  1. 调用wait()时,会释放锁,但是只会释放调用了 wait() 的锁,不会影响其他的锁
  2. 调用 notify() 时,不会释放锁

接下来,让我们用代码来进行验证是否释放锁

1. 对 wait() 来进行验证
    public void run() {
        super.run();
      	// 这里对 condition 来进行加锁
        synchronized (condition){
            try {
                System.out.println("进入临界区");
                condition.wait();
              	//因为释放了锁,所以不会输出下面的语句
                System.out.println("调用了 wait() ,这里不会输出");
            }catch (Exception e){
                e.printStackTrace();
            }
        }
    }
2. 对 notify() 来进行验证
    public void run() {
        super.run();
      	// 这里对 condition 来进行加锁
        synchronized (condition){
                System.out.println("进入临界区");
                condition.notify();
              	//因为释放了锁,所以不会输出下面的语句
                System.out.println("调用了 notify() ,这里仍然会进行输出");
        }
    }

分别运行上面的 代码,我们会发现调用了 wait() 以后的语句不会进行输出,而调用了 notify() 以后的语句仍然会进行输出,这个说明了 wait() 会释放锁, notify() 不会释放锁

从 notify() 不会释放锁中,我们可以得知

  • notify()最好尽量放在临界区代码的末尾

    因为 notify() 不会释放锁,如果我们在临界区代码的中间或者前面调用了notify,会唤醒其他线程,而 notify 不会释放锁,从而导致那个线程可能有一段时间不会获得锁,发生上下文切换,导致性能不太好

1.4 生产者消费者

wait 和 notify一般在消费者生产者中使用,消费者生产一般分为四种

  • 单生产者单消费者
  • 多生产者单消费者
  • 单生产者多消费者
  • 多生产者多消费者

但是这四种模式中 wait 和 notify 的使用都大同小异,比如有以下的注意点:

  • wait 和 notify 存放的东西在一个位置,一般我们使用阻塞队列,如 ArrayList,LinkedList 等

  • notify 放在消费线程的末尾

  • wait 的调用需要放在一个循环中,并且该循环需要在临界区中

    循环的目的是:保证多生产者的情况下,阻塞队列不会超过最大容量

    临界区的目的是:保证循环中的判断条件的修改是线程安全的

    wait 的代码一般如下:

    // 保证判断条件的修改是原子操作
    atomic{
      while(判断条件成立){
        调用 wait()
      }
      执行生产操作
    }
    
    
    // 通过 synchronized 来保证
    synchorized(res){
      // res 是生产者和消费者共享的
      while(判断条件成立){
        res.wait();
      }
      执行生产操作
    }
    

2. 使用wait()和notify()时的注意事项

2.1 wait() 放在循环中

wait() 一般放在一个循环中,``循环的判断条件决定了是否调用 wait()`,

我们可以使用while 和 for,但是一般来说我们使用while循环

如 1.4 的代码所示

循环一般放在临界区的前面部分

我们知道 wait() 和 notify() 最常用的场景是在生产者消费者的模式中,wait()一般使用在消费者线程中,当判断条件成立时(如:生产的东西超过了可以存储的数量,消费者消费的速度跟不上生产的速度),这个时候我们就会把生产线程暂停,但是当我们唤醒生产线程时,它又会继续执行 wait() 以后的代码

并且,我们使用wait()的目的就是控制生产线程生产的速度,如果生产的代码不放在 wait() 后面,实际上不能达到控制速度的目的

为什么不使用 if,而使用循环

因为如果我们使用if,在多生产者的情景下,会出现错误

比如会出现这样的场景:

有3个生产者ABC,他们生产的东西放在一个有界队列中

A运行时队列满了,调用 wait() 暂停

然后在某一时间,消费线程唤醒了所有生产线程,但是A没有抢到锁,当A抢到锁时,BC生产的东西又把队列填满了,但是我们的wait()放在if中,这个时候因为队列满了,所以应该继续调用 wait() ,但是因为 if 只能使用一次,所以A只能继续执行生产代码,导致会出现异常,如以下的代码

// 消费者和生产者在一个 ArrayList 中存取东西
// ArrayList 中最多可以存储 3 个,如果超过 3 个,会抛出异常
class Producer extends Thread {
    ArrayList<Integer> list ;
    public Producer (ArrayList<Integer> list){
        this.list = list;
    }
    @Override
    public void run() {
        super.run();
        while (true){
            synchronized (list){
                try {
                  // 这里使用 if
                  // 有可能有线程别唤醒时,size 还是等于3
                    if (list.size() >= 3){
                        list.wait();
                    }
                    list.add(1);
                    System.out.println("生产了一个");
                  	// 如果存储超过 3 个,抛出异常
                    if (list.size() > 3) throw new Exception();
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
    }
}
class Consumer extends Thread{
    ArrayList<Integer> list ;
    public Consumer (ArrayList<Integer> list){
        this.list = list;
    }
    @Override
    public void run() {
        while (true){
            synchronized (list){
                     list.remove(0);
                     System.out.println("消费了一个");
                     list.notifyAll();
            }
        }
    }
}

在多生产者的情况下运行上面的代码时,会发现有时候会抛出异常,而我们把 if改成了`while ,就不会报异常了

在单生产者的情况下,使用 if 和 while都不会抛出异常,但是我们不能保证这个类被使用时,是单生产者,还是多生产者

所以

  • 在多生产者的情况下,一定使用循环
  • 在单生产者的情况下,可以使用` if,但是因为不能保证使用该类时,一定是单生产者,所以还是建议使用循环

2.2 保证判断条件的线程安全

因为判断条件可能在多线程的情况下被修改,如多生产者或者多消费者的情况下,而在判断条件被修改的情况下,可能发生线程不安全的情况

一般来说,我们只要保证判断条件的修改是原子性的就行了

但是为了方便,一般我们就**把判断条件所在的循环放在临界区即可**

如果不保证线程安全,可能会出现IllegalMonitorStateException异常

通常常见的情况就是:没有对 res 进行加锁(如没有 synchonized(res) ),res 就是消费者和生产者共享的资源

下面是一个正常的消费者线程的代码

class Produce extends Thread{
    private Integer res ;
    private boolean condition = true;
    public Produce(Integer res){
        this.res = res;
    }
    @Override
    public void run() {
        super.run();
        synchronized (res){
            try{
                while (condition){
                    res.wait();
                }
                System.out.println("生产");
            }catch (Exception e){
                e.printStackTrace();
            }
        }

    }
}

但是如果我们删掉 synchronized (res) ,那么就会报错

image-20201116182327301

所以,如果出现了 IllegalMonitorStateException 异常,检查一下是否保证了线程安全

2.3 notify() 和 wait()在临界区的位置

  1. notify() 放在临界区代码的末尾

    因为 notify() 不会释放锁,如果放在前面,唤醒其他线程后,因为 notify() 所对应的锁没有释放,所以可能导致上下文切换

    所以为了其他线程尽快得到锁,不发送上下文切换,我们尽可能放在后面

  2. wait()所在的循环一般放在临界区的前面部分

    因为 wait() 的目的是控制生产者线程生产的速度

    如果 wait() 不放在生产代码的前面,那么就不能达到控制速度的目的,( wait() 不能影响生产代码,因为生产代码在调用 wait() 之前就执行了)

2.4 使用 notify 还是 notifyAll

我们要注意使用 notify 还是 notifyAll

  • notify 随机唤醒一个线程
  • notifyAll 唤醒所有的线程,但是 notifyAll 可能导致过早唤醒,上下文切换的问题

因为使用 notifyALl() 会出现一些问题,所以如果我们可以用 notify() 来完成,就不要使用notifyAll()

一般来说,如果满足下面的两个条件,那么我们可以使用 notify ,而不使用 notifyAll

  • 一次通知最多需要唤醒一个线程

  • 所有的等待线程都是相同的线程

    这个可以理解为:

    多生产者,如果都生产一个东西,那么不管唤醒哪个线程都可以满足消费者线程的需要

    多生产者,不同的生产者线程生产不同的东西,假设为a,b,c,如果消费者线程需要 a ,但是 notify 随机唤醒的可能是 b,c,所以这个时候我们需要使用 notifyAll()

2.5 考虑使用 Condition

Condition是Java中一个替代 wait notify的一个库,他可以**解决过早唤醒的问题,并且解决了 wait()不能区分其返回是否是因为超时的问题**

如果 wait notify 不能满足需要,可以考虑使用 Condition

3. 使用 notify 和 wait 遇到的一些问题

3.1 过早唤醒

这个问题常常出现在:有多个判断条件,但是都依赖于一个对象的 wait 和 notify

那么可以会出现一种情况:当消费线程使用 notifyAll() 来唤醒生产线程时,这个时候可以满足判断条件A,但是不满足判断条件B,所以判断条件B所在的线程就被提前唤醒了

我们可以使用 Condition 来解决这个问题

我们可以使用 Condition 来解决这个问题,使用 Condition 时,我们可以为每一个判断条件设置一个 Condition,调用 signal() 时,只会影响相应的线程,从而解决了过早唤醒的问题

但是注意:Condition只解决了过早唤醒的问题,没有解决信号丢失和上下文切换的问题

3.2 信号丢失

这种问题有两种情况

  1. 在不恰当的时候调用了 notify()

    如可能有这种情况:

    我们还没有调用 wait() ,但是已经调用了 notify() ,导致我们需要唤醒一个线程的时候,不能唤醒,因为我们已经调用过 notify() 了,这个时候因为等待线程没有收到唤醒线程的唤醒信号,所以这个信号就丢失了

    这个问题,我们在notify()外面嵌套一层循环即可

  2. 在应该使用 notify() 时,使用了 notifyAll()

    比如:我们有A,B两个线程,我们这个时候想要唤醒A,但是 notify() 随机唤醒一个线程,如果唤醒了B,那么对于A来说,这个信号就相当于丢失了

我们可以看到,信号丢失实际上是代码层面的问题,不是Java自带的问题,所以只要我们写代码时注意就可以了

3.3 欺骗性唤醒

顾名思义,欺骗性唤醒就是没有 notify() 或 notifyAll() 的情况下唤醒了线程

有两种常见的情景:

  1. 非 InterruptedException导致的问题

    这个是JVM中出现的问题,但是出现的频率很低

    针对这种情况,我们只要将 判断条件 和 wait() ,放在临界区的一个循环中就行了

    因为只有不满足判断条件,即使被欺骗唤醒,下一次循环还是会调用wait()

  2. InterruptedException导致的问题

    调用了 interrupt() 但是没有进行处理,因为当线程处于sleep() 或 wait()时,如果我们调用 Interrupt(),那么会抛出java.lang.InterruptedException异常,这个时候JVM会自动唤醒该线程

    针对这种情况,我们只要在catch中,对InterruptedException进行处理即可

InterruptedException 导致欺骗性唤醒,如下面的代码

public class Main{
    public static void main(String[] args) {
            MyThread myThread = new MyThread(1);
            myThread.start();

            while (true){
                try {
                    Thread.sleep(1000);
                }catch (Exception e){
                    e.printStackTrace();
                }
                System.out.println("main线程sleep完成");
                myThread.interrupt();
            }
    }
}
class MyThread extends Thread{
    Integer input;
    public MyThread(Integer input){
        this.input = input;
    }
    @Override
    public void run() {
        super.run();
        synchronized (input){
            while (true){
                try {
                    System.out.println("这里调用wait()");
                    input.wait();
                } catch (InterruptedException e) {
                  //这里没有对 InterruptException 进行处理,所以会导致问题
//                    e.printStackTrace();
                }
                System.out.println("这里调用了interrupt");
                System.out.println("这里调用了没有调用notify,但是线程被唤醒了");
                System.out.println("--------------------------------------");
            }
        }

    }
}

那么我们会发现:我们没有调用 notify ,但是会发现没有调用,但是线程被唤醒了,每一秒会出现执行一次

image-20201025195937182

3.4 上下文切换

上下文切换的问题就是因为多个线程不能及时的抢到锁导致的

上下文切换是一个JVM层面的问题,我们不能完全的解决,但是我们可以对他进行优化,有下面的两种方法

  1. notify()能完成时,就不要使用 notifyAll()

  2. notify() notifyAll() 放在临界区的末尾

    因为 notify() 不会释放锁,所以我们为了不发生上下文切换,得让等待线程在被唤醒时,尽快获得锁,所以我们要让 notify() 唤醒时,尽快释放锁

3.5 锁死

wait 和 notify 导致的锁死是嵌套监视器锁死的情况,即嵌套了多个锁,内部的锁一直不能被唤醒的

如以下的代码:

public class Main{
    public static void main(String[] args) {
        Integer outer = new Integer(1);
        Integer res = new Integer(0);

        Thread producer = new Producer(outer,res);
        producer.start();
        Thread consumer = new Consumer(outer,res);
        consumer.start();

        // 让主线程暂停三秒,然后查看消费者和生产者线程的状态
        try {
            Thread.sleep(3000);
        }catch (Exception e){
            e.printStackTrace();
        }
        System.out.println("consumer线程的状态: "+consumer.getState());
        System.out.println("producer线程的状态: "+producer.getState());
    }
}
class Producer extends Thread{
    private Integer outer ;
    private Integer res;

    public Producer (Integer outer,Integer res){
        this.outer = outer;
        this.res = res;
    }
    @Override
    public void run (){
        super.run();
        synchronized (outer){
            synchronized (res){
                try {
                  //这里为了方便,直接设置为true
                    while (true){
                        System.out.println("producer线程调用wait()");
                        res.wait();
                    }
                    // 因为 condition 一直为true,所以不会运行到这里
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
    }
}

class Consumer extends Thread{
    private Integer outer ;
    private Integer res;
    public Consumer (Integer outer,Integer res){
        this.outer = outer;
        this.res = res;
    }
    @Override
    public void run (){
        System.out.println("消费者线程开始运行");
        synchronized (outer) {
            synchronized (res){
                //在这个例子中,消费者线程是不会得到 outer 锁的
                //所以 synchronized (outer) 里面的代码不会运行
                System.out.println("消费者线程得到了 outer 锁");
                System.out.println("消费");
                res.notify();
            }
        }
    }
}

上面代码的主要内容如下:

  • 消费者和生产者内部都有两个变量 outer和res,并且在 run() 中都对这两个加锁,并且要得到 outer 锁后才可以得到 res 锁
  • 生产线程的判断条件设置为 true,所以生产线程第一时间调用 wait(),释放了 res 锁
  • 主线程启动消费者和生产者线程后,沉睡3秒,然后查看两个线程的状态

运行后,发现结果如下

image-20201116183720412

我们发现:

  • 生产者线程调用了 wait() 后,消费者线程没有执行生产操作
  • 两个线程最终都处于阻塞状态

经过分析,原因如下:

当前结果的原因是锁死,更确切地说是嵌套监视器锁死

造成的原因是因为 wait() 只能影响调用 wait 的对象锁,而在这里我们必须先得到外部锁,才可以继续申请 wait 的对象锁

生产者线程一直没有释放外部锁,导致消费者线程不能申请 wait 的对象锁,从而导致锁死