并发编程之 wait notify 方法剖析

时间:2022-11-03 18:20:47

并发编程之 wait notify 方法剖析

前言

2018 元旦快乐。

摘要:

  1. notify wait 如何使用?
  2. 为什么必须在同步块中?
  3. 使用 notify wait 实现一个简单的生产者消费者模型
  4. 底层实现原理

1. notify wait 如何使用?

今天我们要学习或者说分析的是 Object 类中的 wait notify 这两个方法,其实说是两个方法,这两个方法包括他们的重载方法一共有5个,而Object 类中一共才 12 个方法,可见这2个方法的重要性。我们先看看 JDK 中的代码:

public final native void notify();

public final native void notifyAll();

public final void wait() throws InterruptedException {
wait(0);
} public final native void wait(long timeout) throws InterruptedException; public final void wait(long timeout, int nanos) throws InterruptedException {
if (timeout < 0) {
throw new IllegalArgumentException("timeout value is negative");
} if (nanos < 0 || nanos > 999999) {
throw new IllegalArgumentException(
"nanosecond timeout value out of range");
} if (nanos > 0) {
timeout++;
} wait(timeout);
}

就是这五个方法。其中有3个方法是 native 的,也就是由虚拟机本地的c代码执行的。有2个 wait 重载方法最终还是调用了 wait(long) 方法。

首先还是 know how。来一个最简单的例子,看看如何使用这两个方法。

package cn.think.in.java.two;

import java.util.concurrent.TimeUnit;

public class WaitNotify {

  final static Object lock = new Object();

  public static void main(String[] args) {

    new Thread(new Runnable() {
@Override
public void run() {
System.out.println("线程 A 等待拿锁");
synchronized (lock) {
try {
System.out.println("线程 A 拿到锁了");
TimeUnit.SECONDS.sleep(1);
System.out.println("线程 A 开始等待并放弃锁");
lock.wait();
System.out.println("被通知可以继续执行 则 继续运行至结束");
} catch (InterruptedException e) {
}
}
}
}, "线程 A").start(); new Thread(new Runnable() {
@Override
public void run() {
System.out.println("线程 B 等待锁");
synchronized (lock) {
System.out.println("线程 B 拿到锁了");
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
}
lock.notify();
System.out.println("线程 B 随机通知 Lock 对象的某个线程");
}
}
}, "线程 B").start();
} }

运行结果:

线程 A 等待拿锁

线程 B 等待锁

线程 A 拿到锁了

线程 A 开始等待并放弃锁

线程 B 拿到锁了

线程 B 随机通知 Lock 对象的某个线程

被通知可以继续执行 则 继续运行至结束

在上面的代码中,线程 A 和 B 都会抢这个 lock 对象的锁,A 的运气比较好(也可能使 B 拿到锁),他先拿到了锁,然后调用了 wait 方法,放弃了锁,并挂起了自己,这个时候等待锁的 B 就拿到了锁,然后通知了A,但是请注意,通知完毕之后,B 线程并没有执行完同步代码块中的代码,因此,A 还是拿不到锁的,因此无法运行,等到B线程执行完毕,出了同步块,这个时候 A 线程才被激活得以继续执行。

使用 wait 方法和 notify 方法可以使 2 个无关的线程进行通信。也就是面试题中常提到的线程之间如何通信。

如果没有 wait 方法和 noitfy 方法,我们如何让两个线程通信呢?简单的办法就是让某个线程循环去检查某个标记变量,比如:

while (value != flag) {
Thread.sleep(1000);
}
doSomeing();

上面的这段代码在条件不满足使就睡眠一段时间,这样做到目的是防止过快的”无效尝试“,这种方式看似能够实现所需的功能,但是却存在如下问题:

  1. 难以确保及时性。因为等待的1000时间会导致时间差。
  2. 难以降低开销,如果确保了及时性,休眠时间缩短,将大大消耗CPU。

但是有了Java 自带的 wait 方法 和 notify 方法,一切迎刃而解。官方说法是等待/通知机制。一个线程在等待,另一个线程可以通知这个线程,实现了线程之间的通信。

2. 为什么必须在同步块中?

注意,这两个方法的使用必须是在 synchroized 同步块中,并且在当前对象的同步块中,如果在 A 对象的方法中调用 B 对象的 wait 或者 notify 方法,虚拟机会抛出 IllegalMonitorStateException,非法的监视器异常,因为你这个线程持有的监视器和你调用的监视器的不是一个对象。

那么为什么这两个方法一定要在同步块中呢?

