Java面試最容易被刷的重難點之鎖的使用策略
在多線程的學(xué)習(xí)中,很多時候都要用到鎖,但我們都知道,加鎖這個操作是一個計算機中開銷比較大的操作,因此,本篇文章我會帶大家學(xué)習(xí)在不同場景中進行不同的加鎖處理方式,以讓程序更高效一些有關(guān)鎖策略不僅僅局限于某一種語言,在很多語言中都可能會遇到加鎖操作,而且這部分知識點也是面試中常見的問題,所以本篇文章內(nèi)容基本都是需要大家自己認真理解并做到會用自己的語言組織起來的。內(nèi)容均為博主認真總結(jié),大家可以收藏起來慢慢學(xué)習(xí),希望可以幫到大家哦!

一. 樂觀鎖和悲觀鎖
1. 字面理解
樂觀鎖認為多個線程訪問同一個共享數(shù)據(jù)時產(chǎn)生并發(fā)沖突的概率不大,并不會真的加鎖, 而是直接嘗試訪問數(shù)據(jù), 在訪問的同時識別當前的數(shù)據(jù)是否出現(xiàn)訪問沖突,若沖突,則會返回當前的錯誤信息讓用戶去決定如何去處理悲觀鎖會認為多個線程訪問同一個共享數(shù)據(jù)時產(chǎn)生并發(fā)沖突的概率很大,因此往往會在取數(shù)據(jù)時會進行加鎖操作,這樣的話其他線程想要拿到這個數(shù)據(jù)時就會阻塞等到直到其他線程獲取到鎖
補充:在Java中synchronized這一加鎖操作主要以悲觀鎖為主,初始使用樂觀鎖策略,但當發(fā)現(xiàn)鎖競爭比較頻繁的時候,就會自動切換成悲觀鎖策略
2. 生活實例
在生活中有很多情況都能涉及到樂觀和悲觀的心態(tài),比如今天是陰天,A認為可能會下雨,會提前帶好傘,這就對應(yīng)到了悲觀鎖這一策略;而B比較樂觀,不會認為會下雨,因此B不會帶傘,這顯然可以類比為樂觀鎖這一策略。

3. 基于版本號方式實現(xiàn)樂觀鎖
實現(xiàn)樂觀鎖策略這一功能的方式有很多,接下來我?guī)Т蠹胰W(xué)習(xí)一種:基于版本號方式。
假設(shè)我們要使用多線程修改用戶的賬戶余額,我們可以引入一個版本號來實現(xiàn),具體方法如下:
設(shè)當前的余額為100,引入一個版本號version,將其初始值設(shè)為1,并且我們規(guī)定,提交版本必須大于數(shù)據(jù)庫中記錄的當前版本號才能執(zhí)行更新余額的操作,若不滿足此條件,則認為修改失敗
圖示
以線程1想把主內(nèi)存中的數(shù)據(jù)減50,線程2把主內(nèi)存中的數(shù)據(jù)減20為例:

線程1此時準備將主內(nèi)存中的數(shù)據(jù)讀入自己的工作內(nèi)存中并修改,而線程2也想將主內(nèi)存的數(shù)據(jù)讀入自己的工作內(nèi)存中并修改,此時線程1和線程2以及主內(nèi)存中的版本號都為1
當線程1把主內(nèi)存的數(shù)據(jù)減50后,即修改后,會將自己工作內(nèi)存中的版本號加1,此時線程1工作內(nèi)存中的版本號大于主內(nèi)存中的版本號(2大于1),因此線程1成功修改了主內(nèi)存中的數(shù)據(jù),并將數(shù)據(jù)50寫入主內(nèi)存中,最后將主內(nèi)存中的版本號加1(即為2)

此時線程2修改了自己工作內(nèi)存中的數(shù)據(jù),隨后將自己的工作內(nèi)存版本號改為2:

但正當線程2準備將其改好后的數(shù)據(jù)80寫入主內(nèi)存時,發(fā)現(xiàn)自己的版本號和主內(nèi)存的版本號都一樣,并不滿足大于關(guān)系,因此此次修改失敗,有效避免了多線程并發(fā)修改數(shù)據(jù)時引起的數(shù)據(jù)安全問題。
總結(jié)
基于版本號這樣實現(xiàn)樂觀鎖的機制就是一種典型的實現(xiàn)方式,這個實現(xiàn)方式和之前所學(xué)過的單純的互斥的加鎖方式來說更加輕量一些(只修改版本號,只是在計算機中用戶態(tài)上進行操作,而互斥加鎖方式會涉及到用戶態(tài)和內(nèi)核態(tài)之間的切換,不僅效率不太高,也容易引起線程阻塞)對于這個機制來說,如果修改數(shù)據(jù)失敗,就會涉及到重試操作,如果頻繁重試的話那么效率也就不高了,因此,最好在線程并發(fā)沖突率比較低的場景下使用樂觀鎖這一方式比較好
二. 讀寫鎖
1. 理解

