沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了,還不來狂補?

一、樂觀鎖 VS 悲觀鎖

悲觀鎖樂觀鎖大概是大家聽到最多的兩種鎖了,這兩種鎖的區分更多的是思想上。

對於一個操作,悲觀鎖認爲自己在操作過程中,一定有別的線程也要來修改這個數據,所以一定會加鎖。而樂觀鎖則不認爲會有別的線程來干擾自己,所以不需要加鎖。

在Java中,synchronized關鍵字和Lock的實現類都是悲觀鎖,而樂觀鎖一般採用無鎖編程,也就是CAS算法來實現的。

首先說一說悲觀鎖

1、悲觀鎖

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

悲觀鎖的流程:

1、線程嘗試去獲取鎖

2、線程加鎖成功並執行操作,其他線程等待,線程加鎖失敗則等待獲取鎖(這裏有好幾種辦法,在synchronized中,會有在四種狀態中改變,在下文中我會介紹這四種情況)

3、線程執行完畢釋放鎖,其他線程獲取鎖

通過圖片和文字,我們能看出悲觀鎖適合寫操作多的場景,加鎖可以確保數據的安全,但是會影響一些操作效率。

2、樂觀鎖

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

樂觀鎖的流程:

1、線程直接獲取同步資源數據

2、判斷內存中的同步數據是否被其他線程修改

3、沒有被修改則直接更新

4、如果被其他線程修則選擇報錯或者重試(自旋)

和悲觀鎖不同,樂觀鎖明顯不適合經常進行修改,因爲誰也不能保證不會出現數據安全的問題,所以樂觀鎖適合讀操作的場景。對於讀操作來說,加鎖只會影響效率。

上文說到了,樂觀鎖一般採用CAS算法來實現,那麼我們就來講講什麼是CAS算法

3、CAS算法

CAS的英語是【Compare and Swap】,比較和交換,單單從這一個詞組來看,我們就已經能Get到CAS算法的核心了。

感覺已經講完了,就像之前去面菜鳥的時候,面試官哥哥問我:“TCP和UDP的區別是什麼”。我下意識地說了一句,一個是單工通信,一個是雙工通信。停頓了一下,準備繼續洋洋灑灑的時候,面試官嚴肅地直接打斷了我:“可以了。”

CAS的算法涉及三個操作數: 內存位置(V)、預期原值(A)和新值(B)。

如果內存位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新爲新值。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。

換一種說法,當且僅當 V 的值等於 A時,CAS通過原子方式用新值B來更新V的值(“比較+更新”整體是一個原子操作),否則不會執行任何操作。

在JDK1.5 中新增java.util.concurrent(J.U.C)就是通過實現CAS來實現樂觀鎖的。 我們可以看一下它的重點:

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

在沒有鎖的機制下需要字段value要藉助volatile原語,保證線程間的數據是可見的。這樣在獲取變量的值的時候才能直接讀取,這就是內存的可見性。

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

從上面這三個圖可以看出,CAS每次從內存中讀取數據然後將此數據修改+1後的結果進行CAS操作比較,如果成功就返回結果,否則重試直到成功爲止,compareAndSet利用JNI來完成CPU指令的操作。

是不是很複雜?其實一點也不復雜,我們可以這樣理解:CPU去更新一個值,但如果想改的值和原來的值,操作就失敗(因爲有其它操作先改變了這個值),然後可以去再次嘗試。如果想改的值和原來一樣,那麼就修改之。

但是,CAS也有一些問題

  • ABA問題

一個線程X1從內存位置V中取出A,這時候另一個線程Y1也從內存中取出A,並且Y1進行了一些操作變成了B,然後Y1又將V位置的數據變成A,這時候線程X1進行CAS操作發現內存中仍然是A,然後X1操作成功。儘管線程X1的CAS操作成功,但是不代表這個過程就是沒有問題的。

解決辦法: JDK從1.5開始提供了AtomicStampedReference類來解決ABA問題,具體操作封裝在compareAndSet()中,利用JNI來檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

  • 循環時間長開銷大 CAS操作如果長時間不成功,會導致其一直自旋,給CPU帶來非常大的開銷。
  • 只能保證一個共享變量的原子操作 Java從1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,可以把多個變量放在一個對象裏來進行CAS操作。

