深入理解Java虛擬機-高效併發

本博客主要參考周志明老師的《深入理解Java虛擬機》第二版

讀書是一種跟大神的交流。閱讀《深入理解Java虛擬機》受益匪淺,對Java虛擬機有初步的認識。這裏寫博客主要出於以下三個目的:一方面是記錄,方便日後閱讀;一方面是加深對內容的理解;一方面是分享給大家,希望對大家有幫助。

《深入理解Java虛擬機》全書總結如下:

序號 內容 鏈接地址
1 深入理解Java虛擬機-走近Java https://blog.csdn.net/ThinkWon/article/details/103804387
2 深入理解Java虛擬機-Java內存區域與內存溢出異常 https://blog.csdn.net/ThinkWon/article/details/103827387
3 深入理解Java虛擬機-垃圾回收器與內存分配策略 https://blog.csdn.net/ThinkWon/article/details/103831676
4 深入理解Java虛擬機-虛擬機執行子系統 https://blog.csdn.net/ThinkWon/article/details/103835168
5 深入理解Java虛擬機-程序編譯與代碼優化 https://blog.csdn.net/ThinkWon/article/details/103835883
6 深入理解Java虛擬機-高效併發 https://blog.csdn.net/ThinkWon/article/details/103836167

併發處理的廣泛應用是使得Amdahl定律代替摩爾定律成爲計算機性能發展源動力的根本原因,也是人類“壓榨”計算機運算能力的最有力武器。

Java內存模型與線程

概述

讓計算機同時執行多個任務,不只是因爲處理器的性能更加強大了,更重要是因爲計算機的運算速度和它的存儲以及通信子系統速度差距太大,大量的時間都花費在磁盤 I/O 、網絡通信和數據庫訪問上。爲了不讓處理器因爲等待其它資源而浪費處理器的資源與時間,我們就必須採用讓計算機同時執行多任務的方式去充分利用處理器的性能;同時也是爲了應對服務端高併發的需求。而 Java 內存模型的設計和線程的存在正是爲了更好、更高效的實現多任務。

硬件的效率與一致性

計算機中絕大多數的任務都不可能只靠處理器計算就能完成,處理器至少要和內存交互,如讀取數據、存儲結果等等,這個 I/O 操作是很難消除的。由於計算器的存儲設備和處理器的運算速度有幾個量級的差距,所以計算機不得不加入一層讀寫速度儘可能接近處理器運算速度的高速緩存來作爲內存與處理器之間的緩衝:將運算需要用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存中,這樣處理器就無需等待緩慢的內存讀寫了。

基於高速緩存的存儲交互很好的解決了處理器與內存的速度矛盾,但是也爲計算機系統帶來更高的複雜度,因爲它引入了一個新的問題:緩存一致性。在多處理器中,每個處理器都有自己的高速緩存,而它們又共享同一主內存。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致。爲了解決一致性的問題,需要各個處理器的訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作。

在這裏插入圖片描述

除了增加高速緩存外,爲了使處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入的代碼進行亂序執行優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果一致,但不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致,因此,如果存在一個計算任務依賴另一個計算任務的中間結果,那麼其順序性並不能靠代碼的先後順序來保證。與處理器的亂象執行優化類似,JIT 編譯器中也有類似的指令重排優化。

Java內存模型

Java 虛擬機規範中定義了 Java 內存模型,用來屏蔽各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果。像 C/C++ 這類語言直接使用了物理硬件和操作系統的內存模型,因此會由於不同平臺上內存模型的差異,需要針對不同平臺來編寫代碼。

主內存與工作內存

Java 內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中讀取變量這樣的底層細節。這裏說的變量和 Java 代碼中的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但不包括變量和方法參數,因爲後者是線程私有的,不會被共享。爲了獲得較好的執行性能,Java 內存模型並沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制 JIT 編譯器進行代碼執行順序這類優化措施。

Java 內存模型規定了所有的變量都存儲在主內存,每條線程都有自己單獨的工作內存,線程的工作內存中保存了被該線程使用到的變量的主內存的副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存,線程間變量值的傳遞均需要通過主內存來完成。

在這裏插入圖片描述

內存間交互操作

關於主內存與工作內存間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存之類的細節,Java 內存模型定義了以下 8 種操作來完成,虛擬機實現時必須保證下面的每一種操作都是原子的、不可再分的。

