线程的协作多种多样,这次主要说说wait和notify两种Object的方法。这也是java早期原生的重要的协作机制之一。
先说下这两个英文单词:
wait weɪt v.等待;等候;(尤指长期地)希望,盼望,期待
notify ˈnəʊtɪfaɪ vt.通知;(正式)通报;
也就是一个等待,一个通知。
一般的使用场景就是:
线程a,运行到某一刻发现条件不满足,执行等待方法,
线程b,将条件满足,通知等待的线程,
线程a,收到通知请求,判断等待条件是否满足,满足后继续执行,不满足继续等待。
核心思路就是下边这图这样

那不使用wait/notify等待通知的方式来协作,线程a就一直while循环,等待条件满足,再继续执行可以么?
当然可以,这种情况我们也称之为 忙等(busy-waiting / spin)。自旋锁采用的就是这个逻辑。
但是自旋往往比较占用性能,大部分场景没这个必要。
我们更多的时候还是希望线程主动让出占用的cpu资源,(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )等到条件合适再继续占用。
在确定完使用场景,我们还要注意到,wait 和notify 的使用,是放置在Object 方法上的,为了保证不被乱调,只有拿到了该Object的监视器锁的线程才有资格调用wait/notify方法的,否则就会报出IllegalMonitorStateException的运行时异常。注意看注释的解释:当前线程如果不是object 的显示器锁的持有者,就抛出该异常。
IllegalMonitorStateException if the current thread is not the owner of the object's monitor。
这里所谓的监视器锁,就是我们平常说的同步锁,synchronized锁。
同理由于同步块的存在,被通知唤醒的线程,只是进入就绪状态,还是需要再次拿到锁才可以继续进行。
具体的几个核心方法源码如下:
1 public final native void notify();
2
3 public final native void notifyAll();
4
5 public final void wait() throws InterruptedException {
6 wait(0L);
7 }
8
9 public final native void wait(long timeoutMillis) throws InterruptedException;
10
11 public final void wait(long timeoutMillis, int nanos) throws InterruptedException {
12 if (timeoutMillis < 0) {
13 throw new IllegalArgumentException("timeoutMillis value is negative");
14 }
15
16 if (nanos < 0 || nanos > 999999) {
17 throw new IllegalArgumentException(
18 "nanosecond timeout value out of range");
19 }
20
21 if (nanos > 0 && timeoutMillis < Long.MAX_VALUE) {
22 timeoutMillis++;
23 }
24
25 wait(timeoutMillis);
26 }
都是包装了一下,然后最终调用本地方法,其中
wait() 线程进入等待(更准确的说法是进入了lock对象的等待集),(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )并释放锁
wait(long t) 线程进入等待,等待t的时间,同时释放锁,如果等待时间超过t,则结束等待,进入就绪状态,重新开始争夺监视器锁
notify() 随机通知(唤醒)一个等待中的线程结束等待
notifyAll() 通知(唤醒)所有等待中的线程结束等待
了解了这些必要条件,我们来看一个经典的生产消费者通知的问题:
1 /**
2 * @discription
3 */
4 public class WNStudy {
5
6 public static void main(String[] args) {
7 Cache cache = new Cache();
8 Runnable producer = () -> {
9 try {
10 for (int i = 0; i < 5; i++) {
11 String id = "id" + i;
12 cache.put(id);
13 }
14 } catch (InterruptedException e) {
15 //dosth
16 }
17 };
18
19
20 Runnable consumer = () -> {
21 try {
22 for (int i = 0; i < 5; i++) {
23 cache.take();
24 }
25 } catch (InterruptedException e) {
26 //dosth
27 }
28 };
29 Thread tp1 = new Thread(producer);
30 Thread tp2 = new Thread(producer);
31 Thread tc1 = new Thread(consumer);
32 Thread tc2 = new Thread(consumer);
33
34 tp1.start();
35 tp2.start();
36 tc1.start();
37 tc2.start();
38 }
39
40 static class Cache {
41 private final Object lock = new Object();
42 private static final int MAX_LEN = 3;
43 private String[] idCache = new String[MAX_LEN];
44
45 private int size = 0;
46
47
48 private void put(String id) throws InterruptedException {
49 synchronized (lock) {
50 while (size == (MAX_LEN)) {
51 lock.wait();
52 }
53 idCache[size] = id;
54 size++;
55 System.out.println("producer ok +" + id);
56 lock.notifyAll();
57 }
58 }
59
60 private String take() throws InterruptedException {
61 synchronized (lock) {
62 while (size == 0) {
63 lock.wait();
64 }
65 String id = idCache[size - 1];
66 idCache[size - 1] = null;
67 size--;
68 lock.notifyAll();
69 System.out.println("consumer ok -" + id);
70 return id;
71 }
72 }
73 }
74 }
核心逻辑是 tp1/tp2 两个生产者不断的向栈中推数据
tc1/tc2 两个消费者不断的从栈中取数据
执行后结果如下:
1 producer ok +id0
2 consumer ok -id0
3 producer ok +id1
4 consumer ok -id1
5 producer ok +id0
6 consumer ok -id0
7 producer ok +id1
8 producer ok +id2
9 consumer ok -id2
10 consumer ok -id1
11 producer ok +id3
12 producer ok +id2
13 consumer ok -id2
14 consumer ok -id3
15 producer ok +id3
16 producer ok +id4
17 consumer ok -id4
18 consumer ok -id3
19 producer ok +id4
20 consumer ok -id4
我们可以看到栈的数据是符合入栈出栈的逻辑的。
回到api本身,wait(long t),表示时间到了,线程会自动被唤醒进入就绪状态,无需其它线程唤醒。
同时需要注意的是,唤醒通知非累加动作,notify只影响到等待集中的线程,未进入等待集的线程,不受唤醒的影响,也不会累加这个状态,所收到的唤醒通知也会被丢弃,需要进入wait时重新被唤醒。
不知道大家发现没有,如果由于notify 是随机的,而notifyAll 唤醒所有线程后,究竟是哪个线程抢到锁继续执行,也是未知的。(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )因此可能不是期待中的线程唤醒。比如例子中,可能队列满了,唤醒抢到锁的线程仍然是消费者线程。从而导致无意义的唤醒和重新等待,从而浪费了性能。这种现象我们称之为惊群效应。传统的wait/notify 方式是无法避免这种情况的,需要由更高级的ReentrantLock 配合condition才可以。这个放在后边的文章来说吧。
由于随机性,和不确定性,因此当线程被唤醒后,往往需要重新检查就绪条件,如果不满足还需要重新等待,从而避免被其它线程虚假唤醒,
反思
今天看了下,这个主题中,距离上一篇相关文章已经有十年了,想想很惭愧,但是再一细想,自己也一直没有松懈。
反思了一下为什么拖了这么久,
一方面是使用的技术不断的在演进,刚开始的项目中,接触到的业务中多线程还需要使用wait notify来协作,后边主要在做服务端开发,服务端又慢慢的进入到分布式场景中,这种进程内的锁的使用就更少了。即使有用到,完整的并发工具包也早已满足大部分的业务需要。
一方面是操作的业务不断的在变化,需要学习的东西也不断的在变,最早是原生自研的框架,后边是spring,springboot ,再然后是容器化技术,消息队列,读写分离,db/缓存技术,ai技术等等。基础的东西是很重要,但是不能全身心的死磕基础技术,而放弃了更高层的应用。学习的优先级必然应该向使用方向来倾斜,而不应该将大部分精力花费到太多的用不到,甚至不需要理解的技术层面上。但这样又必然会造成知其然而不知其所以然的窘境。学习,是挺难的。