java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

Life_Goes_On發表於2020-09-17

一、介紹


首先, java 的鎖分為兩類:

  1. 第一類是 synchronized 同步關鍵字,這個關鍵字屬於隱式的鎖,是 jvm 層面實現,使用的時候看不見;
  2. 第二類是在 jdk5 後增加的 Lock 介面以及對應的各種實現類,這屬於顯式的鎖,就是我們能在程式碼層面看到鎖這個物件,而這些個物件的方法實現,大都是直接依賴 CPU 指令的,無關 jvm 的實現。

接下來就從 synchronizedLock 兩方面來講。


二、synchronized


2.1 synchronized 的使用


  • 如果修飾的是具體物件:鎖的是物件
  • 如果修飾的是成員方法:那鎖的就是 this
  • 如果修飾的是靜態方法:鎖的就是這個物件.class

2.2 Java的物件頭和 Monitor


理解 synchronized 原理之前,我們需要補充一下 java 物件的知識。

物件在記憶體中的佈局分為三塊區域:物件頭、例項資料和對齊填充

  1. 物件頭。Hot Spot 虛擬機器物件的物件頭部分包括兩類資訊。第一類是用於儲存物件自身的執行時資料,如雜湊碼( Hash Code)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,這部分資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為32個位元和64個位元,官方稱它為“ Mark Word”。
java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

物件需要儲存的執行時資料很多,其實已經超出了32、64位 Bitmap 結構所能記錄的最大限度,但物件頭裡的資訊是與物件自身定義的資料無關的額外儲存成本,考慮到虛擬機器的空間效率,Mark Word被設計成一個有著動態定義的資料結構,以便在極小的空間記憶體儲儘量多的資料,根據物件的狀態複用自己的儲存空間。

  1. 例項資料。例項資料部分是物件真正儲存的有效資訊,即我們在程式程式碼裡面所定義的各種型別的欄位內容,無論是從父類繼承下來的,還是在子類中定義的欄位都必須記錄起來。

這部分的儲存順序會受到虛擬機器分配策略引數 (-XX: Fields Allocation Style引數) 和欄位在Java原始碼中定義順序的影響。Hot Spot 虛擬機器預設的分配順序為 longs/doubles、ints、shorts/chars、bytes/booleans、oops( Ordinary Object Pointers,OOPs),從以上預設的分配策略中可以看到,相同寬度的欄位總是被分配到一起存放,在滿足這個前提條件的情況下,在父類中定義的變數會出現在子類之前。如果 Hotspot 虛擬機器的 XX: Compact Fields 引數值為 true(預設就為true),那子類之中較窄的變數也允許插入父類變數的空隙之中,以節省出一點點空間。

  1. 對齊填充。並不是必然存在的,由於 Hotspot 虛擬機器的自動記憶體管理系統要求物件起始地址必須是 8 位元組的整數倍,如果物件例項資料部分沒有對齊的話,就需要通過對齊填充來補全。

介紹完了物件的內容,和鎖相關的顯然就是物件頭裡儲存的那幾個內容:

  • 其中的重量級鎖也就是通常說 synchronized 的物件鎖,其中指標指向的是 monitor 物件(也稱為管程或監視器鎖)的起始地址。每個物件都存在著一個 monitor 與之關聯,monitor 是由ObjectMonitor 實現的,C++實現。
  • 注意到還有輕量級鎖,這是在 jdk6 之後對 synchronized 關鍵字底層實現的改進。

2.3 synchronized 原理


我們已經知道 synchronized 和物件頭裡的指令有關,也就是我們以前大概的說法:

Java虛擬機器可以支援方法級的同步和方法內部一段指令序列(程式碼塊)的同步,這兩種同步結構都是使用管程( Monitor,更常見的是直接將它稱為“鎖”) 來實現的。

現在我們講講原理。

因為對於 synchronized 修飾方法(包括普通和靜態方法)、修飾程式碼塊,這兩種用法的實現略有不同:

1.synchronized 修飾方法

我們測試一個同步方法:

