里那些容易被忽視的并發(fā)問題)
一個(gè)看似人畜無害的HashMap在并發(fā)環(huán)境下膨脹時(shí)可能把兩個(gè)線程的指針同時(shí)指向同一個(gè)鏈表頭然后各自寫回導(dǎo)致其中一個(gè)線程的插入悄然丟失。更隱蔽的是當(dāng)它觸發(fā)resize時(shí)舊數(shù)組到新數(shù)組的遷移過程可能讓鏈表形成環(huán)下一次get的那個(gè)key恰好掉進(jìn)環(huán)里CPU瞬間飆到100%而你盯著日志里那行“無異常”的最終狀態(tài)完全想不通系統(tǒng)是怎么死的。這類問題從不寫在報(bào)錯(cuò)頁面里它藏在代碼被多線程觸碰的一瞬間。本文就聊聊那些Java開發(fā)里極容易被忽視的并發(fā)問題它們不是鎖的用法問題而是心智模型里缺了一塊的必然結(jié)果。你以為是原子操作其實(shí)是三行字節(jié)碼最經(jīng)典的誤區(qū)發(fā)生在count上。很多人覺得這一行代碼是“原子的”因?yàn)樵趩尉€程里它從不出錯(cuò)。但在JVM里count被拆成讀取、加一、寫回三步。兩個(gè)線程同時(shí)讀到舊值5然后各自加一最后都寫回6于是兩次自增只生效一次。你用了AtomicInteger也許能躲過計(jì)數(shù)問題但如果是余額扣減呢先檢查再扣減兩個(gè)線程都通過余額檢查然后一起扣款數(shù)據(jù)庫里的金額就會(huì)變成負(fù)數(shù)。并發(fā)問題的第一性原理是可見性、原子性、有序性任何一個(gè)被破壞bug就潛伏在下一行看似正確的代碼里。而更糟糕的是你大概不會(huì)在測試環(huán)境觸發(fā)它。因?yàn)椴l(fā)bug需要精確的時(shí)序競爭普通的功能測試根本壓不出那個(gè)線程切換的窗口。很多開發(fā)者的應(yīng)對方式是用synchronized把所有相關(guān)方法鎖住。這確實(shí)解決了原子性但帶來了另一個(gè)更隱蔽的問題——鎖的粒度決定了你系統(tǒng)的吞吐量天花板而沒人提醒你性能瓶頸往往不是數(shù)據(jù)庫而是你精心放置的那把鎖。當(dāng)一百個(gè)線程都在等待同一個(gè)鎖對象時(shí)你的應(yīng)用看起來像在正常運(yùn)行實(shí)際上QPS已經(jīng)塌陷了只不過監(jiān)控圖表上的曲線是慢慢走平的不像宕機(jī)那么刺眼。volatile不是萬能的它管不了復(fù)合操作volatile是Java并發(fā)里最容易被誤解的關(guān)鍵字。它保證了可見性和一定程度的有序性也就是禁止指令重排。但很多文章沒講透的是volatile并不保證原子性。它只能保證一個(gè)線程修改了變量后其他線程立刻看到最新值??蓪@個(gè)變量的“讀取—判斷—修改”這個(gè)復(fù)合流程它毫無辦法。舉個(gè)例子你有一個(gè)volatile boolean initialized線程A負(fù)責(zé)初始化資源然后置為true。線程B循環(huán)等待這個(gè)標(biāo)志變成true才繼續(xù)。這個(gè)場景volatile確實(shí)有效因?yàn)閺膶憳?biāo)志到讀標(biāo)志之間沒有其他中間操作。但如果你有一個(gè)volatile int counter然后執(zhí)行counter那問題依然存在。讀counter、算新值、寫回counter——這三步中的任何一步都可能被別的線程插一腳??吹竭@里你應(yīng)該明白凡是“先讀后寫”的邏輯volatile都幫不上忙。它只適合那種“一個(gè)線程寫其他線程只讀”的狀態(tài)發(fā)布場景。就這么簡單的規(guī)則不知坑了多少從網(wǎng)上抄來“volatile保證線程安全”結(jié)論的新手。他們守著volatile變量做計(jì)數(shù)器、做庫存扣減最后線上數(shù)據(jù)對不上賬還以為是分布式事務(wù)的問題。線程池的“優(yōu)雅關(guān)閉”是個(gè)偽命題每個(gè)Java程序員都用過ExecutorService也都會(huì)在應(yīng)用關(guān)閉時(shí)調(diào)用shutdown()。但shutdown只是停止接收新任務(wù)已經(jīng)提交的任務(wù)還會(huì)繼續(xù)跑。你可能需要shutdownNow()來嘗試中斷正在執(zhí)行的任務(wù)。但更核心的問題是當(dāng)你的JVM進(jìn)程即將退出時(shí)那些還沒執(zhí)行完的任務(wù)到底應(yīng)該被強(qiáng)行終止、等它執(zhí)行完、還是超時(shí)后放棄這三個(gè)決策里藏著極大的業(yè)務(wù)風(fēng)險(xiǎn)。假設(shè)你有一個(gè)訂單超時(shí)任務(wù)線程池每個(gè)任務(wù)在處理用戶退款。如果直接shutdownNow中斷信號拋給正在跑的任務(wù)——一個(gè)業(yè)務(wù)方法里被InterruptedException打斷如果代碼沒有正確恢復(fù)中斷狀態(tài)任務(wù)可能卡在某個(gè)中間狀態(tài)訂單被標(biāo)記為“處理中”卻永遠(yuǎn)沒有下文。如果你等所有任務(wù)跑完再退出數(shù)據(jù)庫連接池卻已經(jīng)開始銷毀任務(wù)里的SQL全部拋連接異常你又得設(shè)計(jì)重試補(bǔ)償。優(yōu)雅關(guān)閉的核心難點(diǎn)根本不是關(guān)閉線程池本身而是如何協(xié)調(diào)線程池與它依賴的外部資源數(shù)據(jù)庫連接池、MQ連接的生命周期。很多團(tuán)隊(duì)忽略這一點(diǎn)結(jié)果上線新版本時(shí)頻繁出現(xiàn)“發(fā)布期間有少量訂單狀態(tài)異常”因?yàn)槔线M(jìn)程還在消化內(nèi)存里的任務(wù)新進(jìn)程已經(jīng)開始接受新流量兩個(gè)進(jìn)程同時(shí)操作同一批數(shù)據(jù)。你的冪等設(shè)計(jì)如果只防了分布式調(diào)用沒防“同一個(gè)任務(wù)被兩個(gè)進(jìn)程各執(zhí)行一次”那問題就會(huì)在每次發(fā)版時(shí)準(zhǔn)時(shí)露面。鎖重入的陷阱synchronized之外的ReentrantLockReentrantLock因?yàn)橹С止芥i、可中斷、支持超時(shí)常被當(dāng)作synchronized的高配替代品。但你一旦用tryLock()方法就掉進(jìn)了一個(gè)語義陷阱。tryLock無參版本是非公平的它會(huì)立即嘗試搶鎖搶不到就返回false。如果你在業(yè)務(wù)代碼里寫了個(gè)循環(huán)不斷tryLock代碼在鎖競爭激烈時(shí)可能讓某個(gè)線程永遠(yuǎn)搶不到鎖這叫“線程饑餓”。你以為加了超時(shí)控制能避免結(jié)果第100次循環(huán)的tryLock恰恰在另一個(gè)線程釋放鎖的一瞬間前被系統(tǒng)調(diào)度于是又失敗——這種概率事件最難查。另一個(gè)ReentrantLock的隱藏問題是你必須手動(dòng)在finally里unlock。這是對開發(fā)紀(jì)律的極大考驗(yàn)。業(yè)務(wù)中任何一處的異常提前return忘了unlock鎖就永遠(yuǎn)不釋放。調(diào)試時(shí)你看不到任何鎖相關(guān)的報(bào)錯(cuò)因?yàn)榫€程不會(huì)exception它只是悄悄阻塞在lock()方法上。如果你是配合Condition做等待喚醒忘了解鎖會(huì)讓調(diào)用await()的線程直接IllegalMonitorStateException但很多人會(huì)誤以為是“業(yè)務(wù)邏輯狀態(tài)不對”而排查半天。靜態(tài)變量與ThreadLocal的管理失序靜態(tài)變量天然被所有線程共享。很多人寫了一個(gè)靜態(tài)的SimpleDateFormat作為全局日期格式化工具因?yàn)镾impleDateFormat在單線程下表現(xiàn)良好。但并發(fā)環(huán)境下它的內(nèi)部Calendar狀態(tài)會(huì)被多個(gè)線程同時(shí)修改導(dǎo)致parse結(jié)果錯(cuò)亂甚至拋NumberFormatException。你有兩種解法要么用ThreadLocal給每個(gè)線程一個(gè)獨(dú)立實(shí)例要么用Java 8的DateTimeFormatter它是線程安全的。但ThreadLocal本身又是一個(gè)內(nèi)存泄漏的溫床。當(dāng)你用線程池執(zhí)行任務(wù)每個(gè)任務(wù)往ThreadLocal里塞數(shù)據(jù)任務(wù)結(jié)束后線程沒有被銷毀而是歸還池中。ThreadLocal的內(nèi)容依然在線程里扎根如果這些數(shù)據(jù)引用的是大對象或ClassLoader就會(huì)造成GC無法回收。很多Web應(yīng)用重啟后PermGen/Metaspace溢出就是因?yàn)榭蚣艿腡hreadLocal沒有及時(shí)remove。所以用ThreadLocal時(shí)永遠(yuǎn)要問自己這個(gè)線程什么時(shí)候結(jié)束如果它是個(gè)池化線程那我的數(shù)據(jù)什么時(shí)候清理雙重檢查鎖與可見性的愛恨情仇單例模式的雙重檢查鎖是教科書典范但如果你寫的不是經(jīng)典版本而是自己改了一版很可能踩到指令重排的雷。經(jīng)典寫法必須是private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; }注意instance必須聲明為volatile。因?yàn)閚ew Singleton()不是原子的它分為分配內(nèi)存、調(diào)用構(gòu)造器、將引用指向內(nèi)存三步。JVM可能優(yōu)化為先執(zhí)行第三步引用賦值再執(zhí)行第二步構(gòu)造器調(diào)用。另一個(gè)線程此時(shí)進(jìn)來看到instance不為null直接返回一個(gè)尚未完成構(gòu)造的對象——如果你的構(gòu)造函數(shù)里有依賴其他字段初始化的邏輯必然出錯(cuò)。這里最迷惑人的是不加volatile的單例在99%的運(yùn)行場景都正常因?yàn)镃PU緩存一致性協(xié)議偶然發(fā)揮作用但剩下1%的極端時(shí)序足以讓你的支付回調(diào)里的單例回調(diào)處理器用半天初始化了一半的配置。這種bug是真正的地獄模式——你沒法復(fù)現(xiàn)只能靠推理。大多數(shù)人的解決手段是直接用枚舉或者靜態(tài)內(nèi)部類實(shí)現(xiàn)單例干脆避開DCL。但問題在于你的團(tuán)隊(duì)還有無數(shù)個(gè)用DCL手寫的老代碼它們就是無volatile版本就像一枚枚定時(shí)炸彈。非阻塞算法的ABA還有你根本不知道的版本號當(dāng)你放棄鎖使用AtomicStampedReference或AtomicMarkableReference來解決CAS的ABA問題時(shí)又引入了新的心智負(fù)擔(dān)。ABA問題是指線程A讀到變量值為X另一個(gè)線程B把它改成Y又改回XA的CAS操作會(huì)成功因?yàn)楸容^的是值而不是“中間被改過”這一事實(shí)。對不需要關(guān)心中間狀態(tài)的數(shù)據(jù)比如計(jì)數(shù)器沒問題但如果你在實(shí)現(xiàn)一個(gè)無鎖棧/隊(duì)列ABA會(huì)讓某個(gè)線程把已出隊(duì)的節(jié)點(diǎn)重新鏈接回鏈表中。很多開發(fā)者面對ABA的第一反應(yīng)是“用AtomicStampedReference加版本號”。但版本號本身如果是用普通int維護(hù)的又會(huì)溢出。你想想System.currentTimeMillis()當(dāng)版本號——它是不變的如果兩次操作發(fā)生在同一毫秒內(nèi)版本號根本沒變化ABA照樣發(fā)生。這很冷門但真實(shí)存在。所以設(shè)計(jì)并發(fā)數(shù)據(jù)結(jié)構(gòu)時(shí)要么確保你的業(yè)務(wù)能容忍ABA要么用AtomicLong那個(gè)單調(diào)增長的內(nèi)部version而不是想當(dāng)然地拿時(shí)間戳。文件與I/O的并發(fā)一致性比內(nèi)存更刺激Java開發(fā)里很多人對內(nèi)存中的并發(fā)問題繃緊了弦卻對文件I/O放松了警惕。多線程寫同一個(gè)文件各自用FileWriter打開同一個(gè)路徑底層操作系統(tǒng)會(huì)為每次open分配獨(dú)立文件指針。兩個(gè)線程寫同一位置時(shí)后寫的會(huì)覆蓋先寫的數(shù)據(jù)交錯(cuò)丟失。如果你用RandomAccessFile設(shè)置相同偏移量并發(fā)寫結(jié)果可能是兩個(gè)線程的內(nèi)容以極小的粒度交織在一起產(chǎn)生一個(gè)完全損壞的文件。如果每個(gè)線程各自打開文件并追加寫入OS級別的O_APPEND一般能保證單次write的原子性但Java里的BufferedWriter.write()不是一次write系統(tǒng)調(diào)用它可能把數(shù)據(jù)拆成多個(gè)字節(jié)塊發(fā)送。這些塊之間可能插入其他線程的寫入。所以本地日志、消息落盤這些看起來“簡單”的寫文件在并發(fā)場景下需要你顯式加鎖或使用單一寫入線程。沒有人告訴你生產(chǎn)環(huán)境下的log文件錯(cuò)行、JSON截?cái)喽喟氩皇谴疟P壞了而是你自己多線程寫同一個(gè)文件的騷操作。死鎖不是只能靠jstack查它常以“活鎖”的形式隱身兩個(gè)線程互相持有所需的鎖經(jīng)典死鎖直接導(dǎo)致線程永久阻塞。但死鎖之外還有一種更隱蔽的“活鎖”兩個(gè)線程嘗試獲取同一對鎖檢測到?jīng)_突后各自釋放自己的鎖并重試然后再次同時(shí)沖突無限循環(huán)但線程狀態(tài)始終是RUNNABLE。你的監(jiān)控顯示CPU很高線程沒有BLOCKED于是你根本不會(huì)往鎖的方向去想。活鎖的典型場景出現(xiàn)在分布式鎖的自動(dòng)續(xù)期代碼里。兩個(gè)服務(wù)節(jié)點(diǎn)同時(shí)持有同一個(gè)資源的不同分片然后互相等待對方釋放自己需要的鎖——這種循環(huán)等待如果設(shè)置成遇到?jīng)_突就重試而且重試間隔相同就會(huì)同步震蕩。破局方式是讓每個(gè)線程在重試時(shí)加入隨機(jī)退避就像以太網(wǎng)CSMA/CD那樣。說了這么多真正想強(qiáng)調(diào)的是Java里的并發(fā)bug遠(yuǎn)遠(yuǎn)不止死鎖、競態(tài)、內(nèi)存可見性那幾個(gè)詞條。它們更多地出現(xiàn)在你對某個(gè)同步原語的理解偏差上出現(xiàn)在線程的生命周期與共享資源的生命周期不匹配上出現(xiàn)在“單線程時(shí)正常的代碼在多線程下就被撕碎”的認(rèn)知斷層里。這不是Java語言的錯(cuò)而是并發(fā)環(huán)境的本質(zhì)一旦共享了可變狀態(tài)你的單線程心智模型就破產(chǎn)了。要治好這個(gè)病只能強(qiáng)制自己在每次寫共享變量時(shí)問三句話它能被多個(gè)線程看到嗎它會(huì)被同時(shí)修改嗎它的修改是否需要依賴之前讀到的值如果任何一個(gè)回答為“是”你就必須放下“它看起來沒問題”的直覺老老實(shí)實(shí)地用鎖、原子類或不可變設(shè)計(jì)去應(yīng)對。那才是Java并發(fā)世界里最容易被忽視的生存法則。