Java 併發程式設計:執行緒間的協作(wait/notify/sleep/yield/join)

葛倫兒發表於2017-10-12

Java併發程式設計系列:

一、執行緒的狀態

   Java中執行緒中狀態可分為五種:New(新建狀態),Runnable(就緒狀態),Running(執行狀態),Blocked(阻塞狀態),Dead(死亡狀態)。

  New:新建狀態,當執行緒建立完成時為新建狀態,即new Thread(...),還沒有呼叫start方法時,執行緒處於新建狀態。

  Runnable:就緒狀態,當呼叫執行緒的的start方法後,執行緒進入就緒狀態,等待CPU資源。處於就緒狀態的執行緒由Java執行時系統的執行緒排程程式(thread scheduler)來排程。

  Running:執行狀態,就緒狀態的執行緒獲取到CPU執行權以後進入執行狀態,開始執行run方法。

  Blocked:阻塞狀態,執行緒沒有執行完,由於某種原因(如,I/O操作等)讓出CPU執行權,自身進入阻塞狀態。

  Dead:死亡狀態,執行緒執行完成或者執行過程中出現異常,執行緒就會進入死亡狀態。

  這五種狀態之間的轉換關係如下圖所示:

  

  有了對這五種狀態的基本瞭解,現在我們來看看Java中是如何實現這幾種狀態的轉換的。 

二、wait/notify/notifyAll方法的使用

  1、wait方法:

void wait() Causes the current thread to wait until another thread invokes the notify() method or the notifyAll() method for this object.
void wait(long timeout) Causes the current thread to wait until either another thread invokes the notify() method or the notifyAll() method for this object, or a specified amount of time has elapsed.
void wait(long timeout, int nanos) Causes the current thread to wait until another thread invokes the notify() method or the notifyAll() method for this object, or some other thread interrupts the current thread, or a certain amount of real time has elapsed.

  JDK中一共提供了這三個版本的方法,

  (1)wait()方法的作用是將當前執行的執行緒掛起(即讓其進入阻塞狀態),直到notify或notifyAll方法來喚醒執行緒.

  (2)wait(long timeout),該方法與wait()方法類似,唯一的區別就是在指定時間內,如果沒有notify或notifAll方法的喚醒,也會自動喚醒。

  (3)至於wait(long timeout,long nanos),本意在於更精確的控制排程時間,不過從目前版本來看,該方法貌似沒有完整的實現該功能,其原始碼(JDK1.8)如下:

 1 public final void wait(long timeout, int nanos) throws InterruptedException {
 2         if (timeout < 0) {
 3             throw new IllegalArgumentException("timeout value is negative");
 4         }
 5 
 6         if (nanos < 0 || nanos > 999999) {
 7             throw new IllegalArgumentException(
 8                                 "nanosecond timeout value out of range");
 9         }
10 
11         if (nanos >= 500000 || (nanos != 0 && timeout == 0)) {
12             timeout++;
13         }
14 
15         wait(timeout);
16     }

  從原始碼來看,JDK8中對納秒的處理,只做了四捨五入,所以還是按照毫秒來處理的,可能在未來的某個時間點會用到納秒級別的精度。雖然JDK提供了這三個版本,其實最後都是呼叫wait(long timeout)方法來實現的,wait()方法與wait(0)等效,而wait(long timeout,int nanos)從上面的原始碼可以看到也是通過wait(long timeout)來完成的。下面我們通過一個簡單的例子來演示wait()方法的使用:

 1 package com.paddx.test.concurrent;
 2 
 3 public class WaitTest {
 4 
 5     public void testWait(){
 6         System.out.println("Start-----");
 7         try {
 8             wait(1000);
 9         } catch (InterruptedException e) {
10             e.printStackTrace();
11         }
12         System.out.println("End-------");
13     }
14 
15     public static void main(String[] args) {
16         final WaitTest test = new WaitTest();
17         new Thread(new Runnable() {
18             @Override
19             public void run() {
20                 test.testWait();
21             }
22         }).start();
23     }
24 }

  這段程式碼的意圖很簡單,就是程式執行以後,讓其暫停一秒,然後再執行。執行上述程式碼,檢視結果:

