## 08 集体协作,什么最重要?沟通!—线程的等待和通知
> 世界上最宽阔的是海洋,比海洋更宽阔的是天空,比天空更宽阔的是人的胸怀。
> ——雨果
通过前面几节的学习,我们了解了在 Java 中如何启动一个线程,并且学习了 Thread 类的 API 以及线程的状态。假如将多线程比作多个机器人一起工作,那么我们讲到现在,所生产的机器人确实能够担当干活的责任了。每个机器人各司其职,尽职尽责地去完成自己的工作。他们每个人不断去查看自己的任务列表,有了新的任务就去工作,没有的话会持续查看。OK,这样没有问题,机器人能够一起把工作干完。但是你不觉得缺了点什么吗?或者说你不觉得机器人少了什么器官?没错,嘴巴!这个过程太安静了,居然没有机器人说话!这在现实世界是不可想象的。在现实世界里,即便最简单的两人配合工作都需要沟通和交流。
![图片描述](https://img.mukewang.com/5d897ec500018ff908900571.jpg)
我们回忆一下之前学生抄写单词的例子。那个例子中抄写的次数提前预置,每个学生抄写前领取一次抄写的任务,然后更新剩余抄写次数,直到所有抄写次数全部完成。这个例子比较简单,但是假如今天老师发飙了,除了抄写 internationalization 这个单词,她还会一直给你新的单词抄写作业。此时,作为学生在抄写完 internationalization 后他有两个选择。一是不停的盯着老师,直到老师发给他新的作业。二是他先休息一会,老师准备好新的作业时再叫他。假如我是学生,我肯定选择第二种。因为第一种太累了,要一直盯着老师。第一种方式在程序中叫做轮询。而第二种方式就引出了我们本节要讲解的 wait/notify。
## 1\. wait/notify 概念
我们先从概念上初步了解 wait/notify。原本 RUNNING 的线程,可以通过调用 wait 方法,进入 BLOCKING 状态。此线程会放弃原来持有的锁。而调用 notify 方法则会唤醒 wait 的线程,让其继续往下执行。
你可能有疑问,既然这两个方法都和线程有关系,为什么没有放在上一节线程 API 中讲解呢?这是因为这两个方法并不在 Thread 对象中,而是在 Object 中。也就是说所有的 Java 类都继承了这两个方法。所有 Java 类都会继承这两个方法的原因是 Java 中同步操作的需要。
## 2、同步
讲到这里,我们必须要对线程同步有所了解。那么什么是线程同步呢?我们先看看什么是异步,异步其实就是指多个线程同时执行。但在多个线程同时执行的过程中,可能会访问共享资源,此时我们希望确保多个线程在同一时间只能有一个线程访问,此时就称之为线程同步。
在多线程开发中最基本的同步方式就是通过 synchronized 关键字来实现。第三节中我们单词抄写的程序并没有彻底解决线程安全问题,仍旧可能出现重复抄写。这是因为我们对抄写次数这个共享资源的访问没有做同步。现在我们使用 synchronized 关键字对抄写单词的核心逻辑进行改写,如下:
~~~java
while (true) {
int leftCopyCount = 0;
//在同步代码块中访问punishment,确保读取和更新数量时,只有一个线程访问到共享资源
synchronized (punishment){
if (punishment.getLeftCopyCount() > 0) {
leftCopyCount = punishment.getLeftCopyCount();
punishment.setLeftCopyCount( leftCopyCount - 1);
}
}
if(leftCopyCount>0){
System.out.println(threadName + "线程-" + name + "抄写" + leftCopyCount + "。还要抄写" + leftCopyCount - 1 + "次");
count++;
}else{
break;
}
}
~~~
原来的代码如下:
~~~java
while (true) {
if (punishment.getLeftCopyCount() > 0) {
int leftCopyCount = punishment.getLeftCopyCount();
if (leftCopyCount == punishment.getLeftCopyCount()) {
punishment.setLeftCopyCount(leftCopyCount - 1);
System.out.println(threadName + "线程-" + name + "抄写" + punishment.getWordToCopy() + "。还要抄写" + leftCopyCount-1 + "次");
count++;
}
} else {
break;
}
}
~~~
可以做一下比较。修改后的代码中,读取和更新 leftCopyCount 的两步操作放在了 synchronized 代码块中,在此代码块中的代码会确保同一时间只有一个线程能够执行。这也称之为加上了锁,只有获取锁的线程才能执行同步代码,执行完成后则会释放锁。因此在 synchronized 代码块中操作 punishment 是安全的。当前线程取出来的 leftCopyCount 值,在同步代码块结束前,也就是 set 回去前,并不会被其它线程所改变。所以它并不需要像原来代码那么啰嗦,取出来后更新前还要再比较一次。其实原来代码即使又做了比较,也无法 100% 确保更新操作前没有被别的线程修改。这在第三节的实验中已经得到证实。正确的编写方式应该把共享资源的操作放在 synchronized 代码块中,这样才能 100% 确保程序的正确性。
注意修改后的代码,并没有把输出抄写内容放到 synchronized 代码块中。因为这一步操作其实和共享资源已经无关,所以没必要再持有锁,这会延长其它线程等待锁的时间,降低了并行代码的效率。这在我们实际开发中要注意,尽量把不需要同步的代码移出 synchronized 代码块。
## 3、使用 wait/notify
了解完 synchronized,我们再回头看 wait 操作。synchronized 关键字需要配合一个对象使用,其实这个对象可以是任何对象,只不过为了代码好懂,这里使用了共享资源对象 punishment,语义上表示对该对象上锁,但你换成其它任何对象一样是可以的。
其实 synchronized 所使用的对象,只是用来记录等待同步操作的线程集合。他相当于一位排队管理员,所有线程都要在此排队,并接受他的管理,他说谁能进就可以进。另外他维护了一个 wait set,所有调用了 wait 方法的线程都保存于此。一旦有线程调用了同步对像的 notify 方法,那么 wait set 中的线程就会被 notify,继续执行自己的逻辑。
![图片描述](https://img.mukewang.com/5d897ee20001567708430622.jpg)这也解释了为什么 synchronized 的对象并不一定是共享资源对象。这个对象只是看门人,确保同步代码块中的代码只有一个线程能够进入执行,但这个看门的工作并不一定要共享资源对象来做。任何对象都可担当此工作。
需要注意的是,我们对哪个对象做了 synchronized 操作,那么就只能在同步代码块中使用此对象进行 wait 和 notify 的操作。这也很好理解,只有当看门人在听你讲话时,他才能按你的要求去做事情。我们只有获得了和同步对象的对话权,这个对象才能听此线程的命令。无论是请求加入 wait set 还是要通知 wait set 中的线程出来,均是如此。
wait 和 notify 示例代码如下:
~~~java
synchronized (punishment){
//do something
punishment.wait();
//continue to do something
}
~~~
假如此段代码在 A 线程中。这段代码会在执行一些逻辑后把 A 线程放入 punishment 对象的 wait set 中,并且 A 线程会释放持有的锁。
我们再看看另外一个 B 线程中的部分代码:
~~~java
synchronized (punishment){
//do something
punishment.nofity();
//continue to do something
}
~~~
这段代码会 notify 在 punishment 对象的 wait set 中的一个线程,将其弹出。比如此时 A 线程在 wait set 中,那么 A 线程将被弹出。被弹出的 A 线程会在获取 CPU 资源后继续执行 wait 方法后面的逻辑。
最后再说一下 notifyAll 方法。我们知道 notify 可以唤醒 wait set 中的一个线程,但是如果 wait set 中存在多于一个线程时,我们并无法控制哪个线程被唤醒。假如所有线程的执行逻辑都是一样的,那么无所谓谁被唤醒,因为都是干一样的工作。
但如果是本节前面提出的问题,一个老师线程负责留作业,一个学生线程负责写作业。假如此时开启了多个学生线程,当学生写完作业后本来需要通知老师留作业,但被 notify 的并不一定的是老师线程,也可能 notify 了其他学生线程。
为了解决这个问题,我们可以使用 notifyAll 来唤醒所有在此对象的 wait set 上的线程。而获得锁的线程是否真的需要做什么工作是由自己控制的。如果学生线程先抢到 CPU 资源,但是由于作业列表为空,他又会选择 wait 进入 wait set。此时他会释放锁。而老师线程此时会获得锁,在看到作业列表为空后,则会添加新的作业。通过 wait/notifyAll 让多个线程交互,同时通过共享资源的状态,各线程控制自己的逻辑。这样的程序称之为状态驱动程序。也就是说是否真的执行逻辑,是由状态值所决定的。如果状态不满足,即使被 notify 了,也会再次进入 wait set。
## 4、总结
本节首先简单介绍了同步的概念,然后讲解了如何通过 wait 和 notify 实现多线程间的沟通和协调。讲了这么多,其实不如写一写代码,更容易理解。在下节中我们将采用生产者 / 消费者模式,来开发一个多位老师留作业,多个学生一起完成的作业的程序。届时,我们本节学习的内容都会被使用上。
}
- 前言
- 第1章 Java并发简介
- 01 开篇词:多线程为什么是你必需要掌握的知识
- 02 绝对不仅仅是为了面试—我们为什么需要学习多线程
- 03 多线程开发如此简单—Java中如何编写多线程程序
- 04 人多力量未必大—并发可能会遇到的问题
- 第2章 Java中如何编写多线程
- 05 看若兄弟,实如父子—Thread和Runnable详解
- 06 线程什么时候开始真正执行?—线程的状态详解
- 07 深入Thread类—线程API精讲
- 08 集体协作,什么最重要?沟通!—线程的等待和通知
- 09 使用多线程实现分工、解耦、缓冲—生产者、消费者实战
- 第3章 并发的问题和原因详解
- 10 有福同享,有难同当—原子性
- 11 眼见不实—可见性
- 12 什么?还有这种操作!—有序性
- 13 问题的根源—Java内存模型简介
- 14 僵持不下—死锁详解
- 第4章 如何解决并发问题
- 15 原子性轻量级实现—深入理解Atomic与CAS
- 16 让你眼见为实—volatile详解
- 17 资源有限,请排队等候—Synchronized使用、原理及缺陷
- 18 线程作用域内共享变量—深入解析ThreadLocal
- 第5章 线程池
- 19 自己动手丰衣足食—简单线程池实现
- 20 其实不用造轮子—Executor框架详解
- 第6章 主要并发工具类
- 21 更高级的锁—深入解析Lock
- 22 到底哪把锁更适合你?—synchronized与ReentrantLock对比
- 23 按需上锁—ReadWriteLock详解
- 24 经典并发容器,多线程面试必备—深入解析ConcurrentHashMap上
- 25 经典并发容器,多线程面试必备—深入解析ConcurrentHashMap下
- 26不让我进门,我就在门口一直等!—BlockingQueue和ArrayBlockingQueue
- 27 倒数计时开始,三、二、一—CountDownLatch详解
- 28 人齐了,一起行动—CyclicBarrier详解
- 29 一手交钱,一手交货—Exchanger详解
- 30 限量供应,不好意思您来晚了—Semaphore详解
- 第7章 高级并发工具类及并发设计模式
- 31 凭票取餐—Future模式详解
- 32 请按到场顺序发言—Completion Service详解
- 33 分阶段执行你的任务-学习使用Phaser运行多阶段任务
- 34 谁都不能偷懒-通过 CompletableFuture 组装你的异步计算单元
- 35拆分你的任务—学习使用Fork/Join框架
- 36 为多线程们安排一位经理—Master/Slave模式详解
- 第8章 总结
- 37 结束语