Java中的等待通知机制

Java中的等待通知机制

综述

在Java中, 等待-通知机制是多线程编程中的一种同步机制, 主要用于线程之间的协调. 常见于广义生产者-消费者模式应用场景. 因为应用相当广泛, 又没有严格意义上的生产者-消费者模式那么难, 所以本文将单独整理一篇, 讨论这一类场景.

API和概念介绍

当前, 可以实现等待通知机制的api有两组, 一组以 synchronized/wait/notify 为代表, 另一组则以 Lock/Condition 为代表. 其中 synchronized 和 Lock 属于基础的锁功能, 此处不再额外介绍. 各组其余的api的功能简述如下.

wait等api由object类提供, 也就是说, 任何可以作为锁用于synchronized的Object子类实例都可以提供这几个方法.

  • wait(): 让当前线程进入等待状态, 并释放当前对象的锁. 线程在调用 wait() 方法后, 进入"等待"状态, 直到其他线程调用同一对象上的 notify() 或 notifyAll() 方法来唤醒它.
  • notify(): 唤醒在当前对象上等待的一个线程. 这个线程将会重新竞争锁, 等待重新获得锁之后才能继续执行.
  • notifyAll(): 唤醒在当前对象上等待的所有线程. 所有等待的线程都会重新竞争锁, 并继续执行.

而condition提供的则是与wait/notify相对应的await()和signal(), 这几个方法都由Condition实例提供.

  • await(): 使当前线程等待, 并释放锁, 直到其他线程调用相同条件上的 signal() 或 signalAll() 方法来唤醒它.
  • awaitUninterruptibly(): 与 await() 类似, 但不响应中断.
  • signal(): 唤醒一个在该条件上等待的线程. 如果有多个线程在等待, 只会唤醒其中一个, 具体唤醒哪个线程不确定.
  • signalAll(): 唤醒所有在该条件上等待的线程.

当然这么介绍api对于没接触过等待通知机制的读者来说会完全不知所谓, 找不到重点. 因为这些知识点杂乱无章, 不成体系. 所以我们需要一个认知模型/体系将它们串起来.
那么, 问题回到了关键的部分: 什么是等待通知机制? 怎么使用等待通知机制? 在整个等待通知的流程中哪里用得上这些api?

设想一个单人经营的咖啡店中, 一个顾客过来购买咖啡的场景. 这里老板和顾客分别相当于线程A和线程B, 那么顾客从下单到买到咖啡总计有以下几个步骤.

  • 顾客(线程B)进到咖啡店里, 扫描二维码获取咖啡菜单. 这一步等同于线程B获取锁开始执行某些操作.
  • 顾客(线程B)选择自己想要的咖啡, 下单并付款, 然后找了个小桌子坐下开始玩手机. 这一步相当于线程B执行完前置操作, 调用wait/await, 进入等待状态.
  • 老板(线程A)从后台接到新的已经付款的订单, 开始制备咖啡, 比如研磨, 加冰, 加牛奶等. 这一步相当于线程A获取到锁, 并在持有锁的期间执行A应该做的任务.
  • 老板(线程A)准备好咖啡并放在服务台上, 通知客户可以来取咖啡了. 然后发现后台没有新订单, 转身去玩手机了. 这一步相当于线程A执行完毕, 调用 notify/signal 唤醒线程B, 完成这一步后释放锁.
  • 顾客(线程B)收到提醒, 去服务台取走咖啡, 然后带着咖啡离开小店. 这一步相当于线程B被唤醒后, 尝试重新获取锁, 成功后执行后续的操作并最终释放锁.

当然, 现实世界中没有锁的对照物, 那是因为现实世界中每个人都有各自的脑袋, 所有人都是同时行动的. 而程序里是利用一个cpu模拟多个人的行为, 通常会有先后顺序, 为了防止cpu执行的两个线程互相修改了对方还没用完的参数, 需要加上锁以防万一.

示例代码

好, 现在考虑在代码中实现上述的获取咖啡的操作

public class WaitNNotify {
    ExecutorService executor ;
    private boolean coffeePrepared;
    private final Object lock;

