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