public class Tues {
    public static int i ;
    public synchronized static void syncTask(){
        i++;
    }
}

然後反編譯 class檔案,可以看到:

java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

其中的方法標識:

  • ACC_PUBLIC 代表public修飾
  • ACC_STATIC 表示是靜態方法
  • ACC_SYNCHRONIZED 指明該方法為同步方法。

這個時候我們可以理解《深入理解java虛擬機器》裡,對於同步方法底層實現的描述如下:

方法級的同步是隱式的。 無須通過位元組碼指令來控制,它實現在方法呼叫和返回操作之中。虛擬機器可以從方法常量池中的方法表結構中的 ACC_SYNCHRONIZED 訪問標誌得知一個方法是否被宣告為同步方法。(靜態方法也是如此)

  • 當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒就要求先成功持有管程(Monitor),然後才能執行方法,最後當方法完成 (無論是正常完成還是非正常完成)時釋放管程。
  • 在方法執行期間,執行執行緒持有了管程,其他任何執行緒都無法再獲取到同一個管程。
  • 如果一個同步方法執行期間丟擲了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的管程將在異常拋到同步方法邊界之外時自動釋放。

2.synchronized修飾程式碼塊

測試一段同步程式碼:

public class Tues {
   public int i;
   public void syncTask(){
       synchronized (this){
           i++;
       }
   }
}

然後反編譯 class 檔案:

java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

可以看到,在指令方面多了關於 Monitor 操作的指令,或者和上一種修飾方法的區別來看,是顯式的用指令去操作管程(Monitor)了。

同理,這個時候我們可以理解《深入理解java虛擬機器》裡的描述如下:

同步一段指令集序列的情況。Java虛擬機器的指令集中有 monitorenter 和 monitorexit 兩條指令來支援 synchronized 關鍵字的語義。(monitorenter 和 monitorexit 兩條指令是 C 語言的實現)正確實現 synchronized 關鍵字需要 Javac 編譯器與 Java 虛擬機器兩者共同協作支援。Monitor的實現基本都是 C++ 程式碼,通過JNI(java native interface)的操作,直接和cpu的互動程式設計。

2.4 早期 synchronized 的問題


關於操作 monitor 的具體實現,我們沒有再深入,持有管程、計數、阻塞等等的思路和直接在 java 中顯式的用 lock 是類似的。

早期的 synchronized 的實現就是基於上面所講的原理,因為監視器鎖(monitor)是依賴於底層的作業系統的 Mutex Lock 來實現的,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的 synchronized 效率低的原因。

更具體一些的開銷,還涉及 java 的執行緒和作業系統核心執行緒的關係

前面講到物件頭裡儲存的內容的時候我們也留了線索,那就是 jdk6 之後多出來輕量級的鎖,來改進 synchronized 的實現。

我的理解,這個改進就是:從加鎖到最後變成以前的那種重量級鎖的過程裡,新實現出狀態不同的鎖作為過渡。

2.5 改進後的各種鎖


偏向鎖->自旋鎖->輕量級鎖->重量級鎖。按照這個順序,鎖的重量依次增加。

  • 偏向鎖。他的意思是這個鎖會偏向於第一個獲得它的執行緒,當這個執行緒再次請求鎖的時候不需要進行任何同步操作,從而提高效能。那麼處於偏向鎖模式的時候,物件頭的Mark Word 的結構會變為偏向鎖結構。

研究發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,因此為了減少同一執行緒獲取鎖的代價而引入偏向鎖。那麼顯然,一旦另一個執行緒嘗試獲得這個鎖,那麼偏向模式就會結束。另一方面,如果程式的大多數鎖都是多個執行緒訪問,那麼偏向鎖就是多餘的。

  • 輕量級鎖。當偏向鎖的條件不滿足,亦即的確有多執行緒併發爭搶同一鎖物件時,但併發數不大時,優先使用輕量級鎖。一般只有兩個執行緒爭搶鎖標記時,優先使用輕量級鎖。 此時,物件頭的Mark Word 的結構會變為輕量級鎖結構。

