文章目錄
- 樂觀鎖 vs 悲觀鎖
-
- 悲觀鎖(預期鎖沖突的機率很高)
- 樂觀鎖(預期鎖沖突的機率很低)
- 讀寫鎖
- 重量級鎖 vs 輕量級鎖
- 挂起等待鎖vs自旋鎖
- 公平鎖 vs 非公平鎖
- 可重入鎖 vs 不可重入鎖
- CAS(重點)
-
- 什麼是 CAS
- CAS 是怎麼實作的
- CAS 有哪些應用
- CAS 的 ABA 問題(重中之重)
-
- 什麼是 ABA 問題
- ABA 問題引來的 BUG
- 解決方案
- Synchronized 原理
-
- 基本特點
- 典型的優化手段
-
- 鎖膨脹/鎖更新
- 鎖消除
- 鎖粗化
- 相關面試題(重點)
-
- 你是怎麼了解樂觀鎖和悲觀鎖的,具體怎麼實作呢?
- 介紹下讀寫鎖?
- 什麼是自旋鎖,為什麼要使用自旋鎖政策呢,缺點是什麼?
- synchronized 是可重入鎖麼?
- 講解下你自己了解的 CAS 機制
- ABA問題怎麼解決?
- 什麼是偏向鎖?
- synchronized 實作原理 是什麼?
引言:
接下來我們要講解的鎖政策不僅僅是局限于 Java ,任何和 “鎖” 相關的話題, 都可能會涉及到以下内容;
這些特性主要是給鎖的實作者來參考的,普通的程式猿也需要了解一些,對于合理的使用鎖也是有很大幫助的。
樂觀鎖 vs 悲觀鎖
悲觀鎖(預期鎖沖突的機率很高)
總是假設最壞的情況,每次去拿資料的時候都認為别人會修改,是以每次在拿資料的時候都會上鎖,這樣别人想拿這個資料就會阻塞,直到它拿到鎖。
樂觀鎖(預期鎖沖突的機率很低)
假設資料一般情況下不會産生并發沖突,是以在資料進行送出更新的時候,才會正式對資料是否産生并發沖突進行檢測,如果發現并發沖突了,則讓傳回使用者錯誤的資訊,讓使用者決定如何去做。
讀寫鎖
多線程之間,資料的讀取方之間不會産生線程安全問題,但資料的寫入方互相之間以及和讀者之間都需要進行互斥。
如果兩種場景下都用同一個鎖,就會産生極大的性能損耗。是以讀寫鎖是以而産生。
讀寫鎖(readers-writer lock),看英文可以顧名思義,在執行加鎖操作時需要額外表明讀寫意圖,複數讀者之間并不互斥,而寫者則要求與任何人互斥。
一個線程對于資料的通路, 主要存在兩種操作: 讀資料 和 寫資料。
- 兩個線程都隻是讀一個資料, 此時并沒有線程安全問題, 直接并發的讀取即可;
- 兩個線程都要寫一個資料, 有線程安全問題;
- 一個線程讀另外一個線程寫, 也有線程安全問題。
讀寫鎖就是把讀操作和寫操作區分對待, Java 标準庫提供了 ReentrantReadWriteLock 類,實作了讀寫鎖。
- ReentrantReadWriteLock.ReadLock 類表示一個讀鎖. 這個對象提供了 lock / unlock 方法進行加鎖解鎖;
- ReentrantReadWriteLock.WriteLock 類表示一個寫鎖. 這個對象也提供了 lock / unlock 方法進行加鎖解鎖。
其中
- 讀加鎖和讀加鎖之間, 不互斥;
- 寫加鎖和寫加鎖之間, 互斥;
- 讀加鎖和寫加鎖之間, 互斥。
讀寫鎖特别适合于 “頻繁讀,不頻繁寫” 的場景中, (這樣的場景其實也是非常廣泛存在的)。
重量級鎖 vs 輕量級鎖
- 重量級鎖:就是做了更多的事情,開銷更大
- 輕量級鎖:做的事情更少,開銷更小
通常情況下,也可以認為悲觀鎖一般都是重量級鎖,樂觀鎖一般都是輕量級鎖。(不絕對)
- 在使用的鎖中,如果鎖是基于核心的一些功能來實作的(比如調用了作業系統提供的mutex接口),此時一般認為這是重量級鎖。——(作業系統的鎖會在核心中做很多事情,比如讓線程阻塞等待…)
- 如果鎖是純使用者态實作的,此時一般認為這是輕量級鎖——(使用者态的代碼更可控,也更高效)
挂起等待鎖vs自旋鎖
- 挂起等待鎖,往往就是通過核心的一些機制來實作的,往往較重——(重量級鎖的一種典型實作)
- 自旋鎖,往往就是通過使用者态代碼來實作的,往往較輕——(輕量級鎖的一種典型實作)
公平鎖 vs 非公平鎖
- 公平鎖:多個線程在等待一把鎖的時候,誰是先來的,誰就先擷取到這個鎖(遵守先來後到)
- 非公平鎖:多個線程在等待一把鎖的時候,不遵守先來後到(每個等待的線程取到鎖的機率都是均等的)
可重入鎖 vs 不可重入鎖
由于上一篇部落格已經介紹過此相關知識,是以在此就不在多加贅述了。😊😊簡單來說就是,一個線程,針對一把鎖,咔咔連續加鎖兩次,如果會死鎖,就是不可重入鎖;如果不會死鎖,就是可重入鎖。
CAS(重點)
什麼是 CAS
CAS: 全稱Compare and swap,字面意思:”比較并交換“,一個 CAS 涉及到以下操作:
我們假設記憶體中的原資料V,舊的預期值A,需要修改的新值B。
- 比較 A 與 V 是否相等。(比較)
- 如果比較相等,将 B 寫入 V。(交換)
- 傳回操作是否成功。
【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點) 上面寫的代碼顯然是線程不安全的,既有讀又有寫,而且讀和寫還不是原子的,這個僞代碼隻是輔助了解CAS 的工作流程。
真實的 CAS 指的是,CPU提供了一個單獨的CAS指令,通過這一條指令,就完成了上述僞代碼描述的過程。如果上述過程都是這“這一條指令”就幹完了,就相當于這是原子的了(CPU上面執行的指令就是一條一條執行的,指令是不可分割的最小機關)此時線程就安全了!!!
CAS最大的意義就是,讓我們寫這種多線程安全的代碼時,提供了一個新的思路和方向(就和鎖不一樣了)
有很多功能,既可以是硬體實作也可以是軟體實作,就像上面代碼中的比較交換邏輯,這就相當于硬體直接實作出來了,通過這一 條指令,封裝好,直接讓咱們用了。
CAS 是怎麼實作的
針對不同的作業系統,JVM 用到了不同的 CAS 實作原理,簡單來講:
- java 的 CAS 利用的的是 unsafe 這個類提供的 CAS 操作;
- unsafe 的 CAS 依賴了的是 jvm 針對不同的作業系統實作的Atomic::cmpxchg;
- Atomic::cmpxchg 的實作使用了彙編的 CAS 操作,并使用 cpu 硬體提供的 lock 機制保證其原子性。
簡而言之,是因為硬體予以了支援,軟體層面才能做到。
CAS 有哪些應用
-
基于CAS能夠實作"原子類"
Java标準庫裡提供了一組原子類,針對所常用多一些int, long, int array…進行了封裝可以基于CAS的方式進行修改,并且線程安全。
典型的就是 AtomicInteger 類. 其中的 getAndIncrement 相當于 i++ 操作。
僞代碼實作:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
圖解如下:
具體代碼示例如下:
import java.util.concurrent.atomic.AtomicInteger;
public class Demo2 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger=new AtomicInteger(0);
Thread t1=new Thread(()->{
for(int i=0;i<5000;i++){
//這個方法就相當于atomicInteger++
atomicInteger.getAndIncrement();
}
});
Thread t2=new Thread(()->{
for(int i=0;i<5000;i++){
atomicInteger.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//通過get方法得到原子類内部的數值
System.out.println(atomicInteger.get());
}
}
這個代碼裡面不存線上程安全問題,基于CAS實作的++操作, 這裡面就可以保證既能夠線程安全,又能夠比synchronized高效。因為 synchronized會涉及到鎖的競争,兩個線程要互相等待,而CAS不涉及到線程阻塞等待。
-
實作“自旋鎖"
基于 CAS 實作更靈活的鎖,擷取到更多的控制權。
僞代碼如下:
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通過 CAS 看目前鎖是否被某個線程持有.
// 如果這個鎖已經被别的線程持有, 那麼就自旋等待.
// 如果這個鎖沒有被别的線程持有, 那麼就把 owner 設為目前嘗試加鎖的線程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
圖解如下:
CAS 的 ABA 問題(重中之重)
什麼是 ABA 問題
ABA 的問題:
假設存在兩個線程 t1 和 t2, 有一個共享變量 num, 初始值為 A, 接下來, 線程 t1 想使用 CAS 把 num 值改成 Z, 那麼就需要
- 先讀取 num 的值, 記錄到 oldNum 變量中;
-
使用 CAS 判定目前 num 的值是否為 A, 如果為 A,就修改成 Z。
但是, 在 t1 執行這兩個操作之間, t2 線程可能把 num 的值從 A 改成了 B, 又從 B 改成了 A。
線程 t1 的 CAS 是期望 num 不變就修改, 但是 num 的值已經被 t2 給改了, 隻不過又改成 A 了, 這個時候 t1 究竟是否要更新 num 的值為 Z 呢?
到這一步, t1 線程無法區分目前這個變量始終是 A, 還是經曆了一個變化過程。
ABA 問題引來的 BUG
大部分的情況下, t2 線程這樣的一個反複橫跳改動, 對于 t1 是否修改 num 是沒有影響的, 但是不排除一些特殊情況。
下面給大家舉一個經典的例子:
解決方案
引入一個“版本号”(也可以使用時間戳),這個版本号隻能變大,不能變小,修改變量的時候,比較就不是比較變量本身了,而是比較版本号了~
此處就要求,每次針對餘額進行修改,都讓版本+ 1,每次修改之前,也要先對比版本看看舊值和目前值是否一緻~
當引入版本号之後,t2再嘗試進行這裡的比較版本操作
就發現版本的舊值和目前值并不比對,是以就放棄進行修改~
如果直接拿變量本身進行判定,因為變量的值有加有減,就容易出現ABA的情況,現在是拿版本号來進行判定,要求版本号隻能增加,這個時候就不會有ABA問題了!!
Synchronized 原理
基本特點
結合上面的鎖政策,我們就可以總結出, Synchronized 具有以下特性(隻考慮 JDK 1.8):
- 開始時是樂觀鎖,如果鎖沖突頻繁, 就轉換為悲觀鎖;
- 開始是輕量級鎖實作,如果鎖被持有的時間較長,就轉換成重量級鎖;
- 實作輕量級鎖的時候大機率用到的自旋鎖政策;
- 是一種不公平鎖;
- 是一種可重入鎖;
- 不是讀寫鎖。
典型的優化手段
鎖膨脹/鎖更新
JVM 将 synchronized 鎖分為 無鎖、偏向鎖、輕量級鎖、重量級鎖狀态。會根據情況,進行依次更新。
- 偏向鎖
偏向鎖不是真的 “加鎖”, 隻是給對象頭中做一個 “偏向鎖的标記”,記錄這個鎖屬于哪個線程。
如果後續沒有其他線程來競争該鎖,那麼就不用進行其他同步操作了(避免了加鎖解鎖的開銷) 如果後續有其他線程來競争該鎖(剛才已經在鎖對象中記錄了目前鎖屬于哪個線程了,很容易識别 目前申請鎖的線程是不是之前記錄的線程), 那就取消原來的偏向鎖狀态, 進入一般的輕量級鎖狀态。
偏向鎖本質上相當于 “延遲加鎖” ,能不加鎖就不加鎖, 盡量來避免不必要的加鎖開銷,但是該做的标記還是得做的, 否則無法區分何時需要真正加鎖。
舉個栗子:(❁´◡`❁)
假設男主是一個鎖, 女主是一個線程. 如果隻有這一個線程來使用這個鎖, 那麼男主女主即使不領證結婚(避免了高成本操作),也可以一直幸福的生活下去。
——此時如果女配(相當于其他線程)沒有出現的話,男女主就可以一直不結婚,就不必真的加鎖
但是女配出現了, 也嘗試競争男主, 此時不管領證結婚這個操作成本多高, 女主也勢必要把這個動作完成了, 讓女配死心。——偏向鎖更新到輕量級鎖的過程
-
輕量級鎖
随着其他線程進入競争, 偏向鎖狀态被消除, 進入輕量級鎖狀态(自适應的自旋鎖).
此處的輕量級鎖就是通過 CAS 來實作.
- 通過 CAS 檢查并更新一塊記憶體 (比如 null => 該線程引用)
- 如果更新成功, 則認為加鎖成功
- 如果更新失敗, 則認為鎖被占用, 繼續自旋式的等待(并不放棄 CPU)。
自旋操作是一直讓 CPU 空轉, 比較浪費 CPU 資源。
是以此處的自旋不會一直持續進行, 而是達到一定的時間/重試次數, 就不再自旋了。
也就是所謂的 “自适應”
-
重量級鎖
如果競争進一步激烈, 自旋不能快速擷取到鎖狀态, 就會膨脹為重量級鎖
此處的重量級鎖就是指用到核心提供的 mutex。
- 執行加鎖操作, 先進入核心态.
- 在核心态判定目前鎖是否已經被占用
- 如果該鎖沒有占用, 則加鎖成功, 并切換回使用者态.
- 如果該鎖被占用, 則加鎖失敗. 此時線程進入鎖的等待隊列, 挂起, 等待被操坐系統喚醒。
- 經曆了一系列的滄海桑田, 這個鎖被其他線程釋放了, 作業系統也想起了這個挂起的線程, 于是喚醒這個線程, 嘗試重新擷取鎖
鎖消除
編譯器+JVM 判斷鎖是否可消除, 如果可以, 就直接消除。
什麼是 “鎖消除”
有些應用程式的代碼中, 用到了 synchronized, 但其實沒有在多線程環境下。(例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此時每個 append 的調用都會涉及加鎖和解鎖,但如果隻是在單線程中執行這個代碼, 那麼這些加鎖解鎖操作是沒有必要的,白白浪費了一些資源開銷。
鎖粗化
一段邏輯中如果出現多次加鎖解鎖, 編譯器 + JVM 會自動進行鎖的粗化。
鎖的粒度: 粗和細,“粒度”是指加鎖代碼涉及到的範圍,加鎖代碼的範圍越大,認為鎖的粒度越粗;範圍越小,則認為粒度越細。
那麼鎖粒度是粗好還是細好呢?隻能說各有各的好:
- 如果鎖粒度比較細,那麼多個線程之間的并發性就更高;
- 如果鎖粒度比較粗,則加鎖解鎖的開銷就更小。
是以編譯器就會有一個優化,就會自動判定說如果某個地方的代碼鎖的粒度太細了就會進行粗化–
- 如果兩次加鎖之間的間隔較大(中間隔的代碼多),一般不會進行這種優化
- 如果加鎖之間間隔比較小(中間隔的代碼少),就很可能觸發這個優化
舉個栗子了解鎖粗化:
滑稽老哥當了上司, 給下屬交代工作任務:
方式一:
打電話, 交代任務1, 挂電話.
打電話, 交代任務2, 挂電話.
打電話,交代任務3, 挂電話.
方式二:
打電話, 交代任務1, 任務2, 任務3, 挂電話.
顯然, 方式二是更高效的方案!!!
相關面試題(重點)
你是怎麼了解樂觀鎖和悲觀鎖的,具體怎麼實作呢?
悲觀鎖 認為多個線程通路同一個共享變量沖突的機率較大, 會在每次通路共享變量之前都去真正加鎖;
樂觀鎖 認為多個線程通路同一個共享變量沖突的機率不大,并不會真的加鎖, 而是直接嘗試通路資料,在通路的同時識别目前的資料是否出現通路沖突。
悲觀鎖的實作 就是先加鎖(比如借助作業系統提供的 mutex), 擷取到鎖再操作資料,擷取不到鎖就等待。
樂觀鎖的實作 可以引入一個版本号,借助版本号識别出目前的資料通路是否沖突。
介紹下讀寫鎖?
讀寫鎖就是把讀操作和寫操作分别進行加鎖。
讀鎖和讀鎖之間不互斥; 寫鎖和寫鎖之間互斥; 寫鎖和讀鎖之間互斥。
讀寫鎖最主要用在 “頻繁讀, 不頻繁寫” 的場景中。
什麼是自旋鎖,為什麼要使用自旋鎖政策呢,缺點是什麼?
如果擷取鎖失敗, 立即再嘗試擷取鎖,無限循環, 直到擷取到鎖為止。 第一次擷取鎖失敗, 第二次的嘗試會在極短的時間内到來; 一旦鎖被其他線程釋放, 就能第一時間擷取到鎖,相比于挂起等待鎖:
優點: 沒有放棄 CPU 資源, 一旦鎖被釋放就能第一時間擷取到鎖, 更高效,在鎖持有時間比較短的場景下非常有用。
缺點: 如果鎖的持有時間較長, 就會浪費 CPU 資源。
synchronized 是可重入鎖麼?
是可重入鎖,可重入鎖指的就是連續兩次加鎖不會導緻死鎖。
實作的方式是在鎖中記錄該鎖持有的線程身份,以及一個計數器(記錄加鎖次數), 如果發現目前加鎖的線程就是持有鎖的線程,則直接計數自增。
講解下你自己了解的 CAS 機制
全稱 Compare and swap, 即 “比較并交換”。相當于通過一個原子的操作,同時完成 “讀取記憶體,比較是否相等, 修改記憶體” 這三個步驟。 本質上需要 CPU 指令的支撐。
ABA問題怎麼解決?
給要修改的資料引入版本号, 在 CAS 比較資料目前值和舊值的同時, 也要比較版本号是否符合預期,如果發現目前版本号和之前讀到的版本号一緻,就真正執行修改操作, 并讓版本号自增;如果發現目前版本号比之前讀到的版本号大, 就認為操作失敗。
什麼是偏向鎖?
偏向鎖不是真的加鎖, 而隻是在鎖的對象頭中記錄一個标記(記錄該鎖所屬的線程)。 如果沒有其他線程參與競争鎖, 那麼就不會真正執行加鎖操作, 進而降低程式開銷。 一旦真的涉及到其他的線程競争, 再取消偏向鎖狀态, 進入輕量級鎖狀态。
synchronized 實作原理 是什麼?
參考上面的 synchronized 原理 章節全部内容↑↑↑