五 .线程通信

在前面已经对 wait()notify()notifyAll() 进行了讲解,并得出了等待/通知机制的基本范式,接下来就对如何得到此范式做一个分析。

线程信令的目的是使线程能够相互发送信号。另外,线程信令使线程能够等待来自其他线程的信号。例如,线程B可能等待来自线程A的信号,指示数据已准备好被处理。

通过共享对象发送信号

线程相互发送信号的一种简单方法是在某个共享对象变量中设置信号值。线程A可以从同步块内部将布尔成员变量hasDataToProcess设置为true,然后线程B可以读取同步块内的hasDataToProcess成员变量。下面是一个可以保存这种信号的对象的简单示例,并提供了设置和检查它的方法:

public class MySignal{
    protected boolean hasDataToProcess = false;

    public synchronized boolean hasDataToProcess(){
        return this.hasDataToProcess;
    }

    public synchronized void setHasDataToProcess(boolean hasData){
        this.hasDataToProcess = hasData;  
    }
}

线程A和B必须都要有对同一个MySignal实例的引用才能使信号工作。如果线程A和B具有对不同MySignal实例的引用,则它们将不会检测彼此的信号。要处理的数据可以位于与MySignal实例分开的共享缓冲区中。

忙等待

处理数据的线程B正在等待可用于处理的数据。换句话说,它正在等待来自线程A的信号,线程A能够让hasDataToProcess()方法返回true。这是线程B在等待此信号时运行的循环:

protected MySignal sharedSignal = ...

...

while(!sharedSignal.hasDataToProcess()){
  //do nothing... busy waiting
}

注意while循环如何一直执行,直到hasDataToProcess()返回true,这称为忙等待,等待时线程正忙。

wait(), notify() and notifyAll()

等待线程忙等待运行时不能有效地利用计算机的CPU,除非平均等待时间非常短。否则,如果等待的线程能以某种方式睡眠或变为非活动状态,直到它收到它正在等待的信号,那将更加智能。

Java有一个内置的等待机制,可以让线程在等待信号时变为非活动状态。java.lang.Object类定义了三个方法,wait()notify()notifyAll(),以方便这一点。

在任何对象上调用wait()的线程将变为非活动状态,直到另一个线程在该对象上调用notify()。为了调用wait()或通知调用线程必须首先获取该对象的锁。换句话说,调用线程必须从同步块内部调用wait()notify()。这是一个名为MyWaitNotify的MySignal的修改版本,它使用wait()notify()

public class MonitorObject {
}

public class MyWaitNotify {
    MonitorObject myMonitorObject = new MonitorObject();

    public void doWait() {
        synchronized(myMonitorObject) {
            try{
                myMonitorObject.wait();
            } catch(InterruptedException e) {...}
        }
    }

    public void doNotify(){
        synchronized(myMonitorObject){
            myMonitorObject.notify();
        }
    }
}

等待线程将调用 doWait() ,通知线程将调用 doNotify() 。当一个线程调用一个对象上的 notify() 时,一个在该对象等待的线程被唤醒并被允许执行。还有一个 notifyAll() 方法将唤醒等待给定对象的所有线程。

正如您所看到的,等待和通知线程都在同步块内调用 wait()notify() 。这是强制性的!线程在没有持有调用该方法的对象的锁时,不能调用 wait()notify()notifyAll() 。如果调用的话,则抛出IllegalMonitorStateException

但是,这怎么做到呢?只要在同步块内执行,等待线程不会一直持有监视器对象(myMonitorObject)的锁吗?等待线程是否会阻止通知线程进入 doNotify() 中的同步块?答案是不。一旦线程调用 wait() ,它就会释放它在监视器对象上持有的锁。这允许其他线程也调用 wait()notify() ,因为必须从synchronized块内调用这些方法。

一旦线程被唤醒,它不能立刻退出 wait() 调用,直到调用 notify() 的线程离开其synchronized块。换句话说:被唤醒的线程必须重新获取监视器对象上的锁才能退出 wait() 调用,因为等待调用嵌套在同步块中。如果使用 notifyAll() 唤醒多个线程,则一次只有一个被唤醒的线程可以退出 wait() 方法,因为每个线程必须在退出 wait() 之前依次获取监视器对象上的锁。

信号丢失