Java中的線程安全問題至關重要,要想保證線程安全,就需要用到樂觀鎖與悲觀鎖。悲觀鎖是獨佔鎖,阻塞鎖。樂觀鎖是非獨佔鎖,非阻塞鎖。什麼情況選擇什麼樣的鎖,就是我們開發人員需要思考的問題了。

二、自旋鎖VS非自旋鎖

我們之前提到了CAS操作如果長時間不成功,會導致其一直自旋,非常浪費性能。但是,自旋是非常有用的。

自旋鎖(spinlock):是指當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那麼該線程將循環等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖纔會退出循環

自旋鎖不會放棄CUP時間片,而是通過自旋等待鎖釋放。

爲什麼要自旋,?獲取鎖的線程一直處於活躍狀態,但是並沒有執行任何有效的任務,使用這種鎖不是會造成busy-waiting嗎?

因爲在我們的程序中,如果存在着大量的互斥同步代碼,當出現高併發的時候,系統內核態就需要不斷的去掛起線程恢復線程,頻繁的上下文切換會對我們系統的併發性能有一定影響。在程序的執行過程中鎖定“共享資源“的時間片是極短的,如果僅僅是爲了這點時間而去不斷掛起、恢復線程的話,消耗的時間可能會更長,那就“撿了芝麻丟了西瓜”了。

再次從大佬哪邊借來一張圖:

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

自旋等待雖然避免了線程切換的開銷,但它要佔用處理器時間。如果鎖被佔用的時間很短,自旋等待的效果就會非常好。反之,如果鎖被佔用的時間很長,那麼自旋的線程只會白浪費處理器資源

於是乎,自適應的自旋鎖出現了。

 自旋鎖在JDK1.4.2中引入,使用-XX:+UseSpinning來開啓。JDK 6中變爲默認開啓,並且引入了自適應的自旋鎖(適應性自旋鎖)。
 複製代碼

自適應的自旋鎖

自適應自旋鎖的出現使得自旋操作變得聰明起來,不再跟之前一樣死板。所謂的“自適應”意味着對於同一個鎖對象,線程的自旋時間是根據上一個持有該鎖的線程的自旋時間以及狀態來確定的。例如對於A鎖對象來說,如果一個線程剛剛通過自旋獲得到了鎖,並且該線程也在運行中,那麼JVM會認爲此次自旋操作也是有很大的機會可以拿到鎖,因此它會讓自旋的時間相對延長。但是如果對於B鎖對象自旋操作很少成功的話,JVM甚至可能直接忽略自旋操作。

因此,自適應自旋鎖在一定程度上能強化自旋鎖的性能。

可是,出現了多個線程同時爭搶鎖資源,我們也不能總是自旋啊! 於是,java團隊又進行了進化。

三、無鎖 VS 偏向鎖 VS 輕量級鎖 VS 重量級鎖

學習這四個鎖之前,我們先來了解一下java對象頭Monitor的概念。

synchronized是悲觀鎖,在操作同步資源之前需要給同步資源先加鎖,這把鎖就是存在Java對象頭裏的,Hotspot的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。

  • Mark Word:默認存儲對象的HashCode,分代年齡和鎖標誌位信息
  • Klass Point:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例

每一個Java對象就有一把看不見的鎖,稱爲內部鎖或者Monitor鎖。

Monitor是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表,每一個被鎖住的對象都會和一個monitor關聯,同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用

synchronized通過Monitor來實現線程同步,Monitor是依賴於底層的操作系統的Mutex Lock(互斥鎖)來實現的線程同步

爲了瞭解這幾個概念,我們可以通過兩個代碼來看一個:

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

第一塊代碼很簡單,看一看字節碼,非常清楚,一眼就能看出它做了什麼。

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

再看第二個代碼,看看java代碼,非常簡單,和HelloWorld相比只是多了一個synchronize的代碼塊,但是字節碼卻大不一樣,可以看出在加鎖的代碼塊, 多了個 monitorenter**monitorexit**

每個對象有一個監視器鎖(monitor)。當monitor被佔用時就會處於鎖定狀態,線程執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:

  • 1、如果monitor的進入數爲0,則該線程進入monitor,然後將進入數設置爲1,該線程即爲monitor的所有者。
  • 2、如果線程已經佔有該monitor,只是重新進入,則進入monitor的進入數加1.
  • 3、如果其他線程已經佔用了monitor,則該線程進入阻塞狀態,直到monitor的進入數爲0,再重新嘗試獲取monitor的所有權

