深入理解Java虛擬機-第十三章 Java 內存模型與線程

第十三章 Java 內存模型與線程

13.1 概述

13.2 線程安全

“線程安全”一個比較恰當的定義:“當多個線程同時訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行爲都可以獲得正確的結果,那就稱這個對象是線程安全的。”
這個定義就很嚴謹而且有可操作性,它要求線程安全的代碼都必須具備一個共同特徵:代碼本身封裝了所有必要的正確性保障手段(如互斥同步等),令調用者無須關心多線程下的調用問題,更無須自己實現任何措施來保證多線程環境下的正確調用。

13.2.1 Java 語言中的線程安全

爲了更深入地理解線程安全,在這裏我們可以不把線程安全當作一個非真即假的二元排他選項來看待,而是按照線程安全的“安全程度”由強至弱來排序,我們可以將 Java 語言中各種操作共享的數據分爲以下五類:不可變、絕對線程安全、相對線程安全、線程兼容和線程對立:

  1. 不可變:在Java語言裏面(特指 JDK 5 以後,即 Java 內存模型被修正之後的 Java 語言),不可變(Immutable)的對象一定是線程安全的,無論是對象的方法實現還是方法的調用者,都不需要再進行任何線程安全保障措施。
    Java語言中,如果多線程共享的數據是一個基本數據類型,那麼只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。如果共享數據是一個對象,由於Java語言目前暫時還沒有提供值類型的支持,那就需要對象自行保證其行爲不會對其狀態產生任何影響才行。保證對象行爲不影響自己狀態的途徑有很多種,最簡單的一種就是把對象裏面帶有狀態的變量都聲明爲final,這樣在構造函數結束之後,它就是不可變的。
  2. 絕對線程安全:絕對的線程安全能夠完全滿足Brian Goetz給出的線程安全的定義,這個定義其實是很嚴格的,一個類要達到“不管運行時環境如何,調用者都不需要任何額外的同步措施”可能需要付出非常高昂的,甚至不切實際的代價。如果說java.util.Vector是一個線程安全的容器,相信所有的Java程序員對此都不會有異議,因爲它的add()、get()和size()等方法都是被synchronized修飾的,儘管這樣效率不高,但保證了具備原子性、可見性和有序性。不過,即使它所有的方法都被修飾成synchronized,也不意味着調用它的時候就永遠都不再需要同步手段了,例如:
    public class Test {
    
        private static Vector<Integer> vector = new Vector<>();
    
        public static void main(String[] args) {
            while (true) {
    
                for (int i = 0; i < 10; i++) {
                    vector.add(i);
                }
    
                new Thread(() -> {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                }).start();
    
                new Thread(() -> {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.get(i);
                    }
                }).start();
    
                while (Thread.activeCount() > 20) {
    
                }
            }
        }
    }
    
    會報錯如下:
    報錯
    很明顯,儘管這裏使用到的Vector的get()、remove()和size()方法都是同步的,但是在多線程的環境中,如果不在方法調用端做額外的同步措施,使用這段代碼仍然是不安全的。因爲如果另一個線程恰好在錯誤的時間裏刪除了一個元素,導致序號i已經不再可用,再用i訪問數組就會拋出一個ArrayIndexOutOfBoundsException異常。如果要保證這段代碼能正確執行下去,我們必須在獲取和清除處加上鎖。如:
       new Thread(() -> {
           synchronized (vector) {
               for (int i = 0; i < vector.size(); i++) {
                   vector.remove(i);
               }
           }
       }).start();
    
       new Thread(() -> {
           synchronized (vector) {
               for (int i = 0; i < vector.size(); i++) {
                   System.out.print(vector.get(i));
               }
           }
       }).start();
    
    假如Vector一定要做到絕對的線程安全,那就必須在它內部維護一組一致性的快照訪問才行,每次對其中元素進行改動都要產生新的快照,這樣要付出的時間和空間成本都是非常大的。
  3. 相對線程安全:相對線程安全就是我們通常意義上所講的線程安全,它需要保證對這個對象單次的操作是線程安全的,我們在調用的時候不需要進行額外的保障措施,但是對於一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證調用的正確性。在Java語言中,大部分聲稱線程安全的類都屬於這種類型,例如Vector、HashTable、Collections的synchronizedCollection()方法包裝的集合等。
  4. 線程兼容:線程兼容是指對象本身並不是線程安全的,但是可以通過在調用端正確地使用同步手段來保證對象在併發環境中可以安全地使用。我們平常說一個類不是線程安全的,通常就是指這種情況。Java類庫API中大部分的類都是線程兼容的,如與前面的Vector和HashTable相對應的集合類ArrayList和HashMap等。
  5. 線程對立:線程對立是指不管調用端是否採取了同步措施,都無法在多線程環境中併發使用代碼。由於Java語言天生就支持多線程的特性,線程對立這種排斥多線程的代碼是很少出現的,而且通常都是有害的,應當儘量避免。