当在调用 notify()notifyAll() 方法时如果没有线程在等待,notify()notifyAll() 方法不会保持对等待线程的方法调用。然后,通知信号就丢失了。因此,如果线程在被通知的线程调用 wait() 之前调用 notify() ,则等待线程将丢失该信号。这可能是也可能不是问题,但在某些情况下,这可能导致等待线程永远等待,永不醒来,因为错过了唤醒信号。

为避免丢失信号,应将它们存储在信号类中。在MyWaitNotify示例中,通知信号应存储在MyWaitNotify实例内的成员变量中。以下是MyWaitNotify的修改版本:

public class MyWaitNotify2 {
    MonitorObject myMonitorObject = new MonitorObject();
    boolean wasSignalled = false;

    public void doWait() {
        synchronized(myMonitorObject) {
            if(!wasSignalled) {
                try {
                    myMonitorObject.wait();
               } catch(InterruptedException e) {...}
            }
            //clear signal and continue running.
            wasSignalled = false;
        }
    }

    public void doNotify() {
        synchronized(myMonitorObject) {
            wasSignalled = true;
            myMonitorObject.notify();
        }
    }
}

注意 doNotify() 方法现在在调用 notify() 之前将wasSignalled变量设置为true。另外,注意 doWait() 方法现在在调用 wait() 之前检查wasSignalled变量。事实上,如果在 doWait() 调用之前和期间没有收到信号,它只调用 wait()

意外唤醒

由于莫名其妙的原因,即使没有调用 notify()notifyAll() ,也可以唤醒线程,这被称为虚假唤醒,线程没有任何理由的醒来。

如果在MyWaitNofity2类的 doWait() 方法中发生虚假唤醒,则等待线程在没有收到正确的信号时也可以继续处理,这可能会导致应用程序出现严重问题。

为防止虚假唤醒,在while循环内而不是if语句内部检查信号成员变量。这样的while循环也称为自旋锁。被唤醒的线程自旋,直到自旋锁(while循环)中的条件变为false。以下是MyWaitNotify2的修改版本,如下:

public class MyWaitNotify3 {
    MonitorObject myMonitorObject = new MonitorObject();
    boolean wasSignalled = false;

    public void doWait() {
        synchronized(myMonitorObject) {
            while(!wasSignalled) {
                try{
                    myMonitorObject.wait();
               } catch(InterruptedException e) {...}
            }
            //clear signal and continue running.
            wasSignalled = false;
        }
    }

    public void doNotify() {
        synchronized(myMonitorObject) {
            wasSignalled = true;
            myMonitorObject.notify();
        }
    }
}

注意 wait() 调用现在嵌套在while循环而不是if语句中。如果等待的线程在没有收到信号的情况下唤醒,则wasSignalled值仍将为false,并且while循环将再次执行,导致唤醒的线程返回等待。

多个线程等待同一信号

如果你有多个线程在等待,那么while循环也是一个不错的解决方案,它们是被用notifyAll() 唤醒的,但是只允许其中一个线程继续执行。一次只有一个线程能够获取监视器对象的锁,这意味着只有一个线程可以退出 wait() 调用并清除wasSignalled标志。一旦该线程退出 doWait() 方法中的synchronized块,其他线程也可以获取锁然后退出 wait() 调用并检查while循环内的wasSignalled成员变量。但是,这个标志在第一个线程唤醒时被清除,因此其余的唤醒线程返回等待,直到下一个信号到达。

不要在常量String或全局对象上调用wait()

本文的早期版本有一个MyWaitNotify示例类,它使用常量字符串("")作为监视对象。以下是该示例的样子:

public class MyWaitNotify{
    String myMonitorObject = "";
    boolean wasSignalled = false;

    public void doWait(){
        synchronized(myMonitorObject){
            while(!wasSignalled){
                try{
                    myMonitorObject.wait();
               } catch(InterruptedException e){...}
            }
            //clear signal and continue running.
            wasSignalled = false;
        }
    }

    public void doNotify(){
        synchronized(myMonitorObject){
          wasSignalled = true;
          myMonitorObject.notify();
        }
    }
}

在空字符串或任何其他常量字符串上调用 wait()notify() 的问题是,JVM / Compiler在内部将常量字符串转换为同一对象。这意味着,即使您有两个不同的MyWaitNotify实例,它们也引用相同的空字符串实例。这也意味着在第一个MyWaitNotify实例上调用 doWait() 的线程可能会被第二个MyWaitNotify实例上的 doNotify() 调用唤醒。

