你用對鎖了嗎?淺談 Java 「鎖」 事

每一個時代,都不會虧待會學習的人web

你們好,我是yes。sql

原本打算繼續寫消息隊列的東西的,可是最近在帶新同事,發現新同事對於鎖這方面有一些誤解,因此今天就來談談「鎖」事和 Java 中的併發安全容器使用有哪些注意點。數據庫

不過在這以前仍是得先來盤一盤爲何須要鎖這玩意,這得從併發 BUG 的源頭提及。編程

併發 BUG 的源頭

這個問題我 19 年的時候寫過一篇文章, 如今回頭看那篇文章真的是羞澀啊。緩存

讓咱們來看下這個源頭是什麼,咱們知道電腦有CPU、內存、硬盤,硬盤的讀取速度最慢,其次是內存的讀取,內存的讀取相對於 CPU 的運行又太慢了,所以又搞了個CPU緩存,L一、L二、L3。安全

正是這個CPU緩存再加上如今多核CPU的狀況產生了併發BUG微信

這就一個很簡單的代碼,若是此時有線程 A 和線程 B 分別在 CPU - A 和 CPU - B 中執行這個方法,它們的操做是先將 a 從主存取到 CPU 各自的緩存中,此時它們緩存中 a 的值都是 0。多線程

而後它們分別執行 a++,此時它們各自眼中 a 的值都是 1,以後把 a 刷到主存的時候 a 的值仍是1,這就出現問題了,明明執行了兩次加一最終的結果倒是 1,而不是 2。併發

這個問題就叫可見性問題編輯器

在看咱們 a++ 這條語句,咱們如今的語言都是高級語言,這其實和語法糖很相似,用起來好像很方便實際上那只是表面,真正須要執行的指令一條都少不了。

高級語言的一條語句翻譯成 CPU 指令的時候可不止一條, 就例如 a++ 轉換成 CPU 指令至少就有三條。

  • 把 a 從內存拿到寄存器中;

  • 在寄存器中 +1;

  • 將結果寫入緩存或內存中;

因此咱們覺得 a++ 這條語句是不可能中斷的是具有原子性的,而實際上 CPU 能夠能執行一條指令時間片就到了,此時上下文切換到另外一個線程,它也執行 a++。再次切回來的時候 a 的值其實就已經不對了。

這個問題叫作原子性問題

而且編譯器或解釋器爲了優化性能,可能會改變語句的執行順序,這叫指令重排,最經典的例子莫過於單例模式的雙重檢查了。而 CPU 爲了提升執行效率,還會亂序執行,例如 CPU 在等待內存數據加載的時候發現後面的加法指令不依賴前面指令的計算結果,所以它就先執行了這條加法指令。

這個問題就叫有序性問題

至此已經分析完了併發 BUG 的源頭,即這三大問題。能夠看到無論是 CPU 緩存、多核 CPU 、高級語言仍是亂序重排其實都是必要的存在,因此咱們只能直面這些問題。

而解決這些問題就是經過禁用緩存、禁止編譯器指令重排、互斥等手段,今天咱們的主題和互斥相關。

互斥就是保證對共享變量的修改是互斥的,即同一時刻只有一個線程在執行。而說到互斥相信你們腦海中浮現的就是。沒錯,咱們今天的主題就是鎖!鎖就是爲了解決原子性問題。

說到鎖可能 Java 的同窗第一反應就是 synchronized 關鍵字,畢竟是語言層面支持的。咱們就先來看看 synchronized,有些同窗對 synchronized 理解不到位因此用起來會有不少坑。

synchronized 注意點

咱們先來看一份代碼,這段代碼就是我們的漲工資之路,最終百萬是灑灑水的。而一個線程時刻的對比着咱們工資是否是相等的。我簡單說一下IntStream.rangeClosed(1,1000000).forEach,可能有些人對這個不太熟悉,這個代碼的就等於 for 循環了100W次。

你先本身理解下,看看以爲有沒有什麼問題?第一反應好像沒問題,你看着漲工資就一個線程執行着,這比工資也沒有修改值,看起來好像沒啥毛病?沒有啥併發資源的競爭,也用 volatile 修飾了保證了可見性。