Start-----
Exception in thread "Thread-0" java.lang.IllegalMonitorStateException
	at java.lang.Object.wait(Native Method)
	at com.paddx.test.concurrent.WaitTest.testWait(WaitTest.java:8)
	at com.paddx.test.concurrent.WaitTest$1.run(WaitTest.java:20)
	at java.lang.Thread.run(Thread.java:745)

  這段程式並沒有按我們的預期輸出相應結果,而是丟擲了一個異常。大家可能會覺得奇怪為什麼會丟擲異常?而丟擲的IllegalMonitorStateException異常又是什麼?我們可以看一下JDK中對IllegalMonitorStateException的描述:

Thrown to indicate that a thread has attempted to wait on an object's monitor or to notify other threads waiting on an object's monitor without owning the specified monitor.

  這句話的意思大概就是:執行緒試圖等待物件的監視器或者試圖通知其他正在等待物件監視器的執行緒,但本身沒有對應的監視器的所有權。其實這個問題在《Java併發程式設計:Synchronized及其實現原理》一文中有提到過,wait方法是一個本地方法,其底層是通過一個叫做監視器鎖的物件來完成的。所以上面之所以會丟擲異常,是因為在呼叫wait方式時沒有獲取到monitor物件的所有權,那如何獲取monitor物件所有權?Java中只能通過Synchronized關鍵字來完成,修改上述程式碼,增加Synchronized關鍵字:

 1 package com.paddx.test.concurrent;
 2 
 3 public class WaitTest {
 4 
 5     public synchronized void testWait(){//增加Synchronized關鍵字
 6         System.out.println("Start-----");
 7         try {
 8             wait(1000);
 9         } catch (InterruptedException e) {
10             e.printStackTrace();
11         }
12         System.out.println("End-------");
13     }
14 
15     public static void main(String[] args) {
16         final WaitTest test = new WaitTest();
17         new Thread(new Runnable() {
18             @Override
19             public void run() {
20                 test.testWait();
21             }
22         }).start();
23     }
24 }

  現在再執行上述程式碼,就能看到預期的效果了:

Start-----
End-------

  所以,通過這個例子,大家應該很清楚,wait方法的使用必須在同步的範圍內,否則就會丟擲IllegalMonitorStateException異常,wait方法的作用就是阻塞當前執行緒等待notify/notifyAll方法的喚醒,或等待超時後自動喚醒。

2、notify/notifyAll方法

void notify() Wakes up a single thread that is waiting on this object's monitor.
void notifyAll() Wakes up all threads that are waiting on this object's monitor.

  有了對wait方法原理的理解,notify方法和notifyAll方法就很容易理解了。既然wait方式是通過物件的monitor物件來實現的,所以只要在同一物件上去呼叫notify/notifyAll方法,就可以喚醒對應物件monitor上等待的執行緒了。notify和notifyAll的區別在於前者只能喚醒monitor上的一個執行緒,對其他執行緒沒有影響,而notifyAll則喚醒所有的執行緒,看下面的例子很容易理解這兩者的差別:

 1 package com.paddx.test.concurrent;
 2 
 3 public class NotifyTest {
 4     public synchronized void testWait(){
 5         System.out.println(Thread.currentThread().getName() +" Start-----");
 6         try {
 7             wait(0);
 8         } catch (InterruptedException e) {
 9             e.printStackTrace();
10         }
11         System.out.println(Thread.currentThread().getName() +" End-------");
12     }
13 
14     public static void main(String[] args) throws InterruptedException {
15         final NotifyTest test = new NotifyTest();
16         for(int i=0;i<5;i++) {
17             new Thread(new Runnable() {
18                 @Override
19                 public void run() {
20                     test.testWait();
21                 }
22             }).start();
23         }
24 
25         synchronized (test) {
26             test.notify();
27         }
28         Thread.sleep(3000);
29         System.out.println("-----------分割線-------------");
30         
31         synchronized (test) {
32             test.notifyAll();
33         }
34     }
35 }