這 8 種操作分別是:lock(鎖定)、unlock(解鎖)、read(讀取)、load(載入)、use(使用)、assign(賦值)、store(存儲)、write(寫入)。

對 volatile 型變量的特殊規則

volatile 是 Java 虛擬機提供的最輕量級的同步機制。當一個變量被定義爲 volatile 後,它將具備兩種特性:

第一是保證此變量對所有線程的可見性,這裏的「可見性」是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。普通變量則做不到這一點,需要通過主內存來在線程間傳遞數據。比如,線程 A 修改了一個普通的變量值,然後向主內存進行回寫,另一條線程 B 在 A 線程回寫完成之後再從主內存進行讀寫操作,新變量值纔會對線程 B 可見。

第二是禁止指令重排優化。普通變量僅僅會保證方法的執行過程中所有依賴賦值結果的地方 能夠獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。因爲在一個線程的方法執行過程中無法感知到這點,這也就是 Java 內存模型中描述的所謂的「線程內表現爲串行的語義」。

對 long 和 double 型變量的特殊規則

Java 內存模型要求 lock、unlock、read、load、assign、use、store、writer 這 8 個操作都具有原子性,但對於 64 位數據類型(long 和 double),在模型中特別定義了一條相對寬鬆的規定:允許虛擬機將沒有被 volatile 修飾的 64 位數據的讀寫操作劃分爲兩次 32 位的操作來進行,即允許虛擬機實現選擇可以不保證 64 位數據類型的 load、store、read 和 write 這 4 個操作的原子性。這點就是所謂的 long 和 double 的非原子協定。

如果有多個線程共享一個未聲明爲 volatile 的 long 或 double 類型的變量,並且同時對它們進行讀取和修改操作,那麼某些線程可能會讀取到一個錯誤的值。好在這種情況非常罕見,主流商業虛擬機中也都把對 long 和 double 的操作視爲原子性,因此在實際開發中無需使用 volatile 來修飾變量。

原子性、可見性和有序性

Java 內存模型是圍繞着在併發過程中如何處理原子性、可見性和有序性 3 個特質來建立的。

  1. 原子性(Atomicity):由 Java 內存模型來直接保證原子性變量操作,包括 read、load、assign、use、store 和 write ,我們大致可以認爲基本數據類型的訪問讀寫是具備原子性的。如果應用場景需要一個更大範圍的原子性保證,Java 內存模型還提供了 lock 和 unlock 操作來滿足這種需求,儘管虛擬機未把 lock 和 unlock 操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令 monitorenter 和 monitorexit 來隱式地使用這兩個操作,這兩個字節碼指令反映到 Java 代碼中就是 synchronized 關鍵字,因此被 synchronize 修飾的方法或代碼塊之間的操作是具備原子性的。
  2. 可見性(Visibility):可見性是指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。Java 內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式來實現可見性的,無論是普通變量還是 volatile 變量都是如此,普通變量與 volatile 變量的區別是, volatile 的規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。因此,可以說 volatile 保證了多線程操作變量的可見性,而普通變量則不能保證這一點。除了 volatile 外,Java 還有兩個關鍵字 synchronized 和 final 。synchronized 同步塊的可見性是由「對一個變量執行 unlock 操作前,必須先把此變量同步回主內存中(執行 store、write 操作)」這條規則獲得的;final 的可見性是指:被 final 修飾的字段在構造器中一旦初始化完成,並且構造器沒有「this」的引用傳遞出去,那在其他線程中就能看見 final 字段的值。
  3. 有序性(Ordering):Java 程序中天然的有序性可以總結爲:如果在本線程內,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指「線程內表現爲串行的語義」,後半句是指「指令重排序」現象和「工作內存和主內存同步延遲」現象。Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性,volatile 關鍵字本身就包含了禁止指令重排的語義,而 synchronized 則是由「一個變量在同一時刻只允許一條線程對其進行 lock 操作」這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能串行的進入。

先行發生原則