我們都知道,當我們通過多線程方式嘗試修改同一數(shù)據(jù)時,一般都可能引發(fā)線程安全問題,但當我們通過多線程方式嘗試讀取同一數(shù)據(jù)時,一般不會引發(fā)線程安全問題,因此,我們可以根據(jù)讀和寫的不同場景來給讀和寫操作分別加上不同的鎖。
Java當中的synchronized不會對讀和寫進行區(qū)分,默認使用后線程都是互斥的
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; }
此方法可以創(chuàng)建出一個讀鎖實例
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
此方法可以創(chuàng)建出一個寫鎖實例
某個線程被讀鎖修飾后,這兩個線程之間不會互斥,而是完全同時并發(fā)執(zhí)行,一般將讀鎖用于線程讀取數(shù)據(jù)比較多的場景;而當某個線程被寫鎖修飾后,這兩個線程會互斥,一個線程會執(zhí)行,而另一個線程會阻塞等待,因此必須是一個線程執(zhí)行完了,另一個線程才會執(zhí)行,一般用于修改數(shù)據(jù)比較多的場景
三. 重量級鎖和輕量級鎖
1. 原理
鎖的核心特性 “原子性”,這樣的機制追根溯源是 CPU 這樣的硬件設(shè)備提供的
1.CPU 提供了 “原子操作指令”。
2. 操作系統(tǒng)基于 CPU 的原子指令,實現(xiàn)了 mutex 互斥鎖.
3. JVM 基于操作系統(tǒng)提供的互斥鎖。實現(xiàn)了 synchronized 和 ReentrantLock 等關(guān)鍵字和類。