这里要说一个专业名词:竞态条件。什么是竞太条件呢?

当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。

竞态条件会导致程序在并发情况下出现一些bugs。多线程对一些资源的竞争的时候就会产生竞态条件,如果首先要执行的程序竞争失败排到后面执行了,那么整个程序就会出现一些不确定的bugs。这种bugs很难发现而且会重复出现,这是因为线程间会随机竞争。

假设有2个线程,分别是生产者和消费者,他们有各自的任务。

1.1生产者检查条件(如缓存满了)-> 1.2生产者必须等待

2.1消费者消费了一个单位的缓存 -> 2.2重新设置了条件(如缓存没满) -> 2.3调用notifyAll()唤醒生产者

我们希望的顺序是: 1.1->1.2->2.1->2.2->2.3

但是由于CPU执行是随机的,可能会导致 2.3 先执行,1.2 后执行,这样就会导致生产者永远也醒不过来了!

所以我们必须对流程进行管理,也就是同步,通过在同步块中并结合 wait 和 notify 方法,我们可以手动对线程的执行顺序进行调整。

3. 使用 notify wait 实现一个简单的生产者消费者模型

虽然很多书中都不建议我们直接使用 notify 和 wait 方法进行并发编程,但仍然需要我们重点掌握。楼主写了一个简单的生产者消费者例子:

简单的缓存类:


