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



一、介紹


首先, 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”。

對象需要存儲的運行時數據很多,其實已經超出了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文件,可以看到:

其中的方法標識:

  • 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 文件:

可以看到,在指令方面多了關於 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)的子類來完成線程訪問控制的。

我們可以看看:

這里面的各個鎖實現了 Lock 接口,然后任意打開一個類,可以發現里面的實現,Lock 的操作借助於內部類 Sync,而 Sync 是繼承了 AbstractQueuedSynchronizer類的,這個類就是很重要的一個 AQS 類。

整體來看,這些類的關系還是挺復雜:

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

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

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

在 Lock 接口里定義的方法有 6 個,他們的含義如下:

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

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
    里面維護了一個讀鎖、一個寫鎖,其中讀鎖是共享鎖,寫鎖是排他鎖。

因為分了讀寫鎖,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 操作來保證使用這些類型的變量的時候,操作都是原子操作,當使用他們作為共享變量的時候,也就不存在線程安全問題了。

參考:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM