【JAVA进化论】LV5-4:线程通信:wait、notify、notifyall

一、Object类里对线程实施控制的方法

前面说过,Object是所有类的父类,它自带equalshashCode等方法,我们这次来介绍几个新的成员方法:

图1

这节要讲的waitnotifynotifyall都是Object里自带的方法,通过这些方法可以控制当前正在运行这个对象的线程,如何控制?往下看~

二、利用线程控制方法完成线程通信

上述几个方法是属于每个实例对象的,所有实例都拥有一个“等待队列”(虚拟概念,实例里并不存在该字段),它是在实例的wait方法调用后存放停止操作线程的队列。执行wait方法后,线程进入当前实例的“等待队列”,以下几种情况可以让线程退出“等待队列”:

  1. 其他线程调用notifynotifyAll方法来将其唤醒
  2. 其他线程调用interrupt来将其唤醒
  3. wait方法本身超时

2.1:wait

当执行了下面的代码:

代码块1
1
obj.wait();

我们可以说当前线程在obj上发生了等待,当前线程进入了obj的“等待队列”,此时当前线程会让出锁,让其他线程继续竞争获得该实例的锁(因此这里有个规则,调用wait的线程必须持有当前实例对象的锁

⚠️ 普通不加synchronized的方法也可以使用wait,这在编译器是没问题的,但是运行期会报IllegalMonitorStateException异常。

还是以前面提过的Word对象为例,但是现在A这个方法里调用了wait方法,这时多线程访问时过程就变成了下图:

图2

2.2:notify

现在先来介绍下notify,该方法会将等待队列里的线程取出,让其退出等待并参与锁竞争然后继续执行上次wait后没有执行完的语句。整体过程如下图所示:

图3

可以看到,t1在被挂起后,会因为t2调用了同实例的notify方法,而让t1被从等待队列里释放,重新加入到所得竞争力,t2执行完毕后释放锁,锁又再次被t1竞争到,t1将继续执行上次被挂起时后面未执行完的语句。

需要指出的是,如果等待队列里的线程是多个,那么被唤醒的那一个,将会是等待队列里所有线程随机的一个,不会特定哪一个线程会被唤起。

2.3:notifyAll

接下来介绍notifyAll方法,顾名思义,就是将等待队列里的线程全部唤起,然后这些线程将全部加入到锁竞争,竞争到,继续完成上次被挂起时未执行完毕的操作,流程图如下:

图4

说明,当线程调用实例的waitnotifynotifyAll方法有个大前提,就是必须要求该线程拥有该实例的,否则会抛IllegalMonitorStateException异常。

在编写程序时,是该选择notify还是选择notifyAll?这个可以指出的是,notifyAll往往更加健壮,而notify由于唤起的线程少,因此效率会更高,但是存在程序停止的风险

三、实例

3.1:wait+notify的简单例子

图4里的Word对象为例,我们将这个Word类定义出来:

代码块2
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Word {

public synchronized void A() throws Exception {
System.out.println("A方法 wait触发前");
this.wait(); //将竞争到对象锁后运行到这里的线程,放入到自己的wait队列,让它处于"挂起"的状态
System.out.println("A方法 wait触发后");
}

public synchronized void B() {
System.out.println("B方法被触发~");
this.notifyAll();
}
}

测试类,我们开启两个线程来访问同一个word对象里的同步方法:

代码块3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(String[] args) throws Exception {
Word w1 = new Word();

Thread t1 = new Thread(()->{
try {
w1.A(); //第一个线程触发w1对象的A方法
} catch (Exception e) {
e.printStackTrace();
}
});

Thread t2 = new Thread(()->{
try {
w1.B(); //第二个线程触发w1对象的B方法
} catch (Exception e) {
e.printStackTrace();
}
});

t1.start();
Thread.sleep(1000L); //t1启动后main线程睡眠一秒,确保t1可以抢到w1的对象锁
t2.start();
}

这个运行结果为:

代码块4
1
2
3
A方法 wait触发前
B方法被触发~
A方法 wait触发后

看到了吗?明明t1抢到了对象锁,但是只运行了A方法wait前面的部分,这时因为运行到wait时被挂起,释放锁,然后1s后t2抢到锁,运行B方法,然后触发B方法里的notifyAll后,所有放到w1对象等待队列里的线程被唤起,重新加入到锁竞争里去,此时t1再次竞争到w1的对象锁,然后把自己没执行完的方法栈执行完。

3.2:利用线程通信实现阻塞队列

大体流程如下:

图5

简单来说,就是有一个公共缓冲带,我们管它叫做消息队列,可以由多个生产者往它里面生产数据,可以由多个消费者获取,当队列内无消息可读时,所有消费者陷入等待,等待新的消息到来:

图6

根据这个结构的特性,我们可以利用本节所学waitnotify来编写。

定义阻塞队列类,根据我们上面的描述,一个组的队列应该具备生产和获取的能力:

代码块5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//阻塞队列类
public class BlockQueue {

private List<String> msgs = new LinkedList<>(); //用于存放生产者生产的消息的结构

//因为支持多个生产者往里面生产数据,之前说过ArrayList线程不安全,所以这里需要给这个方法加锁
public synchronized void put(String msg) {
msgs.add(msg); //存放进
this.notifyAll(); //生产完就通知消费者们,让它们知道有消息可以消费了~
}

public synchronized String get() {
if (msgs.size() == 0) {
try {
this.wait(); //如果生产者生产的消息都被消费者拿完了,那么消费者想再拿的时候就只能陷入等待,等待生产者再次生产信息
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return msgs.remove(msgs.size() - 1);
}
}

我们来开启几个独立的线程来当成是生产者程序和消费者程序,分别对BlockQueue对象进行生产-消费的操作:

代码块6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public static void main(String[] args) throws Exception {

BlockQueue queue = new BlockQueue();

Thread p1 = new Thread(() -> {
queue.put("1号生产者生产的消息");
});

Thread p2 = new Thread(() -> {
queue.put("2号生产者生产的消息");
});

Thread p3 = new Thread(() -> {
queue.put("3号生产者生产的消息");
});

p1.start();
p2.start();
p3.start(); //此时这三个代表着生产者的线程被启动,意味着队列里的list已经有了三条数据

Thread c1 = new Thread(() -> {
System.out.println("消费者1消费到的消息:" + queue.get()); //消费者线程从阻塞队列里获取消息
});

Thread c2 = new Thread(() -> {
System.out.println("消费者2消费到的消息:" + queue.get()); //消费者线程从阻塞队列里获取消息
});

Thread c3 = new Thread(() -> {
System.out.println("消费者3消费到的消息:" + queue.get()); //消费者线程从阻塞队列里获取消息
});

c1.start();
c2.start();
c3.start(); //此时这三个代表着消费者的线程被启动,若执行完毕,则意味着队列里的list已变成空值



//此时队列里的消息已经被消费完,现在我们再开启一个消费者,让它继续获取消息(此时便会陷入等待)
Thread c4 = new Thread(() -> {
System.out.println("消费者4消费到的消息:" + queue.get()); //由于消息空了,因此该消费者线程会陷入queue对象的等待队列,挂起自己
});
c4.start();

Thread.sleep(5000L); //等待5s后,我们再开启一个生产者线程,让它产生一条消息

Thread p4 = new Thread(() -> {
queue.put("4号生产者生产的消息");
});
p4.start();
}

运行结果:

图7

这个流程,如果对线程通信还不是很熟悉的话就需要仔细分析一下😁