踩坑:三個(gè)事故讓你重新理解線程安全)
敘事框架面試題 → 標(biāo)準(zhǔn)答案驗(yàn)證 → 三個(gè)翻車現(xiàn)場 → 邊界分析 → 升級(jí)版答案上篇講了線程池參數(shù)面試和生產(chǎn)場景的落差這篇我們來看另一道高頻面試題——ConcurrentHashMap。線程安全標(biāo)準(zhǔn)答案背得滾瓜爛熟但組合操作照樣翻車。面試題ConcurrentHashMap 為什么線程安全QConcurrentHashMap 和 HashMap 有什么區(qū)別為什么 ConcurrentHashMap 線程安全這道題幾乎每次 Java 面試都會(huì)出現(xiàn)標(biāo)準(zhǔn)答案也高度統(tǒng)一JDK 7分段鎖Segment 數(shù)組繼承 ReentrantLock默認(rèn) 16 個(gè) Segment鎖粒度粗JDK 8CAS synchronized 鎖單個(gè) bin鏈表/紅黑樹頭節(jié)點(diǎn)鎖粒度降到數(shù)組元素級(jí)面試官聽到 JDK 7 vs JDK 8 的差異一般就滿意了。這道題從《Java 并發(fā)編程實(shí)戰(zhàn)》到各大公司的面試題庫答案幾乎一字不差。標(biāo)準(zhǔn)答案的隱含假設(shè)但如果你把標(biāo)準(zhǔn)答案拆開看它隱含了三個(gè)假設(shè)你只用單操作——只 put 一個(gè) key、只 get 一個(gè) key、只 remove 一個(gè) key你來決定 JDK 版本——JDK 8 默認(rèn)JDK 7 是歷史你只關(guān)心容器本身——不關(guān)心調(diào)用方怎么編排這些操作三個(gè)假設(shè)在面試中都被認(rèn)為是理所當(dāng)然的。直到生產(chǎn)環(huán)境把它們一個(gè)個(gè)擊穿。生產(chǎn)事故線程安全容器也翻車事故一“賣了 5 件只扣了 3 件”陳姐維護(hù)的庫存服務(wù)核心邏輯只有兩行intstockcache.get(key);cache.put(key,stock-1);100 個(gè)線程同時(shí)扣庫存。上線第一周正?!l(fā)量低。第二周大促流量進(jìn)來庫存對(duì)不上了賬面顯示還有 3 件實(shí)際賣了 5 件。這不是 ConcurrentHashMap 線程不安全——是get和put各自線程安全但它們之間沒有原子性。兩個(gè)線程同時(shí)讀到stock 3各自減 1 寫回2——賣了兩件只扣了一件。兩個(gè)get()之間沒有 happens-before 關(guān)系所以讀到了相同值。面試的標(biāo)準(zhǔn)答案是對(duì)的“put 和 get 是線程安全的?!薄愕臉I(yè)務(wù)代碼不是map.put(key, value)你的代碼是map.put(key, map.get(key) - 1)。事故二CPU 100%所有線程卡在 get() 上如果事故一還算溫和數(shù)據(jù)錯(cuò)但服務(wù)還在跑事故二是直接宕機(jī)。某網(wǎng)關(guān)服務(wù)JDK 7上線一個(gè)月沒出過問題。某天 CPU 突然 100%jstack顯示所有線程全部停在ConcurrentHashMap.get()上。排查發(fā)現(xiàn)服務(wù)需要定期刷新緩存大量并發(fā) put 觸發(fā)了 ConcurrentHashMap 的 resize。JDK 7 的 resize 使用頭插法遷移——多線程同時(shí) resize鏈表形成環(huán)get()遍歷這個(gè)環(huán)永遠(yuǎn)停不下來。JDK 8 換用了 ForwardingNode 做無鎖遷移不存在此問題。但問題在于你的依賴 jar 可能還在用 JDK 7 編譯的版本。Gateway 本身是 JDK 8但引入的某個(gè)中間件客戶端依賴了 JDK 7 版本的 ConcurrentHashMap 用法。面試的標(biāo)準(zhǔn)答案也沒錯(cuò)——JDK 8 確實(shí)沒有這個(gè)問題。但它沒告訴你你的依賴可能悄悄拖著一個(gè) JDK 7。事故三批量寫入后 size 對(duì)不上第三個(gè)事故最隱蔽——數(shù)據(jù)沒丟、服務(wù)沒掛但報(bào)表對(duì)不上。批處理任務(wù)批量寫入 20 萬條數(shù)據(jù)寫入完成后讀size()cache.putAll(batch);log.info(寫入完成總數(shù){},cache.size());// 輸出156,842期望 200,000實(shí)際 156,842。差了 43,158 條。不是 bug——size()在 JDK 8 中使用CounterCell[]baseCount做近似計(jì)數(shù)。高并發(fā)寫入時(shí)size()返回的是能快速拿到的最新近似值不是精確的事務(wù)計(jì)數(shù)。但業(yè)務(wù)方把它當(dāng)精確值用了下游系統(tǒng)按這個(gè)數(shù)做結(jié)算差了 4 萬多。面試的標(biāo)準(zhǔn)答案繼續(xù)成立——“ConcurrentHashMap 線程安全”。但線程安全不意味著size()是實(shí)時(shí)精確的。為什么標(biāo)準(zhǔn)答案不夠標(biāo)準(zhǔn)答案對(duì)在哪?單操作原子性put(k, v)、get(k)、remove(k)各自是線程安全的——面試說的這個(gè)完全正確?弱一致性迭代迭代器不拋ConcurrentModificationException——對(duì)面試說的也正確?JDK 8 的演進(jìn)方向?qū)?Segment 到 CAS synchronized粒度更細(xì)、并發(fā)度更高——正確標(biāo)準(zhǔn)答案漏了哪漏了什么面試場景生產(chǎn)場景組合操作只問單操作是否安全業(yè)務(wù)代碼全是組合getput、containsKeyput、putAll版本差異默認(rèn) JDK 8依賴 jar 可能用 JDK 7 編譯間接拖入舊版本size() 語義“size 返回元素?cái)?shù)量”近似計(jì)數(shù)高并發(fā)下不準(zhǔn)修復(fù)手段不討論compute / putIfAbsent / mappingCount / 外部鎖ConcurrentHashMap 安全性的三層邊界安全級(jí)別1單操作原子性 ? ← 面試只問到這 put(k,v)/ get(k)/ remove(k)各自線程安全 安全級(jí)別2弱一致性迭代 ? ← 面試偶爾問到 迭代器不拋 ConcurrentModificationException 但不保證看到全部最新寫入 安全級(jí)別3組合操作原子性 ? ← 生產(chǎn)踩坑全在這 get put、containsKey put、putAll、size()需要外部同步或使用 compute()/ merge()面試升級(jí)版答案第一層基礎(chǔ)答案及格線ConcurrentHashMap 用 CAS synchronized 保證線程安全。JDK 7 用分段鎖JDK 8 鎖粒度降到 bin 級(jí)別。大多數(shù)候選人到此為止。能答出 JDK 版本差異的算合格。第二層推導(dǎo)邊界拉開差距但’線程安全’只保證單操作的原子性。組合操作getput、containsKeyput沒有跨操作保證。一個(gè)線程 put 完另一個(gè)線程 get 能讀到——但一個(gè)線程 get 然后 put這兩個(gè)操作之間的窗口另一個(gè)線程也能進(jìn)來。真正的安全邊界面試不會(huì)考三層——單操作 ?、弱一致性迭代 ?、組合操作 ?。這一步把背結(jié)論變成了講邊界。面試官會(huì)意識(shí)到你不只是刷了八股。第三層生產(chǎn)案例面試加分項(xiàng)結(jié)合真實(shí)案例講我之前維護(hù)過一個(gè)庫存服務(wù)用 ConcurrentHashMap 做緩存也是標(biāo)準(zhǔn)的 get put 扣庫存——上線前壓測(cè)正常大促流量進(jìn)來庫存對(duì)不上。排查發(fā)現(xiàn)是 read-modify-write 丟失更新。修復(fù)方案把裸 put 改成 compute() 或 merge()保證 read 和 write 的原子性。同時(shí)補(bǔ)充了 JDK 版本檢查——某個(gè)依賴 jar 的 ConcurrentHashMap 用法從 JDK 7 編譯過來的修改了依賴版本才解決。同步展示三個(gè)事故的修復(fù)方案對(duì)比第四層監(jiān)控驗(yàn)證真正的高階面試官可能追問“修復(fù)完你就放心了”不放心。加了三道防線代碼審查grep 檢查ConcurrentHashMap.*\.get(.*put模式——所有 RMW 都要改成 computeJDK 版本審計(jì)mvn dependency:tree檢查所有傳遞依賴的 JDK 版本數(shù)據(jù)校驗(yàn)重要業(yè)務(wù)加對(duì)賬——ConcurrentHashMap 的 size 不用來做業(yè)務(wù)判斷用 mappingCount 做參考生產(chǎn)中這么用安全操作速查場景? 面試八股寫法? 生產(chǎn)正確用法原子增減map.put(k, map.get(k) 1)map.compute(k, (k,v) - vnull ? 1 : v1)不存在時(shí)寫入if (!map.containsKey(k)) map.put(k, v)map.putIfAbsent(k, v)批量寫入后計(jì)數(shù)map.putAll(batch); map.size()map.putAll(batch); long n map.mappingCount()遍歷時(shí)刪除for (Entry e: map.entrySet()) map.remove(...)map.forEach(2, (k,v) - { map.remove(k); })?compute內(nèi)拋異常會(huì)刪除該 key——短操作用 compute長業(yè)務(wù)用外部鎖。grep 檢查你的項(xiàng)目# 檢查 read-modify-write 模式最常翻車grep-rnConcurrentHashMap.*\.get(src/|grep-Eput|remove# 檢查裸 check-then-actgrep-rncontainsKey.*ConcurrentHashMapsrc/# 檢查傳遞依賴的 JDK 版本mvn dependency:tree|grepconcurrent# 檢查 size() 做業(yè)務(wù)判斷grep-rnConcurrentHashMap.*\.size()src/|grep-vlog\|print“面試題的標(biāo)準(zhǔn)答案只是地圖——只有到生產(chǎn)里走一次才知道地圖漏了哪條路?!毕缕覀兞膹?qiáng)/軟/弱/虛引用——面試全能背生產(chǎn) OOM 還是不會(huì)查。