執行monitorexit的線程必須是objectref所對應的monitor的所有者

  • 1、指令執行時,monitor的進入數減1
  • 2、如果減1後進入數爲0,那線程退出monitor,不再是這個monitor的所有者
  • 3、其他被這個monitor阻塞的線程可以嘗試去獲取這個 monitor 的所有權

通過這兩個圖,大家大概就能理解之前的那兩個概念了。

我們知道,高併發的情況,不斷地爭搶鎖,系統內核態就需要不斷的去掛起線程恢復線程,頻繁的上下文切換會對我們系統的併發性能有一定影響。如果同步代碼塊中的內容過於簡單,狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長”。這種方式就是synchronized最初實現同步的方式,這就是JDK 6之前synchronized效率低的原因。這種依賴於操作系統Mutex Lock所實現的鎖我們稱之爲“重量級鎖”,JDK6中爲了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”。

所以目前鎖一共有4種狀態,級別從低到高依次是:無鎖、偏向鎖、輕量級鎖重量級鎖。鎖狀態只能升級不能降級。

這是四種鎖狀態對應的的:Mark Word(標記字段)內容:

鎖狀態存儲內容Mark Word無鎖對象的hashCode、對象分代年齡、是否是偏向鎖(0)01偏向鎖偏向線程ID、偏向時間戳、對象分代年齡、是否是偏向鎖(1)01輕量級鎖指向棧中鎖記錄的指針00重量級鎖指向互斥量(重量級鎖)的指針10

1、無鎖

無鎖的特點就是修改操作在循環內進行,線程會不斷的嘗試修改共享資源。

如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功,CAS原理及應用即是無鎖的實現。

2、偏向鎖

偏向鎖是指一段同步代碼一直被一個線程所訪問,那麼該線程會自動獲取鎖,降低獲取鎖的代價。

偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖,線程不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態。撤銷偏向鎖後恢復到無鎖(標誌位爲“01”)或輕量級鎖(標誌位爲“00”)的狀態。

3、輕量級鎖

當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。

在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,然後拷貝對象頭中的Mark Word複製到鎖記錄中。

拷貝成功後,虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,並將Lock Record裏的owner指針指向對象的Mark Word。

如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,表示此對象處於輕量級鎖定狀態。

如果輕量級鎖的更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明多個線程競爭鎖。

若當前只有一個等待線程,則該線程通過自旋進行等待。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級爲重量級鎖。

4、重量級鎖

升級爲重量級鎖時,鎖標誌的狀態值變爲“10”,此時Mark Word中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞狀態。

四、公平鎖 VS 非公平鎖

1、公平鎖

公平鎖是指多個線程按照申請鎖的順序來獲取鎖,線程直接進入隊列中排隊,隊列中的第一個線程才能獲得鎖。

公平鎖的優點是:等待鎖的線程不會餓死,人人有飯喫,人人有書讀

公平鎖的缺點是:整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大

2、非公平鎖

非公平鎖是多個線程加鎖時直接嘗試獲取鎖,獲取不到纔會到等待隊列的隊尾等待。如果此時此刻鎖剛好可用,那麼這個線程就可以插隊,無阻塞地獲取鎖。

非公平鎖的優點是:可以減少喚起線程的開銷,整體的吞吐效率高,因爲線程有機率不阻塞直接獲得鎖,CPU不必喚醒所有線程

非公平鎖的缺點是:處於等待隊列中的線程可能會餓死,或者等很久纔會獲得鎖

我們可以通過一些源碼來看一看公平鎖和非公平鎖在java中的應用。

3、看公平鎖FairSync和非公平鎖NonfairSync的代碼(看源碼1/10000)

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

從結構中來看,ReentrantLock裏面有一個內部類Sync,Sync繼承自AQS(

AbstractQueuedSynchronizer),添加鎖和釋放鎖的大部分操作實際上都是在Sync中實現的。它有公平鎖FairSync和非公平鎖NonfairSync兩個子類。ReentrantLock默認使用非公平鎖,也可以通過構造器來顯示的指定使用公平鎖。

公平鎖FairSync非公平鎖NonfairSync

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

我們用軟件比較一下:

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

是不是很清晰了?公平鎖和非公平鎖只有一個地方不一樣

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

閱讀一下注釋:是否返回true取決於頭是否在尾部之前初始化以及頭是否準確(如果當前線程在隊列中)

