Java多线程之Wait、Notify、NotifyAll

Wait、Notify、NotifyAll都是属于java.lang.Object对象中的方法,也就是所有在Java代码中生成的类都会继承这几个方法

wait

wait方法用于让一个线程进入等待状态,它有几个重载方法

  • wait():无参的wait方法,内部调用了wait(0),即第二个方法,如果使用该方法让线程进入等待状态,则线程永远不会主动唤醒
  • wait(long timeout):增加了超时时间的wait()方法,超时时间单位为毫秒
  • wait(long timeout, int nanoseconds):增加nanoSeconds,更加精确了超时时间的控制

在调用对象的wait()方法时,当前线程必须要持有该对象的monitor(以下统称为锁),这个monitor存放于对象的对象头(资料:JVM中对象的内存布局)中

每个对象都会有一个唯一的锁,如果某个线程获取到对象的锁,那么它就可以调用对象的wait()方法使自己进入等待状态

每个对象都有一个自己的等待集合,集合里存放的就是想要获取当前对象的锁的线程的ID,线程进入等待状态意味着该线程的线程ID被添加到等待集合中

那么一个线程如何获取到对象的锁?通常有3种方式

  • 将对象包裹在synchronized语句块中
public void test() {
    synchronized (object) {
        // 如果方法执行到这里,表示该线程已经获取到对象object的锁了
        // do something here
    }
}
  • 调用对象的被synchronized关键字修饰的方法
public synchronized void test () {
    // 如果线程执行到这里,表示该线程已经获取到对象object的锁了
    // do something here
}
  • 调用对象所属类的被synchronized关键字修饰的类方法
public static synchronized void test () {
    // 如果线程执行到这里,表示线程已经获取到了对象所属类的锁了
    // do something here
}

线程通过调用对象的wait()方法来使自己进入等待状态,会立即将当前对象的锁释放,其他正在等待该对象锁的线程就可以争抢这把锁了

那么等待状态的线程如何退出等待状态?通常有几种方式

  • 其他线程调用相同对象的notify方法
  • 其他线程调用相同对象的notifyAll方法
  • 其他线程对当前线程执行中断操作
  • 当前线程使用的是带超时时间参数的wait方法,超时时间已经结束

综上述,调用对象的wait方法时,必须要持有对象锁,就必须要通过synchronized关键字来获取对象锁

public void test () {
    synchronized (object) {
        try {
            object.wait()
        } catch (InterruptedException e) {
            e.printStackTrace()
        }
    }
}

接着线程等待其他线程调用notify或者notifyAll方法,但是有一种情况,其他线程没有调用唤醒方法,该线程也被唤醒了,这在JVM中被叫做虚假唤醒,但实际上其他线程还持有该对象锁,这样程序一定会发生异常,为了防止这种情况的发生,在调用对象的wait方法时,应该将其放置在while循环中,并在循环条件中添加一个标志位,如果对象被虚假唤醒,线程还会执行while循环来判断标志位是否真的被置为了可唤醒状态,如果不是,则当前线程继续陷入等待

notify、notifyAll

调用对象的notify方法,会随机唤醒一个该对象等待集合中的线程,让其加入对当前对象锁的争抢中

要知道的是,被唤醒的线程不是一定会获得对象锁的,而是会和其他线程一样,公平竞争该对象锁,如果争抢到锁后,则线程会跳转到之前调用wait方法的代码的下一行,继续执行下面的逻辑,如果没有争抢到锁,那么线程继续进入等待状态,等待下一次唤醒

notifyAll方法则会唤醒所有在该对象等待集合中的线程,让它们加入到对象锁的争抢中,获得执行权

与wait方法的执行要求一样,notify方法执行时也需要获取到对象锁,否则会抛出IllegalMonitorStateException

在同一时刻,只有一个线程能够持有对象锁

一个例子

大概需求如下:

  • 有一个对象,对象内有成员变量count,有两个成员方法,一个是add(),用于将该count变量的值+1,一个是sub(),用于将该count变量的值-1
  • 启动两个线程分别调用add和sub方法
  • 保证每次调用的输出为1 0 1 0 1 0 1 0 1 0 1 0...

示例代码如下

public class Counter {

    public int count = 0;

    public static void main(String[] args) {
        Counter counter = new Counter();

        // 开启两个线程
        AddThread addThread = new AddThread(counter);
        SubThread subThread = new SubThread(counter);
    
        addThread.start();
        subThread.start();
    }

