java 单例 读写锁_你用对锁了吗?浅谈 Java “锁” 事
每個時代,都不會虧待會學習的人
大家好,我是yes。
本來打算繼續(xù)寫消息隊列的東西的,但是最近在帶新同事,發(fā)現(xiàn)新同事對于鎖這方面有一些誤解,所以今天就來談談“鎖”事和 Java 中的并發(fā)安全容器使用有哪些注意點。
不過在這之前還是得先來盤一盤為什么需要鎖這玩意,這得從并發(fā) BUG 的源頭說起。
并發(fā) BUG 的源頭
這個問題我 19 年的時候?qū)戇^一篇文章, 現(xiàn)在回頭看那篇文章真的是羞澀啊。
讓我們來看下這個源頭是什么,我們知道電腦有CPU、內(nèi)存、硬盤,硬盤的讀取速度最慢,其次是內(nèi)存的讀取,內(nèi)存的讀取相對于 CPU 的運行又太慢了,因此又搞了個CPU緩存,L1、L2、L3。
正是這個CPU緩存再加上現(xiàn)在多核CPU的情況產(chǎn)生了并發(fā)BUG。
這就一個很簡單的代碼,如果此時有線程 A 和線程 B 分別在 CPU - A 和 CPU - B 中執(zhí)行這個方法,它們的操作是先將 a 從主存取到 CPU 各自的緩存中,此時它們緩存中 a 的值都是 0。
然后它們分別執(zhí)行 a++,此時它們各自眼中 a 的值都是 1,之后把 a 刷到主存的時候 a 的值還是1,這就出現(xiàn)問題了,明明執(zhí)行了兩次加一最終的結(jié)果卻是 1,而不是 2。
這個問題就叫可見性問題。
在看我們 a++ 這條語句,我們現(xiàn)在的語言都是高級語言,這其實和語法糖很類似,用起來好像很方便實際上那只是表面,真正需要執(zhí)行的指令一條都少不了。
高級語言的一條語句翻譯成 CPU 指令的時候可不止一條, 就例如 a++ 轉(zhuǎn)換成 CPU 指令至少就有三條。把 a 從內(nèi)存拿到寄存器中;
在寄存器中 +1;
將結(jié)果寫入緩存或內(nèi)存中;
所以我們以為 a++ 這條語句是不可能中斷的是具備原子性的,而實際上 CPU 可以能執(zhí)行一條指令時間片就到了,此時上下文切換到另一個線程,它也執(zhí)行 a++。再次切回來的時候 a 的值其實就已經(jīng)不對了。
這個問題叫做原子性問題。
并且編譯器或解釋器為了優(yōu)化性能,可能會改變語句的執(zhí)行順序,這叫指令重排,最經(jīng)典的例子莫過于單例模式的雙重檢查了。而 CPU 為了提高執(zhí)行效率,還會亂序執(zhí)行,例如 CPU 在等待內(nèi)存數(shù)據(jù)加載的時候發(fā)現(xiàn)后面的加法指令不依賴前面指令的計算結(jié)果,因此它就先執(zhí)行了這條加法指令。
這個問題就叫有序性問題。
至此已經(jīng)分析完了并發(fā) BUG 的源頭,即這三大問題。可以看到不管是 CPU 緩存、多核 CPU 、高級語言還是亂序重排其實都是必要的存在,所以我們只能直面這些問題。
而解決這些問題就是通過禁用緩存、禁止編譯器指令重排、互斥等手段,今天我們的主題和互斥相關(guān)。
互斥就是保證對共享變量的修改是互斥的,即同一時刻只有一個線程在執(zhí)行。而說到互斥相信大家腦海中浮現(xiàn)的就是鎖。沒錯,我們今天的主題就是鎖!鎖就是為了解決原子性問題。
鎖
說到鎖可能 Java 的同學第一反應就是 synchronized 關(guān)鍵字,畢竟是語言層面支持的。我們就先來看看 synchronized,有些同學對 synchronized 理解不到位所以用起來會有很多坑。
synchronized 注意點
我們先來看一份代碼,這段代碼就是咱們的漲工資之路,最終百萬是灑灑水的。而一個線程時刻的對比著我們工資是不是相等的。我簡單說一下IntStream.rangeClosed(1,1000000).forEach,可能有些人對這個不太熟悉,這個代碼的就等于 for 循環(huán)了100W次。
你先自己理解下,看看覺得有沒有什么問題?第一反應好像沒問題,你看著漲工資就一個線程執(zhí)行著,這比工資也沒有修改值,看起來好像沒啥毛病?沒有啥并發(fā)資源的競爭,也用 volatile 修飾了保證了可見性。
讓我們來看一下結(jié)果,我截取了一部分。
可以看到首先有 log 打出來就已經(jīng)不對了,其次打出來的值竟然還相等!有沒有出乎你的意料之外?有同學可能下意識就想到這就raiseSalary在修改,所以肯定是線程安全問題來給raiseSalary?加個鎖!
請注意只有一個線程在調(diào)用raiseSalary方法,所以單給raiseSalary方法加鎖并沒啥用。
這其實就是我上面提到的原子性問題,想象一下漲工資線程在執(zhí)行完yesSalary++還未執(zhí)行yourSalary++時,比工資線程剛好執(zhí)行到y(tǒng)esSalary != yourSalary?是不是肯定是 true ?所以才會打印出 log。
再者由于用 volatile 修飾保證了可見性,所以當打 log 的時候,可能yourSalary++已經(jīng)執(zhí)行完了,這時候打出來的 log 才會是yesSalary == yourSalary。
所以最簡單的解決辦法就是把raiseSalary()?和?compareSalary()?都用 synchronized 修飾,這樣漲工資和比工資兩個線程就不會在同一時刻執(zhí)行,因此肯定就安全了!
看起來鎖好像也挺簡單,不過這個 synchronized 的使用還是對于新手來說還是有坑的,就是你要關(guān)注 synchronized 鎖的究竟是什么。
比如我改成多線程來漲工資。這里再提一下parallel,這個其實就是利用了 ForkJoinPool 線程池操作,默認線程數(shù)是 CPU 核心數(shù)。
由于?raiseSalary()?加了鎖,所以最終的結(jié)果是對的。這是因為 synchronized 修飾的是yesLockDemo實例,我們的 main 中只有一個實例,所以等于多線程競爭的是一把鎖,所以最終計算出來的數(shù)據(jù)正確。
那我再修改下代碼,讓每個線程自己有一個 yesLockDemo 實例來漲工資。
你會發(fā)現(xiàn)這鎖怎么沒用了?這說好的百萬年薪我就變 10w 了??這你還好還有 70w。
這是因為此時我們的鎖修飾的是非靜態(tài)方法,是實例級別的鎖,而我們?yōu)槊總€線程都創(chuàng)建了一個實例,因此這幾個線程競爭的就根本不是一把鎖,而上面多線程計算正確代碼是因為每個線程用的是同一個實例,所以競爭的是一把鎖。如果想要此時的代碼正確,只需要把實例級別的鎖變成類級別的鎖。
很簡單只需要把這個方法變成靜態(tài)方法,synchronized 修飾靜態(tài)方法就是類級別的鎖。
還有一種就是聲明一個靜態(tài)變量,比較推薦這種,因為把非靜態(tài)方法變成靜態(tài)方法其實就等于改了代碼結(jié)構(gòu)了。
我們來小結(jié)一下,使用 synchronized 的時候需要注意鎖的到底是什么,如果修飾靜態(tài)字段和靜態(tài)方法那就是類級別的鎖,如果修飾非靜態(tài)字段和非靜態(tài)方法就是實例級別的鎖。
鎖的粒度
相信大家知道 Hashtable 不被推薦使用,要用就用 ConcurrentHashMap,是因為 Hashtable 雖然是線程安全的,但是它太粗暴了,它為所有的方法都上了同一把鎖!我們來看下源碼。
你說這 contains 和 size 方法有啥關(guān)系? 我在調(diào)用 contains 的時候憑啥不讓我調(diào) size ? 這就是鎖的粒度太粗了我們得評估一下,不同的方法用不同的鎖,這樣才能在線程安全的情況下再提高并發(fā)度。
但是不同方法不同鎖還不夠的,因為有時候一個方法里面有些操作其實是線程安全的,只有涉及競爭競態(tài)資源的那一段代碼才需要加鎖。特別是不需要鎖的代碼很耗時的情況,就會長時間占著這把鎖,而且其他線程只能排隊等著,比如下面這段代碼。
很明顯第二段代碼才是正常的使用鎖的姿勢,不過在平時的業(yè)務代碼中可不是像我代碼里貼的 sleep 這么容易一眼就看出的,有時候還需要修改代碼執(zhí)行的順序等等來保證鎖的粒度足夠細。
而有時候又需要保證鎖足夠的粗,不過這部分JVM會檢測到,它會幫我們做優(yōu)化,比如下面的代碼。
可以看到明明是一個方法里面調(diào)用的邏輯卻經(jīng)歷了加鎖-執(zhí)行A-解鎖-加鎖-執(zhí)行B-解鎖,很明顯的可以看出其實只需要經(jīng)歷加鎖-執(zhí)行A-執(zhí)行B-解鎖。
所以 JVM 會在即時編譯的時候做鎖的粗化,將鎖的范圍擴大,類似變成下面的情況。
而且 JVM 還會有鎖消除的動作,通過逃逸分析判斷實例對象是線程私有的,那么肯定是線程安全的,于是就會忽略對象里面的加鎖動作,直接調(diào)用。
讀寫鎖
讀寫鎖就是我們上面提交的根據(jù)場景減小鎖的粒度了,把一個鎖拆成了讀鎖和寫鎖,特別適合在讀多寫少的情況下使用,例如自己實現(xiàn)的一個緩存。
ReentrantReadWriteLock
讀寫鎖允許多個線程同時讀共享變量,但是寫操作是互斥的,即寫寫互斥、讀寫互斥。講白了就是寫的時候就只能一個線程寫,其他線程也讀不了也寫不了。
我們來看個小例子,里面也有個小細節(jié)。這段代碼就是模擬緩存的讀取,先上讀鎖去緩存拿數(shù)據(jù),如果緩存沒數(shù)據(jù)則釋放讀鎖,再上寫鎖去數(shù)據(jù)庫取數(shù)據(jù),然后塞入緩存中返回。
這里面的小細節(jié)就是再次判斷?data = getFromCache()?是否有值,因為同一時刻可能會有多個線程調(diào)用getData(),然后緩存都為空因此都去競爭寫鎖,最終只有一個線程會先拿到寫鎖,然后將數(shù)據(jù)又塞入緩存中。
此時等待的線程最終一個個的都會拿到寫鎖,獲取寫鎖的時候其實緩存里面已經(jīng)有值了所以沒必要再去數(shù)據(jù)庫查詢。
當然 Lock 的使用范式大家都知道,需要用?try- finally,來保證一定會解鎖。而讀寫鎖還有一個要點需要注意,也就是說鎖不能升級。什么意思呢?我改一下上面的代碼。
但是寫鎖內(nèi)可以再用讀鎖,來實現(xiàn)鎖的降級,有些人可能會問了這寫鎖都加了還要什么讀鎖。
還是有點用處的,比如某個線程搶到了寫鎖,在寫的動作要完畢的時候加上讀鎖,接著釋放了寫鎖,此時它還持有讀鎖可以保證能馬上使用寫鎖操作完的數(shù)據(jù),而別的線程也因為此時寫鎖已經(jīng)沒了也能讀數(shù)據(jù)。
其實就是當前已經(jīng)不需要寫鎖這種比較霸道的鎖!所以來降個級讓大家都能讀。
小結(jié)一下,讀寫鎖適用于讀多寫少的情況,無法升級,但是可以降級。Lock 的鎖需要配合?try- finally,來保證一定會解鎖。
對了,我再稍稍提一下讀寫鎖的實現(xiàn),熟悉 AQS 的同學可能都知道里面的 state ,讀寫鎖就是將這個 int 類型的 state 分成了兩半,高 16 位與低 16 位分別記錄讀鎖和寫鎖的狀態(tài)。它和普通的互斥鎖的區(qū)別就在于要維護這兩個狀態(tài)和在等待隊列處區(qū)別處理這兩種鎖。
所以在不適用于讀寫鎖的場景還不如直接用互斥鎖,因為讀寫鎖還需要對state進行位移判斷等等操作。
StampedLock
這玩意我也稍微提一下,是 1.8 提出來的出鏡率似乎沒有 ReentrantReadWriteLock 高。它支持寫鎖、悲觀讀鎖和樂觀讀。寫鎖和悲觀讀鎖其實和 ReentrantReadWriteLock 里面的讀寫鎖是一致的,它就多了個樂觀讀。
從上面的分析我們知道讀寫鎖在讀的時候其實是無法寫的,而 StampedLock 的樂觀讀則允許一個線程寫。樂觀讀其實就是和我們知道的數(shù)據(jù)庫樂觀鎖一樣,數(shù)據(jù)庫的樂觀鎖例如通過一個version字段來判斷,例如下面這條 sql。
StampedLock 樂觀讀就是與其類似,我們來看一下簡單的用法。
它與 ReentrantReadWriteLock 對比也就強在這里,其他的不行,比如 StampedLock 不支持重入,不支持條件變量。還有一點使用 StampedLock 一定不要調(diào)用中斷操作,因為會導致CPU 100%,我跑了下并發(fā)編程網(wǎng)上面提供的例子,復現(xiàn)了。
具體的原因這里不再贅述,文末會貼上鏈接,上面說的很詳細了。
所以出來一個看似好像很厲害的東西,你需要真正的去理解它,熟悉它才能做到有的放矢。
CopyOnWrite
寫時復制的在很多地方也會用到,比如進程?fork()?操作。對于我們業(yè)務代碼層面而言也是很有幫助的,在于它的讀操作不會阻塞寫,寫操作也不會阻塞讀。適用于讀多寫少的場景。
例如 Java 中的實現(xiàn)?CopyOnWriteArrayList,有人可能一聽,這玩意線程安全讀的時候還不會阻塞寫,好家伙就用它了!
你得先搞清楚,寫時復制是會拷貝一份數(shù)據(jù),你的任何一個修改動作在CopyOnWriteArrayList?中都會觸發(fā)一次Arrays.copyOf,然后在副本上修改。假如修改的動作很多,并且拷貝的數(shù)據(jù)也很大,這將是災難!
并發(fā)安全容器
最后再來談一下并發(fā)安全容器的使用,我就拿相對而言大家比較熟悉的 ConcurrentHashMap 來作為例子。我看新來的同事好像認為只要是使用并發(fā)安全容器一定就是線程安全了。其實不盡然,還得看怎么用。
我們先來看下以下的代碼,簡單的說就是利用 ConcurrentHashMap 來記錄每個人的工資,最多就記錄 100 個。
最終的結(jié)果都會超標,即 map 里面不僅僅只記錄了100個人。那怎么樣結(jié)果才會是對的?很簡單就是加個鎖。
看到這有人說,你這都加鎖了我還用啥 ConcurrentHashMap ,我 HashMap 加個鎖也能完事!是的你說的沒錯!因為當前我們的使用場景是復合型操作,也就是我們先拿 map 的 size 做了判斷,然后再執(zhí)行了 put 方法,ConcurrentHashMap?無法保證復合型的操作是線程安全的!
而 ConcurrentHashMap 合適只是用其暴露出來的線程安全的方法,而不是復合操作的情況下。比如以下代碼
當然,我這個例子不夠恰當其實,因為 ConcurrentHashMap 性能比 HashMap + 鎖高的原因在于分段鎖,需要多個 key 操作才能體現(xiàn)出來,不過我想突出的重點是使用的時候不能大意,不能純粹的認為用了就線程安全了。
總結(jié)一下
今天談了談并發(fā) BUG 的源頭,即三大問題:可見性問題、原子性問題和有序性問題。然后簡單的說了下 synchronized 關(guān)鍵字的注意點,即修飾靜態(tài)字段或者靜態(tài)方法是類層面的鎖,而修飾非靜態(tài)字段和非靜態(tài)方法是實例層面的類。
再說了下鎖的粒度,在不同場景定義不同的鎖不能粗暴的一把鎖搞定,并且方法內(nèi)部鎖的粒度要細。例如在讀多寫少的場景可以使用讀寫鎖、寫時復制等。
最終要正確的使用并發(fā)安全容器,不能一味的認為使用并發(fā)安全容器就一定線程安全了,要注意復合操作的場景。
當然我今天只是淺淺的談了一下,關(guān)于并發(fā)編程其實還有很多點,要寫出線程安全的代碼不是一件容易的事情,就像我之前分析的 Kafka 事件處理全流程一樣,原先的版本就是各種鎖控制并發(fā)安全,到后來bug根本修不動,多線程編程難,調(diào)試也難,修bug也難。
因此 Kafka 事件處理模塊最終改成了單線程事件隊列模式,將涉及到共享數(shù)據(jù)競爭相關(guān)方面的訪問抽象成事件,將事件塞入阻塞隊列中,然后單線程處理。
所以在用鎖之前我們要先想想,有必要么?能簡化么?不然之后維護起來有多痛苦到時候你就知道了。
最后
之后繼續(xù)開始寫消息隊列相關(guān)的包括 RocketMQ 和 Kafka,有不少同學在后臺留言想和我深入的交流一下,發(fā)生點關(guān)系,我把公眾號菜單加了個聯(lián)系我,有需求的小伙伴可以加我微信。
掃碼可關(guān)注我的公眾號哦~
總結(jié)
以上是生活随笔為你收集整理的java 单例 读写锁_你用对锁了吗?浅谈 Java “锁” 事的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: php生日验证,PHP验证生日
- 下一篇: java连接access_关于k8s下使