死磕Java併發?首先需要學習的併發機制底層實現的三個原理

開篇

說到併發編程,相信很多人和我一樣,對於“併發編程” 這個名詞,一下就想到了多線程程序。緊接着又會想到涉及到鎖的相關知識、線程池等一些知識片段。 但是對於這些知識的底層原理或者說JDK提供給我們的併發包下面的工具類,都是沒怎麼接觸過的。

這就導致一些問題,比如JDK隨着版本的升級已經提供了很多應付高併發場景的工具類,而我們還只是會用synchnorized加鎖,hashtable這種同步容器等。 之所以我們不敢用新的工具類,比如說 Lock 和 ConCurrentHashMap 這些併發包下提供的性能更好更靈活的工具類,主要原因在於我們不清楚他們的實現原理怕用不好,導致業務生產問題。一口大鍋就蓋下來。。。

痛定思痛,這次一定要系統的學習Java併發編程,不僅是爲了能夠落地到工作項目中,得到領導欣賞和同事欽佩的目光,也爲了以後出去面試不出現和麪試官之間的尷尬,你懂得。。。

今天開始學習的第一天分享。 作爲碼農,我們一定要多寫多練多分享,寫錯了也沒關係嘛,畢竟你也沒喫他家飯。 被別的同學指出錯誤,一起探討,也是自己快速進步的不二法門。

好了,不囉嗦了。。開始學習吧!!!


Java併發編程的基底層實現

作爲開篇,首先學習下,Java整個併發編程的的本質,也就是它的底層實現原理。 後面所有的併發容器也好、鎖也好、線程池等都是基於此實現的。 萬變不離其宗嘛,先學會了基礎實現,後面的在慢慢啃,也有助於我們學習過程中發散思維,舉一反三。

在學習Java多線程時,我們肯定都用過 synchnorizedvolatile 。沒錯這兩個東西在我們Java併發編程中佔據着半壁江山。

有這樣一句話,一切併發問題的根源都是由 可見性、原子性、有序性導致的。這裏volatile 就是爲了解決在多處理器開發中解決共享變量的 可見性 問題的。

探究volatile的前世今生

Java語言規範中對 volatile的定義如下:

Java編程語言允許線程訪問共享變量,爲了確保共享變量能被準確和一致地更新,線程應該確保通過排他鎖單獨獲得這個變量。Java語言提供了volatile,在某些情況下比鎖要更加方便。如果一個字段被聲明成volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。

這句話該怎麼理解呢? 別急,爲了更好的理解這個概念,我們需要先來學習下與之相關的 CPU 概念。

volatile是如何來保證可見性的呢? 當對volatile變量進行寫操作時,jvm在多核處理器下會做兩件事:

  1. 將當前處理器緩存行的數據寫回到系統內存
  2. 這個寫回內存的操作會使在其他CPU裏緩存了該內存地址的數據無效

爲了彌補越來越快的CPU 與 內存 之間的速度差距,CPU引入了多級緩存(L1,L2,L3或其他)。也就是說處理器是不直接與內存交互的,而是先將系統內存中的數據讀到 CPU緩存後再進行操作,但是操作何時寫到內存時不確定的。 這個時候就展現出 volatile 的強大了,JVM 實現了 當對 volatile 變量進行寫操作時,JVM會向處理器發送一條Lock前綴的指令,將這個變量所在的CPU緩存行數據寫到內存

經過上面的步驟,雖然每次對 volatile 的寫操作會立即寫到內存,但是好像還缺了點什麼? 喜歡思考的你發現了?

是的,就算上面的步驟已經學會到主存,但是其他處理的緩存行還是舊的呀,依然會出併發的bug。 所以在多處理器下,爲了保證各個處理器的緩存是一致的,就實現了緩存的一致性性協議。每個處理器通過嗅探在總線上傳播的數據來檢查自己的緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改時,就會將當前處理器的緩存的緩存行設置爲無效。

這樣就實現了 volatile 修飾的變量,在寫操作時保證多處理器下的數據可見性。

synchnorized的原理與應用

synchnorized 是jdk1.5之前提供的唯一鎖機制了。作爲元老,你可能會稱之爲重量級鎖,性能很差等等。其實在jdk1.6 對 synchnorized 做了各種優化之後,某些場景下已經不差了,也就是說性能不能在成爲廣受詬病的槽點了。 jdk1.6 爲了減少獲取鎖和釋放鎖帶來的性能消耗而引入了偏向鎖、輕量級鎖以及鎖的存儲結構和升級過程。

先來看下利用synchronized實現同步的基礎:Java中的每一個對象都可以作爲鎖。具體表現爲以下3種形式。

  • ·對於普通同步方法,鎖是當前實例對象
  • 對於靜態同步方法,鎖是當前類的Class對象
  • ·對於同步方法塊,鎖是Synchonized括號裏配置的對象