如果 Java 內存模型中所有的有序性都僅僅靠 volatile 和 synchronized 來保證,那麼有一些操作就會變得很繁瑣,但是我們在編寫 Java 併發代碼的時候並沒有感覺到這一點,這是因爲 Java 語言中有一個「先行發生」(happens-before)原則。這個原則非常重要,它是判斷數據是否存在競爭、線程是否安全的主要依據,依靠這個原則,我們可以通過幾條規則一攬子解決併發環境下兩個操作之間是否可能存在衝突的所有問題。

先行發生是 Java 內存模型中定義的兩項操作之間的偏序關係,如果說操作 A 先行發生於操作 B,其實就是說在發生操作 B 之前,操作 A 產生的影響能被操作 B 觀察到,「影響」包括修改了內存中共享變量的值、發送了消息、調用了方法等。

Java 內存模型下有一些天然的先行發生關係,這些先行發生關係無需任何同步器協助就已存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推導出來,它們就沒有順序性保障,虛擬機就可以隨意的對它們進行重排序。

  • 程序次序規則:在一個線程內,按照程序代碼順序,寫在前面的代碼先行發生寫在後面的代碼。準確的講,應該是控制流順序而不是程序代碼順序,因爲要考慮分支、循環等結構;
  • 管程鎖定規則:一個 unlock 操作先行發生於後面對於同一個鎖的 lock 操作;
  • volatile 變量規則:對一個 volatile 變量的寫操作先行發生於後面對這個變量的讀操作,理解了這個原則我們就能理解爲什麼 DCL 單例模式中爲什麼要用 volatile 來標識實例對象了;
  • 線程啓動規則:線程的 start() 方法先行發生於此線程的所有其它動作;
  • 線程終止規則:線程中所有的操作都先行發生於對此線程的終止檢測;
  • 程序中斷規則:對線程 interrupt() 的調用先行發生於被中斷線程的代碼檢測到中斷時間的發生;
  • 對象終結規則:一個對象的初始化完成先行發生於它的 finalize() 的開始;
  • 傳遞性:操作 A 先行發生於 B,B 先行發生於 C,那麼 A 就先行發生於 C。

Java與線程

談論 Java 中的併發,通常都是和多線程相關的。這一小節我們就講講 Java 線程在虛擬機中的實現。

線程的實現

主流的操作系統都提供了線程實現,Java 語言則提供了在不同硬件和操作系統平臺下對線程操作的統一處理,每個已經執行 start() 且還未結束的 Thread 類的實例就代表了一個線程。Thread 類所有關鍵方法都是 Native 的。Java API 中,一個 Native 方法往往意味着這個方法沒有使用或者無法使用平臺無關的手段來實現(當然也可能是爲了執行效率而使用 Native 方法,不過,通常最高效率的手段就是平臺相關的手段)。

實現線程主要有 3 種方式:使用內核線程實現、使用用戶線程實現、使用用戶線程加輕量級進程混合實現。

Java線程的實現

Java 線程在 JDK 1.2 之前是基於稱爲「綠色線程」的用戶線程實現的。而在 JDK 1.2 中,線程模型替換爲基於操作系統原生線程模型來實現。因此,在目前的 JDK 版本中,操作系統支持怎樣的線程模型,在很大程度上決定了 Java 虛擬機的線程是怎樣映射的,這點在不同的平臺上沒有辦法達成一致,虛擬機規範中也沒有限定 Java 線程需要使用哪種線程模型來實現。線程模型只對線程的併發規模和操作成本產生影響,對 Java 程序的編碼和運行過程來說,這些差異都透明的。

Java線程調度

線程調度是指系統爲線程分配處理器使用權的過程,主要調度方式有兩種,分別是協同式線程調度和搶佔式線程調度。

協同式線程調度

如果是使用協同式調度的多線程系統,線程的執行時間由線程本身來控制,線程把自己的工作執行完之後,要主動通知系統切換到另外一個線程上。協同式多線程的最大好處是實現簡單,而且由於線程要把自己的事情做完後纔會進行線程切換,切換操作對線程自己是可知的,所有沒有線程同步的問題。但是它的壞處也很明顯:線程執行時間不可控,甚至如果一個線程編寫有問題,一直不告訴操作系統進行線程切換,那麼程序就會一直阻塞在那裏。很久以前的 Windows 3.x 系統就是使用協同式來實現對進程多任務,相當不穩定,一個進程堅持不讓出 CPU 執行時間就可能導致整個系統崩潰。

搶佔式線程調度

