Java 線程安全與鎖的那些事

線程安全

何為線程安全?維基百科上是這樣描述的:線程安全是指函數(shù)、函數(shù)庫在多線程環(huán)境中被調(diào)用時,能夠正確地處理多個線程之間的共享變量,使程序功能正確完成。
《Java 并發(fā)編程實戰(zhàn)》的作者之一 Brain Goetz 認(rèn)為線程安全是指:當(dāng)多個線程訪問一個對象時,如果不用考慮這些線程在運(yùn)行環(huán)境下的調(diào)度和交替執(zhí)行,也不需要進(jìn)行額外的同步,或者在調(diào)用方法進(jìn)行任何其他的協(xié)調(diào)操作,調(diào)用這個對象的行為都可以獲得正確的結(jié)果,那這個對象是線程安全的。

實現(xiàn)線程安全的幾種方式

1. 使用 final 關(guān)鍵字定義不可變性

在 Java 中,不可變(Immutable)的對象一定是線程安全的,無論是對象的方法實現(xiàn)還是方法的調(diào)用者,都不需要再采取任何的線程安全保障措施。只要一個不可變的對象被正確地構(gòu)建出來,那其外部的可見狀態(tài)永遠(yuǎn)不會改變,永遠(yuǎn)也不會看到它在多個線程之中處于不一致的狀態(tài)。

2. 使用 volatile 關(guān)鍵字保證線程間的可見性,但不能保證原子性

volatile 是 Java 虛擬機(jī)提供的輕量級同步機(jī)制,當(dāng)一個變量被定義為 volatile 后,它就具有兩種特性:

  • \color{red}{可見性}
    簡單來說是當(dāng)一個線程修改了被 volatile 修飾的值,新值對于其他線程來說是可以立即得知的,而普通變量不能做到這一點(diǎn),普通變量的值在線程間傳遞均需通過主內(nèi)存來完成。
  • \color{red}{禁止指令重排序優(yōu)化}
    普通的變量僅僅會保證在該方法執(zhí)行過程中所有依賴賦值結(jié)果的地方都能獲取到正確的結(jié)果,而不能保證變量賦值操作的順序與代碼執(zhí)行順序一致。指令重排序是 Java 虛擬機(jī)所做的一項優(yōu)化,但是在某些特定情況下會出現(xiàn)問題,比如使用 DCL 實現(xiàn)單例時就要加上 volatile 關(guān)鍵字,否則可能會出現(xiàn)單例失效的情況,代碼如下:
 public class Singleton {
     //注意此處的volatile修飾符
     //Java編譯器允許處理器亂序執(zhí)行,會有DCL失效的問題
     //JDK大于等于1.5的版本,具體化了volatile關(guān)鍵字,定義時加上它可以保證執(zhí)行的順序(雖然會影響性能)
     //從而單例起效
     private static volatile Singleton instance;
 
     //private的構(gòu)造函數(shù),只能在本類內(nèi)部實例化
     private Singleton() {
     }
 
     //通過此靜態(tài)方法提供全局獲取唯一可用對象的實例
     public static Singleton getInstance() {
         if (instance == null) {
             //第一次check,避免不必要的同步
             synchronized (Singleton.class) { //同步
                 if (instance == null) {
                     //第二次check,保證線程安全
                     instance = new Singleton();
                 }
             }
         }
         return instance;
     }
 }
3. 使用 Atomic 原子類

Atomic 原子類位于 java.util.concurrent.atomic 包下面,如下圖:


實現(xiàn)同步的原理是把操作委托給 Unsafe 類中相關(guān)的 CAS 操作(系統(tǒng)級別支持),下文會具體闡述 CAS,關(guān)于 Unsafe 可以參考 Java魔法類:Unsafe應(yīng)用解析
。

4. 使用 ThreadLocal

每個 Thread 維護(hù)著一個 ThreadLocalMap 的引用,而 ThreadLocalMap 是 ThreadLocal 的內(nèi)部類,ThreadLocalMap 內(nèi)部有個 Entry 數(shù)組,是真正存儲數(shù)據(jù)的地方。總的來說,ThreadLocal 本省并不存儲值,只是作為一個 key 來讓 Thread 從自身的 ThreadLocalMap 獲取對應(yīng)的 value。從這個角度來看,使用 ThreadLocal 是線程安全的,有關(guān) ThreadLocal 可以查看這篇文章——ThreadLocal 簡析。

5. 使用 synchronized 關(guān)鍵字

synchronized 關(guān)鍵字實現(xiàn)線程安全是因為采用了互斥同步,具體說來,synchronized 關(guān)鍵字經(jīng)過編譯后,會在同步塊的前后分別形成 monitorenter 和 monitorexit 這兩個字節(jié)碼指令,這兩個字節(jié)碼都需要一個 reference 類型的參數(shù)來指明要鎖定和解鎖的對象。如果 Java 程序中的 synchronized 明確指定了對象參數(shù),那就是這個對象的 reference;如果沒有明確指定,那就根據(jù) synchronized 修飾的是實例方法還是類方法,去取對應(yīng)的對象實例或 Class 對象來作為鎖對象,也就是通常所說的對象鎖和類鎖。

