带你玩转关键字Synchronized
synchronized關鍵字是Java并發編程中線程同步的常用手段之一,當多個線程同時訪問某個線程間的共享變量時,我們可以使用synchronized來保證線程安全。synchronized可以保證互斥性,可見性和有序性:
- 互斥性:確保線程互斥的訪問同步代,鎖自動釋放,多個線程操作同個代碼塊或函數必須排隊獲得鎖;
- 可見性:保證共享變量的修改能夠及時可見,獲得鎖的線程操作完畢后會將所數據刷新到共享內存區;
- 有序性:有效解決指令重排問題。
接下來我們一步一步來了解synchronized的底層實現原理。
synchronized使用方式
有如下程序,有兩個線程需要對共享變量i進行加1的操作,每個線程都加到10000,最終需要輸出20000。
/*** @Author likangmin* @create 2020/12/11 13:36*/ public class Thread4 implements Runnable{//共享資源(臨界資源)static int i=0;public void increase(){i++;}public void run() {for(int j=0;j<10000;j++){increase();}}public static void main(String[] args) throws InterruptedException {Thread4 t=new Thread4();Thread t1=new Thread(t);Thread t2=new Thread(t);t1.start();t2.start();t1.join();//主線程等待t1執行完畢t2.join();//主線程等待t2執行完畢System.out.println(i);} }在不使用synchronized關鍵字的時候,我們看一下最后執行的結果:
發現最終的結果是小于20000的,顯然結果是不正確的。這個時候就需要使用synchronized關鍵字, 一共有三種使用的方法:直接修飾某個實例方法,直接修飾某個靜態方法,修飾代碼塊。每個類都有一個類鎖,類的每個對象也有一個內置鎖,它們是互不干擾的,也就是說一個線程可以同時獲得類鎖和該類實例化對象的內置鎖,當線程訪問synchronzied修飾的方法時,依據修飾的不同類型獲取不同的鎖。
修飾實例方法
synchronized關鍵詞作用在方法的前面,用來鎖定方法,默認鎖定的是this對象。synchronized修飾的實例方法,多線程并發訪問時,只能有一個線程進入,獲得對象內置鎖,其他線程阻塞等待,但在此期間線程仍然可以訪問其他方法。
我們還是以上面的例子來進行測試,在實例方法上加上synchronized:
看一下輸出,的確是20000,符合最終的結果。
修飾靜態方法
synchronized修飾在方法上,不過修飾的是靜態方法,等價于鎖定的是Class對象。synchronized修飾的靜態方法,多線程并發訪問時,只能有一個線程進入,獲得類鎖,其他線程阻塞等待,但在此期間線程仍然可以訪問其他方法。
我們還是以上面的例子來進行測試,在靜態方法上加上synchronized:
看一下輸出,的確是20000,符合最終的結果。
修飾代碼塊
在函數體內部對于要修改的參數區間用synchronized來修飾,相比與鎖定函數這個范圍更小,可以指定鎖定什么對象。synchronized修飾的代碼塊,多線程并發訪問時,只能有一個線程進入,根據括號中的對象或者是類,獲得相應的對象內置鎖或者是類鎖。
我們還是以上面的例子來進行測試,在代碼塊上加上synchronized:
看一下輸出,的確是20000,符合最終的結果。
有的同學可能會問,synchronized修飾方法和修飾代碼塊有什么區別?這個我們在后面會解答,請你耐心往后看。
synchronized底層原理
synchronized內存模型
講清 synchronized 關鍵字的原理前需要理清 Java 對象在內存中的表示方法。我們知道在Java的JVM內存區域中一個對象在堆區創建,創建后的對象由三部分組成:
這三部分功能如下:
- 對象頭:主要包括兩部分 Klass Point跟 Mark Word,如果是數組,還包括數組的長度;
- 實例變量:存放類的屬性數據信息,包括父類的屬性信息,這部分內存按4字節對齊;
- 填充數據:由于虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是為了字節對齊。
synchronized不論是修飾方法還是代碼塊,都是通過持有修飾對象的鎖來實現同步,synchronized鎖對象是存在對象頭Mark Word。
Mark Word 中的某些字段發生變化,就可以代表鎖不同的狀態。Mark Word狀態表示位如下:
其中輕量級鎖和偏向鎖是Java6對synchronized鎖進行優化后新增加的,這里我們主要分析一下重量級鎖也就是通常說synchronized的對象鎖,鎖標識位為10,其中指針指向的是monitor對象(也稱為管程或監視器鎖)的起始地址。
每個對象都存在著一個 monitor與之關聯,而monitor可以被線程擁有或釋放,在Java虛擬機(HotSpot)中,monitor是由ObjectMonitor實現的,其主要數據結構如下(位于HotSpot虛擬機源碼ObjectMonitor.hpp文件,C++實現的):
monitor運行圖如下:
依據以上圖示我們簡單梳理一下執行過程:
因為監視器鎖(monitor)是依賴于底層的操作系統的Mutex Lock來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是早期的synchronized效率低的原因。在Java 6之后Java官方對從JVM層面對synchronized較大優化最終提升顯著,Java 6之后,為了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了鎖升級的概念。
synchronizedd修改方法與代碼塊區別
在介紹鎖升級之前,我們再回到最開始一個問題,synchronized修飾方法和修飾代碼塊有什么區別。針對這兩種情況,Java 編譯時的處理方法并不相同。我們可以通過反匯編看下同步方法跟同步方法塊在匯編語言級別是什么樣的指令。
對之前的Thread1,即synchronized修改方法的類,在終端執行javap -v Thread1.calss,得到的字節碼文件部分如下:
對之前的Thread3,即synchronized修改代碼塊,在終端執行javap -v Thread1.calss,得到的字節碼文件部分如下:
我們可以看到:
- 第一種情況,編譯器會為其自動生成了一個 ACC_SYNCHRONIZED 關鍵字用來標識。在 JVM 進行方法調用時,當發現調用的方法被 ACC_SYNCHRONIZED 修飾,則會先嘗試獲得鎖,然后開始執行方法,方法執行之后再釋放監視器鎖。這時如果其他線程來請求執行方法,會因為無法獲得監視器鎖而被阻斷住。值得注意的是,如果在方法執行過程中,發生了異常,并且方法內部并沒有處理該異常,那么在異常被拋到方法外面之前監視器鎖會被自動釋放。
- 第二種情況,編譯時在代碼塊開始前生成對應的1個 monitorenter 指令,代表同步塊進入。2個 monitorexit 指令,前一個代表同步塊正常退出,后一個在同步塊異常時退出。每個對象維護著一個記錄著被鎖次數的計數器。未被鎖定的對象的該計數器為0,當一個線程獲得鎖(執行monitorenter)后,該計數器自增變為 1 ,當同一個線程再次獲得該對象的鎖的時候,計數器再次自增。當同一個線程釋放鎖(執行monitorexit指令)的時候,計數器再自減。當計數器為0的時候。鎖將被釋放,其他線程便可以獲得鎖。
CAS算法
在講其他之前,我們還要給大家介紹一個算法- CAS 算法。CAS 算法全稱為 Compare And Swap。顧名思義,該算法涉及到了兩個操作,比較(Compare)和交換(Swap)。其基本流程如下圖:
在對共享變量進行多線程操作的時候,難免會出現線程安全問題。對該問題的一種解決策略就是對該變量加鎖,保證該變量在某個時間段只能被一個線程操作。但是這種方式的系統開銷比較大,因此提出了一種新的算法-CAS 算法。算法的思路如下:
當線程運行 CAS 算法時,該運行過程是原子操作,原子操作的含義就是線程開始跑這個函數后,運行過程中不會被別的程序打斷。
鎖升級
synchronized鎖有四種狀態,無鎖、偏向鎖、輕量級鎖、重量級鎖。這幾個狀態會隨著競爭狀態逐漸升級,鎖可以升級但不能降級,但是偏向鎖狀態可以被重置為無鎖狀態。
偏向鎖
HotSpot的作者大量研究發現大多數時候是不存在鎖競爭的,經常是一個線程多次獲得同一個鎖,因此如果每次都要競爭鎖會增大很多沒有必要付出的代價,為了降低獲取鎖的代價,引入偏向鎖。以下為偏向鎖的 Mark Word 字段。
如果一個線程獲得了鎖,那么鎖就進入偏向模式,此時Mark Word 的結構也變為偏向鎖結構。當這個線程再次請求鎖時,無需再做任何同步操作,即不用再去重復獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程序的性能。偏向鎖的申請流程:
偏向鎖使用場景
- 對于沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個線程申請相同的鎖;
- 對于鎖競爭比較激烈的場合,偏向鎖就失效了,因為這樣場合極有可能每次申請鎖的線程都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失;
- 偏向鎖失敗后,并不會立即膨脹為重量級鎖,而是先升級為輕量級鎖。
輕量級鎖
輕量級鎖考慮的是競爭鎖對象的線程不多,而且線程持有鎖的時間也不長的情景。因為阻塞線程需要高昂的耗時實現CPU從用戶態轉到內核態的切換,如果剛剛阻塞不久這個鎖就被釋放了,那這個代價就有點得不償失了,因此這個時候就干脆不阻塞這個線程,讓它自旋這等待鎖釋放。如果當前對象是輕量級鎖狀態,對象的 Mark Word 如下圖所示:
該對象頭Mark Word分為兩個部分。第一部分是指向棧中的鎖記錄的指針,第二部分是鎖標記位,針對輕量級鎖該標記位為 00。輕量級鎖的申請流程:
重量級鎖
在 Java 的早期版本中,synchronized 鎖屬于重量級鎖,此時對象的 Mark Word 如圖所示:
該對象頭的 Mark Word 分為兩個部分,第一部分是指向重量級鎖的指針,第二部分是鎖標記位。這里所說的指向重量級鎖的指針就是 monitor,monitor 是監視器,Java 中每個對象會對應一個監視器,這個監視器其實也就是監控鎖有沒有釋放,釋放的話會通知下一個等待鎖的線程去獲取。monitor 的成員變量比較多,我們可以將 monitor 簡單理解成兩部分,第一部分表示當前占用鎖的線程,第二部分是等待這把鎖的線程隊列。
如果當前占用鎖的線程把鎖釋放了,那就需要在線程隊列中喚醒下一個等待鎖的線程。但是阻塞或喚醒一個線程需要依賴底層的操作系統來實現,Java 的線程是映射到操作系統的原生線程之上的。而操作系統實現線程之間的切換需要從用戶態轉換到核心態,這個狀態轉換需要花費很多的處理器時間,甚至可能比用戶代碼執行的時間還要長。由于這種效率太低,所以提出了偏向鎖,輕量級鎖等的改進優化。
總結
最后給大家總結一下各個鎖的優缺點以及各自適用的場景:
| 偏向鎖 | 加鎖解鎖無需額外消耗,跟非同步方法時間相差納秒級別 | 如果競爭線程多,會帶來額外的鎖撤銷的消耗 | 基本沒有其他線程競爭的同步場景 |
| 輕量級鎖 | 競爭的線程不會阻塞而是在自旋,可提高程序響應速度 | 如果一直無法獲得會自旋消耗CPU | 少量線程競爭,持有鎖時間不長,追求響應速度 |
| 重量級鎖 | 線程競爭不會導致CPU自旋跟消耗CPU資源 | 線程阻塞,響應時間長 | 很多線程競爭鎖,切鎖持有時間長,追求吞吐量時候 |
最后再奉上鎖升級大圖(感謝unbelievableme大神的繪制):
想看更多文章,請點擊此處
參考文章:
https://www.cnblogs.com/kundeg/p/8422557.html
總結
以上是生活随笔為你收集整理的带你玩转关键字Synchronized的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java的TheadLocal使用
- 下一篇: 教你用BitMap排序、查找和存储大量数