1. 程式人生 > >JVM之——基本概念、可見性與同步

JVM之——基本概念、可見性與同步

開發高效能併發應用不是一件容易的事情。這類應用的例子包括高效能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()方法來重新設定這個標記。