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()方法來重新設置這個標記。

JVM基礎學習之基本概念、可見性與同步