讓咱們來看一下結果,我截取了一部分。

能夠看到首先有 log 打出來就已經不對了,其次打出來的值居然還相等!有沒有出乎你的意料以外?有同窗可能下意識就想到這就raiseSalary在修改,因此確定是線程安全問題來給raiseSalary 加個鎖!

請注意只有一個線程在調用raiseSalary方法,因此單給raiseSalary方法加鎖並沒啥用。

這其實就是我上面提到的原子性問題,想象一下漲工資線程在執行完yesSalary++還未執行yourSalary++時,比工資線程恰好執行到yesSalary != yourSalary 是否是確定是 true ?因此纔會打印出 log。

再者因爲用 volatile 修飾保證了可見性,因此當打 log 的時候,可能yourSalary++已經執行完了,這時候打出來的 log 纔會是yesSalary == yourSalary

因此最簡單的解決辦法就是把raiseSalary()compareSalary() 都用 synchronized 修飾,這樣漲工資和比工資兩個線程就不會在同一時刻執行,所以確定就安全了!

看起來鎖好像也挺簡單,不過這個 synchronized 的使用仍是對於新手來講仍是有坑的,就是你要關注 synchronized 鎖的到底是什麼。

好比我改爲多線程來漲工資。這裏再提一下parallel,這個其實就是利用了 ForkJoinPool 線程池操做,默認線程數是 CPU 核心數。

因爲 raiseSalary() 加了鎖,因此最終的結果是對的。這是由於 synchronized 修飾的是yesLockDemo實例,咱們的 main 中只有一個實例,因此等於多線程競爭的是一把鎖,因此最終計算出來的數據正確。

那我再修改下代碼,讓每一個線程本身有一個 yesLockDemo 實例來漲工資。

你會發現這鎖怎麼沒用了?這說好的百萬年薪我就變 10w 了??這你還好還有 70w。

這是由於此時咱們的鎖修飾的是非靜態方法,是實例級別的鎖,而咱們爲每一個線程都建立了一個實例,所以這幾個線程競爭的就根本不是一把鎖,而上面多線程計算正確代碼是由於每一個線程用的是同一個實例,因此競爭的是一把鎖。若是想要此時的代碼正確,只須要把實例級別的鎖變成類級別的鎖

很簡單隻須要把這個方法變成靜態方法,synchronized  修飾靜態方法就是類級別的鎖

還有一種就是聲明一個靜態變量,比較推薦這種,由於把非靜態方法變成靜態方法其實就等於改了代碼結構了。

咱們來小結一下,使用 synchronized 的時候須要注意鎖的究竟是什麼,若是修飾靜態字段和靜態方法那就是類級別的鎖,若是修飾非靜態字段和非靜態方法就是實例級別的鎖

鎖的粒度

相信你們知道 Hashtable 不被推薦使用,要用就用 ConcurrentHashMap,是由於 Hashtable 雖然是線程安全的,可是它太粗暴了,它爲全部的方法都上了同一把鎖!咱們來看下源碼。

你說這 contains 和 size 方法有啥關係?我在調用 contains 的時候憑啥不讓我調 size ? 這就是鎖的粒度太粗了咱們得評估一下,不一樣的方法用不一樣的鎖,這樣才能在線程安全的狀況下再提升併發度。

可是不一樣方法不一樣鎖還不夠的,由於有時候一個方法裏面有些操做實際上是線程安全的,只有涉及競爭競態資源的那一段代碼才須要加鎖。特別是不須要鎖的代碼很耗時的狀況,就會長時間佔着這把鎖,並且其餘線程只能排隊等着,好比下面這段代碼。

很明顯第二段代碼纔是正常的使用鎖的姿式,不過在平時的業務代碼中可不是像我代碼裏貼的 sleep 這麼容易一眼就看出的,有時候還須要修改代碼執行的順序等等來保證鎖的粒度足夠細

而有時候又須要保證鎖足夠的粗,不過這部分JVM會檢測到,它會幫咱們作優化,好比下面的代碼。

