【重難點】【JUC 04】synchronized 原理、ReentrantLock 原理、synchronized 和 Lock 的對比、CAS 無鎖原理
文章目錄
- 【重難點】【JUC 04】synchronized 原理、ReentrantLock 原理、synchronized 和 Lock 的對比、CAS 無鎖原理
- 一、synchronized 原理
-
- 1.概述
- 2.實作原理
- 二、ReentrantLock 原理
-
- 1.概述
- 2.重入性實作原理
- 3.公平鎖和非公平鎖
- 三、synchronized 和 Lock 的對比
-
- 1.synchronized 和 ReentrantLock
- 2.synchronized 和 Lock
- 四、CAS 無鎖原理
-
- 1.為什麼要使用 CAS
- 2.CAS 原理分析
- 3.JDK 中的 CAS 實作
- 4.JVM 中的 CAS
- 5.資料庫中的樂觀鎖機制
一、synchronized 原理
1.概述
synchronized 是 Java 最常用的保證線程安全的方式,synchronized 的作用主要有兩方面
- 確定線程互斥地通路代碼塊,同一時刻隻有一個方法可以進入到臨界區
- 保證共享變量的修改的可見性
synchronized 有三種用法:
- 修飾普通方法,鎖的是目前執行個體對象
- 修飾靜态方法,鎖的是目前 Class 對象
- 修飾代碼塊,鎖的是小括号裡的對象
public class SynTest{
private static List<String> list = new ArrayList<String>();
//目前執行個體的鎖
public synchronized void add1(String s){
list.add(s);
}
//SynTest.class 鎖
public static synchronized void add2(String s){
list.add(s);
}
//Synchronized.class 鎖
public void add3(String s){
synchronized(SynTest.class){
list.add(s);
}
}
//目前執行個體的鎖
public void add4(String s){
synchronized(this){
list.add(s);
}
}
}
2.實作原理
synchronized 同步代碼塊的語義底層是基于對象内部的螢幕鎖(monitor),通過 monitorenter 和 monitorexit 指令完成。wait 和 notify 方法也依賴于 monitor 對象,是以其必須要在 synchronized 同步代碼塊内使用。monitorenter 指令在編譯為位元組碼後插入到同步代碼塊的開始位置,monitorexit 指令在編譯為位元組碼後插入到同步代碼塊結束處和異常處。JVM 保證每個 monitorenter 必須有對應的 monitorexit
monitorenter
每個對象都有一個 monitor,當 monitor 被某個線程占用時就會處于鎖定狀态,線程執行 monitorenter 指令時會嘗試擷取 monitor 的所有權,即嘗試擷取對象的鎖,過程如下:
- 如果 monitor 的進入數為 0,則該線程進入 monitor,然後将進入數設定為 1,該線程成為 monitor 的所有者
- 如果線程已經是 monitor 的所有者,隻是重新進入,則 monitor 的進入數 +1
- 如果其它線程已經占用 monitor,則該線程進入阻塞狀态,直至 monitor 的進入數為 0,再重新嘗試擷取 monitor 的所有權
monitorexit
執行 monitorexit 的線程必須是 objectref 所對應的 monitor 的所有者。執行指令時,monitor 的進入數減 1,如果減 1 後進入數為 0,則該線程退出 monitor,不再是該 monitor 的所有者,其它被這個 monitor 阻塞的線程可以嘗試擷取這個 monitor 的所有權
線程狀态和狀态轉化
在 HotSpot JVM 中,monitor 由 ObjectMonitor 實作,其主要資料結構如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個數
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; //持有monitor的線程
_WaitSet = NULL; //處于wait狀态的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處于等待鎖block狀态的線程,會被加入到該清單
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor 中有兩個隊列,_WaitSet 和 _EntryList,用來儲存 ObjectWaiter 對象清單(每個等待鎖的線程都會被封裝成 ObjectWaiter 對象),_owner 指向持有 ObjectMonitor 對象的線程
- 當多個線程同時通路一段同步代碼時,首先會進入 _EntryList,等待鎖處于阻塞狀态
- 當線程擷取到對象的 monitor 後進入 The Owner 區域,并把 ObjectMonitor 中的 _owner 變量設定為目前線程,同時 monitor 中的計數器 count 加 1
- 若線程調用 wait() 方法,将釋放目前持有的 monitor,_owner 變量恢複為 null,count 減 1,同時該線程進入 _WaitSet 集合中等待被喚醒,處于 waiting 狀态
- 若目前線程執行完畢,将釋放 monitor 并複位變量的值,以便其它線程進入擷取 monitor
二、ReentrantLock 原理
1.概述
ReentrantLock 重入鎖,實作了 Lock 接口,在實際程式設計中使用頻率很高,支援重入性,即目前線程擷取鎖後再次擷取同一把鎖不會被阻塞。此外,ReentrantLock 還支援公平鎖和非公平鎖。是以,想要弄懂 ReentrantLock 的原理就是要弄懂其重入性的實作原理以及公平鎖和非公平鎖
2.重入性實作原理
支援重入性,需要解決兩個問題:
- 線上程擷取鎖的時候,如果已經擷取鎖的線程是目前線程的話則直接擷取成功
- 由于鎖會被擷取很多次,那麼在鎖被釋放的時候也要釋放同樣的次數
ReentrantLock 的實作依賴于 AQS,以非公平鎖為例,ReentrantLock 判斷是否可以擷取鎖的核心方法為 nonfairTryAcquir
fianl bolean nonfairTryAcquire(int acquires){
fianl Thread current = Thread.currentThread();
int c = getState();
//1.如果該鎖未被任何線程占有,該鎖能被目前線程擷取
if(c == 0){
if(compareAndSetState(0, acquires)){
setExclusiveOwnerThread(current);
return true;
}
}
//2.若被占有,檢查占有線程是否是目前線程
else if(current == getExclusiveOwnerThread()){
//3.再次擷取,計數加一
int nextc = c + acquires;
if(nextc < 0) //overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
釋放鎖的核心方法為 tryRelease
protected final boolean tryRelease(int releases){
//1.同步狀态減 1
int c = getState() - releases;
if(Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if(c == 0){
//2.隻有當同步狀态為 0 時,鎖成功釋放,傳回 true
free = true;
setExclusiveOwnerThread(null);
}
//3.鎖未被完全釋放,傳回 false
setState(c);
return free;
}
3.公平鎖和非公平鎖
何為公平,是針對擷取鎖而言的,如果一個鎖是公平的,那麼鎖的擷取順序就應該符合請求的絕對時間順序
ReentrantLock 的無參構造預設非公平
public ReentrantLock(){
sync = new NonfairSync();
}
有參構造提供了一個 boolean 類型的 fair 參數,true 為公平鎖,false 為非公平鎖
public ReentrantLock(boolean fair)P
sync = fair ? new FairSync() : new NonfairSync();
}
nonfairTryAcquire 方法隻是簡單地擷取了目前狀态進行了一些邏輯判斷,并沒有考慮目前同步隊列中線程等待的情況,我們對比一下公平鎖的處理邏輯,其核心方法為 tryAcquire
protected final boolean tryAcquire(int acquires){
final Thread current = Thread.currentThread();
int c = getState();
if(c == 0){
if(!hasQueuedPredecessors() && compareAndSetState(0, acquires)){
setExclusiveOwnerThread(current);
return true;
}
}
else if(current == getExclusiveOwnerThread()){
int nextc = c + acquires;
if(nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
這段代碼的邏輯與 nonfairTryAcquire 基本上一緻,唯一的不同在于增加了 hasQueuedPredecessors 的邏輯判斷。hasQueuedPredecessors 方法用于判斷目前節點在同步隊列中是否有前驅節點,如果有前驅節點,說明有線程比目前線程更早地請求資源,根據公平性,目前線程請求資源失敗
- 公平鎖每次擷取到鎖的總是同步隊列中的第一個節點,保證了線程請求資源按照時間上的絕對順序,隻要程式一直正常運作下去,每一個請求資源的線程都可以擷取到資源。反之,非公平鎖會導緻某些線程長時間擷取不到資源,造成 “饑餓” 現象
- 公平鎖為了保證時間上的絕對順序,需要頻繁地進行上下文切換,而非公平鎖會降低一定的上下文切換,進而降低性能開銷。是以,ReentrantLock 預設選擇的是非公平鎖,保證了系統更大的吞吐量
三、synchronized 和 Lock 的對比
1.synchronized 和 ReentrantLock
synchronized | ReentrantLock | |
---|---|---|
性能 | 相對較差 | 相對較好 |
公平性 | 隻支援非公平鎖 | 同時支援公平鎖與非公平鎖 |
是否可重入 | 支援 | 支援 |
嘗試擷取鎖的支援 | 不支援,一旦到了同步塊,且沒有擷取到鎖,就阻塞在這裡 | 支援,通過 tryLock 方法實作,可通過其傳回值判斷是否成功擷取鎖,是以即使擷取鎖失敗也不會阻塞在這裡 |
逾時的擷取鎖 | 不支援,如果一直擷取不到鎖,就會一直等待下去 | 支援,通過 tryLock(time, TimeUnit) 方法實作,如果逾時了還沒擷取鎖,就放棄擷取鎖,不會一直阻塞下去 |
是否可響應中斷 | 不支援,不可響應線程的 interrupt 信号 | 支援,通過 lockInterruptibly 方法實作,通過此方法擷取鎖之後,線程可響應 interrupted 信号,并抛出 InterruptedException 異常 |
等待條件的支援 | 支援,通過 wait、notify、notifyAll 來實作 | 支援,通過 Condition 接口實作,支援多個 Condition,比 synchronized 更加靈活 |
2.synchronized 和 Lock
相同點
- 都是用來保護資源線程安全的
- 都可以保證可見性
不同點
-
用法不同
synchronized 可以作用于方法和代碼塊上,且 synchronized 的加鎖和解鎖是隐式的,尤其是抛異常的時候也能保證釋放鎖;Lock 接口必須顯式使用 Lock 對象加鎖 lock() 和解鎖 unlock(),一般需要在 finally 塊中 unlock(),反之抛異常的時候發生死鎖
-
加解鎖順序不同
對于 Lock 而言,如果有多把 Lock 鎖,Lock 可以不完全按照加鎖的反序解鎖;synchronized 無法做到,synchronized 解鎖的順序必須和加鎖的順序完全相反
-
synchronized 不夠靈活
一旦 synchronized 鎖已經被某個線程獲得了,此時其他線程如果還想獲得,那它隻能被阻塞,直到持有鎖的線程運作完畢或者發生異常進而釋放這個鎖。如果持有鎖的線程持有很長時間才釋放,那麼整個程式的運作效率就會降低;相比之下,Lock 類在等待的過程中,如果使用的是 lockInterruptibly 方法,那麼可以不等待,直接中斷退出,還可以調用 tryLock 方法嘗試擷取鎖,如果擷取不到鎖也可以做别的事
-
synchronized 鎖隻能同時被一個線程擁有,但是 Lock 鎖沒有這個限制
例如讀寫鎖的讀鎖,是可以同時被多個線程持有的
-
原理不同
synchronized 是内置鎖,由 JVM 實作擷取鎖和釋放鎖;Lock 根據實作不同,有不同的原理,例如 ReentrantLock 内部是通過 AQS 來擷取和釋放鎖的
- synchronized 是非公平鎖,且不可以設定公平鎖,而 Lock 的某些實作類可以選擇設定公平鎖還是非公平鎖
-
性能差別
在 JDK 5 之前,synchronized 的性能比較低,但是到了 JDK 6,synchronized 進行了很多優化,比如自适應自選、鎖消除、鎖粗化、輕量級鎖、偏向鎖等,是以後期版本的 synchronized 的性能并不比 Lock 差
如何選擇?
- 如果能不用,最好既不使用 Lock 也不使用 synchronized。因為在許多情況下你可以使用 JUC 包中的機制,它會為你處理所有的加鎖和解鎖操作,也就是推薦優先使用工具類來加解鎖
- 如果 synchronized 關鍵字适合你的程式,那麼請盡量使用它,這樣可以減少代碼量,減少出錯的機率。因為一旦忘記在 finally 裡 unlock,代碼可能會出很大的問題,而使用 synchronized 更安全
- 如果特别需要 Lock 的特殊功能,比如嘗試擷取鎖、可中斷、逾時功能,才使用 Lock
四、CAS 無鎖原理
1.為什麼要使用 CAS
在多線程高并發程式設計的時候,最關鍵的問題就是保證臨界區的對象的安全通路。通常是用加鎖來處理,其實加鎖本質上是将并發轉變為串行來實作的,勢必會影響吞吐量。而且線程的數量是有限的,依賴于作業系統,而且線程的建立和銷毀帶來的性能損耗不可忽視
對于并發控制而言,鎖是一種悲觀政策,會阻塞線程執行。而無鎖是一種樂觀政策,它會假設對資源的通路不會發生沖突,既然沒有沖突,就不需要等待,線程也不會阻塞。但實際上是會發生沖突的,那該怎麼辦呢?無鎖的政策采用一種比較交換技術 CAS(Compare And Swap)來鑒别線程沖突,一旦檢測到沖突,就重試目前操作,直到沒有沖突
與鎖相比,CAS 會使得程式設計比較複雜,但是由于其優越的性能優勢、天生免疫死鎖,沒有線程競争開銷以及線程間頻繁排程開銷,是以在目前被廣泛應用
2.CAS 原理分析
CAS 算法
一個 CAS 方法包含三個參數 CAS(V, E, N),V 表示要更新的變量,E 表示預期的值,N 表示新值。隻有當 V 的值等于 E 時,才會将 V 的值修改為 N。如果 V 的值不等于 E,說明已經被其它線程修改了,目前線程可以放棄此操作,也可以再次嘗試操作直至修改成功。基于這樣的算法,CAS 操作即使沒有鎖,也可以發現其它線程對目前線程的幹擾(可以通過 volatile 保證可見性),并進行恰當的處理
AtomicInteger
我們借助這個類講解 CAS 原理,檢視一下 AtomicInteger 源碼的核心部分
private volatile int value
public final int getAndSet(int newValue){\
for(;;){
int current = get();
if(compareAndSet(current, newValue))
return current;
}
}
public fianl boolean compareAndSet(int expect, int update){
return unsafe.compareAndSwaoInt(this, valueOffset, expect, update);
}
- AromicInteger 中真正存儲資料的是 value 變量,且被 volatile 修飾,保證了線程之間的可見性。而 Integer 中的 value 是被 final 修飾的,是不可變對象
- getAndSet 方法通過一個死循環不斷嘗試指派操作。而真正的指派操作交給了 unsafe 類實作
unsafe
從名字可知,這個類是不安全的,我們看一下它的構造方法
public static Unsafe getUnsafe(){
Class var0 = Reflection.getCallerClass();
if(var0,getClassLoader() != null){
throw new SecurityException("Unsafe");
}else{
return theUnsafe;
}
}
如果類加載器不是 null 則直接抛出異常,也就是說我們無法在應用程式中使用這個類
我們再來看一個 compareAndSwapInt 的方法聲明
第一個參數是給定的對象,第二個參數是對象内的偏移量(其實就是一個字段到對象頭部的偏移量,通過這個偏移量可以快速定位字段),第三個參數是期望值,最後一個是要設定的值
其實這裡 Unsafe 封裝了一些類似于 C++ 中指針的東西,該類中的方法都是 native 的,而且是原子操作
3.JDK 中的 CAS 實作
-
JUC 下的 atomic 包
該包下的類都是采用 CAS 實作的無鎖
-
JUC 下的跳躍表
ConcurrentSkipListMap 采用典型的空間換時間政策,它是一個有序的,支援高并發的 Map,它對節點的操作都是通過 CAS 機制實作的
-
JUC 下的無鎖隊列
ConcurrentLinkedQueue
4.JVM 中的 CAS
堆中對象的配置設定
詳見 【JVM】第二章 JVM類加載、JVM對象
在單線程情況下,配置設定對象有兩種政策:
- 指針碰撞
- 空閑清單
但是 JVM 不可能一直在單線程狀态下運作,那樣效率太差了。由于再給一個對象配置設定記憶體的時候不是原子性的操作,至少需要以下幾步:查找空閑清單、配置設定記憶體、修改空閑清單等,這是不安全的。解決并發時的安全問題也有兩種政策:
-
CAS
采用 CAS 配合上失敗重試的方式保證更新操作的原子性
-
TLAB
使用 CAS 對性能有一定的影響,是以 JVM 又提出了一種更進階的優化政策:每個線程在 Java 堆中預先配置設定一小塊記憶體,稱為本地線程配置設定緩沖區(TLAB),線程内部需要配置設定記憶體時直接在 TLAB 上配置設定就行,避免了線程沖突,隻有當緩沖區的記憶體用光需要重新配置設定記憶體的時候才進行 CAS 操作配置設定更多空間
5.資料庫中的樂觀鎖機制
資料庫中有悲觀鎖和樂觀鎖
悲觀鎖(Pressimistic Locking)
對資料被外界(包括本系統目前的其他事務,以及來自外部系統的事務處理)修改持保守态度,是以,在整個資料處理過程中,将資料處于鎖定狀态。悲觀鎖的實作,往往依靠資料庫提供的鎖機制(也隻有資料庫層提供的鎖機制才能真正保證資料通路的排他性,否則,即使在本系統中實作了加鎖機制,也無法保證外部系統不會修改資料)
樂觀鎖(Optimistic Locking)
相對悲觀鎖而言,樂觀鎖機制采取了更加寬松的加鎖機制。悲觀鎖大多數情況下依靠資料庫的鎖機制實作,以保證操作最大程度的獨占性。但随之而來的就是資料庫性能的大量開銷,特别是對長事務而言,這樣的開銷往往無法承受。如一個金融系統,當某個操作員讀取使用者的資料,并在讀出的使用者資料的基礎上進行修改時(如更改使用者帳戶餘額),如果采用悲觀鎖機制,也就意味着整個操作過程中(從操作員讀出資料、開始修改直至送出修改結果的全過程,甚至還包括操作員中途去煮咖啡的時間),資料庫記錄始終處于加鎖狀态,可以想見,如果面對幾百上千個并發,這樣的情況将導緻怎樣的後果。
樂觀鎖機制在一定程度上解決了這個問題。樂觀鎖,大多是基于資料版本( Version )記錄機制實作。何謂資料版本?即為資料增加一個版本辨別,在基于資料庫表的版本解決方案中,一般是通過為資料庫表增加一個 “version” 字段來實作。讀取出資料時,将此版本号一同讀出,之後更新時,對此版本号加一。此時,将送出資料的版本資料與資料庫表對應記錄的目前版本資訊進行比對,如果送出的資料版本号大于資料庫表目前版本号,則予以更新,否則認為是過期資料