    private WaitNNotify() {
        executor = new ThreadPoolExecutor(
                2,
                4,
                10000,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(10),
                new ThreadPoolExecutor.CallerRunsPolicy());
        lock = new Object();
        coffeePrepared = false;
    }

    private static class SingletonHolder{
        private static final WaitNNotify instance = new WaitNNotify();
    }

    public static WaitNNotify getInstance(){
        return SingletonHolder.instance;
    }

    private static String formatTime(long time) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
        return sdf.format(new Date(time));
    }

    private void orderCoffee() {
        long startTime = System.currentTimeMillis();
        System.out.println("顾客开始下单时间: "+ formatTime(startTime));
        synchronized (lock) {
            try {
                System.out.println("顾客:下单并付款,等待咖啡准备...");
                //循环检查, 为了防止虚假唤醒
                while (!coffeePrepared) {
                    lock.wait(); // 顾客线程等待咖啡准备好
                }
                long getCoffeeTime = System.currentTimeMillis();
                System.out.println("顾客取到咖啡的时间: "+ formatTime(getCoffeeTime));
                // 顾客拿到咖啡
                System.out.println("顾客:取到咖啡,离开咖啡店");
                coffeePrepared = false; // 准备下一单
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.out.println("顾客线程被中断");
            }
        }
    }

    private void makeCoffee() {
        synchronized (lock) {
            try {
                long getOrderTime = System.currentTimeMillis();
                System.out.println("老板接到订单的时间: "+ formatTime(getOrderTime));
                // 老板准备咖啡
                System.out.println("老板:开始准备咖啡...");
                // 模拟制备咖啡的时间
                TimeUnit.MILLISECONDS.sleep(2000);
                coffeePrepared = true;
                lock.notify(); // 通知顾客咖啡准备好了
                System.out.println("老板:咖啡准备好了,通知顾客取走");
                long notifyTime = System.currentTimeMillis();
                System.out.println("老板通知顾客线程的时间: "+ formatTime(notifyTime));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.out.println("老板线程被中断");
            }
        }
    }

    public void buyCoffee(){
        executor.submit(this::orderCoffee);
        executor.submit(this::makeCoffee);

        executor.shutdown();
        try {
            // 等待所有任务完成
            if (!executor.awaitTermination(8, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

public class Main {
    public static void main(String[] args) {
        WaitNNotify.getInstance().buyCoffee();
    }
}

就像代码显示的那样, 在简单的等待通知机制中用不到notifyAll(), 这个api是为了处理可能存在多个顾客的这类的场景而准备的.

另外, 除了 synchronized/wait/notify 之外还有 Lock/Condition 这一组api也可以用来实现等待通知机制, 而且 Condition 的推出时间比较晚, 功能上更灵活.

public class AwaitNSignal {
    ExecutorService executor;
    private final ReentrantLock lock;
    private final Condition coffeeCondition;
    private boolean coffeePrepared;

    private AwaitNSignal() {
        executor = new ThreadPoolExecutor(
                2,
                4,
                10000,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(10),
                new ThreadPoolExecutor.CallerRunsPolicy());
        lock = new ReentrantLock();
        coffeeCondition = lock.newCondition();
        coffeePrepared = false;
    }

    private static class SingletonHolder {
        private static final AwaitNSignal instance = new AwaitNSignal();
    }

    public static AwaitNSignal getInstance() {
        return SingletonHolder.instance;
    }

    private static String formatTime(long time) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
        return sdf.format(new Date(time));
    }

    private void orderCoffee() {
        long startTime = System.currentTimeMillis();
        System.out.println("顾客开始下单时间: " + formatTime(startTime));
        lock.lock();
        try {
            System.out.println("顾客:下单并付款,等待咖啡准备...");
            while (!coffeePrepared) {
                coffeeCondition.await(); // 顾客线程等待咖啡准备好
            }
            long getCoffeeTime = System.currentTimeMillis();
            System.out.println("顾客取到咖啡的时间: " + formatTime(getCoffeeTime));
            // 顾客拿到咖啡
            System.out.println("顾客:取到咖啡,离开咖啡店");
            coffeePrepared = false; // 准备下一单
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("顾客线程被中断");
        } finally {
            lock.unlock();
        }
    }

    private void makeCoffee() {
        lock.lock();
        try {
            long getOrderTime = System.currentTimeMillis();
            System.out.println("老板接到订单的时间: " + formatTime(getOrderTime));
            // 老板准备咖啡
            System.out.println("老板:开始准备咖啡...");
            // 模拟制备咖啡的时间
            TimeUnit.MILLISECONDS.sleep(2000);
            coffeePrepared = true;
            coffeeCondition.signal(); // 通知顾客咖啡准备好了
            System.out.println("老板:咖啡准备好了,通知顾客取走");
            long notifyTime = System.currentTimeMillis();
            System.out.println("老板通知顾客线程的时间: " + formatTime(notifyTime));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("老板线程被中断");
        } finally {
            lock.unlock();
        }
    }

    public void buyCoffee() {
        executor.submit(this::orderCoffee);
        executor.submit(this::makeCoffee);

        executor.shutdown();
        try {
            // 等待所有任务完成
            if (!executor.awaitTermination(8, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}
public class Main {
    public static void main(String[] args) {
        AwaitNSignal.getInstance().buyCoffee();
    }
}

上述两段代码分别用 synchronized/wait/notify 和 Lock/Condition 两组api实现了几乎相同的购买咖啡流程. 唯一的区别在于 Lock/Condition 中 lock 可以对应多个 condition. 所以同一个lock可以针对不同的线程甚至是同一个线程中不同的步骤使用不同的condition进行等待和唤醒来分别处理. 这一功能拓展可以很方便地解决更多的拓展场景.

真实案例

等待通知机制特别是其高级版本生产者-消费者模式在编程中应用非常广泛, 但是很多时候都用在了被封装好的底层库和中间件上. 一般的新手工程师想找到一个用得着的场景不太容易.
而前面的例子只是解决了「等待通知机制是什么」「怎么使用等待通知机制」, 但是没有解决「在哪些场景可以用得上等待通知机制」这个问题.

所以这里给出一个实际场景, 希望能提供一些启发. 同时, 因为实际场景的代码量比较高, 只能选择伪代码 + 文字阐述的方式来描述.

考虑以下场景:

在一个需要使用自定义协议进行tcp通信的模块中需要实现文件传输功能.
这套自定义通信协议的实现基于nio包提供的SocketChannel

所以实现模块核心功能的方法类似于

------------------------------------------------------

方法 StartClient(){
    IO线程.post(new 线程回调方法{
        StartListening(new 解析数据包callback{
            override方法 handleRead(ip, data){
                解析线程.post(new 线程回调方法{
                    completeDataPackage = 判断包完整性并处理粘包缺包(data)
                    解析完毕的数据包对象 = 完整数据包解析(ip, completeDataPackage)
                    handleReceivedMessage(解析完毕的数据包对象)
                })
            }
        })
    })
}

方法 StartListening (解析数据包callback){
    循环直到线程结束{
        selectedKeys = selector.selectedKeys()
        it = selectedKeys.iterator()
        循环处理it{
            selectionKey = it.next()
            OnSelectionKey(selectionKey, 解析数据包callback)
        }
    }
}

方法 OnSelectionKey(selectionKey, 解析数据包callback){
    socketChannel = selectionKey.getSocketChannel()
    data = socketChannel.read()
    ip = socketChannel.getHostAddress()
    解析数据包callback.handleRead(ip, data)
}

方法 判断包完整性并处理粘包缺包(byteArray){
    //处理略
    return 完整数据包
}

方法 完整数据包解析(ip, byteArray){
    //按照自定义规则解析成完整数据包, 处理略
    return 包含json数据的数据包对象
}

方法 handleReceivedMessage(包含json数据的数据包对象){
    cmd = 包含json数据的数据包对象.getCmd
    //处理cmd和数据包, 处理略
}

------------------------------------------------------

以上部分伪代码负责实现读取功能, 这里省略了大量分支处理, 另一边的写入部分会比较简单

------------------------------------------------------

方法 writeCommand(socketChannel, byteArrayPackage){
    writeBuffer.put(byteArrayPackage.data)
    socketChannel = socketChannelMap.get(byteArrayPackage.ip)
    socketChannel.write(writeBuffer)
}

由于模块的设计, 读取功能是在IO线程中由StartListening中的循环持续运行而实现的, 也就是说, 读取和写入部分被拆分开来, 适合与多个连接对象实现单次通信, 模块项目发送一条信息, 对方返回一条信息. 或者反过来对方返回一条信息, 模块发送一条信息.

但是现在出现了额外的需求, 需要将一个十几M的文件发送给连接对象. 由于自定义规则限制, 单个数据包大小不超过1024kb, 去掉包头包尾和各种验证时间戳, 有效的data数据部分会更短.

这时候就可以选择使用等待通知机制. 这里使用 Lock/Condition 这一组更灵活的api来实现


ReentrantLock mLock = new ReentrantLock();
Condition prepareCondition = mLock.newCondition();
Condition fileSendCondition = mLock.newCondition();
//这个方法就相当于顾客, 每一步都等待通知
方法 sendFirmwareChunks(file, ip, 发送回调){
    发送线程.post(new 线程回调方法{
        mLock.lock()
        try{
            文件接收准备工作Check(ip, file)
            result = prepareCondition.await(timeout, timeUnit)
            if (!result){
                发送回调.发送失败回调()
                return
            }
            发送回调.check成功回调()

            读取file到byte数组 //这里因为文件比较小, 没有做进一步拆分
            分割 file 成小块 chunkList
            对于每个 chunk in chunkList{
                packetTransfer(ip, chuck)
                发送回调.返回传输进度(chuck.length, file.totalLength)
                result = fileSendCondition.await(timeout, timeUnit)
                if (!result){
                    发送回调.发送失败回调()
                    return
                }
            }
            发送回调.发送成功回调()
        }finally{
            mLock.unlock()
        }
        
    })
}

方法 文件接收准备工作Check(ip, file){
    socketChannel = socketChannelMap.get(ip)
    data = 生成文件接受准备工作Check数据包(file)
    writeCommand(socketChannel, data)
}

方法 生成文件接受准备工作Check数据包(file){
    //根据file和其他自定义规则生成数据包, 处理略
    return data
}

方法 packetTransfer(ip, chuck){
    mLock.lock();
    try{
        socketChannel = socketChannelMap.get(ip)
        transferInfo = 生成fileChunk数据包(chunk)
        写入线程.post(new 线程回调方法{
            writeCommand(socketChannel, transferInfo.getPackage)
        })
    }finally{
        mLock.unLock()
    }
}

方法 生成fileChunk数据包(chuck){
    //根据自定义规则将chuck拼接成数据包, 处理略
    return transferInfo
}
------------------------------------------------------

而读取方面, 前面的 handleReceivedMessage 方法被修改为

------------------------------------------------------
//这里, 就相当于老板, 接到单后处理顾客的单子, 
//处理好后通过 condition.signal() 唤醒被同一个 condition.await() 设置为等待的线程
//这个方法运行在解析线程中
方法 handleReceivedMessage(包含json数据的数据包对象){
    cmd = 包含json数据的数据包对象.getCmd
    //其余处理cmd和数据包, 处理略

    如果 (cmd = 发送准备工作check响应) {
        jsonObject = json解析(包含json数据的数据包对象)
        如果(根据 jsonObject 判断响应为成功){
            prepareCondition.signal()
        }
    }
    如果 (cmd = 发送fileChunk响应) {
        jsonObject = json解析(包含json数据的数据包对象)
        如果(根据 jsonObject 判断响应为成功){
            fileSendCondition.signal()
        }
    }
}

方法 json解析(数据包){
    //解析json, 处理略
    return jsonObject
}

总结

在本文中, 我们探讨了多线程编程中等待通知机制的适用场景及其不同实现方式. 通过比较 synchronized/wait/notify 和 Lock/Condition 以及实际案例可以看到后者在灵活性和可扩展性方面具有的优势. 实际场景中的文件传输示例展示了如何利用这些机制处理复杂的通信需求, 提高了程序的可靠性和效率.

posted @ 2024-09-28 10:32  地维藏光  阅读(88)  评论(0)    收藏  举报