輕量級鎖是和傳統的重量級鎖相比較的,傳統的鎖使用的是作業系統的互斥量,而輕量級鎖是虛擬機器基於 CAS 操作進行更新,嘗試比較並交換,根據情況決定要不要改為重量級鎖。(這個動態過程也就是自旋鎖的過程了)

  • 重量級鎖。重量級鎖即為我們在上面探討的具有完整Monitor功能的鎖

  • 自旋鎖。自旋鎖是一個過渡鎖,是從輕量級鎖到重量級鎖的過渡。也就是CAS。

CAS,全稱為Compare-And-Swap,是一條CPU的原子指令,其作用是讓CPU比較後原子地更新某個位置的值,實現方式是基於硬體平臺的彙編指令,就是說CAS是靠硬體實現的,JVM 只是封裝了彙編呼叫,那些AtomicInteger類便是使用了這些封裝後的介面。

注意:Java中的各種鎖對程式設計師來說是透明的: 在建立鎖時,JVM 先建立最輕的鎖,若不滿足條件則將鎖逐次升級.。這四種鎖之間只能升級,不能降級。

2.6 其他鎖的分類


上面說的鎖都是基於 synchronized 關鍵字,以及底層的實現涉及到的鎖的概念,還有一些別的角度的鎖分類:

按照鎖的特性分類:

  1. 悲觀鎖:獨佔鎖,會導致其他所有需要所的執行緒都掛起,等待持有所的執行緒釋放鎖,就是說它的看法比較悲觀,認為悲觀鎖認為對於同一個資料的併發操作,一定是會發生修改的。因此對於同一個資料的併發操作,悲觀鎖採取加鎖的形式。比如前面講過的,最傳統的 synchronized 修飾的底層實現,或者重量級鎖。(但是現在synchronized升級之後,已經不是單純的悲觀鎖了)
  2. 樂觀鎖:每次不是加鎖,而是假設沒有衝突而去試探性的完成操作,如果因為衝突失敗了就重試,直到成功。比如 CAS 自旋鎖的操作,實際上並沒有加鎖。

按照鎖的順序分類:

  1. 公平鎖。公平鎖是指多個執行緒按照申請鎖的順序來獲取鎖。java 裡面可以通過 ReentrantLock 這個鎖物件,然後指定是否公平
  2. 非公平鎖。非公平鎖是指多個執行緒獲取鎖的順序並不是按照申請鎖的順序,有可能後申請的執行緒比先申請的執行緒優先獲取鎖。使用 synchronized 是無法指定公平與否的,他是不公平的。

獨佔鎖(也叫排他鎖)/共享鎖:

  1. 獨佔鎖也叫排他鎖,是指該鎖一次只能被一個執行緒所持有。對 ReentrantLock 和 Sychronized 而言都是獨佔鎖。
  2. 共享鎖:是指該鎖可被多個執行緒所持有。對 ReentrantReadWriteLock 而言,其讀鎖是共享鎖,其寫鎖是獨佔鎖。讀鎖的共享性可保證併發讀是非常高效的,讀寫、寫讀、寫寫的過程都是互斥的。

獨佔鎖/共享鎖是一種廣義的說法,互斥鎖/讀寫鎖是java裡具體的實現。


三、Java 裡的 Lock


上面我們講到了,synchronized 關鍵字下層的鎖,是在 jvm 層面實現的,而後來在 jdk 5 之後,在 juc 包裡有了顯式的鎖,Lock 完全用 Java 寫成,在java這個層面是無關JVM實現的。雖然 Lock 缺少了 (通過 synchronized 塊或者方法所提供的) 隱式獲取釋放鎖的便捷性,但是卻擁有了鎖獲取與釋放的可操作性、可中斷的獲取鎖以及超時獲取鎖等多種 synchronized 關鍵字所不具備的同步特性。