13.2.2 線程安全的實現方法

這裏僅概述,後面會再開專欄講述高併發
實現方法有以下幾種:

  • 互斥同步:互斥同步(Mutual Exclusion & Synchronization)是一種最常見也是最主要的併發正確性保障手段。同步是指在多個線程併發訪問共享數據時,保證共享數據在同一個時刻只被一條(或者是一些,當使用信號量的時候)線程使用。而互斥是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)和信號量(Semaphore)都是常見的互斥實現方式。因此在“互斥同步”這四個字裏面,互斥是因,同步是果;互斥是方法,同步是目的。
    在Java裏面,最基本的互斥同步手段就是s ynchronized 關鍵字,這是一種塊結構(Block Structured)的同步語法。synchronized 關鍵字經過 Javac 編譯之後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 這兩個字節碼指令。這兩個字節碼指令都需要一個 reference 類型的參數來指明要鎖定和解鎖的對象。如果 Java 源碼中的 synchronized 明確指定了對象參數,那就以這個對象的引用作爲 reference ;如果沒有明確指定,那將根據 synchronized 修飾的方法類型(如實例方法或類方法),來決定是取代碼所在的對象實例還是取類型對應的Class對象來作爲線程要持有的鎖。
    關於 synchronized 我們有兩條直接結論:

    • 被 synchronized 修飾的同步塊對同一條線程來說是可重入的。這意味着同一線程反覆進入同步塊也不會出現自己把自己鎖死的情況。
    • 被 synchronized 修飾的同步塊在持有鎖的線程執行完畢並釋放鎖之前,會無條件地阻塞後面其他線程的進入。這意味着無法像處理某些數據庫中的鎖那樣,強制已獲取鎖的線程釋放鎖;也無法強制正在等待鎖的線程中斷等待或超時退出。

    Java 的線程是映射到操作系統的原生內核線程之上的,如果要阻塞或喚醒一條線程,則需要操作系統來幫忙完成,這就不可避免地陷入用戶態到核心態的轉換中,進行這種狀態轉換需要耗費很多的處理器時間。因此才說,synchronized 是Java語言中一個重量級的操作。
    於是 JDK 5 中引入了新的併發包 J.U.C( java.util.concurrent),其中的 java.util.concurrent.locks.Lock 接口便成了Java的另一種全新的互斥同步手段。重入鎖(ReentrantLock)是Lock接口最常見的一種實現,顧名思義,它與 synchronized 一樣是可重入的。在基本用法上,ReentrantLock 也與 synchronized 很相似,只是代碼寫法上稍有區別而已。不過,ReentrantLock 與 synchronized 相比增加了一些高級功能,主要有以下三項:等待可中斷、可實現公平鎖及鎖可以綁定多個條件:

    • 等待可中斷:是指當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改爲處理其他事情。可中斷特性對處理執行時間非常長的同步塊很有幫助。
    • 公平鎖:是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock在默認情況下也是非公平的,但可以通過帶布爾值的構造函數要求使用公平鎖。不過一旦使用了公平鎖,將會導致ReentrantLock的性能急劇下降,會明顯影響吞吐量。
    • 鎖綁定多個條件:是指一個ReentrantLock對象可以同時綁定多個Condition對象。在synchronized中,鎖對象的wait()跟它的notify()或者notifyAll()方法配合可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外添加一個鎖;而ReentrantLock則無須這樣做,多次調用newCondition()方法即可。如果需要使用上述功能,使用ReentrantLock是一個很好的選擇,那如果是基於性能考慮呢?synchronized對性能的影響,尤其在JDK 5之前是很顯著的,爲此在JDK 6中還專門進行過針對性的優化。
      作者在引入 ReentrantLock 後,還是推薦使用 synchronized,主要原因是方便並且一目瞭然。廣大的 Java 程序員還是較爲熟悉 sync。
  • 非阻塞同步:互斥同步面臨的主要問題是進行線程阻塞和喚醒所帶來的性能開銷,因此這種同步也被稱爲阻塞同步(BlockingSynchronization)。從解決問題的方式上看,互斥同步屬於一種悲觀的併發策略,其總是認爲只要不去做正確的同步措施(例如加鎖),那就肯定會出現問題。那我們有悲觀肯定反之有樂觀鎖。所謂樂觀鎖就是不管風險,先進行操作,如果沒有其他線程爭用共享數據,那操作就直接成功了;如果共享的數據的確被爭用,產生了衝突,那再進行其他的補償措施,例如重試或者回滾。硬件保證某些從語義上看起來需要多次操作的行爲可以只通過一條處理器指令就能完成,這類指令常用的有:

    • 測試並設置(Test-and-Set);
    • 獲取並增加(Fetch-and-Increment);
    • 交換(Swap);
    • 比較並交換(Compare-and-Swap,下文稱CAS);
    • 加載鏈接/條件儲存(Load-Linked/Store-Conditional,下文稱LL/SC)。

    因爲Java裏最終暴露出來的是CAS操作,所以我們以CAS指令爲例進行講解。CAS指令需要有三個操作數,分別是內存位置(在Java中可以簡單地理解爲變量的內存地址,用V表示)、舊的預期值(用A表示)和準備設置的新值(用B表示)。CAS指令執行時,當且僅當V符合A時,處理器纔會用B更新V的值,否則它就不執行更新。但是,不管是否更新了V的值,都會返回V的舊值,上述的處理過程是一個原子操作,執行期間不會被其他線程中斷。JDK 9之後,Java類庫在VarHandle類裏開放了面向用戶程序使用的CAS操作。
    但 CAS 仍然無法完美的解決問題如 ABA 問題。就是說操作數在中間改變過一次,但很快又改回來了。操作和比對時都是沒問題的,但實際上中間改過一版。如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更爲高效。

  • 無同步方案:要保證線程安全,也並非一定要進行阻塞或非阻塞同步,同步與線程安全兩者沒有必然的聯繫。同步只是保障存在共享數據爭用時正確性的手段,如果能讓一個方法本來就不涉及共享數據,那它自然就不需要任何同步措施去保證其正確性,因此會有一些代碼天生就是線程安全的。例如:

    • 線程本地存儲(Thread Local Storage):如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行。如果能保證,我們就可以把共享數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。

