Java线程间通信之wait/notify、join

1. wait / notify

1.1 已掌握的实现线程间通信的方法

  • 之前有学习Java的管道流:Java管道输入/输出流的简单学习

  • 通过管道流,可以实现线程间通信

  • 除此之外,还可以使用volatile共享变量实现线程间通信

    public class VolatileCommunicate {
          
          
        private volatile boolean ready;
    
        public VolatileCommunicate() {
          
          
            ready = false;
        }
    
        public static void sleep(TimeUnit timeUnit, long timeout) {
          
          
            try {
          
          
                timeUnit.sleep(timeout);
            } catch (InterruptedException e) {
          
          
                e.printStackTrace();
            }
        }
    
        public void eat() {
          
          
            while (!ready) {
          
            // 食物没有准备好,循环等待
                sleep(TimeUnit.SECONDS, 1);
            }
            System.out.println(Thread.currentThread().getName() + ": 我要开吃了");
        }
    
        public void cook() {
          
          
            // cook food ...
            ready = true;
            System.out.println(Thread.currentThread().getName() + ": 食物准备好了");
        }
    
        public static void main(String[] args) {
          
          
            VolatileCommunicate communicate = new VolatileCommunicate();
            Thread consumer = new Thread(communicate::eat, "顾客老张");
            Thread cook = new Thread(communicate::cook, "厨师老李");
            consumer.start();
            // 一段时间后,厨师做好了食物
            sleep(TimeUnit.SECONDS, 5);
            cook.start();
        }
    }
    

1.2 等待/通知机制

  • 一个线程改变了程序状态,另一个线程感知到符合预期的改变,执行相应操作

  • 前者是生产者,后者是消费者,这种设计模式具有很好的伸缩性

  • 对于消费者,想要感知到状态改变,需要不断地循环检查状态是否符合预期;若符合预期,则退出循环,执行特定操作

  • 伪代码如下:

    while (status!=expectedStatus) {
          
          
        sleep someTime;
    }
    do something
    
  • 这种检查、睡眠等待的时间方式存在不足

    • 难以保证及时性:如果睡眠时间太长,则不能及时发现状态的改变,不能保证后续操作的时效性
    • 难以降低开销:如果睡眠时间过短,虽然保证了及时性,却有可能浪费处理器资源
  • 很难找到一个合适的睡眠时间,这时候可以考虑使用Java对象内置的等待 / 通知机制

    • 线程因为条件不满足,进行等待
    • 另一个线程修改条件,通知等待的线程

1.3 wait / notify / notifyAll

1.3.1 基本方法

  • Java的Object类定义了可以实现等待 / 通知机制的方法:

    方法名 描述
    wait() 调用wait()方法的线程将进入等待状态,直到其他线程通知或被中断
    wait(long timeout) 超时等待一段时间,可能因为超时退出,也可能因为其他线程的通知或被中断而退出
    native方法,wait(0)表示一直等待,也是wait()方法的实际实现
    wait(long timeout, int nanos) 纳秒粒度的超时控制
    notify() native方法,通知一个处于wait状态的线程,使其从wait方法返回;
    如果有多个线程处于wait状态,则随机选择一个进行通知(唤醒)
    notifyAll() native方法,通知所有所有wait状态的线程
  • 执行以上方法前,都必须获取对象的synchronized锁;等待和通知的线程必须获取的是同一对象锁,才能基于synchronized构建等待 / 通知机制

  • 也就是说,Object的等待 / 通知方法 + 基于Object的synchronized = 等待 / 通知机制

1.3.2 实现原理

  • 基于wait() 和notify()方法,展示等待 / 通知机制的实现

    时间 等待线程 通知线程
    t1 获取对象obj的锁,进入同步方法或同步代码块
    t2 发现条件不满足,调用wait()方法:
    1. 进入等待队列;2. 释放对象锁
    t3 获取对象obj的锁,进入同步方法或同步代码块
    t4 修改条件,调用notify()方法通知等待的线程:
    将等待线程从等待队列移入同步队列,使得等待线程拥有竞争对象锁的机会
    t5 执行完同步方法或同步代码块,释放锁
    t6 其他线程释放锁,尝试获取锁成功,从wait()方法返回
    t7 发现条件满足,执行某些操作;完成同步方法或同步代码块的执行,释放锁
  • 可以使用下图简单描述:
    在这里插入图片描述

1.3.3 等待 / 通知机制的经典范式

等待线程

  • 获取到对象锁
  • 条件不满足,调用对象的wait方法进行等待
    • 当等待线程再次获取到对象锁从wait方法返回时,由于多线程的不确定性,可能条件仍然不满足,还需要继续wait
    • 因此,等待线程需要通过while循环进行条件检查、wait
  • 条件满足执行特定操作
  • 伪代码如下:

