14利用线程间协作机制(通知/等待)模拟羽毛球运动
Diego38 111 1

1. 前言

通过上节的学习,线程中断是一种线程协作机制,一个线程可以通过线程中断传递信号给另外的线程,另外的线程可以通过自检测中断位或捕获异常进行响应。

但这种方式只适用于线程中断,当多个线程合作去完成一件事时并不适用。

我们如何能让多个线程有效协作呢?今天我们就学习线程协作 (通知 / 等待) 机制。

2. 线程协作 (通知 / 等待) 机制的由来

生活中有很多协同工作的场景,以两个人打羽毛球为例,小明发球给小红,小红接到球返还给小明,如此来回往复,如果用代码来实现的话,如下所示:

public class CheckAndDoTest {

    static volatile  Integer flag = 1; //当flag=1说明球到了小明这边,当flag=2说明球到了小红这边
    static Thread xiaoming = new Thread(() ->{
        while (true) {
            if (Objects.equals(flag, 1)) {
                System.out.println("小明击球");
                flag = 2;
            } else {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    } ,"xiaoming");

    static Thread xiaohong = new Thread(() ->{
        while (true) {
            if (Objects.equals(flag, 2)) {
                System.out.println("小红击球");
                flag = 1;
            } else {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    } ,"xiaohong");

    public static void main(String[] args) {
        xiaoming.start();
        xiaohong.start();
    }
}

输出如下:

小明击球
小红击球
小明击球
小红击球
小明击球
...

上述代码中,线程 xiaoming 和线程 xiaohong 分别一直判断 flag 状态值,当球在小明这边线程 xiaoming 即作出小明击球操作,当球在小红这边,线程 xiaoming 即作出小红击球操作。

由于两个线程循环检测 flag 是否符合条件,会消耗很多 CPU 资源,所以在不符合条件时,强制进入一段 10 毫秒的休眠,但这样又带来新的问题,难以及时感知 flag 状态值的变化,实时性难以保证,多线程性能优势无法发挥。

于是有了等待 / 通知机制,当线程 xiaoming 发现条件 flag 状态为 2 时,可以调用 wait 操作直接进入等待状态而不是循环做条件判断,线程 xiaohong 执行完击球操作可以通过 notify () 唤醒 xiaoming,线程 xiaoming 被唤醒后立即执行后续操作。

接下来我们去看下等待通知机制的 API 说明,我们发现每个 Object 类有 wait (),notify (),notifyAll () 方法,当线程执行 Object.wait () 时,意味着该线程进入 Object 对象的等待队列,当执行 Object.notify () 时会唤醒处在 Object 等待队列的第一个线程,接下来我们就分别来看一下这几个方法:

  • Object.wait (): 调用该方法的线程在 Object 对象上进入 WAITING 状态并释放 Object 对象的锁,当收到其他线程的通知之后才会停止 WAITING, 进入 RUNNALBE 状态。
  • Object.wait (long timeout): 与 Object.wait () 不同的是,如果超过 timeout 时间仍然未收到通知,会自行苏醒进入 RUNNALBE 状态。
  • Object.notify ():通过调用 Object.notify () 通知操作,可以唤醒在 Object 对象上进入 WAITING 状态的线程。
  • Object.notifyAll ():与 notify () 不同的是,notifyAll () 会唤醒处在 Object 等待队列的所有线程。

熟悉了等待 / 通知的 API,我们将小明和小红的羽毛球运行代码加以改造下。

3. 利用线程协作 (通知 / 等待) 模拟羽毛球运动

public class WaitAndNotifyTest {
    static Object badminton = new Object();// 羽毛球
    static Thread xiaoming = new Thread(() ->{
        while (true) {
            synchronized (badminton) {
                    System.out.println("【小明】击球");
                    badminton.notify(); //去通知处有可能处在badminton等待队列的小红线程
                    System.out.println("【小明】释放了羽毛球并等待小红回球");
                    try {
                        badminton.wait();  // 进入badminton等待队列,并释放badminton的锁
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
        }
    } ,"xiaoming");

    static Thread xiaohong = new Thread(() ->{
        while (true) {
            synchronized (badminton) {
                    System.out.println("【小红】击球");
                    badminton.notify();//去通知处有可能处在badminton等待队列的小明线程
                    System.out.println("【小红】释放了羽毛球并等待小明回球");
                    try {
                        badminton.wait(); // 进入badminton等待队列,并释放badminton的锁
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
            }
        }
    } ,"xiaohong");

    public static void main(String[] args) {
        xiaoming.start();
        xiaohong.start();
    }
}

输出结果如下:

【小明】击球
【小明】释放了羽毛球并等待小红发球
【小红】击球
【小红】释放了羽毛球并等待小明发球
【小明】击球
【小明】释放了羽毛球并等待小红发球
【小红】击球
【小红】释放了羽毛球并等待小明发球

等待通知机制是需要对象加锁场景下完成的,上述代码中,线程 xiaoming 击完球会通知线程 xiaohong,并且释放锁进入等待状态;线程 xiaohong 收到通知并再次尝试获取锁,进行击球操作,并通知线程 xiaoming,释放锁进入等待,循环往复。

我们发现上述代码相比初始的代码,有两点不同

  1. 引入等待通知机制,对羽毛球进行加锁
  2. 由于只有两个线程,两个线程是精准的唤醒对方线程,所以去除了条件 flag 状态的判断。 我们用图示来表示: image

由线程 xiaoming 和线程 xiaohong 交叉进行通知 -> 等待 -> 通知,模拟了羽毛球运动场景。

其内部的工作原理也相对简单,接下来我们分析一下。

4. 线程协作 (通知等待) 工作原理

前面提到等待 / 通知机制前提是锁同步场景下完成的,等待锁的线程有一个锁同步等待队列,执行 Object.wait 操作的线程会进入一个条件对象等待队列。

整个流程是这样的: image

对于几个核心流程解释如下:

  • 等待通知机制内部会有两个队列,一个是锁的同步队列,一个是等待队列
  • object.wait 操作会执行两步操作,首先将执行当前线程的结点入队等待队列,然后释放锁,唤醒同步队列的首结点
  • object.notify 操作会将等待队列中的头部结点进行唤醒 (notifyAll 会唤醒全部),等待队列的结点被唤醒后直接进入同步队列
  • Synchronized 同步锁是非公平锁,即当多个线程抢占同一个 Synchronized 锁时,可以不进入等待队列直接进行抢占锁,也就是说即使在等待队列头部结点被唤醒后,也需要再次参与锁抢占,所以看到图示中,同步队列中头部结点被唤醒后还需要重新获取锁。

    5. 利用线程协作 (通知 / 等待) 实现经典面试题–多线程交替打印 ABC

    前面我们学到两个两个线程协作进行通知等待,我们看一个多个线程进行通知等待机制的面试题例子。

题目如下:启动三个线程,每个线程打印一个字母,要求顺序打印 ABCABCABC…

分析:假设线程为 A,线程 B,线程 C,每个线程打印自己的线程名字,线程 A 输出之后通知线程 B,线程 B 输出之后通知 C,线程 C 输出之后通过 A,依次循环。

public class ABCTest {

   static Object lock = new Object();

   static Integer index = 0;

   static Map<String, Integer> map = new HashMap<String, Integer>() {
       {
           put("A", 0);
           put("B", 1);
           put("C", 2);
       }
   };

   static Runnable runnable =  () ->{
       while (true){
           synchronized (lock) {
               String name = Thread.currentThread().getName();
               if (Objects.equals(index % 3 , map.get(name))) { //命中条件才会打印
                   System.out.print(name);
                   index ++ ;
                   lock.notifyAll();
               } else {
                   try {
                       lock.wait();
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
               }
           }
       }
   };


    public static void main(String[] args) {
        new Thread(runnable, "A").start();
        new Thread(runnable, "B").start();
        new Thread(runnable, "C").start();
    }
}

输出结果如下:

ABCABCABCABCABCABCABCABCABCABCABCAB...

在上述代码中, 又重新引入了条件变量 index,我们使用 index 对 3 取余来表示当前线程的条件。这使得即使使用 notifyAll 唤醒所有线程时,不同的线程打印操作需要满足各自的条件,不满足将继续进入等待队列,index 顺序递增时 (index % 3) 会一直按照 0,1,2 的顺序持续循环,所以最终我们能得到 ABCABC… 的输出。

  1. 总结 本节我们学习了线程协作的通知等待机制,在后续学习高级锁 Lock,会遇到更高级的通知等待机制,实现原理也比较相似。 image
预览图
评论区

索引目录