讓人討厭的多線程代碼,性能怎么優(yōu)化!
Java 中最煩人的,就是多線程,一不小心,代碼寫(xiě)的比單線程還慢,這就讓人非常尷尬。
通常情況下,我們會(huì)使用 ThreadLocal 實(shí)現(xiàn)線程封閉,比如避免 SimpleDateFormat 在并發(fā)環(huán)境下所引起的一些不一致情況。其實(shí)還有一種解決方式。通過(guò)對(duì)parse方法進(jìn)行加鎖,也能保證日期處理類的正確運(yùn)行,代碼如圖。
1. 鎖很壞
但是,鎖這個(gè)東西,很壞。
所以,鎖對(duì)性能的影響,是非常大的。對(duì)資源加鎖以后,資源就被加鎖的線程所獨(dú)占,其他的線程就只能排隊(duì)等待這個(gè)鎖。此時(shí),程序由并行執(zhí)行,變相的變成了順序執(zhí)行,執(zhí)行速度自然就降低了。
下面是開(kāi)啟了50個(gè)線程,使用ThreadLocal和同步鎖方式性能的一個(gè)對(duì)比。
可以看到,使用同步鎖的方式,性能是比較低的。如果去掉業(yè)務(wù)本身邏輯的影響(刪掉執(zhí)行邏輯),這個(gè)差異會(huì)更大。代碼執(zhí)行的次數(shù)越多,鎖的累加影響越大,對(duì)鎖本身的速度優(yōu)化,是非常重要的。
我們都知道,Java 中有兩種加鎖的方式,一種就是常見(jiàn)的synchronized 關(guān)鍵字,另外一種,就是使用 concurrent 包里面的 Lock。針對(duì)于這兩種鎖,JDK 自身做了很多的優(yōu)化,它們的實(shí)現(xiàn)方式也是不同的。
2. synchronied原理
synchronized關(guān)鍵字給代碼或者方法上鎖時(shí),都有顯示的或者隱藏的上鎖對(duì)象。當(dāng)一個(gè)線程試圖訪問(wèn)同步代碼塊時(shí),它首先必須得到鎖,退出或拋出異常時(shí)必須釋放鎖。
- 給普通方法加鎖時(shí),上鎖的對(duì)象是this
- 給靜態(tài)方法加鎖時(shí),鎖的是class對(duì)象。
- 給代碼塊加鎖,可以指定一個(gè)具體的對(duì)象作為鎖
monitor,在操作系統(tǒng)里,其實(shí)就叫做管程。
那么,synchronized 在字節(jié)碼中,是怎么體現(xiàn)的呢?參照下面的代碼,在命令行執(zhí)行??javac?
?,然后再執(zhí)行??javap -v -p?
?,就可以看到它具體的字節(jié)碼??梢钥吹剑谧止?jié)碼的體現(xiàn)上,它只給方法加了一個(gè)flag:??ACC_SYNCHRONIZED?
?。
我們?cè)賮?lái)看下同步代碼塊的字節(jié)碼??梢钥吹剑止?jié)碼是通過(guò)monitorenter和monitorexit兩個(gè)指令進(jìn)行控制的。
這兩者雖然顯示效果不同,但他們都是通過(guò)monitor來(lái)實(shí)現(xiàn)同步的。我們可以通過(guò)下面這張圖,來(lái)看一下monitor的原理。
注意了,下面是面試題目高發(fā)地。
如圖所示,我們可以把運(yùn)行時(shí)的對(duì)象鎖抽象的分成三部分。其中,EntrySet 和WaitSet 是兩個(gè)隊(duì)列,中間虛線部分是當(dāng)前持有鎖的線程。我們可以想象一下線程的執(zhí)行過(guò)程。
當(dāng)?shù)谝粋€(gè)線程到來(lái)時(shí),發(fā)現(xiàn)并沒(méi)有線程持有對(duì)象鎖,它會(huì)直接成為活動(dòng)線程,進(jìn)入 RUNNING 狀態(tài)。
接著又來(lái)了三個(gè)線程,要爭(zhēng)搶對(duì)象鎖。此時(shí),這三個(gè)線程發(fā)現(xiàn)鎖已經(jīng)被占用了,就先進(jìn)入 EntrySet 緩存起來(lái),進(jìn)入 BLOCKED 狀態(tài)。此時(shí),從??jstack?
?命令,可以看到他們展示的信息都是??waiting for monitor entry?
?。
處于活動(dòng)狀態(tài)的線程,執(zhí)行完畢退出了;或者由于某種原因執(zhí)行了wait 方法,釋放了對(duì)象鎖,就會(huì)進(jìn)入 WaitSet 隊(duì)列。這就是在調(diào)用??wait?
?之前,需要先獲得對(duì)象鎖的原因。就像下面的代碼:
此時(shí),jstack顯示的線程狀態(tài)是 WAITING 狀態(tài),而原因是in Object.wait()。
發(fā)生了這兩種情況,都會(huì)造成對(duì)象鎖的釋放。進(jìn)而導(dǎo)致 EntrySet里的線程重新?tīng)?zhēng)搶對(duì)象鎖,成功搶到鎖的線程成為活動(dòng)線程,這是一個(gè)循環(huán)的過(guò)程。
那 WaitSet 中的線程是如何再次被激活的呢?接下來(lái),在某個(gè)地方,執(zhí)行了鎖的 notify 或者 notifyAll 命令,會(huì)造成WaitSet中 的線程,轉(zhuǎn)移到 EntrySet 中,重新進(jìn)行鎖的爭(zhēng)奪。
如此周而復(fù)始,線程就可按順序排隊(duì)執(zhí)行。
3. 分級(jí)鎖
JDK1.8中,synchronized 的速度已經(jīng)有了顯著的提升。那它都做了哪些優(yōu)化呢?答案就是分級(jí)鎖。JVM會(huì)根據(jù)使用情況,對(duì)synchronized 的鎖,進(jìn)行升級(jí),它大體可以按照下面的路徑:偏向鎖->輕量級(jí)鎖->重量級(jí)鎖。
鎖只能升級(jí),不能降級(jí),所以一旦升級(jí)為重量級(jí)鎖,就只能依靠操作系統(tǒng)進(jìn)行調(diào)度。
和鎖升級(jí)關(guān)系最大的就是對(duì)象頭里的 MarkWord,它包含Thread ID、Age、Biased、Tag四個(gè)部分。其中,Biased 有1bit大小,Tag 有2bit,鎖升級(jí)就是靠判斷Thread Id、Biased、Tag等三個(gè)變量值來(lái)進(jìn)行的。
在只有一個(gè)線程使用了鎖的情況下,偏向鎖能夠保證更高的效率。
具體過(guò)程是這樣的。當(dāng)?shù)谝粋€(gè)線程第一次訪問(wèn)同步塊時(shí),會(huì)先檢測(cè)對(duì)象頭Mark Word中的標(biāo)志位Tag是否為01,以此判斷此時(shí)對(duì)象鎖是否處于無(wú)鎖狀態(tài)或者偏向鎖狀態(tài)(匿名偏向鎖)。
01也是鎖默認(rèn)的狀態(tài),線程一旦獲取了這把鎖,就會(huì)把自己的線程ID寫(xiě)到MarkWord中。在其他線程來(lái)獲取這把鎖之前,鎖都處于偏向鎖狀態(tài)。
輕量級(jí)鎖
當(dāng)下一個(gè)線程參與到偏向鎖競(jìng)爭(zhēng)時(shí),會(huì)先判斷 MarkWord 中保存的線程 ID 是否與這個(gè)線程 ID 相等,如果不相等,會(huì)立即撤銷偏向鎖,升級(jí)為輕量級(jí)鎖。
輕量級(jí)鎖的獲取是怎么進(jìn)行的呢?它們使用的是自旋方式。
參與競(jìng)爭(zhēng)的每個(gè)線程,會(huì)在自己的線程棧中生成一個(gè) LockRecord ( LR ),然后每個(gè)線程通過(guò) CAS (自旋)的方式,將鎖對(duì)象頭中的 MarkWord 設(shè)置為指向自己的 LR 的指針,哪個(gè)線程設(shè)置成功,就意味著哪個(gè)線程獲得鎖。
當(dāng)鎖處于輕量級(jí)鎖的狀態(tài)時(shí),就不能夠再通過(guò)簡(jiǎn)單的對(duì)比Tag的值進(jìn)行判斷,每次對(duì)鎖的獲取,都需要通過(guò)自旋。
當(dāng)然,自旋也是面向不存在鎖競(jìng)爭(zhēng)的場(chǎng)景,比如一個(gè)線程運(yùn)行完了,另外一個(gè)線程去獲取這把鎖。但如果自旋失敗達(dá)到一定的次數(shù),鎖就會(huì)膨脹為重量級(jí)鎖。
重量級(jí)鎖
重量級(jí)鎖即為我們對(duì)synchronized的直觀認(rèn)識(shí),這種情況下,線程會(huì)掛起,進(jìn)入到操作系統(tǒng)內(nèi)核態(tài),等待操作系統(tǒng)的調(diào)度,然后再映射回用戶態(tài)。系統(tǒng)調(diào)用是昂貴的,重量級(jí)鎖的名稱由此而來(lái)。
如果系統(tǒng)的共享變量競(jìng)爭(zhēng)非常激烈,鎖會(huì)迅速膨脹到重量級(jí)鎖,這些優(yōu)化就名存實(shí)亡。如果并發(fā)非常嚴(yán)重,可以通過(guò)參數(shù)??-XX:-UseBiasedLocking?
?禁用偏向鎖,理論上會(huì)有一些性能提升,但實(shí)際上并不確定。
4. Lock
在 concurrent 包里,我們能夠發(fā)現(xiàn)ReentrantLock和ReentrantReadWriteLock兩個(gè)類。Reentrant就是可重入的意思,它們和synchronized關(guān)鍵字一樣,都是可重入鎖。
這里有必要解釋一下可重入這個(gè)概念,因?yàn)樵诿嬖嚨臅r(shí)候經(jīng)常被問(wèn)到。它的意思是,一個(gè)線程運(yùn)行時(shí),可以多次獲取同一個(gè)對(duì)象鎖。這是因?yàn)镴ava的鎖是基于線程的,而不是基于調(diào)用的。比如下面這段代碼,由于方法a、b、c鎖的都是當(dāng)前的this,線程在調(diào)用a方法的時(shí)候,就不需要多次獲取對(duì)象鎖。
主要方法
LOCK是基于AQS(AbstractQueuedSynchronizer)實(shí)現(xiàn)的,而AQS 是基于 volitale 和 CAS 實(shí)現(xiàn)的。關(guān)于CAS,我們將在下一課時(shí)講解。
Lock與synchronized的使用方法不同,它需要手動(dòng)加鎖,然后在finally中解鎖。Lock接口比synchronized靈活性要高,我們來(lái)看一下幾個(gè)關(guān)鍵方法。
- lock: lock方法和synchronized沒(méi)什么區(qū)別,如果獲取不到鎖,都會(huì)被阻塞
- tryLock: 此方法會(huì)嘗試獲取鎖,不管能不能獲取到鎖,都會(huì)立即返回,不會(huì)阻塞。它是有返回值的,獲取到鎖就會(huì)返回true
- tryLock(long time, TimeUnit unit):
- lockInterruptibly: 與lock類似,但是可以鎖等待可以被中斷,中斷后返回InterruptedException
一般情況下,使用lock方法就可以。但如果業(yè)務(wù)請(qǐng)求要求響應(yīng)及時(shí),那使用帶超時(shí)時(shí)間的tryLock是更好的選擇:我們的業(yè)務(wù)可以直接返回失敗,而不用進(jìn)行阻塞等待。tryLock這種優(yōu)化手段,采用降低請(qǐng)求成功率的方式,來(lái)保證服務(wù)的可用性,高并發(fā)場(chǎng)景下經(jīng)常被使用。
讀寫(xiě)鎖
但對(duì)于有些業(yè)務(wù)來(lái)說(shuō),使用Lock這種粗粒度的鎖還是太慢了。比如,對(duì)于一個(gè)HashMap來(lái)說(shuō),某個(gè)業(yè)務(wù)是讀多寫(xiě)少的場(chǎng)景,這個(gè)時(shí)候,如果給讀操作也加上和寫(xiě)操作一樣的鎖的話,效率就會(huì)很慢。
ReentrantReadWriteLock是一種讀寫(xiě)分離的鎖,它允許多個(gè)讀線程同時(shí)進(jìn)行,但讀和寫(xiě)、寫(xiě)和寫(xiě)是互斥的。使用方法如下所示,分別獲取讀寫(xiě)鎖,對(duì)寫(xiě)操作加寫(xiě)鎖,對(duì)讀操作加讀鎖,并在finally里釋放鎖即可。
那么,除了ReadWriteLock,我們能有更快的讀寫(xiě)分離模式么?JDK1.8加入了哪個(gè)API?歡迎留言區(qū)評(píng)論。
公平鎖與非公平鎖
我們平常用到的鎖,都是非公平鎖??梢曰剡^(guò)頭來(lái)看一下monitor的原理。當(dāng)持有鎖的線程釋放鎖的時(shí)候,EntrySet里的線程就會(huì)爭(zhēng)搶這把鎖。這個(gè)爭(zhēng)搶的過(guò)程,是隨機(jī)的,也就是說(shuō)你并不知道哪個(gè)線程會(huì)獲取對(duì)象鎖,誰(shuí)搶到了就算誰(shuí)的。
這就有一定的概率,某個(gè)線程總是搶不到鎖,比如,線程通過(guò)setPriority 設(shè)置的比較低的優(yōu)先級(jí)。這個(gè)搶不到鎖的線程,就一直處于??饑餓?
?狀態(tài),這就是??線程饑餓?
?的概念。
公平鎖通過(guò)把隨機(jī)變成有序,可以解決這個(gè)問(wèn)題。synchronized沒(méi)有這個(gè)功能,在Lock中可以通過(guò)構(gòu)造參數(shù)設(shè)置成公平鎖,代碼如下。
由于所有的線程都需要排隊(duì),需要在多核的場(chǎng)景下維護(hù)一個(gè)同步隊(duì)列,在多個(gè)線程爭(zhēng)搶鎖的時(shí)候,吞吐量就很低。下面是20個(gè)并發(fā)之下鎖的JMH測(cè)試結(jié)果,可以看到,非公平鎖比公平鎖性能高出兩個(gè)數(shù)量級(jí)。
5. 鎖的優(yōu)化技巧
死鎖
我們可以先看一下鎖沖突最嚴(yán)重的一種情況:死鎖。下面這段示例代碼,兩個(gè)線程分別持有了對(duì)方所需要的鎖,進(jìn)入了相互等待的狀態(tài),就進(jìn)入了死鎖。面試中手寫(xiě)這段代碼的頻率,還是挺高的。
使用我們上面提到的,帶超時(shí)時(shí)間的tryLock方法,有一方讓步,可以一定程度上避免死鎖。
優(yōu)化技巧
鎖的優(yōu)化理論其實(shí)很簡(jiǎn)單,那就是減少鎖的沖突。無(wú)論是鎖的讀寫(xiě)分離,還是分段鎖,本質(zhì)上都是為了避免多個(gè)線程同時(shí)獲取同一把鎖。我們可以總結(jié)一下優(yōu)化的一般思路:減少鎖的粒度、減少鎖持有的時(shí)間、鎖分級(jí)、鎖分離 、鎖消除、樂(lè)觀鎖、無(wú)鎖等。
減少鎖粒度
通過(guò)減小鎖的粒度,可以將沖突分散,減少?zèng)_突的可能,從而提高并發(fā)量。簡(jiǎn)單來(lái)說(shuō),就是把資源進(jìn)行抽象,針對(duì)每類資源使用單獨(dú)的鎖進(jìn)行保護(hù)。比如下面的代碼,由于list1和list2屬于兩類資源,就沒(méi)必要使用同一個(gè)對(duì)象鎖進(jìn)行處理。
可以創(chuàng)建兩個(gè)不同的鎖,改善情況如下:
減少鎖持有時(shí)間通過(guò)讓鎖資源盡快的釋放,減少鎖持有的時(shí)間,其他線程可更迅速的獲取鎖資源,進(jìn)行其他業(yè)務(wù)的處理。考慮到下面的代碼,由于slowMethod不在鎖的范圍內(nèi),占用的時(shí)間又比較長(zhǎng),可以把它移動(dòng)到synchronized代碼快外面,加速鎖的釋放。
?鎖分級(jí)鎖分級(jí)指的是我們文章開(kāi)始講解的synchronied鎖的鎖升級(jí),屬于JVM的內(nèi)部?jī)?yōu)化。它從偏向鎖開(kāi)始,逐漸會(huì)升級(jí)為輕量級(jí)鎖、重量級(jí)鎖,這個(gè)過(guò)程是不可逆的。
鎖分離我們?cè)谏厦嫣岬降淖x寫(xiě)鎖,就是鎖分離技術(shù)。這是因?yàn)?,讀操作一般是不會(huì)對(duì)資源產(chǎn)生影響的,可以并發(fā)執(zhí)行。寫(xiě)操作和其他操作是互斥的,只能排隊(duì)執(zhí)行。所以讀寫(xiě)鎖適合讀多寫(xiě)少的場(chǎng)景。
鎖消除通過(guò)JIT編譯器,JVM可以消除某些對(duì)象的加鎖操作。舉個(gè)例子,大家都知道StringBuffer和StringBuilder都是做字符串拼接的,而且前者是線程安全的。
但其實(shí),如果這兩個(gè)字符串拼接對(duì)象用在函數(shù)內(nèi),JVM通過(guò)逃逸分析分析這個(gè)對(duì)象的作用范圍就是在本函數(shù)中,就會(huì)把鎖的影響給消除掉。比如下面這段代碼,它和StringBuilder的效果是一樣的。
End
Java中有兩種加鎖方式,一種是使用synchronized關(guān)鍵字,另外一種是concurrent包下面的Lock。本課時(shí),我們?cè)敿?xì)的了解了它們的一些特性,包括實(shí)現(xiàn)原理。下面對(duì)比如下:
類別 | Synchronized | Lock |
實(shí)現(xiàn)方式 | monitor | AQS |
底層細(xì)節(jié) | JVM優(yōu)化 | Java API |
分級(jí)鎖 | 是 | 否 |
功能特性 | 單一 | 豐富 |
鎖分離 | 無(wú) | 讀寫(xiě)鎖 |
鎖超時(shí) | 無(wú) | 帶超時(shí)時(shí)間的tryLock |
可中斷 | 否 | lockInterruptibly |
Lock的功能是比synchronized多的,能夠?qū)€程行為進(jìn)行更細(xì)粒度的控制。但如果只是用最簡(jiǎn)單的鎖互斥功能,建議直接使用synchronized。有兩個(gè)原因:
- synchronized的編程模型更加簡(jiǎn)單,更易于使用
- synchronized引入了偏向鎖,輕量級(jí)鎖等功能,能夠從JVM層進(jìn)行優(yōu)化,同時(shí),JIT編譯器也會(huì)對(duì)它執(zhí)行一些鎖消除動(dòng)作
多線程代碼好寫(xiě),但bug難找,希望你的代碼即干凈又強(qiáng)壯,兼高性能與高可靠于一身。