如果是使用搶佔式調度的多線程系統,那麼每個線程將由系統來分配執行時間,線程的切換不由線程本身來決定。在這種實現線程調度的方式下,線程的執行實現是系統可控的,也不會有一個線程導致整個進程阻塞的問題,Java 使用的線程調度方式就是搶佔式的。和前面所說的 Windows 3.x 的例子相對,在 Windows 9x/NT 內核中就是使用搶佔式來實現多進程的,當一個進程出了問題,我們還可以使用任務管理器把這個進程「殺掉」,而不至於導致系統崩潰。

狀態轉換

Java 語言定義了 5 種線程狀態,在任意一個時間點,一個線程只能有且只有其中一種狀態,它們分別是:

  • 新建(New):創建後尚未啓動的線程處於這種狀態;
  • 運行(Runnable):Runnable 包括了操作系統線程狀態中的 Running 和 Ready,也就是處於此狀態的線程有可能正在執行,也有可能正在等待着 CPU 爲它分配執行時間;
  • 無限期等待(Waiting):處於這種狀態的線程不會被分配 CPU 執行時間,它們要等待被其它線程顯式地喚醒;以下三種方法會讓線程進入無限期等待狀態:
    • 沒有設置 TimeOut 參數的 Object.wait();
    • 沒有設置 TimeOut 參數的 Thread.join();
    • LockSupport.park()。
  • 限期等待(Timed Waiting):處於這種狀態的線程也不會被分配 CPU 執行時間,不過無需等待被其它線程顯式地喚醒,在一定時間之後它們會由系統自動喚醒;以下方法會讓線程進入限期等待狀態:
    • Thread.sleep();
    • 設置了 TimeOut 參數的 Object.wait();
    • 設置了 TimeOut 參數的 Thread.join();
    • LockSupport.parkNanos();
    • LockSupport.parkUntil()。
  • 阻塞(Blocked):線程被阻塞了,「阻塞狀態」和「等待狀態」的區別是:「阻塞狀態」在等待着獲取一個排他鎖,這個事件將在另一個線程放棄這個鎖的時候發生;而「等待狀態」則是在等待一段時間,或者喚醒動作的發送。在程序等待進入同步區域時,線程將進入這種狀態;
  • 結束(Terminated):線程已經結束執行。

上述 5 中狀態遇到特定事件發生的時候將會互相轉換,如下圖:

在這裏插入圖片描述

線程安全與鎖優化

概述

本文的主題是高效併發,但高效的前提是首先要保證併發的正確性和安全性,所以這一小節我們先從如何保證線程併發安全說起。

Java線程安全

那麼什麼是線程安全呢?可以簡單的理解爲多線程對同一塊內存區域操作時,內存值的變化是可預期的,不會因爲多線程對同一塊內存區域的操作和訪問導致內存中存儲的值出現不可控的問題。

Java語言中的線程安全

如果我們不把線程安全定義成一個非此即彼的概念(要麼線程絕對安全,要麼線程絕對不安全),那麼我們可以根據線程安全的程度由強至弱依次分爲如下五檔:

  1. 不可變;
  2. 絕對線程安全;
  3. 相對線程安全;
  4. 線程兼容;
  5. 線程對立。

線程安全的實現方法

雖然線程安全與否與編碼實現有着莫大的關係,但虛擬機提供的同步和鎖機制也起到了非常重要的作用。下面我們就來看看虛擬機層面是如何保證線程安全的。

  1. 同步互斥

互斥同步是常見的一種併發正確性保障的手段。同步是指在多個線程併發訪問共享數據時,保證共享數據在同一時間只被一個線程使用。而互斥是實現同步的一種手段。Java 中最基本的互斥同步手段就是 synchronized 關鍵字,synchronized 關鍵字在經過編譯之後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 這兩個字節碼指令,這兩個字節碼都需要一個 reference 類型的參數來指明要鎖定和解鎖的對象。如果 Java 程序中的 synchronized 明確指明瞭對象參數,那就是這個對象的 reference;如果沒有,那就根據 synchronized 修飾的是實例方法還是類方法,去取對應的對象實例或 class 對象來作爲鎖對象。