輸出結果如下:

Thread-0 Start-----
Thread-1 Start-----
Thread-2 Start-----
Thread-3 Start-----
Thread-4 Start-----
Thread-0 End-------
-----------分割線-------------
Thread-4 End-------
Thread-3 End-------
Thread-2 End-------
Thread-1 End-------

  從結果可以看出:呼叫notify方法時只有執行緒Thread-0被喚醒,但是呼叫notifyAll時,所有的執行緒都被喚醒了。

  最後,有兩點點需要注意:

  (1)呼叫wait方法後,執行緒是會釋放對monitor物件的所有權的。

  (2)一個通過wait方法阻塞的執行緒,必須同時滿足以下兩個條件才能被真正執行:

  •     執行緒需要被喚醒(超時喚醒或呼叫notify/notifyll)。
  •     執行緒喚醒後需要競爭到鎖(monitor)。

三、sleep/yield/join方法解析

   上面我們已經清楚了wait和notify方法的使用和原理,現在我們再來看另外一組執行緒間協作的方法。這組方法跟上面方法的最明顯區別是:這幾個方法都位於Thread類中,而上面三個方法都位於Object類中。至於為什麼,大家可以先思考一下。現在我們逐個分析sleep/yield/join方法:

  1、sleep

  sleep方法的作用是讓當前執行緒暫停指定的時間(毫秒),sleep方法是最簡單的方法,在上述的例子中也用到過,比較容易理解。唯一需要注意的是其與wait方法的區別。最簡單的區別是,wait方法依賴於同步,而sleep方法可以直接呼叫。而更深層次的區別在於sleep方法只是暫時讓出CPU的執行權,並不釋放鎖。而wait方法則需要釋放鎖。

 1 package com.paddx.test.concurrent;
 2 
 3 public class SleepTest {
 4     public synchronized void sleepMethod(){
 5         System.out.println("Sleep start-----");
 6         try {
 7             Thread.sleep(1000);
 8         } catch (InterruptedException e) {
 9             e.printStackTrace();
10         }
11         System.out.println("Sleep end-----");
12     }
13 
14     public synchronized void waitMethod(){
15         System.out.println("Wait start-----");
16         synchronized (this){
17             try {
18                 wait(1000);
19             } catch (InterruptedException e) {
20                 e.printStackTrace();
21             }
22         }
23         System.out.println("Wait end-----");
24     }
25 
26     public static void main(String[] args) {
27         final SleepTest test1 = new SleepTest();
28 
29         for(int i = 0;i<3;i++){
30             new Thread(new Runnable() {
31                 @Override
32                 public void run() {
33                     test1.sleepMethod();
34                 }
35             }).start();
36         }
37 
38 
39         try {
40             Thread.sleep(10000);//暫停十秒,等上面程式執行完成
41         } catch (InterruptedException e) {
42             e.printStackTrace();
43         }
44         System.out.println("-----分割線-----");
45 
46         final SleepTest test2 = new SleepTest();
47 
48         for(int i = 0;i<3;i++){
49             new Thread(new Runnable() {
50                 @Override
51                 public void run() {
52                     test2.waitMethod();
53                 }
54             }).start();
55         }
56 
57     }
58 }

 執行結果:

Sleep start-----
Sleep end-----
Sleep start-----
Sleep end-----
Sleep start-----
Sleep end-----
-----分割線-----
Wait start-----
Wait start-----
Wait start-----
Wait end-----
Wait end-----
Wait end-----

  這個結果的區別很明顯,通過sleep方法實現的暫停,程式是順序進入同步塊的,只有當上一個執行緒執行完成的時候,下一個執行緒才能進入同步方法,sleep暫停期間一直持有monitor物件鎖,其他執行緒是不能進入的。而wait方法則不同,當呼叫wait方法後,當前執行緒會釋放持有的monitor物件鎖,因此,其他執行緒還可以進入到同步方法,執行緒被喚醒後,需要競爭鎖,獲取到鎖之後再繼續執行。