public class Queue { final int num;
final List<String> list;
boolean isFull = false;
boolean isEmpty = true; public Queue(int num) {
this.num = num;
this.list = new ArrayList<>();
} public synchronized void put(String value) {
try {
if (isFull) {
System.out.println("putThread 暂停了,让出了锁");
this.wait();
System.out.println("putThread 被唤醒了,拿到了锁");
} list.add(value);
System.out.println("putThread 放入了" + value);
if (list.size() >= num) {
isFull = true;
}
if (isEmpty) {
isEmpty = false;
System.out.println("putThread 通知 getThread");
this.notify();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
} public synchronized String get(int index) {
try {
if (isEmpty) {
System.err.println("getThread 暂停了,并让出了锁");
this.wait();
System.err.println("getThread 被唤醒了,拿到了锁");
} String value = list.get(index);
System.err.println("getThread 获取到了" + value);
list.remove(index); Random random = new Random();
int randomInt = random.nextInt(5);
if (randomInt == 1) {
System.err.println("随机数等于1, 清空集合");
list.clear();
} if (getSize() < num) {
if (getSize() == 0) {
isEmpty = true;
}
if (isFull) {
isFull = false;
System.err.println("getThread 通知 putThread 可以添加了");
Thread.sleep(10);
this.notify();
}
}
return value; } catch (InterruptedException e) {
e.printStackTrace();
}
return null;
} public int getSize() {
return list.size();
}

生产者线程:

class PutThread implements Runnable {

  Queue queue;

  public PutThread(Queue queue) {
this.queue = queue;
} @Override
public void run() {
int i = 0;
for (; ; ) {
i++;
queue.put(i + "号"); }
}
}

消费者线程:

class GetThread implements Runnable {

  Queue queue;

  public GetThread(Queue queue) {
this.queue = queue;
} @Override
public void run() {
for (; ; ) {
for (int i = 0; i < queue.getSize(); i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
String value = queue.get(i); }
}
}
}

大家有兴趣可以跑跑看,能够加深这两个方法的理解,实际上,JDK 内部的阻塞队列也是类似这种实现,但是,不是用的 synchronized ,而是使用的重入锁。

基本上经典的生产者消费者模式的有着如下规则:

等待方遵循如下规则:

  1. 获取对象的锁。
  2. 如果条件不满足,那么调用对象的 wait 方法,被通知后仍要检查条件。
  3. 条件满足则执行相应的逻辑。

对应的伪代码入下:

synchroize( 对象 ){
while(条件不满足){
对象.wait();
}
对应的处理逻辑......
}

通知方遵循如下规则:

  1. 获得对象的锁。
  2. 改变条件。
  3. 通知所有等待在对象上的线程。

对应的伪代码如下:

synchronized(对象){
改变条件
对象.notifyAll();
}

4. 底层实现原理

知道了如何使用,就得知道他的原理到底是什么?

首先我们看,使用这两个方法的顺序一般是什么?

  1. 使用 wait ,notify 和 notifyAll 时需要先对调用对象加锁。
  2. 调用 wait 方法后,线程状态有 Running 变为 Waiting,并将当前线程放置到对象的 等待队列
  3. notify 或者 notifyAll 方法调用后, 等待线程依旧不会从 wait 返回,需要调用 noitfy 的线程释放锁之后,等待线程才有机会从 wait 返回。
  4. notify 方法将等待队列的一个等待线程从等待队列种移到同步队列中,而 notifyAll 方法则是将等待队列种所有的线程全部移到同步队列,被移动的线程状态由 Waiting 变为 Blocked。
  5. 从 wait 方法返回的前提是获得了调用对象的锁。

从上述细节可以看到,等待/通知机制依托于同步机制,其目的就是确保等待线程从 wait 方法返回后能够感知到通知线程对变量做出的修改。

该图描述了上面的步骤:

并发编程之 wait notify 方法剖析

WaitThread 获得了对象的锁,调用对象的 wait 方法,放弃了锁,进入的等待队列,然后 NotifyThread 拿到了对象的锁,然后调用对象的 notify 方法,将 WatiThread 移动到同步队列中,最后,NotifyThread 执行完毕,释放锁, WaitThread 再次获得锁并从 wait 方法返回继续执行。

到这里,关于应用层面的 wait 和 notify 基本就差不多了,后面的是关于虚拟机层面的抛砖引玉,涉及到 Java 的内置锁实现,synchronized 关键字底层实现,JVM 源码。算是本文的扩展吧。

注意:我们看到图中出现了 Monitor 这个词,也就是监视器,实际上,在 JDK 的注释中,也有 The current thread must own this object's monitor 这句话,当前线程必须拥有该对象的监视器。

如果我们编译这段含有 synchronized 关键字的代码,就会发现有一段代码被 monitorenter 指令和 monitorexit 指令括住了,这就是 synchronized 在编译期间做的事情,那么,在字节码被执行的时侯,该指令对应的 c 代码将会被执行。这里,我们必须打住,这里已经开始涉及到 synchronized 的相关原理了,本篇文章不会讨论这个。

wait noitfy 的答案都在 Java HotSpot 虚拟机的 C 代码中。但 R 大告诉我们不要轻易阅读虚拟机源码,众多细节可能会掩盖抽象,导致学习效率不高。如果同学们有兴趣,有大神写了3篇文章专门从 HotSpot 中解析源码,地址:

Java的wait()、notify()学习三部曲之一:JVM源码分析

Java的wait()、notify()学习三部曲之二:修改JVM源码看参数

Java的wait()、notify()学习三部曲之三:修改JVM源码控制抢锁顺序

还有狼哥的 JVM源码分析之Object.wait/notify实现.

上面四篇文章都从 JVM 的源码层面解析了 wait ,notify 的实现原理,非常清楚。

拾遗

  1. wait(long) 方法,该方法参数是毫秒,也就是说,如果线程等待了指定的毫秒数,就会自动返回该线程。
  2. wait(long, int)方法,该方法增加了纳秒级别的设置,算法是,前面的毫秒加上后面的纳秒,注意,是直接加一毫秒。
  3. notify 方法调用后,如果等待的线程很多,JDK 源码中说将会随机找一个,但是 JVM 的源码中实际上是找第一个。
  4. notifyAll 和 notify 不会立即生效,必须等到调用方执行完同步代码块,放弃锁之后才起作用。

总结

好了,关于 wait noitfy 的使用和基本原理就介绍到这里,不知道大家发现没有,并发和虚拟机高度相关。因此,可以说,学习并发的过程就是学习虚拟机的过程。而阅读虚拟机里的 openjdk 代码让人头大,但不管怎么样,丑媳妇迟早见公婆,openjdk 代码是一定要看的,加油!!!!

并发编程之 wait notify 方法剖析的更多相关文章

  1. 并发编程之 ConcurrentLinkedQueue 源码剖析

    前言 今天我们继续分析 java 并发包的源码,今天的主角是谁呢?ConcurrentLinkedQueue,上次我们分析了并发下 ArrayList 的替代 CopyOnWriteArrayList ...

  2. 并发编程之 LinkedBolckingQueue 源码剖析

    前言 JDK 1.5 之后,Doug Lea 大神为我们写了很多的工具,整个 concurrent 包基本都是他写的.也为我们程序员写好了很多工具,包括我们之前说的线程池,重入锁,线程协作工具,Con ...

  3. 并发编程之 CopyOnWriteArrayList 源码剖析

    前言 ArrayList 是一个不安全的容器,在多线程调用 add 方法的时候会出现 ArrayIndexOutOfBoundsException 异常,而 Vector 虽然安全,但由于其 add ...

  4. 并发编程之 ThreadLocal 源码剖析

    前言 首先看看 JDK 文档的描述: 该类提供了线程局部 (thread-local) 变量.这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局 ...

  5. 并发编程之 AQS 源码剖析

    前言 JDK 1.5 的 java.util.concurrent.locks 包中都是锁,其中有一个抽象类 AbstractQueuedSynchronizer (抽象队列同步器),也就是 AQS, ...

  6. 并发编程之wait&lpar;&rpar;、notify&lpar;&rpar;

    前面的并发编程之volatile中我们用程序模拟了一个场景:在main方法中开启两个线程,其中一个线程t1往list里循环添加元素,另一个线程t2监听list中的size,当size等于5时,t2线程 ...

  7. 并发编程之:CountDownLatch

    大家好,我是小黑,一个在互联网苟且偷生的农民工. 先问大家一个问题,在主线程中创建多个线程,在这多个线程被启动之后,主线程需要等子线程执行完之后才能接着执行自己的代码,应该怎么实现呢? Thread. ...

  8. &lbrack;转载&rsqb;并发编程之Operation Queue和GCD

    并发编程之Operation Queue http://www.cocoachina.com/applenews/devnews/2013/1210/7506.html 随着移动设备的更新换代,移动设 ...

  9. Java并发编程之CAS

    CAS(Compare and swap)比较和替换是设计并发算法时用到的一种技术.简单来说,比较和替换是使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替 ...

随机推荐

  1. Java豆瓣电影爬虫——小爬虫成长记(附源码)

    以前也用过爬虫,比如使用nutch爬取指定种子,基于爬到的数据做搜索,还大致看过一些源码.当然,nutch对于爬虫考虑的是十分全面和细致的.每当看到屏幕上唰唰过去的爬取到的网页信息以及处理信息的时候, ...

  2. oracle普通用户登录em

    刚新创建一个用户,登陆EM(Enterprise Manager) 如下提示: 应用程序要求的数据库权限超出了您当前具有的权限.有关特定版本的详细信息, 解决办法: 给登陆用户赋予 select_ca ...

  3. Unity3d 在不同设备中的文件读写 的路径

    Application.dataPath : 数据路径   Unity Editor: <path tp project folder>/Assets Unity 编辑器:<工程文件 ...

  4. attr&lpar;&rpar;和prop&lpar;&rpar;的区别

    引用以为一位大神的文章: http://www.365mini.com/page/jquery-prop.htm http://www.365mini.com/page/jquery-attr-vs- ...

  5. SpringBoot&plus;Dubbo&plus;Zookeeper整合搭建简单的分布式应用

    为什么要使用分布式系统? 容错 减少延迟/提高性能 可用性 负载均衡 总而言之,其实目的只有一个,”用户体验“. 什么是分布式系统? 分布式系统是由使用分发中间件连接的自治计算机组成的网络.它们有助于 ...

  6. python测试开发django-54&period;xadmin添加自定义页面

    前言 xadmin后台如何添加一个自己写的页面呢?如果仅仅是在GlobalSettings添加url地址的话,会丢失左侧的导航菜单和顶部的页面,和整体的样式不协调. 新增页面后希望能保留原来的样式,只 ...

  7. 【托业】【跨栏阅读】错题集-REVIEW1

    05 06 REVIEW 1

  8. 【iCore4 双核心板&lowbar;ARM】例程五:SYSTICK定时器 实验——定时点亮LED

    实验原理: 通过STM32的三个GPIO口驱动三色LED的三个通道,设定GPIO为推挽输出模式,采 用灌电流方式与LED连接,输出高电平LED灭,输出低电平LED亮,通过系统定时器实现 1s定时,每秒 ...

  9. &lt&semi;spark入门&gt&semi;&lt&semi;Intellj环境配置&gt&semi;&lt&semi;scala&gt&semi;rk入门&gt&semi;&lt&semi;Intellj环境配置&gt&semi;&lt&semi;scala&gt&semi;

    # 写在前面: 准备开始学spark,于是准备在IDE配一个spark的开发环境. 嫌这篇格式不好的看这里链接 用markdown写的,懒得调格式了,么么哒 # 相关配置: ## 关于系统 * mac ...

  10. position sticky的兼容

    position的sticky这个属性一般用于导航条,因为他在屏幕范围(viewport)时该元素的位置并不受到定位影响(设置是top.left等属性无效),当该元素的位置将要移出偏移范围时,定位又会 ...