能夠看到明明是一個方法裏面調用的邏輯卻經歷了加鎖-執行A-解鎖-加鎖-執行B-解鎖,很明顯的能夠看出其實只須要經歷加鎖-執行A-執行B-解鎖

因此 JVM 會在即時編譯的時候作鎖的粗化,將鎖的範圍擴大,相似變成下面的狀況。

並且 JVM 還會有鎖消除的動做,經過逃逸分析判斷實例對象是線程私有的,那麼確定是線程安全的,因而就會忽略對象裏面的加鎖動做,直接調用。

讀寫鎖

讀寫鎖就是咱們上面提交的根據場景減少鎖的粒度了,把一個鎖拆成了讀鎖和寫鎖,特別適合在讀多寫少的狀況下使用,例如本身實現的一個緩存。

ReentrantReadWriteLock

讀寫鎖容許多個線程同時讀共享變量,可是寫操做是互斥的,即寫寫互斥、讀寫互斥。講白了就是寫的時候就只能一個線程寫,其餘線程也讀不了也寫不了。

咱們來看個小例子,裏面也有個小細節。這段代碼就是模擬緩存的讀取,先上讀鎖去緩存拿數據,若是緩存沒數據則釋放讀鎖,再上寫鎖去數據庫取數據,而後塞入緩存中返回。

這裏面的小細節就是再次判斷 data = getFromCache() 是否有值,由於同一時刻可能會有多個線程調用getData(),而後緩存都爲空所以都去競爭寫鎖,最終只有一個線程會先拿到寫鎖,而後將數據又塞入緩存中。

此時等待的線程最終一個個的都會拿到寫鎖,獲取寫鎖的時候其實緩存裏面已經有值了因此不必再去數據庫查詢。

固然 Lock 的使用範式你們都知道,須要用 try- finally,來保證必定會解鎖。而讀寫鎖還有一個要點須要注意,也就是說鎖不能升級。什麼意思呢?我改一下上面的代碼。

可是寫鎖內能夠再用讀鎖,來實現鎖的降級,有些人可能會問了這寫鎖都加了還要什麼讀鎖。

仍是有點用處的,好比某個線程搶到了寫鎖,在寫的動做要完畢的時候加上讀鎖,接着釋放了寫鎖,此時它還持有讀鎖能夠保證能立刻使用寫鎖操做完的數據,而別的線程也由於此時寫鎖已經沒了也能讀數據

其實就是當前已經不須要寫鎖這種比較霸道的鎖!因此來降個級讓你們都能讀。

小結一下,讀寫鎖適用於讀多寫少的狀況,沒法升級,可是能夠降級。Lock 的鎖須要配合 try- finally,來保證必定會解鎖。

對了,我再稍稍提一下讀寫鎖的實現,熟悉 AQS 的同窗可能都知道里面的 state ,讀寫鎖就是將這個 int 類型的 state 分紅了兩半,高 16 位與低 16 位分別記錄讀鎖和寫鎖的狀態。它和普通的互斥鎖的區別就在於要維護這兩個狀態和在等待隊列處區別處理這兩種鎖

因此在不適用於讀寫鎖的場景還不如直接用互斥鎖,由於讀寫鎖還須要對state進行位移判斷等等操做。

StampedLock

這玩意我也稍微提一下,是 1.8 提出來的出鏡率彷佛沒有 ReentrantReadWriteLock 高。它支持寫鎖、悲觀讀鎖和樂觀讀。寫鎖和悲觀讀鎖其實和 ReentrantReadWriteLock 裏面的讀寫鎖是一致的,它就多了個樂觀讀。

從上面的分析咱們知道讀寫鎖在讀的時候實際上是沒法寫的,而 StampedLock 的樂觀讀則容許一個線程寫。樂觀讀其實就是和咱們知道的數據庫樂觀鎖同樣,數據庫的樂觀鎖例如經過一個version字段來判斷,例以下面這條 sql。

StampedLock 樂觀讀就是與其相似,咱們來看一下簡單的用法。

它與 ReentrantReadWriteLock 對比也就強在這裏,其餘的不行,好比 StampedLock 不支持重入,不支持條件變量。還有一點使用 StampedLock 必定不要調用中斷操做,由於會致使CPU 100%,我跑了下併發編程網上面提供的例子,復現了。