意思就是這個方法主要是判斷當前線程是否位於同步隊列中的第一個。如果是則返回true,否則返回false

由此可得,公平鎖通過同步隊列來實現順序獲取鎖,而非公平鎖加鎖時不考慮先後順序,直接嘗試去獲取鎖,所以存在後申請卻先獲得鎖的情況。

五、可重入鎖 VS 非可重入鎖

可重入鎖這個概念也比較好理解,在同一個線程在外層方法獲取鎖的時候,再進入該線程的內層方法能自動獲取鎖(前提鎖對象得是同一個對象或者class)就是可重入鎖,不能自動獲取那麼這個鎖就是不可重入鎖。

在JAVA中,我們最熟悉的ReentrantLock和synchronized都是可重入鎖。

爲什麼可重入鎖可以自動獲得鎖呢?

我們還是看看源碼吧~!!!(源碼真是好用2/10000)

可重入鎖ReentrantLock不可重入鎖NonReentrantLock

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

這兩個圖是不是很明顯?

由圖可知,當線程嘗試獲取鎖時,可重入鎖先嚐試獲取並更新status值,如果status == 0表示沒有其他線程在執行同步代碼,則把status置爲1,當前線程開始執行。如果status != 0,則判斷當前線程是否是獲取到這個鎖的線程,如果是的話執行status+1,且當前線程可以再次獲取鎖。而非可重入鎖是直接去獲取並嘗試更新當前status的值,如果status != 0的話會導致其獲取鎖失敗,當前線程阻塞。

釋放鎖時,可重入鎖同樣先獲取當前status的值,在當前線程是持有鎖的線程的前提下。如果status-1 == 0,則表示當前線程所有重複獲取鎖的操作都已經執行完畢,然後該線程纔會真正釋放鎖。而非可重入鎖則是在確定當前線程是持有鎖的線程之後,直接將status置爲0,將鎖釋放。

六、獨享鎖 VS 共享鎖

獨享鎖和共享鎖這個概念,可以類比爲讀寫鎖。

舉個例子,A線程獲得數據ZZZ的鎖,如果加鎖後其他的線程不能再對ZZZ加任何形式的鎖,也不能對它進行讀寫,那麼說明ZZZ上的是排他鎖。

如果線程A獲得數據ZZZ上的鎖以後,則其他線程還能對ZZZ再加共享鎖,獲得共享鎖的線程還能讀數據,只是不能修改數據,那麼說明ZZZ上的是共享鎖。

我們可以看看讀寫鎖ReentrantReadWriteLock

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

讀寫鎖裏面有兩把鎖,一把是ReadLock,一把是WriteLock,現在我們不知道里面是什麼樣子的,於是我選擇看源碼(源碼真是太厲害了!3/10000)

ReadLockWriteLock

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

我們驚訝的發現了一個老熟人state,我們總是能看到他。

在獨享鎖中state這個值通常是0或者1(如果是重入鎖的話state值就是重入的次數),在共享鎖中state就是持有鎖的數量。但是在ReentrantReadWriteLock中有讀、寫兩把鎖,所以需要在一個整型變量state上分別描述讀鎖和寫鎖的數量(或者也可以叫狀態)。於是將state變量“按位切割”切分成了兩個部分,高16位表示讀鎖狀態(讀鎖個數),低16位表示寫鎖狀態(寫鎖個數)。 再借大佬的圖片

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

從寫鎖的這一段我們可以看出,它首先判斷是否已經有線程持有了鎖。如果已經有線程持有了鎖(c!=0),則查看當前寫鎖線程的數目,如果寫線程數爲0(即此時存在讀鎖)或者持有鎖的線程不是當前線程就返回失敗。

沒有對象就算了,你連Java中的鎖都不知道?這就是你的不對了

 

從讀鎖中又冷發現,如果其他線程已經獲取了寫鎖,則當前線程獲取讀鎖失敗,進入等待狀態。如果當前線程獲取了寫鎖或者寫鎖未被獲取,則當前線程(線程安全,依靠CAS保證)增加讀狀態,成功獲取讀鎖。


“大清亡於閉關鎖國,學習技術需要交流和資料”。 在這裏我給大家準備了很多的學習資料免費獲取,包括但不限於java進階學習資料、技術乾貨、大廠面試題系列、技術動向、職業生涯等一切有關程序員的分享。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章