策略型益智遊戲《築夢頌》正式版發售 國區原價47元
如果一個多執行緒程式中每個執行緒處理的資源沒有交集,沒有依賴關係那麼這是一個完美的處理狀態。你不用去考慮臨界區域(critical section),不用擔心存在所謂的條件競爭(race condition),當然也不用去單行執行順序,當然這種狀態只是完美情況下,事實往往沒有這麼完美。
當多個執行緒進入臨界區域對臨界資源進行修改或者讀取的時候,往往需要確定執行緒的執行順序,以保證共享資源的可見性和相關操作的原子性。這就涉及到執行緒間的通訊了,即
如果執行緒A正好進入臨界區,他可能對臨界資源進行修改或者讀取,這時候他就要通知隨時想要進入臨界區域的執行緒B:“你丫的等一下,現在只准我來訪問”。我們稱這時候執行緒A擁有了訪問臨界區的鎖。我們可以將鎖看做是一個通行證,擁有鎖的可以在臨界區暢通無阻,而沒有鎖的則需要在門外等著鎖。我們將多個執行緒的執行過程看做是接力賽,執行緒A拿著通行證玩遍臨界區之後,還需要將通行證交給下一個想要進入臨界區的執行緒。當然具體交給誰,你如果純粹交給作業系統來決斷,這就可能產生各種意想不到的後果。極有可能的是剛剛明明決定傳給執行緒B的,但是就因為執行緒A多看了執行緒C一眼,從此就對上了眼,從而把通行證交給了C.....
扯得有點遠,不過從上一段我們可以看出執行緒間最簡單粗暴的通訊可以通過加鎖解鎖來實現。最簡單的方式就是synchronized同步塊。如下程式所示:
1 private int count;
2
3 public synchronized int increment() {
4 return count++;
5 }
這種說是通訊方式,其實說是獨佔方式來的更準確些,其實使用synchronized同步塊之後,能夠訪問進入臨界區域的只有一個執行緒。
我們考慮另外一種情況,通過訊號來實現執行緒間通訊。就像古裝劇裡面,在進攻之前一般會發一些訊號,一些等待的執行緒只有收到訊號改變的時候才會執行,比如下面程式碼的這種情況:
1 public class SimpleSignal { 2 public static void main(String[] args) { 3 Signal signal = new Signal(); 4 SignalThread t1 = new SignalThread(signal); 5 SignalThread t2 = new SignalThread(signal); 6 t1.start(); 7 t2.start(); 8 try { 9 Thread.sleep(1000); 10 } catch (InterruptedException e) { 11 e.printStackTrace(); 12 } 13 signal.start(); 14 } 15 } 16 17 class Signal { 18 private boolean startAction = false; 19 20 public synchronized void start() { 21 this.startAction = true; 22 } 23 24 public synchronized boolean isStarted() { 25 return this.startAction; 26 } 27 } 28 29 class SignalThread extends Thread { 30 private final Signal signal; 31 32 public SignalThread(Signal signal) { 33 this.signal = signal; 34 } 35 36 @Override 37 public void run() { 38 while (!signal.isStarted()) { 39 // 什麼也不做,等待可以開始行動 40 } 41 42 System.out.println("Thread:" + Thread.currentThread() 43 + " Go Go Go!Fighting!"); 44 } 45 46 }
上面的程式碼可以看出在主執行緒呼叫signal.start()之前,執行緒t1.t2都不會繼續執行,而是阻塞在while迴圈中等待主執行緒給出的進攻訊號。這中通訊實現方式叫做忙等待(busy wait),執行緒t1和執行緒t2,一直在while迴圈判斷條件是否符合,這時候會一直佔用CPU處理時間,從CPU利用率上來說不是那麼好。
那麼又沒有改進方法呢,當然是有的,不必像前面的一樣傻傻的望著天空看是否有訊號燈,假如事情順利的話派探子前來告知。在等待的過程中完全可以放棄對CPU的佔用,讓CPU去處理其他更加緊急的事情,從而提高CPU的利用率。當有探子來報的時候,CPU則喚醒原來的執行緒繼續執行。升級版本1.0程式碼如下:
1 public class SignalUpV1Test {
2 public static void main(String[] args) throws InterruptedException {
3 SignalUpV1 signal = new SignalUpV1();
4 SignalThreadUpV1 t1 = new SignalThreadUpV1(signal);
5 SignalThreadUpV1 t2 = new SignalThreadUpV1(signal);
6 t1.start();
7 t2.start();
8 Thread.sleep(1000);
9 System.out
10 .println("Now the Main Thread call doNotify of the signal Object!");
11 signal.doNotify();
12 }
13 }
14
15 class SignalThreadUpV1 extends Thread {
16 private final SignalUpV1 signal;
17
18 public SignalThreadUpV1(SignalUpV1 signal) {
19 this.signal = signal;
20 }
21
22 @Override
23 public void run() {
24 try {
25 // 這裡執行緒等待,給CPU去執行其他事情,然後等著被喚醒
26 signal.doWait();
27 } catch (InterruptedException e) {
28 e.printStackTrace();
29 }
30 System.out.println("Thread" + Thread.currentThread() + " Running");
31 }
32
33 }
34
35 class SignalUpV1 {
36 private final Object monitorObject = new Object();
37
38 public void doWait() throws InterruptedException {
39 // 注意在哪個物件上呼叫wait或者notify則必須對哪個物件加鎖,而不能對其他物件加鎖,否則會報IllegalMonitorStatus異常
40 synchronized (monitorObject) {
41 monitorObject.wait();
42 }
43 }
44
45 public void doNotify() {
46 synchronized (monitorObject) {
47 monitorObject.notify();
48 }
49 }
50 }
51
52 輸出為:
53 Now the Main Thread call doNotify of the signal Object!
54 ThreadThread[Thread-0,5,main] Running
可以看到執行緒t1或者t2必須等待主線呼叫監視物件的doNotify方法才會繼續往下執行,否則會一直等待,當然從輸出結果中也可以看出,doNotify一次只能喚醒一個執行緒,程式執行完後JVM還是沒法退出因為有一個執行緒還是處於等待狀態(要想都喚醒請使用notifyAll而不是notify)。同時還需要注意的一點是Object物件的wait和notify方法,必須在擁有該物件的鎖之後才能呼叫,否則會報IllegalMonitorStatus異常。
這種通訊方式還是會存在訊號丟失的問題(Signal Missing)。即加入呼叫監視物件的doNotify方法在doWait方法之前,那麼前面等待的執行緒可能永遠無法被喚醒,解決這種問題的辦法就是加一個標誌位,來儲存執行緒是否已經被喚醒過,線上程呼叫wait方法之前,判斷執行緒是否已經被喚醒,如果沒有則呼叫wait等待喚醒,如果有則不呼叫wait直接執行。升級版本2.0如下:
1 public class SignalUpV2Test {
2 public static void main(String[] args) {
3 SignalUpV2 signal = new SignalUpV2();
4 SignalThreadUpV2 t1 = new SignalThreadUpV2(signal);
5 SignalThreadUpV2 t2 = new SignalThreadUpV2(signal);
6
7 // 假設先呼叫監視物件的doNotify方法
8 signal.doNotify();
9 t1.start();
10 t2.start();
11
12 try {
13 Thread.sleep(1000);
14 } catch (InterruptedException e) {
15 e.printStackTrace();
16 }
17 System.out
18 .println("Now the main thread call the signal's doNotify method");
19 signal.doNotify();
20 }
21 }
22
23 class SignalThreadUpV2 extends Thread {
24 private final SignalUpV2 signal;
25
26 public SignalThreadUpV2(SignalUpV2 signal) {
27 this.signal = signal;
28 }
29
30 @Override
31 public void run() {
32 try {
33 signal.doWait();
34 } catch (InterruptedException e) {
35 e.printStackTrace();
36 }
37 System.out.println("Thread:" + Thread.currentThread() + " running!");
38 }
39 }
40
41 class SignalUpV2 {
42 /**
43 * 是否已經被喚醒的標誌位。防止先呼叫doNotify導致的訊號丟失問題從而使執行緒一直等待被喚醒
44 */
45 private boolean isNotified = false;
46
47 private final Object monitorObject = new Object();
48
49 public void doWait() throws InterruptedException {
50 synchronized (monitorObject) {
51 if (!isNotified) {
52 monitorObject.wait();
53 }
54 this.isNotified = false;
55 }
56 }
57
58 public void doNotify() {
59 synchronized (monitorObject) {
60 this.isNotified = true;
61 monitorObject.notify();
62 }
63 }
64 }
65
66 輸出結果:
67 Thread:Thread[Thread-1,5,main] running!
68 Now the main thread call the signal's doNotify method
69 Thread:Thread[Thread-0,5,main] running!
這個通訊版本看起來天衣無縫,事實上在大多數情況下是。但是還有一個不幸的訊息,就是作業系統可能無法抑制躁動的心靈。他可能會存在虛假喚醒的情況(Spurious Wakeups)。即存於等待狀態的執行緒可能無緣無故的被喚醒,從而離開wait方法繼續執行。解決這種問題的辦法很簡單,使用while迴圈判斷代替if判斷,這樣即使執行緒被虛假喚醒還是會去校驗喚醒狀態標誌位是否為true,如果標誌位還是false,會繼續進入wait狀態。從而完美解決了這個問題。實際上這種使用while檢測喚醒標識位的方式是通過自旋鎖(Spin Lock)來實現的。自旋鎖在處理的過程中不會進行備份然後完全離開執行緒執行狀態,而是仍然會佔用CPU的處理時間,但是不會有執行緒切換的開銷。升級版本3.0的程式碼這裡不給出了,只需把if改成while即可。