通知线程

  • 获取到对象锁
  • 改变条件,调用对象的notify() 或notifyAll() 方法通知等待线程
  • 执行完同步代码,释放锁

等待 / 通知模式的代码示例

  • 实现一个简单的查询引擎:

    • session收到client传来的SQL后,启动一个SQL结果获取线程,一个SQL执行线程
    • 结果获取线程等待SQL执行结束,执行线程执行完SQL后,通知等待的结果获取线程
  • 代码如下:

    public class QuerySession {
          
          
        private Object engine = new Object();
        private boolean finished = false;
    
        public static void main(String[] args) {
          
          
            QuerySession test = new QuerySession();
            test.startQuery();
        }
    
        public void startQuery() {
          
          
            System.out.println(Thread.currentThread().getName() + ": 收到SQL");
            // 一个线程等待执行结果,一个执行SQL
            new Thread(this:: getQueryResult, "result-thread").start();
            new Thread(this::execute, "query-thread").start();
        }
    
        public void getQueryResult() {
          
          
            synchronized (engine) {
          
          
                while (!finished) {
          
          
                    try {
          
          
                        engine.wait();
                    } catch (InterruptedException e) {
          
          
                        e.printStackTrace();
                    }
                }
                // SQL执行结束
                System.out.println(Thread.currentThread().getName() + ":获取到SQL执行结果");
            }
        }
    
        public void execute() {
          
          
            synchronized (engine) {
          
          
                // 执行SQL查询
                sleep(TimeUnit.MILLISECONDS, 100);
                // 查询结束,唤醒等待线程
                finished = true;
                engine.notify();
                System.out.println(Thread.currentThread().getName() + ":SQL执行结束");
            }
        }
    
        public static void sleep(TimeUnit timeUnit, long timeout) {
          
          
            try {
          
          
                timeUnit.sleep(timeout);
            } catch (InterruptedException e) {
          
          
                e.printStackTrace();
            }
        }
    }
    
  • 执行结果

1.3.4 注意对象的一致性

  • 等待 / 通知方法的调用者,必须为synchronized锁住的对象,否则将抛出IllegalMonitorStateException异常

  • 上述示例程序的getQueryResult()方法改造如下:

    public void getQueryResult() {
          
          
        synchronized (engine) {
          
          
            while (!finished) {
          
          
                try {
          
          
                    wait();
                } catch (InterruptedException e) {
          
          
                    e.printStackTrace();
                }
            }
            // SQL执行结束
            System.out.println(Thread.currentThread().getName() + ":获取到SQL执行结果");
        }
    }
    
  • 执行时,将抛出IllegalMonitorStateException异常
    在这里插入图片描述

2. join方法

  • 个人觉得,使用wait / notify实现等待 / 通知模式挺费事儿的:对象、synchronized、要在调用同一对象的等待 / 通知方法等
  • 如果我的需求很简单,例如:父线程创建一个子线程执行某些操作,等子线程执行完以后,父线程再继续执行
  • 如果使用传统的sleep方法,无法确定父线程需要睡眠多久;如果使用等待 / 通知模式, 子线程和父线程的操作其实无需保证线程安全,这样反而比较麻烦
  • 这时候,可以使用join()方法,让父线程等待子线程执行完毕后,再继续执行

2.1 三种join方法

  • 和wait方法一样,join方法提供有超时和无超时的两种类型

    方法名 描述
    join() Thread类的非静态方法,需要通过线程实例进行调用:thread.join():表示调用者线程需要等待被调线程执行结束(死亡),才能继续执行 join() 方法之后的代码
    join(long millis) 与 join() 方法不同的是,调用者线程等待一段时间(单位:ms)即可继续执行;可能因为被调线程执行结束,而提前结束等待
    join(0)表示永久等待(没有超时限制),这也是join() 方法的底层实现
    join(long millis, int nanos) 等待millis毫秒 + nanos纳秒,从源码上看,实际按照毫秒进行等待的,会将纳秒四舍五入成毫秒

2.2 源码解读

  • join(long millis)方法的代码实现代码如下:

    public final synchronized void join(long millis)
    throws InterruptedException {
          
          
        long base = System.currentTimeMillis();
        long now = 0;
    
        if (millis < 0) {
          
          
            throw new IllegalArgumentException("timeout value is negative");
        }
    
        if (millis == 0) {
          
           // 永久等待
            while (isAlive()) {
          
           
                wait(0);
            }
        } else {
          
          
            while (isAlive()) {
          
           // 超时等待的实现:只要当前线程并未执行结束,就需要不停等待
                long delay = millis - now;  
                if (delay <= 0) {
          
          
                    break;
                }
                wait(delay);  // 在等待中可能被通知,需要根据当前线程的状态和delay决定是否继续等待
                now = System.currentTimeMillis() - base;
            }
        }
    }
    
  • 从源代码可知:

    • 线程本身就是一个Java对象,其join 方法实际通过自身的wait()方法实现
    • 是否等待的前提是,当前线程是否终止:通过isAlive()进行判断
    • 如果等待时间为0,表示永久等待;
    • 等待时间大于0,进行超时等待:等待中可能被通知使其从wait 方法返回,如果线程未终止,需要继续等待delay时间