13.3 鎖優化

鎖優化技術,如適應性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖膨脹(LockCoarsening)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)等

13.3.1 自旋鎖與自適應自旋

自選鎖很簡單,因爲切換線程時對線程進行掛起和喚醒操作都需要轉入內核態中完成,這些對性能帶來很大困擾。於是能不能先不讓這個線程掛起,讓他原地轉一會兒,萬一轉一會兒的時間這個鎖就被釋放了呢,不就省一次掛起和喚醒嘛,這就是自旋鎖。那什麼是自適應鎖呢,就是說我不能一直在這轉啊,你得給我個時間啊,自適應意味着自旋的時間不再是固定的了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定的。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也很有可能再次成功,進而允許自旋等待持續相對更長的時間,比如持續100次忙循環。另一方面,如果對於某個鎖,自旋很少成功獲得過鎖,那在以後要獲取這個鎖時將有可能直接省略掉自旋過程,以避免浪費處理器資源。

13.3.2 鎖消除

鎖消除是指虛擬機即時編譯器在運行時,對一些代碼要求同步,但是對被檢測到不可能存在共享數據競爭的鎖進行消除。主要判定依據是通過逃逸分析的數據支持,如果判斷到一段代碼中,在堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可以把它們當作棧上數據對待,認爲它們是線程私有的,同步加鎖自然就無須再進行。
例如 StringBuffer 的 append 方法,這個方法是同步方法,如果變量未逃逸的話,這裏雖然有鎖,但是可以被安全地消除掉。在解釋執行時這裏仍然會加鎖,但在經過服務端編譯器的即時編譯之後,這段代碼就會忽略所有的同步措施而直接執行。

13.3.3 鎖粗化

原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享數據的實際作用域中才進行同步,這樣是爲了使得需要同步的操作數量儘可能變少,即使存在鎖競爭,等待鎖的線程也能儘可能快地拿到鎖。但是碰到如下代碼:

public class Test {

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            synchronized (Test.class){
                // do something...
            }
        }
    }
}

