JUC多线程:synchronized锁机制原理 与 Lock锁机制
前言:
????????線程安全是并發編程中的重要關注點,造成線程安全問題的主要原因有兩點,一是存在共享數據(也稱臨界資源),二是存在多條線程共同操作共享數據。因此為了解決這個問題,我們可能需要這樣一個方案,當存在多個線程操作共享數據時,需要保證同一時刻有且只有一個線程在操作共享數據,其他線程必須等到該線程處理完數據后再進行,這種方式叫互斥鎖,即能達到互斥訪問目的的鎖,也就是說當一個共享數據被當前正在訪問的線程加上互斥鎖后,在同一個時刻,其他線程只能處于等待的狀態,直到當前線程處理完畢釋放該鎖。
一、synchronized鎖機制:
?1、synchronized 的作用:
?????????synchronized 通過當前線程持有對象鎖,從而擁有訪問權限,而其他沒有持有當前對象鎖的線程無法擁有訪問權限,保證在同一時刻,只有一個線程可以執行某個方法或者某個代碼塊,從而保證線程安全。synchronized 可以保證線程的可見性,synchronized 屬于隱式鎖,鎖的持有與釋放都是隱式的,我們無需干預。synchronized最主要的三種應用方式:
- 修飾實例方法:作用于當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
- 修飾靜態方法:作用于當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
- 修飾代碼塊:指定加鎖對象,進入同步代碼庫前要獲得給定對象的鎖
2、synchronized 底層語義原理:
????????synchronized 鎖機制在 Java 虛擬機中的同步是基于進入和退出監視器鎖對象 monitor 實現的(無論是顯示同步還是隱式同步都是如此),每個對象的對象頭都關聯著一個 monitor 對象,當一個 monitor 被某個線程持有后,它便處于鎖定狀態。在 HotSpot 虛擬機中,monitor 是由 ObjectMonitor 實現的,每個等待鎖的線程都會被封裝成 ObjectWaiter 對象,ObjectMonitor 中有兩個集合,_WaitSet 和 _EntryList,用來保存 ObjectWaiter 對象列表 ,owner 區域指向持有 ObjectMonitor 對象的線程。當多個線程同時訪問一段同步代碼時,首先會進入 _EntryList 集合嘗試獲取 moniter,當線程獲取到對象的 monitor 后進入 _Owner 區域并把 _owner 變量設置為當前線程,同時 monitor 中的計數器 count 加1;若線程調用 wait() 方法,將釋放當前持有的 monitor,count自減1,owner 變量恢復為 null,同時該線程進入 _WaitSet 集合中等待被喚醒。若當前線程執行完畢也將釋放 monitor 并復位變量的值,以便其他線程獲取 monitor。如下圖所示:
_EntryList:存儲處于 Blocked 狀態的 ObjectWaiter 對象列表。
_WaitSet:存儲處于 wait 狀態的 ObjectWaiter 對象列表。
3、?synchronized 的顯式同步與隱式同步:
????????synchronized 分為顯式同步(同步代碼塊)和隱式同步(同步方法),顯式同步指的是有明確的 monitorenter 和 monitorexit 指令,而隱式同步并不是由 monitorenter 和 monitorexit 指令來實現同步的,而是由方法調用指令讀取運行時常量池中方法的 ACC_SYNCHRONIZED 標志來隱式實現的。
3.1、synchronized 代碼塊底層原理:
????????synchronized 同步語句塊的實現是顯式同步的,通過 monitorenter 和 monitorexit 指令實現,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令則指明同步代碼塊的結束位置,當執行 monitorenter 指令時,當前線程將嘗試獲取 objectref(即對象鎖)所對應的 monitor 的持有權:
- 當對象鎖的 monitor 的進入計數器為 0,那線程可以成功取得 monitor,并將計數器值設置為 1,取鎖成功。
- 如果當前線程已經擁有對象鎖的 monitor 的持有權,那它可以重入這個 monitor,重入時計數器的值也會加1。
- 若其他線程已經擁有對象鎖的 monitor 的所有權,那當前線程將被阻塞,直到正在執行線程執行完畢,即monitorexit 指令被執行,執行線程將釋放 monitor 并設置計數器值為0,其他線程將有機會持有 monitor。
????????編譯器會確保無論方法通過何種方式完成,無論是正常結束還是異常結束,代碼中調用過的每條 monitorenter 指令都有執行其對應 monitorexit 指令。為了保證在方法異常完成時,monitorenter 和 monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器可處理所有的異常,它的目的就是用來執行 monitorexit 指令。
3.2、synchronized 方法底層原理:
????????synchronized 同步方法的實現是隱式的,無需通過字節碼指令來控制,它是在方法調用和返回操作之中實現。JVM 可以通過方法常量池中的方法表結構(method_info Structure)中的 ACC_SYNCHRONIZED 訪問標志 判斷一個方法是否同步方法。當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標志是否被設置,如果設置了,標識該方法是一個同步方法,執行線程將先持有 monitor, 然后再執行方法,最后再方法完成(無論是正常完成還是非正常完成)時釋放 monitor。在方法執行期間,執行線程持有了 monitor,其他任何線程都無法再獲得同一個 monitor。
????????如果一個同步方法執行期間拋出了異常,并且在方法內部無法處理此異常,那這個同步方法所持有的 monitor 將在異常拋到同步方法之外時自動釋放。
?4、JVM 對 synchronized 鎖的優化:
????????在早期版本中,synchronized 屬于重量級鎖,效率低下,因為監視器鎖 monitor 是依賴于操作系統的 Mutex 互斥量來實現的,操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高。在 JDK6 之后,synchronized 在 JVM 層面做了優化,減少鎖的獲取和釋放所帶來的性能消耗,主要優化方向有以下幾點:
4.1、鎖升級:偏向鎖->輕量級鎖->自旋鎖->重量級鎖
????????鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,只能從低到高升級,不會出現鎖的降級。重量級鎖基于從操作系統的互斥量實現的,而偏向鎖與輕量級鎖不同,他們是通過 CAS 并配合 Mark Word 一起實現的。
4.1.1、synchronized 的 Mark word 標志位:
????????synchronized 使用的鎖對象是存儲在 Java 對象頭里的,那么 Java 對象頭是什么呢?對象實例分為:
- 對象頭
- Mark Word
- 指向類的指針
- 數組長度
- 實例數據
- 對齊填充
其中,Mark Word 記錄了對象的 hashcode、分代年齡、鎖標記位相關的信息,由于對象頭的信息是與對象自身定義的數據沒有關系的額外存儲成本,因此考慮到 JVM 的空間效率,Mark Word 被設計成為一個非固定的數據結構,以便存儲更多有效的數據,它會根據對象本身的狀態復用自己的存儲空間,在 32位 JVM 中的長度是 32 位,具體信息如下圖所示:
4.1.2、鎖升級過程:
(1)偏向鎖:如果一個線程獲得了鎖,那么進入偏向模式,當這個線程再次請求鎖的時候,只需去對象頭的 Mark Word 中判斷偏向線程ID是否指向它自己,無需再進入 monitor 中去競爭對象,這樣就省去了大量鎖申請的操作,適用于連續多次都是同一個線程申請相同的鎖的場景。偏向鎖只有初始化的時候需要一次 CAS 操作,但如果出現其他線程競爭鎖資源,那么偏向鎖就會被撤銷,并升級為輕量級鎖。
(2)輕量級鎖:不需要申請互斥量,允許短時間內的鎖競爭,每次申請、釋放鎖都至少需要一次 CAS,適用于多個線程交替執行同步代碼塊的場景
(3)自旋鎖:自旋鎖假設在不久將來,當前的線程可以獲得鎖,因此在輕量級鎖升級成為重量級鎖之前,虛擬機會讓當前想要獲取鎖的線程做幾個空循環,在經過若干次循環后,如果得到鎖,就順利進入臨界區,如果還不能獲得鎖,那就會將線程在操作系統層面掛起。
這種方式確實可以提升效率的,但是當線程越來越多競爭很激烈時,占用 CPU 的時間變長會導致性能急劇下降,因此 JVM 對于自旋鎖有一定的次數限制,可能是50或者100次循環后就放棄,直接掛起線程,讓出CPU資源。
(4)自適應自旋鎖:自適應自旋解決的是 “鎖競爭時間不確定” 的問題,自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。
- 如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運行中,那么虛擬機就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100個循環。
- 相反的,如果對于某個鎖,自旋很少成功獲得過,那在以后要獲取這個鎖時將可能減少自旋時間甚至省略自旋過程,以避免浪費處理器資源。
但自旋鎖帶來的副作用就是不公平的鎖機制:處于阻塞狀態的線程,并沒有辦法立刻競爭被釋放的鎖。然而,處于自旋狀態的線程,則很有可能優先獲得這把鎖。
(5)重量級鎖:適用于多個線程同時執行同步代碼塊的場景,且鎖競爭時間長。在這個狀態下,未搶到鎖的線程都會進入到 Monitor 中并阻塞在 _EntryList?集合中(具體的爭奪鎖的原理見該部分的第2點:synchronized 底層語義原理)。
?鎖升級過程詳細解析推薦閱讀:https://juejin.cn/post/6888137809929093133
4.2、鎖消除:
????????消除鎖屬于編譯器對鎖的優化,JIT 編譯時(可以簡單理解為當某段代碼即將第一次被執行時進行編譯,又稱即時編譯)會使用逃逸分析技術,通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間。
4.3、鎖粗化:
????????JIT 編譯器動態編譯時,如果發現幾個相鄰的同步塊使用的是同一個鎖實例,那么 JIT 編譯器將會把這幾個同步塊合并為一個大的同步塊,從而避免一個線程“反復申請、釋放同一個鎖“所帶來的性能開銷。
5、偏向鎖的廢除:
????????在 JDK6 中引入的偏向鎖能夠減少競爭鎖定的開銷,使得 JVM 的性能得到了顯著改善,但是 JDK15 卻將決定將偏向鎖禁用,并在以后刪除它,這是為什么呢?主要有以下幾個原因:
- 為了支持偏向鎖使得代碼復雜度大幅度提升,并且對 HotSpot 的其他組件產生了影響,這種復雜性已成為理解代碼的障礙,也阻礙了對同步系統進行重構
- 在更高的 JDK 版本中針對多線程場景推出了性能更高的并發數據結構,所以過去看到的性能提升,在現在看來已經不那么明顯了。
- 圍繞線程池隊列和工作線程構建的應用程序,性能通常在禁用偏向鎖的情況下變得更好。
二、Lock 鎖機制:
? ? ? ? 講到 Synchronized 鎖機制,肯定離不開的話題就是 Lock 的鎖機制,那這里我們就簡單介紹下 Lock 鎖機制。
1、Lock 鎖是什么:
? ? ? ? Lock 鎖其實指的是 JDK5 之后在 JUC 中引入的?Lock 接口,該接口中只有6個方法的聲明,對于實現該接口的所有鎖可以稱為 Lock 鎖。Lock 鎖是顯式鎖,鎖的持有與釋放都必須手動編寫,當前線程使用 lock() 方法與 unlock() 對臨界區進行加鎖與釋放鎖,當前線程獲取到鎖之后,其他線程由于無法持有鎖將無法進入臨界區,直到當前線程釋放鎖,unlock() 操作必須在 finally 代碼塊中,這樣可以確保即使臨界區執行拋出異常,線程最終也能正常釋放鎖。
2、ReentrantLock 重入鎖:
????????ReentrantLock 重入鎖是基于 AQS 框架并實現了 Lock 接口,支持一個線程對資源重復加鎖,作用與 synchronized 鎖機制相當,但比 synchronized 更加靈活,同時也支持公平鎖和非公平鎖。
對AQS抽象隊列同步器原理感興趣的讀者可以閱讀這篇文章:https://blog.csdn.net/a745233700/article/details/120668889
2.1、ReentrantLock 與 synchronized 的區別:
(1)使用的區別:synchronized 是 Java 的關鍵字,是隱式鎖,依賴于 JVM 實現,當 synchronized 方法或者代碼塊執行完之后,JVM 會自動讓線程釋放對鎖的占用;ReentrantLock 依賴于 API,是顯式鎖,需要 lock() 和 unlock() 方法配合 try/finally 語句塊來完成。在發生異常時,JVM 會自動釋放 synchronized 鎖,因此不會導致死鎖;而 ReentrantLock 在發生異常時,如果沒有主動通過 unLock() 去釋放鎖,則很可能造成死鎖現象,這也是 unLock() 語句必須寫在 finally 語句塊的原因。
(2)功能的區別:ReentrantLock 相比于 synchronzied 更加靈活, 除了擁有 synchronzied 的所有功能外,還提供了其他特性:
-
ReentrantLock 可以實現公平鎖,而 synchronized 不能保證公平性。
-
ReentrantLock 可以知道有沒有成功獲取鎖(tryLock),而 synchronized 不支持該功能
-
ReentrantLock 可以讓等待鎖的線程響應中斷,而使用 synchronized 時,等待的線程不能夠響應中斷,會一直等待下去;
-
ReentrantLock 可以基于 Condition 實現多條件的等待喚醒機制,而如果使用 synchronized,則只能有一個等待隊列
(3)性能的區別:在 JDK6 以前,如果競爭資源不激烈,兩者的性能是差不多的,而當競爭資源非常激烈時,此時 ReentrantLock 的性能要遠遠優于 synchronizsed。但是在 JDK6 及以后的版本,JVM 對 synchronized 進行了優化,所以兩者的性能變得差不多了
????????總的來說,synchronizsed 和 ReentrantLock 都是可重入鎖,在使用選擇上需要根據具體場景而定,大部分情況下依然建議使用 synchronized 關鍵字,原因之一是使用方便語義清晰,二是性能上虛擬機已為我們自動優化。如果確實需要使用到 ReentrantLock 提供的多樣化特性時,我們可以選擇ReentrantLock
“可重入鎖”概念是:自己可以再次獲取自己的內部鎖。比如一個線程獲得了某個對象的鎖,此時這個對象鎖還沒有釋放,當其再次想要獲取這個對象的鎖的時候還是可以獲取的,如果不可重入的話,就會造成死鎖。同一個線程每次獲取鎖,鎖的計數器都自增1,所以要等到鎖的計數器下降為0時才能釋放鎖。
3、ReadWriteLock 讀寫鎖:
????????ReentrantLock 某些時候有局限,如果使用 ReentrantLock,主要是為了防止線程A在寫數據、線程B在讀數據造成的數據不一致,但如果線程C在讀數據、線程D也在讀數據,由于讀數據是不會改變數據內容的,所以就沒有必要加鎖,但如果使用了 ReentrantLock,那么還是加鎖了,反而降低了程序的性能,因此誕生了讀寫鎖 ReadWriteLock。ReadWriteLock 是一個接口,而 ReentrantReadWriteLock 是 ReadWriteLock 接口的具體實現,實現了讀寫的分離,讀鎖是共享的,寫鎖是獨占的,讀和讀之間不會互斥,讀和寫、寫和寫之間才會互斥,提升了讀寫的性能。
參考文章:https://blog.csdn.net/javazejian/article/details/72828483
總結
以上是生活随笔為你收集整理的JUC多线程:synchronized锁机制原理 与 Lock锁机制的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Elasticsearch搜索引擎之缓存
- 下一篇: JUC多线程:JMM内存模型与volat