是谁进入等待状态?

  • 在调用线程中执行被调线程的join方法,调用者线程将会进入等待状态
    • 基于被调线程的wait方法,实现调用者线程的等待
  • 被调线程终止,调用自身的notifyAll 方法,通知调用者线程
  • 调用者线程成功获取到被调线程的对象锁,从被调线程的wait方法返回
  • 最终,调用者线程将从被调线程的join方法返回,从而可以继续执行jion方法之后的代码

进入等待状态的调用者线程,何时被通知?

  • 线程终止时,this.notifyAll()方法会被调用,使其从 wait 方法返回
  • 也有可能应用程序调用了线程的 notify/ notifyAll 方法,使得线程提前从 wait 方法返回:这时线程并未终止、等待也未超时,线程会继续等待
  • 这也是 join 方法的超时等待部分,为什么这样实现的原因
  • 注意: JDK源码的编写者,不建议在应用程序中主动调用线程的wait、notify或notifyAll 方法

提前结束超时等待的代码示例

  • 下面的代码,主线程需要等待子线程1000ms,但是子线程实际只需执行5ms左右

  • 子线程会因为自身终止而提前结束超时等待,主线程实际无需等待1000ms即可继续执行

    public static void main(String[] args) throws InterruptedException {
          
          
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
        Thread thread1 = new Thread(() -> {
          
          
            System.out.println(format.format(new Date()) + "子线程开始执行");
            for (int i = 0; i < 5; i++) {
          
          
                System.out.println("A ---- " + i);
                sleep(TimeUnit.MILLISECONDS, 1);
            }
            System.out.println(format.format(new Date()) + "子线程执行结束");
        });
        thread1.start();
        // thread1.join();
        thread1.join(1000);
        System.out.println(format.format(new Date()) + "等待结束,子线程状态:" + thread1.getState());
    }
    
  • 执行结果与预期一致:

总结

  • 三种 join 方法都是final方法,不能被子类重写
  • 其他两种种 join 方法,其实质都是调用join(long millis)方法
  • join 方法和wait或sleep方法一样,也是不可中断;执行过程中被中断,会抛出InterruptedException异常
  • join 方法,必须在start 方法之后调用

2.3 join、wait、sleep方法的区别

  • 三种方法都是不可中断方法,一旦中断将抛出InterruptedException
    | 方法 | 位置 | 特点|
    |–|--|–|
    | sleep() | Thread类的静态方法 | ① 当前线程会让出CPU资源,但不会释放锁资源。② 在哪个线程中调用Thread.sleep()方法,哪个线程就会睡眠|
    | wait() | Java Object类的方法 | ① 是所有Java对象具有的方法,必须与对象和对象锁一起使用。② 当前线程会让出CPU资源,并释放对象的锁;③ 在哪个线程调用对象的wait方法,哪个线程会等待 |
    | join() | Thread类的非静态方法,即线程对象的方法 | ① 基于线程对象的 wait 方法实现,执行过程中会释放锁资源;② 在哪个线程中调用另一个线程的join方法,哪个线程会等待 |

3. 总结

Java Object的等待 / 通知机制

  • Object的等待/ 通知方法 + Object的synchronized锁 = Object的等待 / 通知机制
  • 等待 / 通知机制的整个流程:
    在这里插入图片描述
  • 注意事项:
    • 必须是同一个对象的锁、等待 / 通知方法,否则将抛出IllegalMonitorStateException异常
    • 经典的等待 / 通知模式,在从wait 方发返回时,必须再次检查条件是否满足(多线程的影响、可能条件的更新不符合预期)
    • wait()方法的实质是native的wait(0)方法,表示永久等待

join方法

  • 调用者线程等待被调线程执行完毕,才继续执行
  • join()方法的实质是 synchronized 的join(0)方法,表示永久等待直到被调线程终止
  • join(long millis)方法的源码解析:等待的前提,线程未终止;超时等待的实现(isAlive()和delay的更新);何时被通知
  • join方法的实质:被调线程自身的等待 、 通知机制

sleep、wait和join方法的比较

  • 重点在方法的位置、是否释放锁、谁进入等待状态等

参考文档:

猜你喜欢

转载自blog.csdn.net/u014454538/article/details/121257703