注意:synchronized 并不僅僅是對 mutex 進行封裝, 在 synchronized 內(nèi)部還做了很多其他的工作
2. 理解
1.重量級鎖依賴了OS提供的mutex,的開銷一般很大,往往是通過內(nèi)核來完成的
2.輕量級加鎖一般不使用mutex,開銷一般比較小,一般通過用戶態(tài)就能直接完成
3. 區(qū)分用戶態(tài)和內(nèi)核態(tài)
我們可以類比一個生活中的例子,當去銀行辦理業(yè)務(wù)時,如果是通過用戶在銀行工作人員的指導(dǎo)下自己在窗口外完成,那么效率會比較高,就像計算機中的用戶態(tài)一樣。而當我們把自己的業(yè)務(wù)交給銀行相關(guān)人員去完成時,由于銀行工作人員的閑忙時間是不可控的,因此無法保證效率,就好比計算機中的內(nèi)核態(tài)。
四. 自旋鎖
1. 理解
當兩個線程為了完成任務(wù)同時競爭一把鎖時, 拿到鎖的那個線程會立馬執(zhí)行任務(wù),而沒拿到就會阻塞等待,當一個線程把鎖釋放后,另一個線程不會被立即喚醒,而是等操作系統(tǒng)將其進行一系列的調(diào)度到CPU中的操作才能被喚醒然后執(zhí)行任務(wù),這種鎖叫做掛起等待鎖,線程在搶鎖失敗后進入阻塞狀態(tài),放棄 CPU,需要過很久才能再次被調(diào)度。但實際上,大部分情況下,雖然當前搶鎖失敗,但過不了很久,鎖就會被釋放,所以沒必要就放棄 CPU。這個時候就可以使用自旋鎖來處理這樣的問題。
2. 實現(xiàn)方式
自旋鎖的偽代碼為:while (搶鎖(lock) == 失敗) {}
如果獲取鎖失敗,就會立即再嘗試獲取鎖,無限循環(huán),直到獲取到鎖為止。第一次獲取鎖失敗, 第二次的嘗試會在非常短的時間內(nèi)到來,一旦鎖被其他線程釋放, 就能第一時間獲取到鎖
3. 優(yōu)缺點
自旋鎖是一種典型的輕量級鎖的實現(xiàn)方式,它沒有放棄 CPU, 不涉及線程阻塞和調(diào)度,一旦鎖被釋放,就能第一時間獲取到鎖,這樣會大大提高代碼的執(zhí)行效率,但如果鎖被其他線程持有的時間比較久, 那么就會持續(xù)地消耗 CPU 資源。(而掛起等待的時候是不消耗 CPU 的)
因此,我們應(yīng)該注意自旋鎖的適用場合:
- 如果多個線程執(zhí)行任務(wù)時鎖的沖突比較低,或者線程持有鎖的時間比較短,此時使用自旋鎖比較合適
- 如果某個線程任務(wù)對CPU比較敏感,且不希望吃太多CPU資源,那么此時就不太適合使用自旋鎖。
注意:synchronized自身已經(jīng)設(shè)置好了自旋鎖和掛起等待鎖,會根據(jù)不同的情況自動選擇最優(yōu)的使用方案
五. 公平鎖和非公平鎖
1. 理解
若有三個線程 A,B,C。
A先嘗試獲取鎖,獲取成功了,因為只有一把鎖,所以B和C線程都會阻塞等待,那么如果A用完了鎖后,B和C線程哪一個會最先獲取到鎖呢?
- 公平鎖:遵守先來后到原則,因為B線程比C線程來的早一點,所以B線程先獲取到鎖
- 非公平鎖:沒有先來后到原則,B和C線程獲取到鎖的概率是隨機的
2. 注意事項
操作系統(tǒng)內(nèi)部的線程調(diào)度就可以視為是隨機的,如果不做任何額外的限制,鎖就是非公平鎖。如果要想實現(xiàn)公平鎖,就需要依賴額外的數(shù)據(jù)結(jié)構(gòu)(比如隊列) 來記錄線程們的先后順序。公平鎖和非公平鎖沒有好壞之分, 關(guān)鍵還是看適用場景(大部分情況下非公平鎖就夠用了,但當我們希望線程的調(diào)度時間成本是可控的,那么此時就需要用到公平鎖了)
注意:synchronized為非公平鎖
六. 可重入鎖和不可重入鎖
1. 為什么要引入這兩把鎖
(1)實例一
在介紹可重入鎖和不可重入鎖之前,大家先來思考一個問題,為什么Java中的main函數(shù)要用static來修飾?
public class Test {
public static void main(String[] args) {
}
}
試想以下,如果main函數(shù)不是static來修飾的話:
public class Test {
public void main(String[] args) {
Test a=new Test();
a.main();
}
}
那么此時這段代碼能否被執(zhí)行呢?答案是不能,因為在java中,沒有static的變量或函數(shù),如果想被調(diào)用的話,是要先新建一個對象才可以。而main函數(shù)作為程序的入口,需要在其它函數(shù)實例化之前就啟動,這也就是為什么要加一個static。main函數(shù)好比一個門,要探索其它函數(shù)要先從門進入程序。static提供了這樣一個特性,無需建立對象,就可以啟動。也可以利用反證法說明,如果不是static修飾的,若不是靜態(tài)的,main函數(shù)調(diào)用的時候需要new對象,new完對象才能調(diào)用main函數(shù)。那么你既想進入到main函數(shù)new對象,又想new完對象來調(diào)用main函數(shù),那么就不行了,相當于自己把自己鎖在里面出不來了
(2)實例二
另外一個Java當中的例子:
synchronized void func1(){
func2();
}
synchronized void func2(){
}
我們對func1這個方法進行加鎖時,是可以成功的,但當我們對func2這個方法再次加鎖后,就比較危險了。因為要執(zhí)行完func1這個方法,就必須執(zhí)行完func2,而此時鎖已經(jīng)被func1這個方法占用了,func2獲取不到鎖,所以func2就會一直阻塞等待,去等func1釋放鎖,但func1一直執(zhí)行不完成,所以鎖永遠不會釋放,func2永遠也獲取不到鎖,這樣就形成了一個閉環(huán),相當于自己把自己鎖在里面出不來了,此時這個線程就會崩潰,是比較危險的
2. 實現(xiàn)方案
了解了上面兩個實例的嚴重性后,我們引入了可重入鎖這個機制,當我們形成死鎖后,如果是可重入鎖的話,它不會讓線程阻塞等待最終死鎖從而奔潰,而是運用計數(shù)器的方法,去記錄當前某個線程針對某把鎖嘗試加了幾次,每加一次鎖計數(shù)都會加1,每次解鎖計數(shù)都會減1,這樣當計數(shù)器里面的計數(shù)完全為0的時候才會真正釋放鎖,正是因為有了這樣的機制,才有效避免了死鎖問題。而在Java中,synchronized就是一把可重入鎖,它給我們提供了很大的方便,保證在我們即使造成死鎖問題時,程序也不至于崩潰。
七. 面試題
第一題
如何理解樂觀鎖和悲觀鎖,具體實現(xiàn)方式如何 如何理解?
見樂觀鎖和悲觀鎖字面理解部分(嘗試用自己的語言組織)實現(xiàn)方式:
(1)樂觀鎖:見基于版本號方式實現(xiàn)樂觀鎖部分
(2)悲觀鎖:多個線程訪問同一個共享數(shù)據(jù)時產(chǎn)生并發(fā)沖突時,會在取數(shù)據(jù)時會進行加鎖操作,這樣的話其他線程想要拿到這個數(shù)據(jù)時就會阻塞等到直到其他線程獲取到鎖
第二題
簡單介紹一下讀寫鎖
讀寫鎖實際是一種特殊的自旋鎖,它能把同一塊共享數(shù)據(jù)的訪問者分為讀者和寫者,讀寫鎖會把讀操作和寫操作分別進行加鎖,且讀鎖和讀鎖之間的線程不會發(fā)生互斥,寫鎖和寫鎖之間以及讀鎖和寫鎖之間的線程會發(fā)生互斥。讀鎖適用于線程讀取數(shù)據(jù)比較多的場景,而寫鎖適用于線程修改數(shù)據(jù)比較多的場景。
第三題
簡單介紹一下自旋鎖
- 理解:當兩個線程為了完成任務(wù)同時競爭一把鎖時, 拿到鎖的那個線程會立馬執(zhí)行任務(wù),而沒拿到鎖的線程就會立即再嘗試獲取鎖,無限循環(huán),直到獲取到鎖為止,這樣的鎖就叫自旋鎖
- 優(yōu)點和缺點:見自旋鎖優(yōu)缺點部分
第四題
簡單介紹一下Java中synchronized充當了哪些鎖
- 主要以悲觀鎖為主,初始使用樂觀鎖策略,但當發(fā)現(xiàn)鎖競爭比較頻繁的時候,就會自動切換成悲觀鎖策略
- 并不區(qū)分讀寫鎖
- synchronized自身已經(jīng)設(shè)置好了自旋鎖和掛起等待鎖,會根據(jù)不同的情況自動選擇最優(yōu)的使用方案
- synchronized是一把非公平鎖
- synchronized就是一把可重入鎖
到此這篇關(guān)于Java面試最容易被刷的重難點之鎖的使用策略的文章就介紹到這了,更多相關(guān)Java 鎖內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
java中關(guān)于文本文件的讀寫方法實例總結(jié)
這篇文章主要介紹了java中關(guān)于文本文件的讀寫方法,實例總結(jié)了Java針對文本文件讀寫的幾種常用方法,并對比了各個方法的優(yōu)劣及特點,具有一定參考借鑒價值,需要的朋友可以參考下2015-11-11
關(guān)于Http持久連接和HttpClient連接池的深入理解
眾所周知,httpclient是java開發(fā)中非常常見的一種訪問網(wǎng)絡(luò)資源的方式了,下面這篇文章主要給大家介紹了關(guān)于Http持久連接和HttpClient連接池的相關(guān)資料,文中通過示例代碼介紹的非常詳細,需要的朋友可以參考借鑒,下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-05-05
淺析Java設(shè)計模式編程中的單例模式和簡單工廠模式
這篇文章主要介紹了淺析Java設(shè)計模式編程中的單例模式和簡單工廠模式,使用設(shè)計模式編寫代碼有利于團隊協(xié)作時程序的維護,需要的朋友可以參考下2016-01-01
Java基于動態(tài)規(guī)劃法實現(xiàn)求最長公共子序列及最長公共子字符串示例
這篇文章主要介紹了Java基于動態(tài)規(guī)劃法實現(xiàn)求最長公共子序列及最長公共子字符串,簡單描述了動態(tài)規(guī)劃法的概念、原理,并結(jié)合實例形式分析了Java使用動態(tài)規(guī)劃法求最長公共子序列以及最長公共子字符串相關(guān)實現(xiàn)技巧,需要的朋友可以參考下2018-08-08
詳解MyBatis-Plus updateById方法更新不了空字符串/null解決方法
這篇文章主要介紹了詳解MyBatis-Plus updateById方法更新不了空字符串/null解決方法,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-09-09

