開發高效能並發應用不是一件容易的事情。這類應用的例子包括高效能Web伺服器、遊戲伺服器和搜尋引擎爬蟲等。這樣的應用可能需要同時處理成千上萬個請求。對於這樣的應用,一般採用多線程或事件驅動的架構。對於Java來說,在語言內部提供了線程的支援。但是Java的多線程應用開發會遇到很多問題。首先是很難編寫正確,其次是很難測試是否正確,最後是出現問題時很難調試。一個多線程應用可能運行了好幾天都沒問題,然後突然就出現了問題,之後卻又無法再次重現出來。如果在正確性之外,還需要考慮應用的輸送量和效能最佳化的話,就會更加複雜。本文主要介紹Java中的線程的基本概念、可見度和線程同步相關的內容。
Java線程基本概念
在作業系統中兩個比較容易混淆的概念是進程(process)和線程(thread)。作業系統中的進程是資源的組織單位。進程有一個包含了程式內容和資料的地址空間,以及其它的資源,包括開啟的檔案、子進程和訊號處理器等。不同進程的地址空間是互相隔離的。而線程表示的是程式的執行流程,是CPU調度的基本單位。線程有自己的程式計數器、寄存器、棧和幀等。引入線程的動機在於作業系統中阻塞式I/O的存在。當一個線程所執行的I/O被阻塞的時候,同一進程中的其它線程可以使用CPU來進行計算。這樣的話,就提高了應用的執行效率。線程的概念在主流的作業系統和程式設計語言中都得到了支援。
一部分的Java程式是單線程的。程式的機器指令按照程式中給定的順序依次執行。Java語言提供了java.lang.Thread類來為線程提供抽象。有兩種方式建立一個新的線程:一種是繼承java.lang.Thread類並覆寫其中的run()方法,另外一種則是在建立java.lang.Thread類的對象的時候,在建構函式中提供一個實現了java.lang.Runnable介面的類的對象。在得到了java.lang.Thread類的對象之後,通過調用其start()方法就可以啟動這個線程的執行。
一個線程被建立成功並啟動之後,可以處在不同的狀態中。這個線程可能正在佔用CPU時間運行;也可能處在就緒狀態,等待被調度執行;還可能阻塞在某個資源或是事件上。多個就緒狀態的線程會競爭CPU時間以獲得被執行的機會,而CPU則採用某種演算法來調度線程的執行。不同線程的運行順序是不確定的,多線程程式中的邏輯不能依賴於CPU的調度演算法。
可見度
可見度(visibility)的問題是Java多線程應用中的錯誤的根源。在一個單線程程式中,如果首先改變一個變數的值,再讀取該變數的值的時候,所讀取到的值就是上次寫操作寫入的值。也就是說前面操作的結果對後面的操作是肯定可見的。但是在多線程程式中,如果不使用一定的同步機制,就不能保證一個線程所寫入的值對另外一個線程是可見的。造成這種情況的原因可能有下面幾個:
- CPU 內部的緩衝:現在的CPU一般都擁有階層的幾級緩衝。CPU直接操作的是緩衝中的資料,並在需要的時候把緩衝中的資料與主存進行同步。因此在某些時刻,緩衝中的資料與主存內的資料可能是不一致的。某個線程所執行的寫入操作的新值可能當前還儲存在CPU的緩衝中,還沒有被寫回到主存中。這個時候,另外一個線程的讀取操作讀取的就還是主存中的舊值。
- CPU的指令執行順序:在某些時候,CPU可能改變指令的執行順序。這有可能導致一個線程過早的看到另外一個線程的寫入操作完成之後的新值。
- 編譯器代碼重排:出於效能最佳化的目的,編譯器可能在編譯的時候對產生的目標代碼進行重新排列。
現實的情況是:不同的CPU可能採用不同的架構,而這樣的問題在多核處理器和多處理器系統中變得尤其複雜。而Java的目標是要實現“編寫一次,到處運行”,因此就有必要對Java程式訪問和操作主存的方式做出規範,以保證同樣的程式在不同的CPU架構上的運行結果是一致的。Java記憶體模型(Java Memory Model)就是為了這個目的而引入的。JSR 133則進一步修正了之前的記憶體模型中存在的問題。總得來說,Java記憶體模型描述了程式中共用變數的關係以及在主存中寫入和讀取這些變數值的底層細節。Java記憶體模型定義了Java語言中的synchronized、volatile和final等關鍵詞對主存中變數讀寫操作的意義。Java開發人員使用這些關鍵詞來描述程式所期望的行為,而編譯器和JVM負責保證產生的程式碼在運行時刻的行為符合記憶體模型的描述。比如對聲明為volatile的變數來說,在讀取之前,JVM會確保CPU中緩衝的值首先會失效,重新從主存中進行讀取;而寫入之後,新的值會被馬上寫入到主存中。而synchronized和volatile關鍵詞也會對編譯器最佳化時候的代碼重排帶來額外的限制。比如編譯器不能把 synchronized塊中的代碼移出來。對volatile變數的讀寫操作是不能與其它讀寫操作一塊重新排列的。
Java 記憶體模型中一個重要的概念是定義了“在之前發生(happens-before)”的順序。如果一個動作按照“在之前發生”的順序發生在另外一個動作之前,那麼前一個動作的結果在多線程的情況下對於後一個動作就是肯定可見的。最常見的“在之前發生”的順序包括:對一個對象上的監視器的解鎖操作肯定發生在下一個對同一個監視器的加鎖操作之前;對聲明為volatile的變數的寫操作肯定發生在後續的讀操作之前。有了“在之前發生”順序,多線程程式在運行時刻的行為在關鍵區段上就是可預測的了。編譯器和JVM會確保“在之前發生”順序可以得到保證。比如下面的一個簡單的方法:
public void increase() { this.count++;}
這是一個常見的計數器遞增方法,this.count++實際是this.count = this.count + 1,由一個對變數this.count的讀取操作和寫入操作組成。如果在多線程情況下,兩個線程執行這兩個操作的順序是不可預期的。如果 this.count的初始值是1,兩個線程可能都讀到了為1的值,然後先後把this.count的值設為2,從而產生錯誤。錯誤的原因在於其中一個線程對this.count的寫入操作對另外一個線程是不可見的,另外一個線程不知道this.count的值已經發生了變化。如果在increase() 方法聲明中加上synchronized關鍵詞,那就在兩個線程的操作之間強制定義了一個“在之前發生”順序。一個線程需要首先獲得當前對象上的鎖才能執行,在它擁有鎖的這段時間完成對this.count的寫入操作。而另一個線程只有在當前線程釋放了鎖之後才能執行。這樣的話,就保證了兩個線程對 increase()方法的調用只能依次完成,保證了線程之間操作上的可見度。
如果一個變數的值可能被多個線程讀取,又能被最少一個線程鎖寫入,同時這些讀寫操作之間並沒有定義好的“在之前發生”的順序的話,那麼在這個變數上就存在資料競爭(data race)。資料競爭的存在是Java多線程應用中要解決的首要問題。解決的辦法就是通過synchronized和volatile關鍵詞來定義好“在之前發生”順序。
Java中的鎖
當資料競爭存在的時候,最簡單的解決辦法就是加鎖。鎖機制限制在同一時間只允許一個線程訪問產生競爭的資料的臨界區。Java語言中的 synchronized關鍵字可以為一個代碼塊或是方法進行加鎖。任何Java對象都有一個自己的監視器,可以進行加鎖和解鎖操作。當受到 synchronized關鍵字保護的代碼塊或方法被執行的時候,就說明當前線程已經成功的擷取了對象的監視器上的鎖。當代碼塊或是方法正常執行完成或是發生異常退出的時候,當前線程所擷取的鎖會被自動釋放。一個線程可以在一個Java對象上加多次鎖。同時JVM保證了在擷取鎖之前和釋放鎖之後,變數的值是與主存中的內容同步的。
Java線程的同步
在有些情況下,僅依靠線程之間對資料的互斥訪問是不夠的。有些線程之間存在協作關係,需要按照一定的協議來協同完成某項任務,比如典型的生產者-消費者模式。這種情況下就需要用到Java提供的線程之間的等待-通知機制。當線程所要求的條件不滿足時,就進入等待狀態;而另外的線程則負責在合適的時機發出通知來喚醒等待中的線程。Java中的java.lang.Object類中的wait/notify/notifyAll方法組就是完成線程之間的同步的。
在某個Java對象上面調用wait方法的時候,首先要檢查當前線程是否擷取到了這個對象上的鎖。如果沒有的話,就會直接拋出java.lang.IllegalMonitorStateException異常。如果有鎖的話,就把當前線程添加到對象的等待集合中,並釋放其所擁有的鎖。當前線程被阻塞,無法繼續執行,直到被從對象的等待集合中移除。引起某個線程從對象的等待集合中移除的原因有很多:對象上的notify方法被調用時,該線程被選中;對象上的notifyAll方法被調用;線程被中斷;對於有逾時限制的wait操作,當超過時間限制時;JVM內部實現在非正常情況下的操作。
從上面的說明中,可以得到幾條結論:wait/notify/notifyAll操作需要放在synchronized代碼塊或方法中,這樣才能保證在執行 wait/notify/notifyAll的時候,當前線程已經獲得了所需要的鎖。當對於某個對象的等待集合中的線程數目沒有把握的時候,最好使用 notifyAll而不是notify。notifyAll雖然會導致線程在沒有必要的情況下被喚醒而產生效能影響,但是在使用上更加簡單一些。由於線程可能在非正常情況下被意外喚醒,一般需要把wait操作放在一個迴圈中,並檢查所要求的邏輯條件是否滿足。典型的使用模式如下所示:
private Object lock = new Object();synchronized (lock) { while (/* 邏輯條件不滿足的時候 */) { try { lock.wait(); } catch (InterruptedException e) {} } //處理邏輯}
上述代碼中使用了一個私人對象lock來作為加鎖的對象,其好處是可以避免其它代碼錯誤的使用這個對象。
中斷線程
通過一個線程對象的interrupt()方法可以向該線程發出一個插斷要求。插斷要求是一種線程之間的協作方式。當線程A通過調用線程B的interrupt()方法來發出插斷要求的時候,線程A 是在請求線程B的注意。線程B應該在方便的時候來處理這個插斷要求,當然這不是必須的。當中斷髮生的時候,線程對象中會有一個標記來記錄當前的中斷狀態。通過isInterrupted()方法可以判斷是否有插斷要求發生。如果當插斷要求發生的時候,線程正處於阻塞狀態,那麼這個插斷要求會導致該線程退出阻塞狀態。可能造成線程處於阻塞狀態的情況有:當線程通過調用wait()方法進入一個對象的等待集合中,或是通過sleep()方法來暫時休眠,或是通過join()方法來等待另外一個線程完成的時候。線上程阻塞的情況下,當中斷髮生的時候,會拋出java.lang.InterruptedException,代碼會進入相應的異常處理邏輯之中。實際上在調用wait/sleep/join方法的時候,是必須捕獲這個異常的。中斷一個正在某個對象的等待集合中的線程,會使得這個線程從等待集合中被移除,使得它可以在再次獲得鎖之後,繼續執行java.lang.InterruptedException異常的處理邏輯。
通過中斷線程可以實現可取消的任務。在任務的執行過程中可以定期檢查當前線程的中斷標記,如果線程收到了插斷要求,那麼就可以終止這個任務的執行。當遇到 java.lang.InterruptedException的異常,不要捕獲了之後不做任何處理。如果不想在這個層次上處理這個異常,就把異常重新拋出。當一個在阻塞狀態的線程被中斷並且拋出java.lang.InterruptedException異常的時候,其對象中的中斷狀態標記會被清空。如果捕獲了java.lang.InterruptedException異常但是又不能重新拋出的話,需要通過再次調用interrupt()方法來重新設定這個標記。
轉載自:http://www.infoq.com/cn/articles/cf-java-thread 作者 成富