Java多線程編程(5)--線程間通信(上)

一.等待與通知

  某些情況下,程序要執行的操作需要滿足一定的條件(下文統一將其稱之爲保護條件)才能執行。在單線程編程中,我們可以使用輪詢的方式來實現,即頻繁地判斷是否滿足保護條件,若不滿足則繼續判斷,若滿足則開始執行。但在多線程編程中,這種方式無疑是非常低效的。如果一個線程持續進行無意義的判斷而不釋放CPU,這就會造成資源的浪費;而如果定時去判斷,不滿足保護條件就釋放CPU,又會造成頻繁的上下文切換。總之,不推薦在多線程編程中使用輪詢的方式。
  等待與通知是這樣一種機制:當保護條件不滿足時,可以將當前線程暫停;而當保護條件成立時,再將這個線程喚醒。一個線程因其保護條件未滿足而被暫停的過程就被稱爲等待,一個線程使得其他線程的保護條件得以滿足的時候喚醒那些被暫停的線程的過程就被稱爲通知。

1.wait

  在Java平臺中,Object.wait方法可以用來實現等待,下面是wait方法的三個重載方法:

  • void wait(long timeoutMillis)
    調用該方法會使線程進入TIMED_WAITING狀態,當等待時間結束或其他線程調用了該對象的notify或notifyAll方法時會將該線程喚醒。
  • void wait(long timeoutMillis, int nanos)
    這個方法看上去可以精確到納秒級別,但實際上並不是。如果nanos的值在0~999999之間,就給timeoutMillis加1,然後調用wait(timeoutMillis)。
  • void wait()
    該方法相當於wait(0),即永不超時。調用後當前線程會進入WAITING狀態,直到其他線程調用了該對象的notify或notifyAll方法。

  先通過一張圖來介紹wait的實現機制:

  在上一篇文章中我們瞭解到JVM會爲每個對象維護一個入口集(Entry Set)用於存儲申請該對象內部鎖的線程。此外,JVM還會爲每個對象維護一個被稱爲等待集(Wait Set)的隊列,該隊列用於存儲該對象上的等待線程。當在線程中調用某個對象(這裏我們稱之爲對象A)的wait方法後,當前線程會釋放內部鎖並進入WAITING或TIMED_WAITING狀態,然後進入等待集中。當其他線程調用對象A的notify方法後,等待集中的某個線程會被喚醒並被移出等待集。這個線程可能會馬上獲得內部鎖,也有可能因競爭內部鎖失敗而進入入口集,直到獲得內部鎖。當重新獲取到內部鎖後,wait方法纔會返回,當前線程繼續執行後面的代碼。
  由於wait方法會釋放內部鎖,因此在wait方法中會判斷當前線程是否持有被調用wait方法的對象的內部鎖。如果當前線程沒有持有該對象的內部鎖,JVM會拋出一個IllegalMonitorStateException異常。因此,wait方法在調用時當前線程必須持有該對象的內部鎖,即wait方法的調用必須要放在由該對象引導的synchronized同步塊中。綜上所述,使用wait方法實現等待的代碼模板如下僞代碼所示:

synchronized(someObject) {
    while(!someCondition) {
        someObject.wait();
    }
    doSomething();
}

  這裏使用while而不是if的原因是,通知線程可能只是更新了保護條件中的共享變量,但並不一定會使保護條件成立;即使通知線程可以保證保護條件成立,但是在線程從等待集進入入口集再到獲取到內部鎖的這段時間內,其他線程仍然可能更新共享變量而導致保護條件不成立。線程雖然因爲保護條件不成立而進入wait方法,但wait方法的返回並不能說明保護條件已經成立。因此,在wait方法返回後需要再次進行判斷,若保護條件成立則執行接下來的操作,否則應該繼續進入wait方法。正是基於這種考慮,我們應該將wait方法的調用放在while循環而不是if判斷中。

