上篇文章中介绍了死锁的形成以及破解方式,其中破坏 占用且等待 条件使用了一个死循环的方式去持续申请资源,直到申请到所有资源才继续向下执行:

		while(!actr.apply(this,target));
		// 继续向下执行业务逻辑
		...

如果 apply 申请资源的操作 耗时非常短 ,并且并发量冲突不大时,这个方案是一个比较合适的解决方案。

但是如果申请资源的操作**耗时长,并发量冲突大,就可能导致循环很久,在这种情况下可能循环上万次才能获取到锁,非常耗费 CPU 资源**。

在这种情况下比较好的解决方案:

如果线程要求的条件不满足(比如没有申请到需要的资源),则线程进入 等待状态,当继续执行的条件满足后,通知 等待的线程重新执行。

使用线程阻塞的方式就可以避免循环等待消耗 CPU 的问题。

Java 也是支持这种 等待——通知 机制的。

完美的就医流程

下面是一个作者的例子,帮助我们理解 等待——通知 机制,就是我们平时去看病的叫号机制:

  1. 挂号,找到对应门诊,等待 叫号 —— 【线程需要获取锁进入临界区执行互斥代码】
  2. 叫到自己时进屋就诊 —— 【获取到了
  3. 就诊过程中可能需要做检查,于是去做检查,此时大夫会让下一位患者进来就诊 —— 【不满足某些执行条件,该线程进入等待状态释放锁
  4. 做完检查重新拿个号等待叫号 ——【 条件满足后等待线程被唤醒,重新进入 RUNABLE 状态等待调度,重新获取
  5. 再次叫到自己号时带着报告去找大夫 —— 【获取,进入临界区,继续执行下面的业务】

一个完整的等待通知——机制的机制描述:

线程首先获取互斥锁,当线程要求的条件不满足时,释放锁,进入等待状态

当要求满足时,**通知**等待线程,重新获取锁

使用 synchronized 实现 等待 — 通知机制

Java中关于 等待——通知 的实现有多种方式,使用 内置的 synchronized 配合 wait()notify()notifyAll() 就可以实现这个机制。

首先使用 synchronized 定义一个互斥的代码块,想进入其中必须获取对应的锁对象,这个临界区也就是上面例子中的医生诊室。

当一个线程进入后,其他线程只能等待锁被释放后才能获取锁进入临界区,也就是下图中的等待队列。

这个等待队列和互斥锁是一对一的关系,每个锁都有自己独立等待队列

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,就需要进入等待状态,Object 中的 wait 方法就是提供这个功能的。

如上图所示,当调用 wait 方法后,当前线程会被阻塞,进入到右边的等待队列中,这个等待队列同时也是获取互斥锁的等待队列。当线程进入等待队列后,会释放持有的互斥锁,这样其他线程就有机会获取锁进入临界区了。

当线程要求的条件满足时,需要通知这个处于 WAITING 状态的线程,这需要用到 notifynotifyAll 方法。

下面这个图大致描述了该过程:当条件满足时调用 notify 会通知互斥锁的等待队列中的线程,告诉它条件曾经满足过

这里条件曾经满足过指的是:notify 只能保证在通知的时间点条件是满足的,而被通知线程的执行时间点通知时间点基本不会重合,所以可能当线程执行时条件已经不满足了(例如其他线程插队)。这一点需要注意

这里一直强调 waitnotifynotifyAll 操作的等待队列指的是 互斥锁等待队列,如果 synchronized 的锁对象是 this,对应的调用者就是 this.wait()this.notify()this.notifyAll(),一定是**锁对象**来调用这些方法,而这些方法的调用前提也是已经获取了相应的互斥锁。

如果在临界区外调用的话,就会抛出 java.lang.IllegalMonitorStateException 运行时异常。

实践:修改上一章分配器,实现一个更好的

上一章中针对转账存在的死锁条件分别实现了不同的解决方法,这里将 等待—通知 机制的基本原理搞清楚后,可以使用这个机制来解决资源的一次性申请问题,在这个机制中我们需要考虑4个要素:

  1. 互斥锁Allocator 是单例的,所以可以用 this 作为互斥锁(效果等同于用 Class 对象做互斥锁,都是将程序变为串行程序了。)
  2. 线程要求的条件:转出账户和转入账户都同时可用。
  3. 何时等待:条件不满足时等待。
  4. 何时通知:有线程释放账户时通知。

考虑清楚这4个问题就可以使用 等待—通知 机制完成代码了:

while(条件不满足) {
	wait()
}

利用这个方法可以解决上面提到的:条件曾经满足过这个问题,当 wait() 返回时,有可能条件已经发生变化,曾经满足但是现在已经不满足了。所以需要重新校验条件。

范式意味着经典写法,所以没有特殊理由不要改变写法。

public class Allocator2 {
    private List<Object> als;

    // 一次性申请所有资源
    synchronized void apply(Object from, Object to) {
        // 经典写法
        while (als.contains(from) || als.contains(to)) {
            try {
                wait();
            } catch (InterruptedException e) {

            }
        }
        als.add(from);
        als.add(to);
    }

    // 释放资源
    synchronized void free(Object from, Object to) {
        als.remove(from);
        als.remove(to);
        notifyAll();
    }

}

尽量使用 notifyAll

因为 notify 是**随机**通知等待队列中的一个线程,可能我们需要通知的线程一直无法被通知到,而 notifyAll 则是通知队列中的所有线程,所以一般情况下使用 notifyAll 更安全。

假设有4个资源:ABCD

线程1 申请到了 资源 AB

线程2申请到了 资源 CD

此时线程3申请 AB 会进入等待队列(因为资源以及被分配给了线程1)

线程4申请CD 也会进入等待队列

假设线程1 归还了资源AB,如果使用的是 notify 来通知等待队列中的线程,因为其通知是随机性的,可能通知到的是 线程4,但是线程4 申请的资源是 CD,于是线程4继续等待,由于没有通知到3,所以线程3将一直等待下去,没有机会被唤醒。

总结

等待—通知 机制是一种非常普遍的线程间协作的方式。我们平时经常使用轮询的方式来等待某个状态,很多情况下都可以使用 等待—通知来进行优化,因为在等待的时候不会耗费 CPU 的资源。

Java 语言的这套实现背后的理论模型是 管程

个人小结:

最近正好在看 ObjectThread 类的源码,同时也在梳理 sleep & wait &notify 以及线程之间状态转换的关系。

刚开始觉得这些东西挺简单的,写了几个demo看了看注释,发现程序真正运行起来和自己之前想象的结果还是不一样的。

这篇极客时间的文章主要就是讲了 waitnotify ,刚好也对应上了最近正在学的东西,还不错,作者也总是在举现实中的例子帮助理解。

但是这些基础的API好像用的并不是很多,JDK的JUC包中有更强大更方便的类供我们使用,但是一定是要理解其基本的原理的。

精选留言:

1、 wait 与 sleep 的区别:

  1. wait 释放锁,而 sleep 不释放锁
  2. wait 只能在临界区内使用, sleep 可以在任何地方调用
  3. wait 无需捕获异常,sleep 需要。

相同点:两者在调用后都会出让 CPU 时间片,等待再次调度。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。