根據虛擬機規範的要求,在執行 monitorenter 指令時,首先要嘗試獲取對象的鎖。如果這個對象沒被鎖定,或者當前線程已經擁有了那個對象的鎖,就把鎖的計數器加 1;相應的,在執行monitorexit 指令時將鎖計數器減 1,當鎖計數器爲 0 時,鎖就被釋放。如果獲取鎖對象失敗,當前線程就要阻塞等待,直到對象鎖被另一個線程釋放爲止。

另外要說明的一點是,同步塊在已進入的線程執行完之前,會阻塞後面其它線程的進入。由於 Java 線程是映射到操作系統原生線程之上的,如果要阻塞或者喚醒一個線程,都需要操作系統來幫忙完成,這就需要從用戶態轉換到內核態,線程狀態轉換需要耗費很多的處理器時間。對於簡單的同步塊(如被 synchronized 修飾的 getter() 和 setter() 方法),狀態轉換消耗的時間可能比用戶代碼消耗的時間還要長。所以 synchronized 是 Java 中一個重量級的操作,因此我們只有在必要的情況下才應該使用它。當然虛擬機本身也會做相應的優化,比如在操作系統阻塞線程前加入一段自旋等待過程,避免頻繁的用戶態到內核態的轉換過程。這一點我們在介紹鎖優化的時候再細聊。

  1. 非阻塞同步

    互斥同步最大的問題就是進行線程阻塞和喚醒所帶來的性能問題,因此這種同步也成爲阻塞同步。從處理問題的方式上來說,互斥同步是一種悲觀的併發策略,認爲只要不去做正確的同步措施(例如加鎖),就肯定會出問題,無論共享數據是否會出現競爭,它都要進行加鎖(當然虛擬機也會優化掉一些不必要的鎖)。隨着硬件指令集的發展,我們有了另外一個選擇:基於衝突檢查的樂觀併發策略。通俗的說,就是先進行操作,如果沒有其他線程競爭,那操作就成功了;如果共享數據有其它線程競爭,產生了衝突,就採取其它的補救措施,這種樂觀的併發策略的許多實現都不需要把線程掛起,因此這種同步操作稱爲非阻塞同步

    前面之所以說需要硬件指令集的發展,是因爲我們需要操作和衝突檢測這兩個步驟具備原子性。

    這個原子性靠什麼來保證呢?如果這裏再使用互斥同步來保證原子性就失去意義了,所以我們只能靠硬件來完成這件事,保證一個從語義上看起來需要多次操作的行爲只通過一條處理器指令就能完成,這類指令常用的有:

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

    前三條是之前的處理器指令集裏就有的,後兩條是新增的。

    CAS 指令需要 3 個操作數,分別是內存位置(在 Java 中可以簡單理解爲變量的內存地址,用 V 表示)、舊的預期值(用 A 表示)和新值(用 B 表示)。CAS 執行指令時,當且僅當 V 符合舊預期值 A 時,處理器用新值 B 更新 V 的值,否則他就不執行更新,但是無論是否更新了 V 的值,都會返回 V 的舊值,上述的處理過程是一個原子操作。

    在 JDK 1.5 之後,Java 程序中纔可以使用 CAS 操作,該操作由 sun.misc.Unsafe 類裏的 compareAndSwapInt() 和 compareAndSwapLong() 等幾個方法包裝提供,虛擬機在內部對這些方法做了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器 CAS 指令,沒有方法的調用過程,或者可以認爲是無條件內聯進去了。

    由於 Unsafe 類不是提供給用戶程序調用的類,因此如果不用反射,我們只能通過其他的 Java API 來間接使用,比如 J.U.C 包裏的整數原子類,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 類的 CAS 操作。

    儘管 CAS 看起來很美,但是這種操作卻無法覆蓋互斥同步的所有場景,並且 CAS 從語義上來說並不是完美的。如果一個變量 V 初次讀取的時候是 A 值,並且在準備賦值的時候檢查它仍然是 A 值,那我們就能說它的值沒有被其他線程修改過嗎?如果在這段時間內曾經被改爲了 B,後來又被改回爲 A,那 CAS 操作就會認爲它從來沒有被改變過。這個漏洞稱爲 CAS 操作的「ABA」問題。

    爲了解決「ABA」問題,J.U.C 包提供了一個帶有標記的原子引用類 AtomicStamoedReference,它可以通過控制變量值的版本來保證 CAS 的正確性。不過這個類比較「雞肋」,大部分情況下 ABA 問題不會影響程序併發的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。

  2. 無同步方案

    要保證線程安全不一定要進行同步,如果一個方法本來就不涉及共享數據,那它自然無需任何同步措施,因此會有一些代碼天生就是線程安全的,其中就包括下面要說的可重入代碼線程本地存儲

    可重入代碼(Reentrant Code):也叫純代碼,可以在代碼執行的任何時候中斷它,轉而去執行另一端代碼(包括遞歸調用自己),而在重新獲得控制權後,原來的程序不會出現任何錯誤。可重入代碼有一些共同特徵,例如不依賴存儲在堆上的數據和公用的系統資源,用到的狀態量都由參數傳入、不調用非可重入的方法等。如果一個方法的返回結果可以預測,只要輸入相同,就能返回相同的輸出,那它就是可重入代碼,當然也就是線程安全的。

    線程本地存儲(Thread Local Storage):也就是說這個數據是線程獨有的,ThreadLocal 就是用來實現線程本地存儲的。