2.notify/notifyAll

  下圖是notify的實現機制:

  和wait方法一樣,notify方法在執行時也必須持有對象的內部鎖,否則會拋出IllegalMonitorStateException異常,因此notify方法也必須放在由該對象引導的synchronized同步塊中。notify方法會將等待集中的任意一個線程移出隊列。和wait方法不同的是,notify方法本身不會釋放內部鎖,而是在臨界區代碼執行完成後自動釋放。因此,爲了使等待線程在其被喚醒之後能夠儘快獲得內部鎖,應該儘可能地將notify調用放在靠近臨界區結束的地方。
  調用notify方法所喚醒的線程是相應對象上的一個任意等待線程,但是這個被喚醒的線程可能不是我們真正想要喚醒的那個線程。因此,有時候我們需要藉助notifyAll,它和notify方法的唯一不同之處在於它可以喚醒相應對象上的所有等待線程。

3.過早喚醒問題

  假設通知線程N和等待線程W1和W2同步在對象obj上,W1和W2的保護條件C1和C2均依賴於obj的實例變量state,但C1和C2判斷的內容並不相同。初始狀態下C1和C2均不成立。某一時刻,當線程N更新了共享變量state使得保護條件C1得以成立,此時爲了喚醒W1而執行了obj.notifyAll()方法(調用obj.notify()並不一定會喚醒W1)。由於notifyAll喚醒的是obj上的所有等待線程,因此W2也會被喚醒,即使W2的保護條件並未成立。這就使得W2在被喚醒之後仍然需要繼續等待。這種等待線程在保護條件並未成立的情況下被喚醒的現象被稱爲過早喚醒。過早喚醒使得那些無需被喚醒的等待線程也被喚醒了,造成了資源的浪費。過早喚醒問題可以利用下一節中介紹的Condition接口來解決。

二.條件變量Condition

  總的來說,Object.wait()/notify()過於底層,且Object.wait(long timeout)還存在過早喚醒和無法區分其返回是由於等待超時還是被通知線程喚醒的問題。不過,瞭解wait/notify有助於我們閱讀部分源碼,以及學習和使用Condition接口。
  Condition接口可以作爲wait/notify的替代品來實現等待/通知,它爲解決過早喚醒問題提供了支持,並解決了Object.wait(long timeout)無法區分其返回是由於等待超時還是被通知線程喚醒的問題。Condition接口中定義了以下方法:

  在上一篇文章中,我們在介紹Lock接口時曾經提到過它的newCondition方法,它返回的就是一個Condition實例。類似於Object.wait()/notify()要求其執行線程必須持有這些方法所屬對象的內部鎖,Condition.await()/signal()也要求其執行線程持有創建該Condition實例的顯式鎖。每個Condition實例內部都維護了一個用於存儲等待線程的隊列。設condition1和condition2是從一個顯式鎖上獲取的兩個不同的Condition實例,一個線程執行condition1.await()會導致其被暫停並進入condition1的等待隊列。condition1.signal()會使condition1的等待隊列中的一個任意線程被喚醒,而condition1.signaAll()則會使condition1的等待隊列中的所有線程被喚醒,而condition2的等待隊列中的線程則不受影響。
  和wait/notify類似,await/signal的使用方法如下:

public class ConditionUsage {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    public void waitMethod() throws InterruptedException {
        lock.lock();
        try {
            while (保護條件不成立) {
                condition.await();
            }
            // 業務邏輯
        } finally {
            lock.unlock();
        }
    }
    
    public void notifyMethod() {
        lock.unlock();
        try {
            // 更新共享變量
            condition.signal();
        } finally {
            lock.unlock();
        }
    }
}

  最後,以一個例子來結束本小節。這裏我們以經典的生產者-消費者模型來舉例。假設有一個生產整數的生產者,一個消費奇數的消費者和一個消費偶數的消費者。當生產奇數時,生產者會通知奇數消費者,偶數同理。下面是完整代碼:


展開查看