情况如下图所示:

请记住,即使4个线程在同一个共享字符串实例上调用 wait()notify() ,来自doWait()和doNotify()调用的信号也会分别存储在两个MyWaitNotify实例中。MyWaitNotify 1上的 doNotify() 调用可能会唤醒在MyWaitNotify 2中等待的线程,但该信号将仅存储在MyWaitNotify 1中。

这可能不是一个大问题。毕竟,如果在第二个 MyWaitNotify 实例上调用 doNotify() ,那么真正发生的是线程A和B被错误唤醒。这个唤醒的线程(A或B)将在while循环中检查其信号,然后返回等待,因为在第一个MyWaitNotify实例上没有调用 doNotify() ,所以信号没有被改变,设置为true。这种情况等于主动的虚假唤醒。线程A或B在没有发出信号的情况下唤醒。但是代码可以处理这个问题,所以线程会回来等待。

问题是,由于 doNotify() 调用只调用 notify() 而不调用 notifyAll() ,因此即使4个线程在同一个字符串实例(空字符串)上等待,也只会唤醒一个线程。因此,一个信号真正用于C或D时,但是线程A或B中的一个被唤醒,则被唤醒线程(A或B)将检查其信号,看到没有接收到信号,然后返回等待。C或D不会醒来检查他们实际收到的信号,因此信号丢失了。这种情况等同于前面描述的信号丢失问题,C和D被发送了一个信号但没有响应它。

如果 doNotify() 方法调用了 notifyAll() 而不是 notify() ,则所有等待的线程都被唤醒并依次检查信号。线程A和B将返回等待,但C或D中的一个会发现该信号并离开 doWait() 方法调用。C和D中的另一个将返回等待,因为发现信号的线程在离开 doWait() 时清除了信号。

你可能会被诱惑然后总是调用 notifyAll() 而不是 notify() ,但这是一个糟糕的主意。当只有其中一个线程能够响应信号时,没有理由唤醒所有等待的线程。

所以:不要对 wait() / notify() 机制使用全局对象,字符串常量等。例如,每个MyWaitNotify3(前面部分的示例)实例都有自己的MonitorObject实例,而不是使用空字符串进行 wait() / notify() 调用。

下面是一个说明上面问题的示例:

import org.junit.Test;

import static java.lang.Thread.sleep;

public class MyWaitNotify {
    private final String myMonitorObject = "";
    boolean wasSignalled = false;

    public void doWait() {
        synchronized(myMonitorObject) {
            while(!wasSignalled) {
                try{
                    myMonitorObject.wait();                  
                    System.out.println(Thread.currentThread().getName() + " is notified");
                } catch(InterruptedException e) {
                    e.printStackTrace();
                }
            }
            //clear signal and continue running.
            wasSignalled = false;
        }
    }

    public void doNotify() {
        synchronized(myMonitorObject) {
            wasSignalled = true;
            myMonitorObject.notify();
        }
    }

    @Test
    public void test() throws Exception {
        MyWaitNotify myWaitNotify1 = new MyWaitNotify();
        MyWaitNotify myWaitNotify2 = new MyWaitNotify();

        Thread thread1 = new Thread(() -> {
            myWaitNotify1.doWait();
            System.out.println(Thread.currentThread().getName() + " is notified successfully");
        }, "Thread-1");
        Thread thread2 = new Thread(() -> {
            myWaitNotify1.doWait();
            System.out.println(Thread.currentThread().getName() + " is notified successfully");
        }, "Thread-2");
        Thread thread3 = new Thread(() -> {
            myWaitNotify2.doWait();
            System.out.println(Thread.currentThread().getName() + " is notified successfully");
        }, "Thread-3");
        Thread thread4 = new Thread(() -> {
            myWaitNotify2.doNotify();
            System.out.println(Thread.currentThread().getName() + " notify");
        }, "Thread-4");

        thread1.start();
        thread2.start();
        thread3.start();
        //等待三个线程充分运行,即期待他们都已经在等待
        sleep(1000);
        thread4.start();

        sleep(1000);
    }
}

输出结果如下:

Thread-4 notify
Thread-1 is notified

可能需要多次运行才会出现线程1或线程2被通知的情况。

经过此章内容的讲解,相信对等待/通知的范式是如何形成的,有了一个充分的认知。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容