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