這裏對用法就不多做介紹了,總結一句話:就是要訪問同步代碼塊,首先獲取鎖,退出或拋出異常必須釋放鎖,獲取和釋放都是JVM幫我們做的,下面一起來看下實現。

從JVM規範中可以看到Synchonized在JVM裏的實現原理,JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步。

monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。

Java對象頭

synchronized用的鎖是存在Java對象頭裏的, 在64位虛擬機下,Mark Word是64bit大小的,其存儲結構如下所示:

鎖的升級與對比

Java SE 1.6爲了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,在Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。

偏向鎖

HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,以後該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

  • 偏向鎖撤銷

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活着,如果線程不處於活動狀態,則將對象頭設置成無鎖狀態;如果線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼重新偏向於其他線程,要麼恢復到無鎖或者標記對象不適合作爲偏向鎖,最後喚醒暫停的線程。下圖線程1演示了偏向鎖初始化的流程,線程2演示了偏向鎖撤銷的流程。

ps:偏向鎖在Java1.6 後 裏是默認啓用的,但是它在應用程序啓動幾秒鐘之後才激活,如有必要可以使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程序裏所有的鎖通常情況下處於競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。

輕量級鎖

  • 輕量級鎖加鎖

接下來很重要,敲黑板哦,加鎖的過程很奇妙:
線程在執行同步代碼塊之前,JVM會先在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Work 複製到鎖記錄中。 接下來線程會嘗試使用CAS操作將對象頭中的Mark Word替換爲指向鎖記錄的指針。 如果成功則獲取鎖,失敗則表示被佔用,自旋重試。

  • 輕量級鎖解鎖

輕量級鎖解鎖時,會使用原子性的CAS操作將棧幀中的 Mark Word替換回到對象頭,如果成功則表示沒有競爭。失敗則表示存在競爭,鎖膨脹爲重量級鎖。

因爲自旋會消耗CPU,爲了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。

經過上面的學習,我們知道了JVM底層幾種鎖的實現,那麼鎖能解決同步問題,必然會引入新的問題,那就是性能問題。 下面總結了幾種鎖的優缺點

原子操作

原子操作,聽到這個概念你肯定會想到我們常說的原子性。是的,就是說一個操作的執行是不可打斷的。現代處理器保證了在多核處理器中內存操作的原子性。

處理器保證從系統內存中讀取或者寫入一個字節是原子的,意思是當一個處理器讀取一個字節時,其他處理器不能訪問這個字節的內存地址,處理器使用基於對緩存加鎖或總線加鎖的方式來實現多處理器之間的原子操作。

1. 使用總線鎖保證原子性

如果多個處理器同時對共享變量進行讀改寫操作(i++就是經典的讀改寫操作),那麼共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變量的值會和期望的不一致。舉個例子,如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2。

原因參考上圖,當多個處理器同時從各自的緩存裏讀取到i,進行加一操作,然後分別寫入到內存。

所以想要保證原子性,就必須當一個處理器在讀寫共享變量時,另一個處理器不能操作緩存了該共享變量內存地址的緩存。

處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔共享內存。

通過對總線鎖的學習,你肯定會問,使用總線鎖後,同一時刻只有一個處理器可以訪問共享內存,豈不是很浪費資源,其他處理器都得歇着,而我們僅僅只需要保證某一個共享變量的原子性,這是殺雞用了牛刀呀!!!

別急,我們能想到的事情,現在處理器肯定都已經解決了。。。 接着往下看吧

2. 使用緩存鎖保證原子性

這種方案,我們只需要同一時刻對某個內存地址的操作原子性即可。

“緩存鎖定”是指內存區域如果被緩存在處理器的緩存行中,並且在Lock操作期間被鎖定,那麼當它執行鎖操作回寫到內存時,處理器不在總線上聲言LOCK#信號,而是修改內部的內存地址,並允許它的緩存一致性機制來保證操作的原子性,因爲緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據,當其他處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效。

Java如何實現原子操作

在Java中可以通過鎖和循環CAS的方式來實現原子操作。

使用循環CAS實現原子操作

JVM中的CAS操作正是利用了處理器提供的CMPXCHG指令實現的。自旋CAS實現的基本
思路就是循環進行CAS操作直到成功爲止。

CAS實現原子操作的三大問題

  1. ABA問題
  2. 循環時間長開銷大
  3. 只能保證一個共享變量的原子操作

使用鎖機制實現原子操作

鎖機制保證了只有獲得鎖的線程才能夠操作鎖定的內存區域。JVM內部實現了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是除了偏向鎖,JVM實現鎖的方式都用了循環CAS,即當一個線程想進入同步塊的時候使用循環CAS的方式來獲取鎖,當它退出同步塊的時候使用循環CAS釋放鎖。

總結

我們一起學習volatile、synchronized和原子操作的實現原理。Java中的大部分容器和框架都依賴於volatile和原子操作的實現原理,瞭解這些原理對我們進行併發編程會更有幫助

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