鎖優化

HotSpot 虛擬機開發團隊花費了很大的精力實現了各種鎖優化,比如自旋鎖與自適應自旋、鎖消除、鎖粗化、輕量級鎖、偏向鎖等。

自旋鎖與自適應自旋

自旋鎖前面我們在聊互斥同步的時候就提到過,互斥同步對性能最大的影響就是阻塞的實現,掛起線程和恢復線程都涉及到了用戶態到內核態的轉換,這種狀態的轉換會給系統併發性能帶來很大的壓力。但是大多數場景下,共享數據的鎖定狀態只會持續很短的一段時間,爲了這短暫的時間去掛起和恢復線程顯得不那麼划算。如果物理機有一個以上的處理器,能讓兩個或以上的線程同時並行處理,我們就可以讓後面請求鎖的那個線程「稍等一下」,但是不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,我們只需要執行一個空轉的循環(自旋),這就是所謂的自旋鎖。

自旋等待雖然避免了線程切換的開銷,但是它要佔用處理器的時間。如果鎖被佔用的時間很短,那麼自旋等待的效果當然很好;反之,如果鎖被佔用的時間很長,那麼自旋的線程就會白白消耗處理器資源,反而形成負優化。所以自旋等待必須有個限度,但是這個限度如果設置一個固定值並不是最有選擇,因此虛擬機開發團隊設計了自適應自旋鎖,讓自旋等待的時間不再固定,而是由前一次在同一個鎖上自旋的時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行,那麼虛擬機就會認爲這次自旋也有可能會成功,會將自旋等待的時間延長。如果對於某個鎖,自旋等待很少成功獲得過,那在以後要獲取這個鎖的時候就會放棄自旋。有了自適應自旋,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測就會越來越準確。

鎖消除

即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖就會進行鎖消除。所消除的主要判定依據來源於逃逸分析的數據支持,如果判定一段代碼中,堆上的所有數據都不會逃逸出去從而被其它線程訪問到,那就可以把它們當做棧上數據對待,認爲它們是線程私有的,同步加鎖自然就沒必要了。

鎖粗化

我們在編碼時,總是推薦將同步塊的作用範圍限制到最小,只在共享數據的實際作用域中才進行同步,這樣是爲了使得需要的同步操作數量儘可能變小,如果存在競爭,那等待鎖的線程也能儘快拿到鎖。通常,這樣做是正確的,但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體中,那即使沒有線程競爭,頻繁的進行互斥同步也會導致不必要的性能損耗。那加鎖出現在循環體中來舉例,虛擬機遇到這種情況,就會把加鎖同步的範圍擴展(粗化)到循環體外,這樣只要加鎖一次就可以了,這就是鎖粗化。

輕量級鎖

感興趣的小夥伴可以自行閱讀《深入理解Java虛擬機》

偏向鎖

感興趣的小夥伴可以自行閱讀《深入理解Java虛擬機》

結束語

許多資深的程序員都說過,能夠寫出高伸縮性的併發程序是一門藝術,而瞭解併發在系統底層是如何實現的,則是掌握這門藝術的前提條件,也是成爲高級程序員的必備知識之一。

發佈了224 篇原創文章 · 獲贊 2509 · 訪問量 19萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章