每個對象都有一把隱形的鎖,稱為內(nèi)部鎖或者 Monitor 鎖,Mointor 是線程私有的數(shù)據(jù)結(jié)構(gòu),其依賴于底層的操作系統(tǒng)的 Mutex Lock(互斥鎖)來實現(xiàn)的線程同步。

自旋鎖與自適應(yīng)自旋 VS 偏向鎖 VS 輕量級鎖 VS 重量級鎖

這四種鎖是指鎖的狀態(tài),專門針對 synchronized 的。

  • 自旋鎖與自適應(yīng)自旋
    自旋鎖與自適應(yīng)自旋沒有對資源進(jìn)行鎖定,因為掛起與恢復(fù)線程的操作都需要耗費(fèi)大量的資源,因此如果有兩個或以上的線程同時并行執(zhí)行,可以讓后面請求鎖的線程執(zhí)行一個忙循環(huán)(自旋),這就是自旋鎖機(jī)制。
    自旋等待并不能代替阻塞,其本身雖然避免了線程切換的開銷,但是它要占用處理器時間的,如果長時間的等待只會白白浪費(fèi)處理器資源,因此在自旋到一定次數(shù)后(默認(rèn)是10)就會掛起線程了。
    自適應(yīng)自旋是對自旋的一種優(yōu)化,如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運(yùn)行中,那么虛擬機(jī)就會認(rèn)為這次自旋也很有可能再次成功,進(jìn)而允許自旋等待持續(xù)相對更長的時間。反之,如果對于某個鎖,自旋很少成功獲得過,那在以后獲取這個鎖時將可能省略掉自旋過程,以避免浪費(fèi)處理器資源。
  • 偏向鎖
    偏向鎖是指一段同步代碼一直被一個線程所訪問,那么該線程會自動獲取鎖,降低獲取鎖的代價。
    當(dāng)一個線程訪問同步代碼塊并獲取鎖時,會在對象頭的 Mark Word 里存儲鎖偏向的線程ID。在線程進(jìn)入和退出同步塊時不再通過 CAS 操作來加鎖和解鎖,而是檢測 Mark Word 里是否存儲著指向當(dāng)前線程的偏向鎖。引入偏向鎖是為了在無多線程競爭的情況下盡量減少不必要的輕量級鎖執(zhí)行路徑,因為輕量級鎖的獲取及釋放依賴多次 CAS 原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令即可。
    偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖,線程不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(diǎn)(在這個時間點(diǎn)上沒有字節(jié)碼正在執(zhí)行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處于被鎖定狀態(tài)。撤銷偏向鎖后恢復(fù)到無鎖(標(biāo)志位為“01”)或輕量級鎖(標(biāo)志位為“00”)的狀態(tài)。
  • 輕量級鎖
    輕量級鎖是指當(dāng)鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。若當(dāng)前只有一個等待線程,則該線程通過自旋進(jìn)行等待。但是當(dāng)自旋超過一定的次數(shù),或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級為重量級鎖。
  • 重量級鎖
    升級為重量級鎖時,此時等待鎖的線程都會進(jìn)入阻塞狀態(tài)。

整體的鎖升級流程如下圖:


6. 使用鎖機(jī)制
樂觀鎖與悲觀鎖

對于共享數(shù)據(jù),悲觀鎖認(rèn)為自己在使用數(shù)據(jù)的時候一定有別的線程來修改數(shù)據(jù),因此在獲取數(shù)據(jù)的時候會先加鎖,確保數(shù)據(jù)不會被別的線程修改。在 Java 中 synchronized 關(guān)鍵字和 Lock 的實現(xiàn)類都是悲觀鎖。

而樂觀鎖認(rèn)為自己在使用數(shù)據(jù)的時候不會有別的線程修改數(shù)據(jù),所以不會添加鎖,只是在更新數(shù)據(jù)的時候去判斷之前是否有別的線程去修改了數(shù)據(jù),如果數(shù)據(jù)沒有被更新,當(dāng)前線程將自己修改的數(shù)據(jù)成功寫入,如果數(shù)據(jù)已經(jīng)被其他線程更新,則根據(jù)不同的實現(xiàn)方式執(zhí)行不同的操作(例如報錯或者自動重試)。

樂觀鎖在 Java 中最常采用的算法就是上面原子類提到的 CAS 算法,CAS 即 Compare And Swap(比較與交換),是一種無鎖算法,在不適用鎖(沒有線程被阻塞)的情況下實現(xiàn)多線程之間的變量同步。CAS 算法如下:

  • 需要讀寫的內(nèi)存值 V。
  • 進(jìn)行比較的 A。
  • 需要寫入的新值 B。

當(dāng)且僅當(dāng) V 的值等于 A 時,CAS 通過原子方式用新值 B 更新 V 的值(“比較 + 更新” 整體是一個原子操作),否則不會執(zhí)行任何操作。一般情況下,“更新” 是一個不斷重試的操作。CAS 雖然高效,但是也存在著三大問題:

  • ABA 問題,如果在內(nèi)存中的值經(jīng)歷了 A-B-A 的變化,CAS 在進(jìn)行檢查是發(fā)現(xiàn)不了值是變更過的。ABA 問題的解決思路就是在變量前面添加版本號,每次更新的時候都把版本號加一,這樣變化過程就從 A-B-A 變成了 A1 - B2 - A3。
  • 循環(huán)時間長開銷大,CAS 操作如果長時間不成功,會導(dǎo)致其一直自旋,給 CPU 帶來了非常大的開銷。
  • 只能保證一個共享變量的原子操作,對一個共享變量執(zhí)行操作是,CAS 能夠保證原子操作,但是對多個共享變量操作時,CAS 是無法保證操作的原子性的,Java 從 1.5 開始提供了 AtomicReference 類來保證引用對象之間的原子性,可以把多個變量放在一個對象來進(jìn)行 CAS 操作。
公平鎖與非公平鎖

公平鎖是指多個線程按照申請鎖的順序來獲取鎖,線程直接進(jìn)入隊列中排隊,隊列中的第一個線程才能獲得鎖。公平鎖的優(yōu)點(diǎn)是等待鎖的線程不會餓死。缺點(diǎn)是整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大。

非公平鎖是多個線程加鎖時直接嘗試獲取鎖,獲取不到才會到等待隊列的隊尾等待。但如果此時鎖剛好可用,那么這個線程可以無需阻塞直接獲取到鎖,所以非公平鎖有可能出現(xiàn)后申請鎖的線程先獲取鎖的場景。非公平鎖的優(yōu)點(diǎn)是可以減少喚起線程的開銷,整體的吞吐效率高,因為線程有幾率不阻塞直接獲得鎖,CPU不必喚醒所有線程。缺點(diǎn)是處于等待隊列中的線程可能會餓死,或者等很久才會獲得鎖。synchronized 屬于非公平鎖。

可重入鎖與非可重入鎖

可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,再進(jìn)入該線程的內(nèi)層方法會自動獲取鎖(前提鎖對象得是同一個對象或者class),不會因為之前已經(jīng)獲取過還沒釋放而阻塞。Java 中 ReentrantLock 和synchronized 都是可重入鎖,可重入鎖的一個優(yōu)點(diǎn)是可一定程度避免死鎖。

獨(dú)享鎖與共享鎖

獨(dú)享鎖也叫排他鎖,是指該鎖一次只能被一個線程所持有。如果線程 T 對數(shù)據(jù)A加上排它鎖后,則其他線程不能再對 A 加任何類型的鎖。獲得排它鎖的線程即能讀數(shù)據(jù)又能修改數(shù)據(jù)。JDK 中的 synchronized 和 JUC 中 Lock 的實現(xiàn)類就是互斥鎖。

共享鎖是指該鎖可被多個線程所持有。如果線程 T 對數(shù)據(jù) A 加上共享鎖后,則其他線程只能對 A 再加共享鎖,不能加排它鎖。獲得共享鎖的線程只能讀數(shù)據(jù),不能修改數(shù)據(jù)。

Lock 與 Synchronized 的區(qū)別
  • Lock 是一個接口,而synchronized是 Java 的關(guān)鍵字,synchronized 是內(nèi)置的語言實現(xiàn)。
  • synchonizd 在發(fā)生異常時,會自動釋放線程占有的鎖,因此不會導(dǎo)致死鎖現(xiàn)象發(fā)生;而 Lock 在發(fā)生異常時,如果沒有主動的調(diào)用 unlock 去釋放鎖,則很可能造成死鎖現(xiàn)象,因此使用 Lock 時需要及時釋放。
  • Lock 可以讓等待鎖的線程響應(yīng)中斷,而 synchronized 卻不行,使用synchronized 時,等待的線程會一直等待下去,不能夠響應(yīng)中斷。
  • 通過 Lock 可以知道有沒有成功獲取鎖,而 synchronized 卻無法辦到。
  • Lock 可以提高多個線程進(jìn)行讀操作的效率。

在性能上來說,如果競爭資源不激烈,兩者的性能是差不多的,特別是隨著虛擬機(jī)對 synchronized 的不斷優(yōu)化。

總結(jié)

本文主要闡述了幾種實現(xiàn)線程安全的方式,在實踐中需根據(jù)具體場景具體使用。

參考

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容