這裏的 sync 就會被粗化到鎖住整個 for 循環。

13.3.4 輕量級鎖

輕量級鎖是JDK 6時加入的新型鎖機制,它名字中的“輕量級”是相對於使用操作系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制就被稱爲“重量級”鎖。
還記得我們第二章就學過的 Mark Word 嘛。那時候提了一句輕量級鎖,我們接着來看看下面這個熟悉的表格吧:
Mark Word
輕量級鎖的工作過程就是:在代碼即將進入同步塊的時候,如果此同步對象沒有被鎖定(鎖標誌位爲“01”狀態),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝(官方爲這份拷貝加了一個Displaced前綴,即Displaced Mark Word),這時候線程堆棧與對象頭的狀態如圖所示:
線程堆棧與對象頭的狀態
然後,虛擬機將使用CAS操作嘗試把對象的Mark Word更新爲指向Lock Record的指針。如果這個更新動作成功了,即代表該線程擁有了這個對象的鎖,並且對象Mark Word的鎖標誌位(Mark Word的最後兩個比特)將轉變爲“00”,表示此對象處於輕量級鎖定狀態。這時候線程堆棧與對象頭的狀態如圖所示。
線程堆棧與對象頭的狀態
如果這個更新操作失敗了,那就意味着至少存在一條線程與當前線程競爭獲取該對象的鎖。虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是,說明當前線程已經擁有了這個對象的鎖,那直接進入同步塊繼續執行就可以了,否則就說明這個鎖對象已經被其他線程搶佔了。如果出現兩條以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹爲重量級鎖,鎖標誌的狀態值變爲“10”,此時Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也必須進入阻塞狀態。
上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也同樣是通過CAS操作來進行的,如果對象的Mark Word仍然指向線程的鎖記錄,那就用CAS操作把對象當前的Mark Word和線程中複製的Displaced Mark Word替換回來。假如能夠成功替換,那整個同步過程就順利完成了;如果替換失敗,則說明有其他線程嘗試過獲取該鎖,就要在釋放鎖的同時,喚醒被掛起的線程。

13.3.5 偏向鎖

偏向鎖也很簡單,就是在上文講到過的 Mark Word 中,存上當前持有鎖的線程ID,如果這個線程下次進來還是自己的 ID 時,就不需要再去搶鎖了,直接執行就好了。
偏向鎖、輕量級鎖的狀態轉化及對象Mark Word的關係
細心的讀者看到這裏可能會發現一個問題:當對象進入偏向狀態的時候,Mark Word大部分的空間(23個比特)都用於存儲持有鎖的線程ID了,這部分空間佔用了原有存儲對象哈希碼的位置,那原來對象的哈希碼怎麼辦呢?
在Java語言裏面一個對象如果計算過哈希碼,就應該一直保持該值不變(強烈推薦但不強制,因爲用戶可以重載hashCode()方法按自己的意願返回哈希碼),否則很多依賴對象哈希碼的API都可能存在出錯風險。而作爲絕大多數對象哈希碼來源的Object::hashCode()方法,返回的是對象的一致性哈希碼(Identity Hash Code),這個值是能強制保證不變的,它通過在對象頭中存儲計算結果來保證第一次計算之後,再次調用該方法取到的哈希碼值永遠不會再發生改變。因此,當一個對象已經計算過一致性哈希碼後,它就再也無法進入偏向鎖狀態了;而當一個對象當前正處於偏向鎖狀態,又收到需要計算其一致性哈希碼請求[插圖]時,它的偏向狀態會被立即撤銷,並且鎖會膨脹爲重量級鎖。在重量級鎖的實現中,對象頭指向了重量級鎖的位置,代表重量級鎖的ObjectMonitor類裏有字段可以記錄非加鎖狀態(標誌位爲“01”)下的Mark Word,其中自然可以存儲原來的哈希碼。

至此,深入理解虛擬機終於完結。
專欄文章內大篇幅引用了原書 《深入理解 Java 虛擬機:JVM高級特性與最佳實踐》 的文字,希望有條件的童鞋購買原書支持作者!
截止完篇 最新的已經出到了第三版。迫不及待要去買一本比對了。

讀書越多越發現自己的無知,Keep Fighting!

本文僅是在自我學習 《深入理解Java虛擬機》這本書後進行的自我總結,有錯歡迎友善指正。

歡迎友善交流,不喜勿噴~
Hope can help~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章