2、yield方法
  yield方法的作用是暫停當前執行緒,以便其他執行緒有機會執行,不過不能指定暫停的時間,並且也不能保證當前執行緒馬上停止。yield方法只是將Running狀態轉變為Runnable狀態。我們還是通過一個例子來演示其使用:

 1 package com.paddx.test.concurrent;
 2 
 3 public class YieldTest implements Runnable {
 4     @Override
 5     public void run() {
 6         try {
 7             Thread.sleep(100);
 8         } catch (InterruptedException e) {
 9             e.printStackTrace();
10         }
11         for(int i=0;i<5;i++){
12             System.out.println(Thread.currentThread().getName() + ": " + i);
13             Thread.yield();
14         }
15     }
16 
17     public static void main(String[] args) {
18         YieldTest runn = new YieldTest();
19         Thread t1 = new Thread(runn,"FirstThread");
20         Thread t2 = new Thread(runn,"SecondThread");
21 
22         t1.start();
23         t2.start();
24 
25     }
26 }

執行結果如下:

FirstThread: 0
SecondThread: 0
FirstThread: 1
SecondThread: 1
FirstThread: 2
SecondThread: 2
FirstThread: 3
SecondThread: 3
FirstThread: 4
SecondThread: 4

  這個例子就是通過yield方法來實現兩個執行緒的交替執行。不過請注意:這種交替並不一定能得到保證,原始碼中也對這個問題進行說明:

/**
     * A hint to the scheduler that the current thread is willing to yield
     * its current use of a processor. The scheduler is free to ignore this
     * hint.
     *
     * <p> Yield is a heuristic attempt to improve relative progression
     * between threads that would otherwise over-utilise a CPU. Its use
     * should be combined with detailed profiling and benchmarking to
     * ensure that it actually has the desired effect.
     *
     * <p> It is rarely appropriate to use this method. It may be useful
     * for debugging or testing purposes, where it may help to reproduce
     * bugs due to race conditions. It may also be useful when designing
     * concurrency control constructs such as the ones in the
     * {@link java.util.concurrent.locks} package.
*/

這段話主要說明了三個問題:

  •   排程器可能會忽略該方法。
  •   使用的時候要仔細分析和測試,確保能達到預期的效果。
  •   很少有場景要用到該方法,主要使用的地方是除錯和測試。  

3、join方法

void join() Waits for this thread to die.
void join(long millis) Waits at most millis milliseconds for this thread to die.
void join(long millis, int nanos) Waits at most millis milliseconds plus nanos nanoseconds for this thread to die.

  join方法的作用是父執行緒等待子執行緒執行完成後再執行,換句話說就是將非同步執行的執行緒合併為同步的執行緒。JDK中提供三個版本的join方法,其實現與wait方法類似,join()方法實際上執行的join(0),而join(long millis, int nanos)也與wait(long millis, int nanos)的實現方式一致,暫時對納秒的支援也是不完整的。我們可以看下join方法的原始碼,這樣更容易理解:

 1 public final void join() throws InterruptedException {
 2         join(0);
 3     }
 4 
 5  public final synchronized void join(long millis)
 6     throws InterruptedException {
 7         long base = System.currentTimeMillis();
 8         long now = 0;
 9 
10         if (millis < 0) {
11             throw new IllegalArgumentException("timeout value is negative");
12         }
13 
14         if (millis == 0) {
15             while (isAlive()) {
16                 wait(0);
17             }
18         } else {
19             while (isAlive()) {
20                 long delay = millis - now;
21                 if (delay <= 0) {
22                     break;
23                 }
24                 wait(delay);
25                 now = System.currentTimeMillis() - base;
26             }
27         }
28     }
29 
30 public final synchronized void join(long millis, int nanos)
31     throws InterruptedException {
32 
33         if (millis < 0) {
34             throw new IllegalArgumentException("timeout value is negative");
35         }
36 
37         if (nanos < 0 || nanos > 999999) {
38             throw new IllegalArgumentException(
39                                 "nanosecond timeout value out of range");
40         }
41 
42         if (nanos >= 500000 || (nanos != 0 && millis == 0)) {
43             millis++;
44         }
45 
46         join(millis);
47     }

  大家重點關注一下join(long millis)方法的實現,可以看出join方法就是通過wait方法來將執行緒的阻塞,如果join的執行緒還在執行,則將當前執行緒阻塞起來,直到join的執行緒執行完成,當前執行緒才能執行。不過有一點需要注意,這裡的join只呼叫了wait方法,卻沒有對應的notify方法,原因是Thread的start方法中做了相應的處理,所以當join的執行緒執行完成以後,會自動喚醒主執行緒繼續往下執行。下面我們通過一個例子來演示join方法的作用:

(1)不使用join方法:

 1 package com.paddx.test.concurrent;
 2 
 3 public class JoinTest implements Runnable{
 4     @Override
 5     public void run() {
 6 
 7         try {
 8             System.out.println(Thread.currentThread().getName() + " start-----");
 9             Thread.sleep(1000);
10             System.out.println(Thread.currentThread().getName() + " end------");
11         } catch (InterruptedException e) {
12             e.printStackTrace();
13         }
14     }
15 
16     public static void main(String[] args) {
17         for (int i=0;i<5;i++) {
18             Thread test = new Thread(new JoinTest());
19             test.start();
20         }
21 
22         System.out.println("Finished~~~");
23     }
24 }

執行結果如下:

Thread-0 start-----
Thread-1 start-----
Thread-2 start-----
Thread-3 start-----
Finished~~~
Thread-4 start-----
Thread-2 end------
Thread-4 end------
Thread-1 end------
Thread-0 end------
Thread-3 end------

(2)使用join方法:

 1 package com.paddx.test.concurrent;
 2 
 3 public class JoinTest implements Runnable{
 4     @Override
 5     public void run() {
 6 
 7         try {
 8             System.out.println(Thread.currentThread().getName() + " start-----");
 9             Thread.sleep(1000);
10             System.out.println(Thread.currentThread().getName() + " end------");
11         } catch (InterruptedException e) {
12             e.printStackTrace();
13         }
14     }
15 
16     public static void main(String[] args) {
17         for (int i=0;i<5;i++) {
18             Thread test = new Thread(new JoinTest());
19             test.start();
20             try {
21                 test.join(); //呼叫join方法
22             } catch (InterruptedException e) {
23                 e.printStackTrace();
24             }
25         }
26 
27         System.out.println("Finished~~~");
28     }
29 }

執行結果如下:

Thread-0 start-----
Thread-0 end------
Thread-1 start-----
Thread-1 end------
Thread-2 start-----
Thread-2 end------
Thread-3 start-----
Thread-3 end------
Thread-4 start-----
Thread-4 end------
Finished~~~

 對比兩段程式碼的執行結果很容易發現,在沒有使用join方法之間,執行緒是併發執行的,而使用join方法後,所有執行緒是順序執行的。

四、總結

  本文主要詳細講解了wait/notify/notifyAll和sleep/yield/join方法。最後回答一下上面提出的問題:wait/notify/notifyAll方法的作用是實現執行緒間的協作,那為什麼這三個方法不是位於Thread類中,而是位於Object類中?位於Object中,也就相當於所有類都包含這三個方法(因為Java中所有的類都繼承自Object類)。要回答這個問題,還是得回過來看wait方法的實現原理,大家需要明白的是,wait等待的到底是什麼東西?如果對上面內容理解的比較好的話,我相信大家應該很容易知道wait等待其實是物件monitor,由於Java中的每一個物件都有一個內建的monitor物件,自然所有的類都理應有wait/notify方法。

相關文章