具體的緣由這裏再也不贅述,文末會貼上連接,上面說的很詳細了。

因此出來一個看似好像很厲害的東西,你須要真正的去理解它,熟悉它才能作到有的放矢。

CopyOnWrite

寫時複製的在不少地方也會用到,好比進程 fork() 操做。對於咱們業務代碼層面而言也是頗有幫助的,在於它的讀操做不會阻塞寫,寫操做也不會阻塞讀。適用於讀多寫少的場景。

例如 Java 中的實現 CopyOnWriteArrayList,有人可能一聽,這玩意線程安全讀的時候還不會阻塞寫,好傢伙就用它了!

你得先搞清楚,寫時複製是會拷貝一份數據,你的任何一個修改動做在CopyOnWriteArrayList 中都會觸發一次Arrays.copyOf,而後在副本上修改。假如修改的動做不少,而且拷貝的數據也很大,這將是災難!

併發安全容器

最後再來談一下併發安全容器的使用,我就拿相對而言你們比較熟悉的 ConcurrentHashMap 來做爲例子。我看新來的同事好像認爲只要是使用併發安全容器必定就是線程安全了。其實不盡然,還得看怎麼用。

咱們先來看下如下的代碼,簡單的說就是利用 ConcurrentHashMap 來記錄每一個人的工資,最多就記錄 100 個。

最終的結果都會超標,即 map 裏面不只僅只記錄了100我的。那怎麼樣結果纔會是對的?很簡單就是加個鎖。

看到這有人說,你這都加鎖了我還用啥 ConcurrentHashMap ,我 HashMap 加個鎖也能完事!是的你說的沒錯!由於當前咱們的使用場景是複合型操做,也就是咱們先拿 map 的 size 作了判斷,而後再執行了 put 方法,ConcurrentHashMap 沒法保證複合型的操做是線程安全的!

而 ConcurrentHashMap 合適只是用其暴露出來的線程安全的方法,而不是複合操做的狀況下。好比如下代碼

固然,我這個例子不夠恰當其實,由於 ConcurrentHashMap 性能比 HashMap + 鎖高的緣由在於分段鎖,須要多個 key 操做才能體現出來,不過我想突出的重點是使用的時候不能大意,不能純粹的認爲用了就線程安全了。

總結一下

今天談了談併發 BUG 的源頭,即三大問題:可見性問題、原子性問題和有序性問題。而後簡單的說了下 synchronized 關鍵字的注意點,即修飾靜態字段或者靜態方法是類層面的鎖,而修飾非靜態字段和非靜態方法是實例層面的類。

再說了下鎖的粒度,在不一樣場景定義不一樣的鎖不能粗暴的一把鎖搞定,而且方法內部鎖的粒度要細。例如在讀多寫少的場景可使用讀寫鎖、寫時複製等。

最終要正確的使用併發安全容器,不能一味的認爲使用併發安全容器就必定線程安全了,要注意複合操做的場景。

固然我今天只是淺淺的談了一下,關於併發編程其實還有不少點,要寫出線程安全的代碼不是一件容易的事情,就像我以前分析的 Kafka 事件處理全流程同樣,原先的版本就是各類鎖控制併發安全,到後來bug根本修不動,多線程編程難,調試也難,修bug也難。

所以 Kafka 事件處理模塊最終改爲了單線程事件隊列模式將涉及到共享數據競爭相關方面的訪問抽象成事件,將事件塞入阻塞隊列中,而後單線程處理

因此在用鎖以前咱們要先想一想,有必要麼?能簡化麼?否則以後維護起來有多痛苦到時候你就知道了。

最後

以後繼續開始寫消息隊列相關的包括 RocketMQ 和 Kafka,有很多同窗在後臺留言想和我深刻的交流一下,發生點關係,我把公衆號菜單加了個聯繫我,有需求的小夥伴能夠加我微信。

StampedLock bug 的那個連接:http://ifeve.com/stampedlock-bug-cpu/

本文分享自微信公衆號 - 編碼以外(ithuangqing)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索