美文网首页Java并发
线程间通信的方式

线程间通信的方式

作者: CodeKing2017 | 来源:发表于2018-08-08 13:38 被阅读4次

    在Java平台中, Object.wait()/Object. wait(long)以及 Object.notify()/Object. notifyAll() 可用于实现等待和通知 :Object.wait()的作用是使其执行线程被暂停 (其生命周期状态变更为 WAITING),该方法可用来实现等待;Object.notify ()的作用是唤醒一个被暂停的线程。调用该方法可实现通知。相应地,Object.wait()的执行线程就被称为等待线程;Object.notify()的执行线程就被称为通知线程。由于Object 类是 Java 中任何对象的父类,因此使用 Java 中的任何对象都能够实现等待与通知。

    使用Object.wait()实现等待。代码模板如下伪代码所示:

    其中,保护条件是一个包含共享变量的布尔表达式。当这些共享变量被其他线程(通知线程)更新之后使相应的保护条件得以成立时,这些线程会通知等待线程。由于一个线程只有在持有一个对象的内部锁的情况下才能够调用该对象的wait方法,因此 Object.wait() 调用总是放在相应对象所引导的临界区之中。包含上述模板代码的方法被称为受保护方法(Guarded Method)。受保护方法包括 3 个要素 :保护条件、暂停当前线程和目标动作。

    设someObject为 Java 中任意一个类的实例,因执行someObject.wait()而被暂停的线程就称为对象 someObject 上的等待线程。由于同一个对象的同一个方法 (someObject.wait())可以被多个线程执行,因此一个对象可能存在多个等待线程。someObject 上的等待线程可以通过其他线程执行 someObject.notify()来唤醒。someObject.wait()会以原子操作的方式使其执行线程( 当前线程 )暂停并使该线程释放其持有的someObject 对应的内部锁。当前线程被暂停的时候其对 someObject.wait()的调用并未返回。其他线程在该线程所需的保护条件成立的时候执行相应的 notify 方法,即 someObject.notify()可以唤醒 someObject上的一个(任意的)等待线程。被唤醒的等待线程在其占用处理器继续运行的时候,需要再次申请 someObject 对应的内部锁。被唤醒的线程在其再次持有 someObject 对应的内部锁的情况下继续执行 someObject.wait()中剩余的指令,直到wait方法返回。

    等待线程只在保护条件不成立的情况下才执行Object.wait()进行等待,即在执行 Object.wait()前我们需要判断保护条件是否成立(当然,此时保护条件也是有可能成立的)。 另外,等待线程在其被唤醒、继续运行到其再次持有相应对象的内部锁的这段时间内,由于其他线程可能抢先获得相应的内部锁并更新了相关共享变量而导致该线程所需的保护条件又再次不成立,因此Object.wait()调用返回之后我们需要再次判断此时保护条件是否成立。所以,对保护条件的判断以及 Object.wait()调用应该放在循环语句之中,以确保目标动作只有在保护条件成立的情况下才能够执行!

    另外,等待线程对保护条件的判断以及目标动作的执行必须是个原子操作,否则可能产生竞态目标动作被执行前的那一刻其他线程对共享变量的更新又使得保护条件重新不成立。因此,目标动作的执行必须和保护条件的判断以及Object.wait()调用放在同一个对象所引导的临界区中 。

    注意

    • 等待线程对保护条件的判断、Object.wait()的调用总是应该放在相应对象所引导的临界区中的一个循环语句之中 。

    • 等待结程对保护条件的判断、Object.wait()的执行以及目标动作的执行必须放在 同一个对象(内部锁)所引导的临界区之中。

    • Object.wait()暂停当前线程时释放的锁只是与该 wait 方法所属对象的内部锁。 当前线程所持有的其他内部锁、显式锁并不会因此而被释放。

    使用Object.notify()实现通知 ,其代码模板如下伪代码所示 :

    包含上述模板代码的方法被称为通知方法,它包含两个要素:更新共享变量、唤醒其他线程。由于一个线程只有在持有一个对象的内部锁的情况下才能够执行该对象的notify方法。因此 Object.notify()调用总是放在相应对象内部锁所引导的临界区之中。也正是由于 Object.notify()要求其执行线程必须持有该方法所属对象的内部锁,因此 Object.wait() 在暂停其执行线程的同时必须释放相应的内部锁;否则通知线程无法获得相应的内部锁,也就无法执行相应对象的 notify 方法来通知等待线程!Object.notify()的执行线程持有的相应对象的内部锁只有在 Object.notify()调用所在的临界区代码执行结束后才会被释放,而Object.notify()本身并不会将这个内部锁释放。因此,为了使等待线程在其被唤醒之后能够尽快再次获得相应的内部锁,我们要尽可能地将  Object.notify()调用放在靠近临界区结束的地方。等待线程被唤醒之后占用处理器继续运行时,如果有其他线程持有了相应对象的内部锁,那么这个等待线程可能又会再次被暂停,以等待再次获得相应内部锁的机会,而这会导致上下文切换。

    调用Object.notify()所唤醒的线程仅是相应对象上的一个任意等待线程,所以这个被唤醒的线程可能不是我们真正想要唤醒的那个线程。因此,有时候我们需要借助 Object.notify()的兄弟Object.notifyAll(),它可以唤醒相应对象上的所有等待线程。由于等待线程和通知线程在其实现等待和通知的时候必须是调用同一个对象的wait 方法、notify 方法,而这两个方法都要求其执行线程必须持有该方法所属对象的内部锁。因此等待线程和通知线程是同步在同一对象之上的两种线程。

    Object.wait()/notify()的内部实现

    我们知道Java虚拟机会为每个对象维护一个入口集( Entry Set ) 用于存储申请该对象内部锁的线程。此外,Java 虚拟机还会为每个对象维护一个被称为等待集( Wait  Set )的队列,该队列用于存储该对象上的等待线程。Object.wait()将当前线程暂停并释放相应内部锁的同时会将当前线程(的引用)存入该方法所属对象的等待集中。执行一个对象的 notify方法会使该对象的等待集中的一个任意线程被唤醒。被唤醒的线程仍然会停留在相应对象的等待集之中,直到该线程再次持有相应内部锁的时候(此时 Object.wait ()调用尚未返回)Object.wait()会使当前线程从其所在的等待集移除,接着Object.wait()调用就返回了。Object.wait()/notify()实现的等待/通知中的几个关键动作,包括将当前线程加入等待集、暂停当前线程、释放锁以及将唤醒后的等待线程从等待集中移除等 ,都是在 Object.wait() 中实现的。Object.wait()的部分内部实现相当于如下伪代码 :

    等待线程在语句①被执行之后就被暂停了。被唤醒的线程在其占用处理器继续运行的时候会继续执行其暂停前调用的 Object.wait()中的其他指令,即从上述代码中的语句②开始继续执行:先再次申请 Object.wait()所属对象的内部锁,接着将当前线程从相应的等待集中移除,然后 Object.wait()调用才返回!

    wait/notify的开销及问题

    下面我们看wait/notify实现的等待/通知时可能遇到的问题及其解决方法 。

    • 过早唤醒(Wakeup too soon)问题。设一组等待/通知线程同步在对象someObject 之上,如图所示,初始状态下所有保护条件均不成立。接着,线程N1 更新了共享变量 state1 使得保护条件1得以成立,此时为了唤醒使用该保护条件的所有等待线程(线 程 W1和线程W2),N1执行了 someObject.notifyAll()。由于someObject.notifyAll()唤醒的是 someObject 上的所有等待线程。因此这时线程 W3 也会被唤醒。然而,W3所使用保的护条件2此时并没有成立,这就使得该线程被唤醒之后仍然需要继续等待。这种等待线程在其所需的保护条件并未成立的情况下被唤醒的现象就被称为过早唤醒( Wakeup too soon)。过早唤醒使得那些本来无须被唤醒的等待线程也被唤醒了。从而造成资源浪费。过早唤醒问题可以利用JDK1.5引入的 java.util.concurrent.locks.Condition 接口来解决。

    信号丢失(Missed Signal)问题。如果等待线程在执行 Object.wait()前没有先判断保护条件是否已然成立,那么有可能出现这种情形通知线程在该等待线程进入临界区之前就已经更新了相关共享变量,使得相应的保护条件成立并进行了通知,但是此时等待线程还没有被暂停,自然也就无所谓唤醒了。这就可能造成等待线程直接执行 Object.wait()而被暂停的时候,该线程由于没有其他线程进行通知而一直处于等待状态。这种现象就相当于等待线程错过了一个本来 “发送” 给它的 “信号”,因此被称为信号丢失( Missed  Signal )。只要将对保护条件的判断和 Object.wait()调用放在一个循环语句之中就可以避免上述场景的信号丢失。

    信号丢失的另外一个表现是在应该调用Object.notifyAll()的地方却调用了 Object.notify()。比如,对于使用同一个保护条件的多个等待线程,如果通知线程在侦测到这个保护条件成立后调用的是 Object.notify(),那么这些等待线程最多只有一个线程能够被唤醒,甚至一个也没有被唤醒被唤醒的线程是 Object.notify()所属对象上使用其他保护条件的一个等待线程!也就是说,尽管通知线程在调用 Object.notify()前可能考虑(判断)了某个特定的保护条件是否成立,但是Object.notify()本身在其唤醒线程时是不考虑任何保护条件的!这就可能使得通知线程执行 Object.notify()进行的通知对于使用相应保护条件的等待线程来说丢失了。这种情形下,避免信号丢失的一个方法是在必要的时候使用Object.notifyAll()来通知。总的来说,信号丢失本质上是一种代码错误,而不是 Java 标准库 API 自身的问题。

    欺骗性唤醒(Spurious Wakeup问题。等待线程也可能在没有其他任何线程执行Object.notify()/notifyAll()的情况下被唤醒。这种现象被称为欺骗性唤醒 ( Spurious Wakeup )。由于欺骗性唤醒的作用,等待线程被唤醒的时候该线程所需的保护条件可能仍然未成立。因为此时没有任何线程对相关共享变量进行过更新。可见,欺骗性唤醒也会导致过早唤醒。欺骗性唤醒虽然在实践中出现的概率非常低,但是由于操作系统是允许这种现象产生的,因此 Java 平台同样也允许这种现象的存在。欺骗性唤醒是 Java  平台对操作系统妥协的一种结果。只要我们将对保护条件的判断和 Object.wait()调用行放在一个循环语句之中,欺骗性唤醒就不会对我们造成实际的影响。

    欺骗性唤醒和信号丢失问题的规避方法前文已经提及:将等待线程对保护条件的判断、Object.wait()的调用放在相应对象所引导的临界区中的一个循环语句之中即可。

    上下文切换问题。wait/notify 的使用可能导致较多的上下文切换。

    首先,等待线程执Object.wait()至少会导致该线程对相应对象内部锁的两次申请与释放。通知线程在执行 Object.notify()/notifyAll()时需要持有相应对象的内部锁,因此 Object.notify()/notifyAll()调用会导致一次锁的申请。而锁的申请与释放可能导致上下文切换。

    其次,等待线程从被暂停到唤醒这个过程本身就会导致上下文切换。

    再次,被唤醒的等待线程在继续运行时需要再次申请相应对象的内部锁。此时等待线程可能需要和相应对象的入口集中的其他线程以及其他新来的活跃线程(即申请相应的内部锁且处于RUNNABLE状态的线程)争用相应的内部锁,而这又可能导致上下文切换。

    最后,过早唤醒问题也会导致额外的上下文切换,这是因为被过早唤醒的线程仍然需要继续等待,即再次经历被暂停和唤醒的过程。

    以下方法有助于避免或者减少wait/notify导致过多的上下文切换。

    • 在保证程序正确性的前提下,使用 Object.notify(替代 Object.notifyAll()。Object.notify()调用不会导致过早唤醒,因此减少了相应的上下文切换开销。

    • 通知线程在执行完 Object.notify()/notifyAll()之后尽快释放相应的内部锁。这样可以避免被唤醒的线程在Object.wait()调用返回前再次申请相应内部锁时,由于该锁尚未被通知线程释放而导致该线程被暂停(以等待再次获得锁的机会)。

    Object.notify()/notifyAll()的选用

    Object.notify()可能导致信号丢失这样的正确性问题,而 Object.notifyAll()虽然效率不太高(把不需要唤醒的等待线程也给唤醒了),但是其在正确性方面有保障。因此实现通知的一种比较流行的保守性方法是优先使用 Object.notifyAll ()以保障正确性。只有在有证据表明使用 Object.notify()足够的情况下才使用 Object.notify()Object.notify ()只有在下列条件全部满足的情况下才能够用于替代 notifyAll 方法。

    条件1一次通知仅需要唤醒至多一个线程。这一点容易理解,但是光满足这一点还不足以用 Object.notify()去替代 Object.notifyAll()。在不同的等待线程可能使用不同的保护条件的情况下,Object.notify()唤醒的一个任意线程可能并不是我们需要唤醒的那一个(种)线程。因此,这个问题还需要通过满足条件 2 来排除。

    条件2相应对象的等待集中仅包含同质等待线程。所谓同质等待线程指这些线程使用同一个保护条件,并且这些线程在 Object.wait ()调用返回之后的处理逻辑一致。最为典型的同质线程是使用同一个 Runnable 接口实例创建的不同线程(实例)或者从同一个 Thread 子类的 new 出来的多个实例。

    注意|Object.notify()唤醒的是其所属对象上的一个任意等待线程。Object.notify()本身在唤醒线程时是不考虑保护条件的。Object.notifyAll()方法唤醒的是其所属对象上的所有等待线程。使用 Object.notify()替代 Object.notify All()时需要确保以下两个条件同时得以满足:

    • 一次通知仅需要唤醒至多一个线程。

    • 相应对象上的所有等待线程都是同质等待线程。

    Java条件变量

    总的来说,Object.wait()/notify()过于底层,并且还存在过早唤醒问题以及 Object.wait (long)无法区分其返回是由于等待超时还是被通知线程唤醒等问题。但是,了解wait/notify 有助于我们理解和维护现有系统,以及学习和使用 JDK1.5中引入的新的标准库类 java.util.concurent.locks.Condition 接口。

    Condition接口可作为 wait/notify 的替代品来实现等待/通知,它为解决过早唤醒问题提供了支持,并解决了Object.wait(long )不能区分其返回是否是由等待超时而导致的问题。 Condition 接口定义的 await 方法 、signal 方法和 signalAll 方法分别相当于 Object.wait()、 Object.notify()和 Object.notifyAll()。

    Lock.newCondition()的返回值就是一个 Condition 实例,因此调用任意一个显式锁实例的newCondition方法可以创建一个相应的Conditio接口。Object.wait()/notify ()要求其执行线程持有这些方法所属对象的内部锁。类似地,Condition.await()/signal()也要求其执行线程持有创建该 Condition 实例的显式锁。Condition 实例也被称为条件变量(Condition Variable)或者条件队列 (Condition   Queue )。每个 Condition  =实例内部都维护了一个用于 =存储等待线程的队列(等待队列)。设 cond1和 cond2是两个不同的 Condition 实例 ,一个线程执行cond1.await()会导致其被暂停(线程生命周期状态变更为 WAITING)并被存入 cond1 的等待队列。cond1.signal()会使cond1 的等待队列中的一个任意线程被唤醒。 cond1.signalAll()会使 cond1的等待队列中的所有线程被唤醒,而cond2 的等待队列中的任何一个等待线程不受此影响。

    Condition接口的使用方法与 wait/notify 的使用方法相似,如下代码模板所示 :

    可见,Condition.await()/signal()的执行线程需要持有创建相应条件变量的显式锁。对保护条件的判断 、Condition.await ()的调用也同样放在一个循环语句之中,并且该循环语句与目标动作的执行放在同一个显式锁所引导的临界区之中,这同样也是考虑到了欺骗性唤醒问题 、信号丢失问题。Condition.await()与 Object.wait()类似,它使当前线程暂停的同时也使当前线程释放其持有的相应显式锁,并且这时 Condition.await()调用也同样未返回。 被唤醒的等待线程继续运行的时候也需要再次申请相应的显式锁,被唤醒的等待线程再次获得相应的显式锁后Condition.await()调用才返回。上述模板代码中的aGuaredMethod 方法是一个受保护方法,anNotificationMethod 方法是个通知方法。

    下面看Condition接口是如何解决过早唤醒问题的。如果我们改用 Condition 接口去实现下图所示的等待/通知,如图所示,情形则有所不同。此时,同步对象someObject 内部可以维护两个条件变量 :cond1  和 cond2。由于 3 个等待线程所使用的保护条件并不完全相同,因此我们可以使等待线程 W1和等待线程 W2 调用 cond1 .await()来实现其等待,

    而等待线程W3则调用 cond2.await()来实现其等待。当通知线程更新了状态变量 state1 之后,该线程只需要调用 cond1.signalAll()来唤醒 cond1 等待队列中的所有等待线程 (W1和 W2 ) 即可。由于线程 W3 进行等待的时候调用的是另外一个条件变量( cond2)的 await  的方法, 它进入的是 cond2 的等待队列,因此通知线程执行 cond1.signalAll()并不会使 W3 被唤醒。 可见,使用 Condition接口之后通知线程在更新 state,后所唤醒的等待线程只有W1和 W2 而等待线程 W3 并不会受此影响,即避免了等待线程 W3 被过早地唤醒。

    可见,应用代码是这样解决过早唤醒问题的:在应用代码这一层次上建立保护条件与条件变量之间的对应关系,即让使用不同保护条件的等待线程调用不同的条件变量的await方法来实现其等待;并让通知线程在更新了共享变量之后,仅调用涉及了这些共享变量的保护条件所对应的条件变量的 signal/signalAll 方法来实现通知。

    注意Condition接口本身只是对解决过早唤醒问题提供了支持。要真正解决过早唤醒问题,我们需要通过应用代码维护保护条件与条件变量之间的对应关系,即使用不同的保护条件的等待线程需要调用不同的条件变量的 await 方法来实现其等待,并使通知线程在更新了相关共享变量之后,仅调用与这些共享变量有关的保护条件所对应的条件变量的 signal/signalAll 方法来实现通知。

    Condition接口还解决了 Object.wait(long)存在的问题,Object.wait(long)无法区分其返回是由于等待超时还是被通知的。Condition.awaitUntil(Date deadline)可以用于实现带超时时间限制的等待,并且该方法的返回值能够区分该方法调用是由于等待超时而返回还是由于其他线程执行了相应条件变量的 signal/signalAll方法而返回。Condition.awaitUntil (Date deadline)的唯一参数 deadline 表示等待的最后期限 ( Deadline )。过了这个时间点就算等待超时。Condition.awaitUntil(Date )返回值 true 表示进行的等待尚未达到最后期限。

    即此时方法的返回是由于其他线程执行了相应条件变量的signal/signalAll方法。由于 Condition.await()/awaitUntil(Date)与 Object.wait()类似,等待线程因执行 Condition. awaitUntil(Date)而被暂停的同时,其持有的相应显式锁(即创建相应条件变量的显式锁) 也会被释放。使等待线程被唤醒之后得以继续运行时需要再次申请相应的显式锁,然后等待线程对 Condition.await()/awaitUntil(Date)的调用才能够返回。在等待线程被唤醒到其再次申请相应的显式锁的这段时间内,其他线程(或者通知线程本身)可能已经抢先获得相 应的显式锁并在其临界区中更新了相关共享变量而使得等待线程所需的保护条件重新不成立。因此,Condition.awaitUntil(Date)返回 true(等待未超时)的情况下我们可以选择继续等待。

    使用条件变量所产生的开销与wait/notify方法基本相似;不过由于条件变量的使用可以避免过早唤醒问题,因此其使用导致的上下文切换要比 wait/notify 少一些。

    相关文章

      网友评论

        本文标题:线程间通信的方式

        本文链接:https://www.haomeiwen.com/subject/kmntbftx.html