Lock 是一個介面,實現類常見的有:

  • 重入鎖(ReentrantLock
  • 讀鎖(ReadLock
  • 寫鎖(WriteLock

實現基本都是通過聚合了一個同步器(AbstractQueuedSynchronizer 縮寫為 AQS)的子類來完成執行緒訪問控制的。

我們可以看看:

java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

這裡面的各個鎖實現了 Lock 介面,然後任意開啟一個類,可以發現裡面的實現,Lock 的操作藉助於內部類 Sync,而 Sync 是繼承了 AbstractQueuedSynchronizer類的,這個類就是很重要的一個 AQS 類。

java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

整體來看,這些類的關係還是挺複雜:

java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

不過一般的直接使用還是很簡單,比如 new 一個鎖,然後在需要的操作之前之後分別加鎖和釋放鎖。

Lock lock = new ReentrantLock();
lock.lock();//獲取鎖的過程不要寫在 try 中,因為如果獲取鎖時發生了異常,異常丟擲的同時也會導致鎖釋放
try{

}finally{
    lock.unlock();//finally塊中釋放鎖,目的是保證獲取到鎖之後最後一定能釋放鎖。
}

在 Lock 介面裡定義的方法有 6 個,他們的含義如下:

java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

接下來我們們就分步看看常用的各種類。

3.1 AbstractQueuedSynchronizer


佇列同步器 AbstractQueuedSynchronizer(以下簡稱同步器或者 AQS),是用來構建鎖或者其他同步元件的基礎框架,它使用了一個 int 成員變數表示同步狀態,通過內建的 FIFO 佇列來完成資源獲取執行緒的排隊工作。

同步器的主要使用方式是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態,在抽象方法的實現過程中免不了要對同步狀態進行更改,這時就需要使用同步器提供的 3 個方法來進行操作,因為它們能夠保證狀態的改變是安全的。

這三個方法分別是:

  1. protected final int getState(),// 獲取當前同步狀態
  2. protected final void setState(int newState),// 設定當前同步狀態
  3. protected final boolean compareAndSetState(int expect, int update),// 使用 CAS 設定當前狀態,該方法能夠保證狀態設定的原子性

子類推薦被定義為自定義同步元件的靜態內部類,同步器自身沒有實現任何同步介面,它僅僅是定義了若干同步狀態獲取和釋放的方法來供自定義同步元件使用,同步器既可以支援獨佔式地獲取同步狀態,也可以支援共享式地獲取同步狀態,這樣就可以方便實現不同型別的同步元件 (ReentrantLock、 ReentrantReadWriteLock 和 CountDownLatch 等)。

AQS 定義的三類别範本方法;

  1. 獨佔式同步狀態獲取與釋放
  2. 共享式同步狀態獲取與釋放
  3. 同步狀態和查詢同步佇列中的等待執行緒情況

同步器的內建 FIFO 佇列,從原始碼裡可以看到,Node 就是儲存著執行緒引用和執行緒狀態的容器

  • 每個執行緒對同步器的訪問,都可以看做是佇列中的一個節點(Node)。
  • 節點是構成同步佇列的基礎,同步器擁有首節點 (head) 和尾節點 (tail);
  • 沒有成功獲取同步狀態的執行緒將會成為節點加入該佇列的尾部。
  • 首節點的執行緒在釋放同步狀態時,將會喚醒後繼節點,而後繼節點將會在獲取同步狀態成功時將自己設定為首節點。

因為原始碼很多,這裡暫且不去分析具體的實現。

3.2 重入鎖 ReentrantLock


  • 重入鎖 ReentrantLock,就是支援重進入的鎖,它表示該鎖能夠支援一個執行緒對資源的重複加鎖。
  • 除此之外,該鎖的還支援獲取鎖時的公平和非公平性選擇。

ReentrantLock 支援公平與非公平選擇,內部實現機制為:

  1. 內部基於 AQS 實現一個公平與非公平公共的父類 Sync ,(在程式碼裡,Sync 是一個內部類,繼承 AQS)用於管理同步狀態;
  2. FairSync 繼承 Sync 用於處理公平問題;
  3. NonfairSync 繼承 Sync 用於處理非公平問題。

3.3 讀寫鎖 ReentrantReadWriteLock


在上面講 synchronized 的最後,提到了鎖的其他維度的分類:

獨佔鎖(排他鎖)/共享鎖,具體實現層面就對應 java 裡的互斥鎖/讀寫鎖

  • ReentrantLock、synchronized 都是排他鎖;
  • ReentrantReadWriteLock
    裡面維護了一個讀鎖、一個寫鎖,其中讀鎖是共享鎖,寫鎖是排他鎖。
java裡的鎖總結(synchronized隱式鎖、Lock顯式鎖、volatile、CAS)

因為分了讀寫鎖,ReentrantReadWriteLock 鎖沒有直接實現 Lock 介面,它的內部是這樣的:

  • 基於 AQS 實現一個公平與非公平公共的父類 Sync ,用於管理同步狀態;
  • FairSync 繼承 Sync 用於處理公平問題;
  • NonfairSync 繼承 Sync 用於處理非公平問題;
  • ReadLock 實現 Lock 介面,內部聚合 Sync
  • WriteLock 實現 Lock 介面,內部聚合 Sync

四、一些總結和對比


到這裡我們知道了 java 的物件都有與之關聯的一個鎖,這個鎖稱為監視器鎖或者內部鎖,通過關鍵字 synchronized 宣告來使用,實際是 jvm 層面實現的,向下則用到了 Monitor 類,再向下虛擬機器的指令則是和 CPU 打交道,插入記憶體屏障等等操作。

而 jdk 5 之後引入了顯式的鎖,以 Lock 介面為核心的各種實現類,他們完全由 java 實現邏輯,那麼實現類還要基於 AQS 這個佇列同步器,AQS 遮蔽了同步狀態管理、執行緒排隊與喚醒等底層操作,提供模板方法,聚合到 Lock 的實現類裡去實現。

這裡我們對比一下隱式和顯式鎖:

  1. 隱式鎖基本沒有靈活性可言,因為 synchronized 控制的程式碼塊無法跨方法,修飾的範圍很窄而顯示鎖則本身就是一個物件,可以充分發揮物件導向的靈活性,完全可以在一個方法裡獲得鎖,另一個方法裡釋放
  2. 隱式鎖簡單易用且不會導致記憶體洩漏而顯式鎖的過程完全要程式設計師控制,容易導致鎖洩露
  3. 隱式鎖只是非公平鎖顯示鎖支援公平/非公平鎖
  4. 隱式鎖無法限制等待時間、無法對鎖的資訊進行監控顯示鎖提供了足夠多的方法來完成靈活的功能
  5. 一般來說,我們預設情況下使用隱式鎖,只在需要顯示鎖的特性的時候才選用顯式鎖。

對比完了 synchronizedLock 兩個。對於 java 的執行緒同步機制,往往還會提到的另外兩個內容就是 volatile 關鍵字和 CAS 操作以及對應的原子類。

因此這裡再提一下:

  • volatile 關鍵字常被稱為輕量級的 synchronized,實際上這兩個完全不是一個東西。我們知道了 synchronized 通過的是 jvm 層面的管程隱式的加了鎖。而 volatile 關鍵字則是另一個角度,jvm 也採用相應的手段,保證:
    • 被它修飾的變數的可見性:執行緒對變數進行修改後,要立刻寫回主記憶體;
    • 執行緒對變數讀取的時候,要從主記憶體讀,而不是快取;
    • 在它修飾變數上的操作禁止指令重排序。
  • CAS 是一種 CPU 的指令,也不屬於加鎖,它通過假設沒有衝突而去試探性的完成操作,如果因為衝突失敗了就重試,直到成功。那麼實際上我們很少直接使用 CAS ,但是 java 裡提供了一些原子變數類,就是 juc 包裡面的各種Atomicxxx類,這些類的底層實現直接使用了 CAS 操作來保證使用這些型別的變數的時候,操作都是原子操作,當使用他們作為共享變數的時候,也就不存線上程安全問題了。

參考:

相關文章