在多線程的學習中,很多時候都要用到鎖,但我們都知道,加鎖這個操作是一個計算機中開銷比較大的操作,因此,本篇文章我會帶大家學習在不同場景中進行不同的加鎖處理方式,以讓程序更高效一些有關鎖策略不僅僅局限于某一種語言,在很多語言中都可能會遇到加鎖操作,而且這部分知識點也是面試中常見的問題,所以本篇文章內容基本都是需要大家自己認真理解并做到會用自己的語言組織起來的。內容均為博主認真總結,大家可以收藏起來慢慢學習,希望可以幫到大家哦!
一. 樂觀鎖和悲觀鎖
1. 字面理解
樂觀鎖認為多個線程訪問同一個共享數據時產生并發沖突的概率不大,并不會真的加鎖, 而是直接嘗試訪問數據, 在訪問的同時識別當前的數據是否出現訪問沖突,若沖突,則會返回當前的錯誤信息讓用戶去決定如何去處理悲觀鎖會認為多個線程訪問同一個共享數據時產生并發沖突的概率很大,因此往往會在取數據時會進行加鎖操作,這樣的話其他線程想要拿到這個數據時就會阻塞等到直到其他線程獲取到鎖
補充:在Java中synchronized
這一加鎖操作主要以悲觀鎖為主,初始使用樂觀鎖策略,但當發現鎖競爭比較頻繁的時候,就會自動切換成悲觀鎖策略
2. 生活實例
在生活中有很多情況都能涉及到樂觀和悲觀的心態,比如今天是陰天,A認為可能會下雨,會提前帶好傘,這就對應到了悲觀鎖這一策略;而B比較樂觀,不會認為會下雨,因此B不會帶傘,這顯然可以類比為樂觀鎖這一策略。
3. 基于版本號方式實現樂觀鎖
實現樂觀鎖策略這一功能的方式有很多,接下來我帶大家去學習一種:基于版本號方式。
假設我們要使用多線程修改用戶的賬戶余額,我們可以引入一個版本號來實現,具體方法如下:
設當前的余額為100,引入一個版本號version,將其初始值設為1,并且我們規定,提交版本必須大于數據庫中記錄的當前版本號才能執行更新余額的操作,若不滿足此條件,則認為修改失敗
圖示
以線程1想把主內存中的數據減50,線程2把主內存中的數據減20為例:
線程1此時準備將主內存中的數據讀入自己的工作內存中并修改,而線程2也想將主內存的數據讀入自己的工作內存中并修改,此時線程1和線程2以及主內存中的版本號都為1
當線程1把主內存的數據減50后,即修改后,會將自己工作內存中的版本號加1,此時線程1工作內存中的版本號大于主內存中的版本號(2大于1),因此線程1成功修改了主內存中的數據,并將數據50寫入主內存中,最后將主內存中的版本號加1(即為2)
此時線程2修改了自己工作內存中的數據,隨后將自己的工作內存版本號改為2:
但正當線程2準備將其改好后的數據80寫入主內存時,發現自己的版本號和主內存的版本號都一樣,并不滿足大于關系,因此此次修改失敗,有效避免了多線程并發修改數據時引起的數據安全問題。
總結
基于版本號這樣實現樂觀鎖的機制就是一種典型的實現方式,這個實現方式和之前所學過的單純的互斥的加鎖方式來說更加輕量一些(只修改版本號,只是在計算機中用戶態上進行操作,而互斥加鎖方式會涉及到用戶態和內核態之間的切換,不僅效率不太高,也容易引起線程阻塞)對于這個機制來說,如果修改數據失敗,就會涉及到重試操作,如果頻繁重試的話那么效率也就不高了,因此,最好在線程并發沖突率比較低的場景下使用樂觀鎖這一方式比較好
二. 讀寫鎖
1. 理解
我們都知道,當我們通過多線程方式嘗試修改同一數據時,一般都可能引發線程安全問題,但當我們通過多線程方式嘗試讀取同一數據時,一般不會引發線程安全問題,因此,我們可以根據讀和寫的不同場景來給讀和寫操作分別加上不同的鎖。
Java當中的synchronized不會對讀和寫進行區分,默認使用后線程都是互斥的
2. 用法
以Java為例,在標準庫中存在這樣一個類ReentrantReadWriteLock
源代碼如下
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { private static final long serialVersionUID = -6992448646407690164L; /** Inner class providing readlock */ private final ReentrantReadWriteLock.ReadLock readerLock; /** Inner class providing writelock */ private final ReentrantReadWriteLock.WriteLock writerLock; /** Performs all synchronization mechanics */ final Sync sync; /** * Creates a new {@code ReentrantReadWriteLock} with * default (nonfair) ordering properties. */ public ReentrantReadWriteLock() { this(false); }
該類中提供了兩個方法:
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
此方法可以創建出一個讀鎖實例
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
此方法可以創建出一個寫鎖實例
某個線程被讀鎖修飾后,這兩個線程之間不會互斥,而是完全同時并發執行,一般將讀鎖用于線程讀取數據比較多的場景;而當某個線程被寫鎖修飾后,這兩個線程會互斥,一個線程會執行,而另一個線程會阻塞等待,因此必須是一個線程執行完了,另一個線程才會執行,一般用于修改數據比較多的場景
三. 重量級鎖和輕量級鎖
1. 原理
鎖的核心特性 “原子性”,這樣的機制追根溯源是 CPU 這樣的硬件設備提供的
1.CPU 提供了 “原子操作指令”。
2. 操作系統基于 CPU 的原子指令,實現了 mutex 互斥鎖.
3. JVM 基于操作系統提供的互斥鎖。實現了 synchronized 和 ReentrantLock 等關鍵字和類。
注意:synchronized 并不僅僅是對 mutex 進行封裝, 在 synchronized 內部還做了很多其他的工作
2. 理解
1.重量級鎖依賴了OS提供的mutex,的開銷一般很大,往往是通過內核來完成的
2.輕量級加鎖一般不使用mutex,開銷一般比較小,一般通過用戶態就能直接完成
3. 區分用戶態和內核態
我們可以類比一個生活中的例子,當去銀行辦理業務時,如果是通過用戶在銀行工作人員的指導下自己在窗口外完成,那么效率會比較高,就像計算機中的用戶態一樣。而當我們把自己的業務交給銀行相關人員去完成時,由于銀行工作人員的閑忙時間是不可控的,因此無法保證效率,就好比計算機中的內核態。
四. 自旋鎖
1. 理解
當兩個線程為了完成任務同時競爭一把鎖時, 拿到鎖的那個線程會立馬執行任務,而沒拿到就會阻塞等待,當一個線程把鎖釋放后,另一個線程不會被立即喚醒,而是等操作系統將其進行一系列的調度到CPU中的操作才能被喚醒然后執行任務,這種鎖叫做掛起等待鎖,線程在搶鎖失敗后進入阻塞狀態,放棄 CPU,需要過很久才能再次被調度。但實際上,大部分情況下,雖然當前搶鎖失敗,但過不了很久,鎖就會被釋放,所以沒必要就放棄 CPU。這個時候就可以使用自旋鎖來處理這樣的問題。
2. 實現方式
自旋鎖的偽代碼為:while (搶鎖(lock) == 失敗) {}
如果獲取鎖失敗,就會立即再嘗試獲取鎖,無限循環,直到獲取到鎖為止。第一次獲取鎖失敗, 第二次的嘗試會在非常短的時間內到來,一旦鎖被其他線程釋放, 就能第一時間獲取到鎖
3. 優缺點
自旋鎖是一種典型的輕量級鎖的實現方式,它沒有放棄 CPU, 不涉及線程阻塞和調度,一旦鎖被釋放,就能第一時間獲取到鎖,這樣會大大提高代碼的執行效率,但如果鎖被其他線程持有的時間比較久, 那么就會持續地消耗 CPU 資源。(而掛起等待的時候是不消耗 CPU 的)
因此,我們應該注意自旋鎖的適用場合:
- 如果多個線程執行任務時鎖的沖突比較低,或者線程持有鎖的時間比較短,此時使用自旋鎖比較合適
- 如果某個線程任務對CPU比較敏感,且不希望吃太多CPU資源,那么此時就不太適合使用自旋鎖。
注意:synchronized自身已經設置好了自旋鎖和掛起等待鎖,會根據不同的情況自動選擇最優的使用方案
五. 公平鎖和非公平鎖
1. 理解
若有三個線程 A,B,C。
A先嘗試獲取鎖,獲取成功了,因為只有一把鎖,所以B和C線程都會阻塞等待,那么如果A用完了鎖后,B和C線程哪一個會最先獲取到鎖呢?
- 公平鎖:遵守先來后到原則,因為B線程比C線程來的早一點,所以B線程先獲取到鎖
- 非公平鎖:沒有先來后到原則,B和C線程獲取到鎖的概率是隨機的
2. 注意事項
操作系統內部的線程調度就可以視為是隨機的,如果不做任何額外的限制,鎖就是非公平鎖。如果要想實現公平鎖,就需要依賴額外的數據結構(比如隊列) 來記錄線程們的先后順序。公平鎖和非公平鎖沒有好壞之分, 關鍵還是看適用場景(大部分情況下非公平鎖就夠用了,但當我們希望線程的調度時間成本是可控的,那么此時就需要用到公平鎖了)
注意:synchronized為非公平鎖
六. 可重入鎖和不可重入鎖
1. 為什么要引入這兩把鎖
(1)實例一
在介紹可重入鎖和不可重入鎖之前,大家先來思考一個問題,為什么Java中的main函數要用static來修飾?
public class Test { public static void main(String[] args) { } }
試想以下,如果main函數不是static來修飾的話:
public class Test { public void main(String[] args) { Test a=new Test(); a.main(); } }
那么此時這段代碼能否被執行呢?答案是不能,因為在java中,沒有static的變量或函數,如果想被調用的話,是要先新建一個對象才可以。而main函數作為程序的入口,需要在其它函數實例化之前就啟動,這也就是為什么要加一個static。main函數好比一個門,要探索其它函數要先從門進入程序。static提供了這樣一個特性,無需建立對象,就可以啟動。也可以利用反證法說明,如果不是static修飾的,若不是靜態的,main函數調用的時候需要new對象,new完對象才能調用main函數。那么你既想進入到main函數new對象,又想new完對象來調用main函數,那么就不行了,相當于自己把自己鎖在里面出不來了
(2)實例二
另外一個Java當中的例子:
synchronized void func1(){ func2(); } synchronized void func2(){ }
我們對func1這個方法進行加鎖時,是可以成功的,但當我們對func2這個方法再次加鎖后,就比較危險了。因為要執行完func1這個方法,就必須執行完func2,而此時鎖已經被func1這個方法占用了,func2獲取不到鎖,所以func2就會一直阻塞等待,去等func1釋放鎖,但func1一直執行不完成,所以鎖永遠不會釋放,func2永遠也獲取不到鎖,這樣就形成了一個閉環,相當于自己把自己鎖在里面出不來了,此時這個線程就會崩潰,是比較危險的
2. 實現方案
了解了上面兩個實例的嚴重性后,我們引入了可重入鎖這個機制,當我們形成死鎖后,如果是可重入鎖的話,它不會讓線程阻塞等待最終死鎖從而奔潰,而是運用計數器的方法,去記錄當前某個線程針對某把鎖嘗試加了幾次,每加一次鎖計數都會加1,每次解鎖計數都會減1,這樣當計數器里面的計數完全為0的時候才會真正釋放鎖,正是因為有了這樣的機制,才有效避免了死鎖問題。而在Java中,synchronized
就是一把可重入鎖,它給我們提供了很大的方便,保證在我們即使造成死鎖問題時,程序也不至于崩潰。
七. 面試題
第一題
如何理解樂觀鎖和悲觀鎖,具體實現方式如何 如何理解?
見樂觀鎖和悲觀鎖字面理解部分(嘗試用自己的語言組織)實現方式:
(1)樂觀鎖:見基于版本號方式實現樂觀鎖部分
(2)悲觀鎖:多個線程訪問同一個共享數據時產生并發沖突時,會在取數據時會進行加鎖操作,這樣的話其他線程想要拿到這個數據時就會阻塞等到直到其他線程獲取到鎖
第二題
簡單介紹一下讀寫鎖
讀寫鎖實際是一種特殊的自旋鎖,它能把同一塊共享數據的訪問者分為讀者和寫者,讀寫鎖會把讀操作和寫操作分別進行加鎖,且讀鎖和讀鎖之間的線程不會發生互斥,寫鎖和寫鎖之間以及讀鎖和寫鎖之間的線程會發生互斥。讀鎖適用于線程讀取數據比較多的場景,而寫鎖適用于線程修改數據比較多的場景。
第三題
簡單介紹一下自旋鎖
- 理解:當兩個線程為了完成任務同時競爭一把鎖時, 拿到鎖的那個線程會立馬執行任務,而沒拿到鎖的線程就會立即再嘗試獲取鎖,無限循環,直到獲取到鎖為止,這樣的鎖就叫自旋鎖
- 優點和缺點:見自旋鎖優缺點部分
第四題
簡單介紹一下Java中synchronized充當了哪些鎖
- 主要以悲觀鎖為主,初始使用樂觀鎖策略,但當發現鎖競爭比較頻繁的時候,就會自動切換成悲觀鎖策略
- 并不區分讀寫鎖
- synchronized自身已經設置好了自旋鎖和掛起等待鎖,會根據不同的情況自動選擇最優的使用方案
- synchronized是一把非公平鎖
- synchronized就是一把可重入鎖
到此這篇關于Java面試最容易被刷的重難點之鎖的使用策略的文章就介紹到這了,更多相關Java 鎖內容請搜索服務器之家以前的文章或繼續瀏覽下面的相關文章希望大家以后多多支持服務器之家!
原文鏈接:https://blog.csdn.net/Mubei1314/article/details/120759983