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