多線程優(yōu)化血虧教訓(xùn)!這坑 99% 的人都踩過
兄弟們,今天咱來(lái)嘮嘮多線程優(yōu)化這事兒。說起來(lái)都是淚啊,當(dāng)年我在項(xiàng)目里搞多線程優(yōu)化,那叫一個(gè)自信滿滿,覺得自己吃透了Java并發(fā)編程,結(jié)果硬生生踩了一堆坑,差點(diǎn)被領(lǐng)導(dǎo)拎去祭天。咱今天就把這些血虧教訓(xùn)掰開了揉碎了,讓大家少走彎路。
一、線程池參數(shù)拍腦袋設(shè)置?坑你沒商量
剛?cè)胄心菚?huì),聽說線程池能提高效率,嘿,那必須用?。∩蟻?lái)就是newFixedThreadPool(100),心想100個(gè)線程同時(shí)干活,這效率不得起飛?結(jié)果上線沒兩天,服務(wù)器直接卡死,GC日志跟下雨似的嘩嘩往外冒。
1. 問題出在哪?
咱先看看FixedThreadPool的源碼,它用的是無(wú)界隊(duì)列LinkedBlockingQueue。我設(shè)置了100個(gè)核心線程,想著處理100個(gè)任務(wù)夠了吧?可現(xiàn)實(shí)是任務(wù)源源不斷過來(lái),全往隊(duì)列里塞,隊(duì)列無(wú)限增長(zhǎng),內(nèi)存直接爆掉。就好比你開了個(gè)餐廳,雇了100個(gè)服務(wù)員(核心線程),結(jié)果來(lái)了1000個(gè)客人,你讓他們?nèi)诖髲d等著(無(wú)界隊(duì)列),大廳擠爆了也不管,最后只能關(guān)門大吉。
2. 正確姿勢(shì)是啥?
咱得根據(jù)任務(wù)類型來(lái)設(shè)置參數(shù)。要是CPU密集型任務(wù),線程數(shù)一般設(shè)為CPU核心數(shù)+1,為啥加1呢?因?yàn)榫€程切換也需要時(shí)間,多一個(gè)線程可以在某個(gè)線程阻塞時(shí)頂上。要是IO密集型任務(wù),那就可以多設(shè)點(diǎn),比如CPU核心數(shù)*2。而且隊(duì)列最好用有界隊(duì)列,比如ArrayBlockingQueue,防止內(nèi)存溢出。還有拒絕策略,不能默認(rèn)用AbortPolicy,直接拋異常,咱可以用CallerRunsPolicy,讓調(diào)用者線程來(lái)處理任務(wù),給系統(tǒng)一個(gè)緩沖機(jī)會(huì)。
我后來(lái)在一個(gè)文件處理系統(tǒng)里優(yōu)化線程池,根據(jù)服務(wù)器是8核CPU,任務(wù)是IO密集型,設(shè)置核心線程數(shù)為16,最大線程數(shù)32,隊(duì)列大小200,拒絕策略用CallerRunsPolicy,結(jié)果處理速度提升了3倍,內(nèi)存也穩(wěn)定了。
二、鎖濫用:以為加鎖就安全,性能直接撲街
在處理共享資源時(shí),大家都知道要加鎖,可我之前就犯了個(gè)傻,在一個(gè)高頻調(diào)用的方法里,不管三七二十一,直接給整個(gè)方法加了synchronized鎖。想著這下安全了,結(jié)果性能測(cè)試的時(shí)候,吞吐量直接腰斬,線程競(jìng)爭(zhēng)特別激烈。
1. 鎖的粒度沒控制好
整個(gè)方法加鎖,相當(dāng)于把整個(gè)房間都鎖起來(lái),每次只能一個(gè)人進(jìn)去,其他人都得在外面等著。其實(shí)咱可以縮小鎖的范圍,只對(duì)共享資源加鎖。比如有個(gè)訂單處理類,里面有個(gè)共享的訂單號(hào)生成變量,我之前給整個(gè)處理訂單的方法加鎖,后來(lái)改成只在生成訂單號(hào)的代碼塊上加鎖,性能立馬提升了50%。
2. 鎖的類型沒選對(duì)
synchronized是獨(dú)占鎖,競(jìng)爭(zhēng)激烈時(shí)效率不高。咱可以用ReentrantLock,它支持公平鎖和非公平鎖,還能嘗試獲取鎖。比如在一個(gè)高并發(fā)的庫(kù)存扣減場(chǎng)景,用ReentrantLock的tryLock方法,避免線程長(zhǎng)時(shí)間阻塞。還有讀寫鎖ReadWriteLock,讀多寫少的場(chǎng)景用它,讀的時(shí)候可以多個(gè)線程同時(shí)讀,寫的時(shí)候才加鎖,效率杠杠的。
我之前在一個(gè)緩存系統(tǒng)里,用synchronized來(lái)控制對(duì)緩存的讀寫,結(jié)果讀操作都被阻塞了。換成ReadWriteLock后,讀操作的吞吐量提升了80%,寫操作雖然稍微慢了點(diǎn),但整體性能大幅提升。
三、盲目追求高并發(fā):線程越多越好?圖樣圖森破
那時(shí)候總覺得線程越多,并行度越高,性能就越好。于是在一個(gè)任務(wù)處理系統(tǒng)里,開了200個(gè)線程去處理任務(wù),結(jié)果任務(wù)處理速度不僅沒提升,反而下降了,CPU利用率倒是100%,但系統(tǒng)就是卡得不行。
1. 上下文切換惹的禍
CPU核心數(shù)是有限的,比如8核CPU,同時(shí)只能運(yùn)行8個(gè)線程。開了200個(gè)線程,CPU就得在這200個(gè)線程之間頻繁切換,每次切換都需要保存當(dāng)前線程的狀態(tài),加載下一個(gè)線程的狀態(tài),這就叫上下文切換。大量的上下文切換消耗了大量的CPU資源,真正處理任務(wù)的時(shí)間反而少了。就好比你雇了200個(gè)工人,但只有8臺(tái)機(jī)器,工人要不斷地?fù)寵C(jī)器,搶來(lái)?yè)屓?,真正干活的時(shí)間沒多少。
2. 怎么確定合適的線程數(shù)?
咱可以用一個(gè)公式:線程數(shù) = CPU核心數(shù) * (1 + 平均等待時(shí)間 / 平均工作時(shí)間)。比如一個(gè)任務(wù),平均工作時(shí)間是1ms,平均等待IO的時(shí)間是9ms,那么線程數(shù) = 8 * (1 + 9/1) = 80。這樣可以讓CPU在等待IO的時(shí)候,去處理其他線程的任務(wù),提高利用率。
后來(lái)我在那個(gè)任務(wù)處理系統(tǒng)里,把線程數(shù)從200降到80,結(jié)果任務(wù)處理速度提升了2倍,CPU利用率也降到了合理范圍,系統(tǒng)終于不卡了。
四、偽共享:緩存行對(duì)齊沒考慮,性能偷偷溜走
這是個(gè)比較隱蔽的坑,我也是在做性能分析的時(shí)候,通過工具才發(fā)現(xiàn)的。有兩個(gè)共享變量,本來(lái)以為沒啥關(guān)系,結(jié)果它們被放到同一個(gè)緩存行里,導(dǎo)致頻繁的緩存失效,性能下降。
1. 啥是偽共享?
CPU緩存是以緩存行為單位的,通常是64字節(jié)。如果兩個(gè)不同的變量被放到同一個(gè)緩存行里,當(dāng)一個(gè)線程修改了其中一個(gè)變量,另一個(gè)變量所在的緩存行也會(huì)失效,導(dǎo)致另一個(gè)線程不得不從主內(nèi)存重新讀取數(shù)據(jù)。比如有兩個(gè)long類型的變量,每個(gè)8字節(jié),放在一起就是16字節(jié),一個(gè)緩存行可以放8個(gè)這樣的變量。如果兩個(gè)線程分別修改這兩個(gè)變量,就會(huì)導(dǎo)致緩存行頻繁失效。
2. 怎么解決?
咱可以在變量之間填充一些無(wú)用的變量,讓它們不在同一個(gè)緩存行里。比如Java里可以用@Contended注解,不過需要在JVM啟動(dòng)時(shí)加上-XX:EnableContended參數(shù)?;蛘咦约菏謩?dòng)填充,比如定義一個(gè)類,里面有幾個(gè)long類型的變量,把需要避免偽共享的變量隔開。
我在一個(gè)計(jì)數(shù)器類里,有兩個(gè)計(jì)數(shù)器變量count1和count2,被多個(gè)線程分別修改,結(jié)果發(fā)現(xiàn)它們?cè)谕粋€(gè)緩存行里。后來(lái)在中間填充了7個(gè)long類型的變量,讓每個(gè)變量單獨(dú)占一個(gè)緩存行,性能提升了30%。
五、volatile用錯(cuò)地方:以為能保證原子性,結(jié)果出大問題
知道volatile能保證可見性,就以為它能保證原子性,在一個(gè)自增操作里用了volatile變量,結(jié)果并發(fā)情況下,數(shù)值還是不對(duì)。
1. volatile的特性
volatile只能保證可見性,不能保證原子性。比如i++這個(gè)操作,實(shí)際上分為讀取、加1、寫入三個(gè)步驟,這三個(gè)步驟不是原子的,在多線程情況下,可能會(huì)出現(xiàn)丟失更新的情況。
2. 正確使用場(chǎng)景
volatile適合用在狀態(tài)標(biāo)志位,比如一個(gè)線程等待另一個(gè)線程完成某個(gè)操作,用volatile變量來(lái)通知。如果需要原子性操作,還是得用synchronized或者AtomicInteger等原子類。
我之前在一個(gè)狀態(tài)機(jī)里,用volatile變量來(lái)控制狀態(tài)轉(zhuǎn)換,結(jié)果在并發(fā)情況下,狀態(tài)轉(zhuǎn)換出現(xiàn)了混亂。后來(lái)?yè)Q成用synchronized來(lái)保護(hù)狀態(tài)轉(zhuǎn)換的代碼塊,問題就解決了。
六、線程安全的單例模式:雙重檢查鎖定,鎖的對(duì)象錯(cuò)了
寫單例模式的時(shí)候,想著用雙重檢查鎖定來(lái)提高效率,結(jié)果鎖的對(duì)象用了類的實(shí)例,而不是類的Class對(duì)象,導(dǎo)致出現(xiàn)多個(gè)實(shí)例的情況。
1. 正確的雙重檢查鎖定
正確的做法是鎖的對(duì)象是類的Class對(duì)象,而且實(shí)例變量要用volatile修飾,防止指令重排。比如:
public class Singleton {
privatevolatilestatic Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
2. 為啥鎖對(duì)象不能是實(shí)例
如果鎖的對(duì)象是實(shí)例,在實(shí)例還沒創(chuàng)建的時(shí)候,多個(gè)線程同時(shí)進(jìn)入,都去創(chuàng)建實(shí)例,就會(huì)出現(xiàn)多個(gè)實(shí)例的情況。而用Class對(duì)象,不管實(shí)例有沒有創(chuàng)建,鎖都是唯一的。
我之前就是鎖的對(duì)象錯(cuò)了,導(dǎo)致系統(tǒng)里出現(xiàn)了多個(gè)單例實(shí)例,引發(fā)了一系列奇怪的問題,排查了好久才發(fā)現(xiàn)是這個(gè)問題。
七、總結(jié):多線程優(yōu)化的正確姿勢(shì)
說了這么多坑,咱來(lái)總結(jié)一下多線程優(yōu)化的正確姿勢(shì):
- 線程池參數(shù)別拍腦袋,根據(jù)任務(wù)類型和系統(tǒng)資源仔細(xì)計(jì)算,用有界隊(duì)列和合適的拒絕策略。
- 鎖的粒度要小,類型要選對(duì),能不用鎖就不用鎖,能用原子類就用原子類。
- 線程數(shù)不是越多越好,考慮上下文切換的開銷,用公式計(jì)算合適的線程數(shù)。
- 注意偽共享問題,尤其是在高并發(fā)場(chǎng)景下,用 @Contended 或者手動(dòng)填充來(lái)避免。
- volatile 和 synchronized、原子類的適用場(chǎng)景要分清,別搞錯(cuò)了。
- 寫線程安全的代碼時(shí),細(xì)節(jié)很重要,比如單例模式的雙重檢查鎖定,鎖的對(duì)象和 volatile 修飾符都不能少。
多線程優(yōu)化就像走鋼絲,看著簡(jiǎn)單,其實(shí)處處都是陷阱。咱得把基礎(chǔ)打扎實(shí),多在實(shí)踐中總結(jié)經(jīng)驗(yàn),遇到問題別慌,用調(diào)試工具和性能分析工具慢慢排查。希望大家看完這篇文章,能避開這些坑,在多線程優(yōu)化的路上少走彎路,寫出高效、穩(wěn)定的代碼。