import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionDemo {
    private final Lock lock = new ReentrantLock();
    private final Condition oddCondition = lock.newCondition();
    private final Condition evenCondition = lock.newCondition();
    private final Random random = new Random();
    private volatile Integer message;
    private AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) {
        ConditionDemo demo = new ConditionDemo();
        Thread producer = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                demo.produce();
            }
        });
        producer.start();
        Thread oddConsumer = new Thread(() -> {
            while (true) {
                try {
                    demo.consumeOdd();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread evenConsumer = new Thread(() -> {
            while (true) {
                try {
                    demo.consumeEven();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        oddConsumer.start();
        evenConsumer.start();
    }

    public void produce() {
        lock.lock();
        if (message == null) {
            message = random.nextInt(100) + 1;
            count.incrementAndGet();
            if (message % 2 == 0) {
                evenCondition.signal();
                System.out.println("Produce even : " + message);
            } else {
                oddCondition.signal();
                System.out.println("Produce odd : " + message);
            }
        }
        lock.unlock();
    }

    public void consumeOdd() throws InterruptedException {
        lock.lock();
        while (message == null) {
            oddCondition.await();
        }
        System.out.println("Consume odd : " + message);
        message = null;
        lock.unlock();
    }

    public void consumeEven() throws InterruptedException {
        lock.lock();
        while (message == null) {
            evenCondition.await();
        }
        System.out.println("Consume even : " + message);
        message = null;
        lock.unlock();
    }
}

  該程序的輸出如下:

Produce even : 34
Consume even : 34
Produce odd : 43
Consume odd : 43
Produce even : 28
Consume even : 28
Produce odd : 27
Consume odd : 27
Produce even : 92
Consume even : 92
...

三.倒數計數器CountDownLatch

  有時候,我們希望一個線程在另一個或多個線程結束之後再繼續執行,這時候我們最先想到的肯定是Thread.join()。有時我們又希望一個線程不一定需要其他線程結束,而只是等其他線程執行完特定的操作就繼續執行。這種情況下無法使用Thread.join(),因爲它會導致當前線程等待其他線程完全結束。當然,此時可以用共享變量來實現。不過,Java爲我們提供了更加方便的工具類來解決上面說的這些情況,那就是CountDownLatch。
  可以將CountDownLatch理解爲一個可以在多個線程之間使用的計數器。這個類提供了以下方法:

  CountDownLatch內部也維護了一個用於存放等待線程的隊列。當計數器不爲0時,調用await方法的線程會被暫停並進入該隊列。當某個線程調用countDown方法的時候,計數器會減1。當計數器到0的時候,等待隊列中的所有線程都會被喚醒。計數器的初始值是在CountDownLatch的構造方法中指定的:

public CountDownLatch(int count)

  當計數器的值達到0之後就不會再變化。此時,調用countDown方法並不會導致異常的拋出,並且後續執行await方法的線程也不會被暫停。因此,CountDownLatch的使用是一次性的。此外,由於CountDownLatch是線程安全的,因此在調用await、countDown方法時無需加鎖。
  下面的例子中,主線程等待兩個子線程結束之後再繼續執行。這裏使用了CountDownLatch來實現:

import java.util.concurrent.CountDownLatch;

public class CountDownLatchDemo {
    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(2);
        Runnable task = () -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " finished.");
            latch.countDown();
        };
        new Thread(task, "Thread 1").start();
        new Thread(task, "Thread 2").start();
        try {
            latch.await();
        } catch (InterruptedException e) {
            return;
        }
        System.out.println("Main thread continued.");
    }
}

  該程序輸出如下:

Thread 2 finished.
Thread 1 finished.
Main thread continued.

  可以看到,當線程1和線程2執行完成後,主線程纔開始繼續執行。
  如果CountDownLatch內部計數器由於程序的錯誤而永遠無法達到0,那麼相應實例上的等待線程會一直處於WAITING狀態。避免該問題的出現有兩種方法:一是確保所有對countDown方法的調用都位於代碼中正確的位置,例如放在finally塊中。二是使用帶有時間限制的await方法。如果在規定時間內計時器值未達到0,該CountDownLatch實例上的等待線程也會被喚醒。該方法的返回值可以用於區分其返回是否是由於等待超時。
  此外,對於同一個CountDownLatch實例latch,latch.countDown()的執行線程在執行該方法之前所執行的任何內存操作對等待線程在latch.await()調用之後的代碼是可見的且有序的。

四.循環屏障CyclicBarrier

  有時候多個線程可能需要互相等待對方執行到代碼中的某個地方纔能繼續執行。這就類似於我們在開會的時候必須等待所有與會人員都到場之後才能開始。Java中爲我們提供了一個工具類CyclicBarrier,該類可以用來實現這種等待。
  使用CyclicBarrier實現等待的線程被稱爲參與方(Party)。參與方只需要CyclicBarrier.await()就可以實現等待。和CountDownLatch類似,CyclicBarrier也有一個計數器。當最後一個線程調用CyclicBarrier.await()時,之前的等待線程都會被喚醒,而最後一個線程本身並不會被暫停。和CountDownLatch不同的是,CyclicBarrier是可以重複使用的,這也是爲什麼它的類名中含有Cyclic。當所有參與方被喚醒的時候,任何線程再次執行await方法又會導致該線程被暫停。
  CyclicBarrier提供了兩個構造器:

public CyclicBarrier​(int parties)
public CyclicBarrier​(int parties, Runnable barrierAction)

  可以看到,在構造CyclicBarrier​時,必須提供參與方的數量。第二個構造器還允許我們指定一個被稱爲barrierAction的任務(Runnable接口實例),該任務會被最後一個執行await方法的線程執行。因此,如果有需要在喚醒所有線程前執行的操作,可以使用這個構造器。
  CyclicBarrier提供了以下6個方法:
1.public int await() throws InterruptedException,BrokenBarrierException
  如果當前線程不是最後一個參與方,那麼該線程在調用await()後將持續等待直到以下情況發生:

  • 最後一個線程到達;
  • 當前線程被中斷;
  • 其他正在等待的線程被中斷;
  • 其他線程等待超時;
  • 其他線程調用了當前屏障的reset()。

  如果當前線程在進入await()方法使已經被標記中斷狀態或在等待時被中斷,那麼await()將會拋出InterruptedException並清除當前線程的中斷狀態。
  如果屏障在參與方等待時被重置或被破壞,或者在調用await()時屏障已經被破壞,那麼await()將會拋出BrokenBarrierException。
  如果某個線程在等待時被中斷,那麼其他等待線程將會拋出BrokenBarrierException並且屏障也會被標記爲broken狀態。
  該方法的返回值表示當前線程的到達索引,getParties()-1表示第一個到達,0表示最後一個到達。
2.public int await​(long timeout,TimeUnit unit) throws InterruptedException,BrokenBarrierException,TimeoutException
  該方法與相當於有時間限制的await(),等待時間結束之後該線程將會拋出TimeOutException,屏障會被標記爲broken狀態,其他正在等待的線程則會拋出BrokenBarrierException。
3.public int getNumberWaiting()
  返回當前正在等待的參與方的數量。
4.public int getParties()
  返回總的參與方的數量。
5.public boolean isBroken()
  如果該屏障已經被破壞則返回true,否則返回false。當等待線程超時或被中斷,或者在執行barrierAction時出現異常,屏障將會被破壞。
6.public void reset()
  將屏障恢復到初始狀態,如果有正在等待的線程,這些線程會拋出BrokenBarrierException異常。

  下面我們通過一個例子來學習如何使用CyclicBarrier。假設現在正在舉行短跑比賽,共有8名參賽選手,而場地上只有4條賽道,因此需要分爲兩場比賽。每場比賽必須等4名選手全都就緒纔可以開始,而上一場比賽結束之後即全部選手離開賽道之後才能進行下一場比賽。該示例代碼如下所示:


展開查看


import java.util.Random;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicInteger;

public class CyclicBarrierDemo {
    private CyclicBarrier startBarrier = new CyclicBarrier(4, () -> System.out.println("比賽開始!"));
    private CyclicBarrier shiftBarrier = new CyclicBarrier(4, () -> System.out.println("比賽結束!"));
    private Runner[] runners = new Runner[8];
    private AtomicInteger next = new AtomicInteger(0);

    CyclicBarrierDemo() {
        for (int i = 0; i < 8; i++) {
            runners[i] = new Runner(i / 4 + 1, i % 4 + 1);
        }
    }

    public static void main(String[] args) {
        CyclicBarrierDemo demo = new CyclicBarrierDemo();
        for (int i = 0; i < 4; i++) {
            demo.new Track().start();
        }
    }

    private class Track extends Thread {
        private Random random = new Random();

        @Override
        public void run() {
            for (int i = 0; i < 2; i++) {
                try {
                    Runner runner = runners[next.getAndIncrement()];
                    System.out.println(runner.getGroup() + "組" + runner.getNumber() + "號準備就緒!");
                    startBarrier.await();
                    System.out.println(runner.getGroup() + "組" + runner.getNumber() + "號出發!");
                    Thread.sleep((random.nextInt(5) + 1) * 1000);
                    System.out.println(runner.getGroup() + "組" + runner.getNumber() + "號到達終點!");
                    shiftBarrier.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    private static class Runner {
        private int group;
        private int number;

        Runner(int group, int number) {
            this.group = group;
            this.number = number;
        }

        int getGroup() {
            return group;
        }

        int getNumber() {
            return number;
        }
    }
}

  該程序輸出如下:


展開查看


1組4號準備就緒!
1組2號準備就緒!
1組3號準備就緒!
1組1號準備就緒!
比賽開始!
1組4號出發!
1組2號出發!
1組1號出發!
1組3號出發!
1組3號到達終點!
1組2號到達終點!
1組4號到達終點!
1組1號到達終點!
比賽結束!
2組1號準備就緒!
2組2號準備就緒!
2組3號準備就緒!
2組4號準備就緒!
比賽開始!
2組4號出發!
2組1號出發!
2組3號出發!
2組2號出發!
2組1號到達終點!
2組4號到達終點!
2組3號到達終點!
2組2號到達終點!
比賽結束!

五.總結

  等待線程可以通過執行Object.wait()/wait(long)來實現等待,通知線程可以通過執行Object.notify()/notifyAll()來實現通知。等待線程和通知線程在執行Object.wait()/wait(long)、Object.notify()/notifyAll()時必須持有相應對象對應的內部鎖。爲了保證線程被喚醒時保護條件一定是成立的,應該將對保護條件的判斷、Object.wait()/wait(long)的調用放在相應對象所引導的臨界區中的一個循環之中。
  條件變量(Condition接口)是wait/notify的替代品。Condition接口對解決過早喚醒問題提供了支持,它的await(long)還解決了Object.wait(long)無法區分其返回是否是因爲等待超時的問題。
  CountDownLatch能夠用來實現一個線程等待其他線程執行的特定操作的結束。等待線程執行CountDownLatch.await(),通知線程執行CountDownLatch.countDown()。爲避免等待線程永遠處於暫停狀態而無法被喚醒,對countDown()的調用通常需要被放在finally塊中。一個CountDownLatch實例只能實現一次等待/通知。對於同一個CountDownLatch實例latch,latch.countDown()的執行線程在執行該方法之前所執行的任何內存操作對等待線程在latch.await()調用之後的代碼是可見的且有序的。
  CyclicBarrier能夠用於實現多個線程間的相互等待。CyclicBarrier.await()既是等待方法又是通知方法。CyclicBarrier實例的所有參與方除最後一個線程外都相當於等待線程,最後一個線程則相當於通知線程。與CountDownLatch 不同的是,CyclicBarrier實例是可以複用的,一個CountDownLatch實例可以實現多次等待/通知。
  線程間的通信方式遠不止上面介紹的這些,在下一篇文章中,我們將會繼續學習線程間的通信方式。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章