    public synchronized void add() {
        // 进入synchronized修饰的方法,表示当前线程已经获取到当前对象的锁

        // 判断count 是否等于0,如果不等于0,则当前线程进入wait状态
        if (count != 0) {
            try {
                // 等待减线程唤醒
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 如果当前count == 0,则对count进行++
        this.count++;
        System.out.print(count + " ");

        // 自增完成后,唤醒减线程
        this.notify();
    }

    public synchronized void sub() {
        if (count == 0) {
            try {
                // 等待减线程唤醒
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        this.count--;
        System.out.print(count + " ");
        this.notify();
    }

    static class AddThread extends Thread {

        private final Counter counter;

        public AddThread(Counter counter) {
            this.counter = counter;
        }

        @Override
        public void run() {
            for (int i = 0; i < 10; i++) {
                try {
                    Thread.sleep((long) (Math.random() * 1000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                this.counter.add();
            }
        }
    }

    static class SubThread extends Thread {

        private final Counter counter;

        public SubThread(Counter counter) {
            this.counter = counter;
        }

        @Override
        public void run() {
            for (int i = 0; i < 10; i++) {
                try {
                    Thread.sleep((long) (Math.random() * 1000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                this.counter.sub();
            }
        }
    }
}

// 输出结果如下
> 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 

更改一下要求,如果线程不止两个呢?我们将线程增加到4个,6个,8个,再执行,结果如何?这里以4个线程为例

public static void main(String[] args) {
    Counter counter = new Counter();

    AddThread addThread = new AddThread(counter);
    SubThread subThread = new SubThread(counter);
    AddThread addThread2 = new AddThread(counter);
    SubThread subThread2 = new SubThread(counter);

    addThread.start();
    subThread.start();
    addThread2.start();
    subThread2.start();
}

// 输出结果如下
1 0 1 0 1 0 -1 -2 -3 -2 -1 -2 -3 -4 -5 -4 -3 -4 -3 -4 -3 -4 -5 -6 -5 -4 -5 -6 -7 -8 -7 -6 -7

// 并且程序一直未停止

首先来看第一个问题:程序为什么一直不停止?

程序未停止,就表示JVM没有退出,那么JVM退出的条件是什么?

  • 所有的非daemon线程都终止了
  • 代码中显示调用System.exit()方法
  • 程序被执行kill -9 等命令导致JVM进程被杀死

那么在这里,很明显是第一种情况。为什么?

这里使用到JDK自带的工具jstack,该工具用于堆栈跟踪,它会生成当前时刻某个正在运行的虚拟机的线程快照。

线程快照:当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。

将程序运行起来,使用命令jstack -l [pid]来查看,工具打印信息如下

2020-07-05 23:00:55
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.172-b11 mixed mode):

# ...

# 重点看如下几个节点

"Thread-2" #12 prio=5 os_prio=31 tid=0x00007fbcce0a6000 nid=0xa603 in Object.wait() [0x0000700007c40000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x000000076ab75e90> (a com.daniel.concurrency.wait_notify.Counter)
	at java.lang.Object.wait(Object.java:502)
	at com.daniel.concurrency.wait_notify.Counter.add(Counter.java:32)
	- locked <0x000000076ab75e90> (a com.daniel.concurrency.wait_notify.Counter)
	at com.daniel.concurrency.wait_notify.Counter$AddThread.run(Counter.java:77)

   Locked ownable synchronizers:
	- None

"Thread-0" #10 prio=5 os_prio=31 tid=0x00007fbcce0a4800 nid=0xa803 in Object.wait() [0x0000700007a3a000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x000000076ab75e90> (a com.daniel.concurrency.wait_notify.Counter)
	at java.lang.Object.wait(Object.java:502)
	at com.daniel.concurrency.wait_notify.Counter.add(Counter.java:32)
	- locked <0x000000076ab75e90> (a com.daniel.concurrency.wait_notify.Counter)
	at com.daniel.concurrency.wait_notify.Counter$AddThread.run(Counter.java:77)

   Locked ownable synchronizers:
	- None

# ...

通过堆栈信息,我们可以看到,这几个线程就是在执行add()和sub()方法的线程,他们的状态都是WAITING,即当前线程正处于等待状态

并且可以发现,我们原本起了4个线程,但是现在只有2个线程的堆栈信息,那么另一个线程去哪里了?它执行完毕后退出了

因为持有对象counter的线程的状态要么是等待,要么已经执行完毕退出了,剩下的这2个线程还处于WAITING状态,但是没有任何其他线程能对它们执行唤醒操作了,所以它们一直无法退出,并且它们不是daemon线程,所以JVM进程一直未退出

接着来看第二个问题:为什么输出的结果不是预期的 1 0 1 0 1 0

执行结果

1 0 1 0 1 0 -1 -2 -3 -2 -1 -2 -3 -4 -5 -4 -3 -4 -3 -4 -3 -4 -5 -6 -5 -4 -5 -6 -7 -8 -7 -6 -7

从上面的堆栈追踪可以看到,还剩下两个加线程在WAITING

我们来分析一下当前程序的执行过程

  • 首先,我们启动了4个线程,分别为加线程Thread-0Thread-2,减线程Thread-1Thread-3

  • 第一次执行,假设加线程抢到了对象锁,执行了add()方法,此时输出count = 1,然后执行notify方法,当前该方法不能唤醒任何线程,因为此时对象counter锁的等待集合中没有任何线程

  • 第二次执行,假设减线程抢到了对象锁,执行了sub()方法,此时输出count = 0,继续执行notify方法

  • 第三次又是一个减线程抢到了对象锁,此时count == 0,它也进入等待状态

  • 然后,减线程又抢到了对象锁,它也进入等待状态

  • 接着加线程抢到对象锁,此时count == 0,执行add()方法,此时count == 1,调用notify

  • 假设唤醒了一个减线程,它继续执行调用wait方法的代码下面的逻辑,对count--,此时count == 0然后notify

  • 接下来假设又唤醒了一个减线程,它也执行了wait代码下面的逻辑,对count--,此时count == -1

那么导致这种情况的原因是什么?
当减线程被唤醒后,它直接执行了后面的逻辑,而此时count的数值可能不为1,所以为了解决这种问题,我们需要将wait方法的调用放在while循环中,如下

public synchronized void add() {
    // 这里通过while来判断,当线程被唤醒时,它会继续执行while,判断count是否等于0,再判断是否是真正需要往下执行接下来的逻辑
    while (count != 0) {
        try {
            this.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // ...
}

public synchronized void sub() {
    // 这里同上
    while (count == 0) {
        try {
            this.wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // ...
}

// 执行数次后,输出结果都为
> 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 

// 并且每次JVM都是正常退出的