面試阿里當場傻眼,被P8質問:ConcurrentHashMap真的執行緒安全嗎?

JAVA下飯程式設計師發表於2020-12-18

沒啥深入實踐的初中級工程師,使用併發工具時,自以為把HashMap改為ConcurrentHashMap,就能完美解決併發。

或者使用寫時複製的CopyOnWriteArrayList,效能更佳呀!

技術言論雖然自由,但面對P8魔鬼面試官時, 你能針對他提問的場景還能做出是否執行緒安全的正確判斷嗎?

我們都知道ConcurrentHashMap是個執行緒安全的雜湊表容器,但它僅保證提供的原子性讀寫操作執行緒安全。

案例

有個含900個元素的Map,現在再補充100個元素進去,這個補充操作由10個執行緒併發進行。 開發人員誤以為使用ConcurrentHashMap就不會有執行緒安全問題,於是不加思索地寫出了下面的程式碼:在每一個執行緒的程式碼邏輯中先通過size方法拿到當前元素數量,計算ConcurrentHashMap目前還需要補充多少元素,並在日誌中輸出了這個值,然後通過putAll方法把缺少的元素新增進去。

為方便觀察問題,我們輸出了這個Map一開始和最後的元素個數。

在這裡插入圖片描述

訪問介面分析日誌輸出可得: - 初始大小900符合預期,還需填充100個元素 - worker13執行緒查詢到當前需要填充的元素為49,還不是100的倍數 - 最後HashMap的總專案數是1549,也不符合填充滿1000的預期

bug 分析

ConcurrentHashMap就像是一個大籃子,現在這個籃子裡有900個桔子,我們期望把這個籃子裝滿1000個桔子,也就是再裝100個桔子。有10個工人來幹這件事兒,大家先後到崗後會計算還需要補多少個桔子進去,最後把桔子裝入籃子。 ConcurrentHashMap這籃子本身,可以確保多個工人在裝東西進去時,不會相互影響干擾,但無法確保工人A看到還需要裝100個桔子但是還未裝時,工人B就看不到籃子中的桔子數量。你往這個籃子裝100個桔子的操作不是原子性的,在別人看來可能會有一個瞬間籃子裡有964個桔子,還需要補36個桔子。

ConcurrentHashMap對外提供能力的限制: - 使用不代表對其的多個操作之間的狀態一致,是沒有其他執行緒在操作它的。如果需要確保需要手動加鎖 - 諸如size、isEmpty和containsValue等聚合方法,在併發下可能會反映ConcurrentHashMap的中間狀態。因此在併發情況下,這些方法的返回值只能用作參考,而不能用於流程控制。顯然,利用size方法計算差異值,是一個流程控制 - 諸如putAll這樣的聚合方法也不能確保原子性,在putAll的過程中去獲取資料可能會獲取到部分資料

解決方案

整段邏輯加鎖:

在這裡插入圖片描述
只有一個執行緒查詢到需補100個元素,其他9個執行緒查詢到無需補,最後Map大小1000

在這裡插入圖片描述

既然使用ConcurrentHashMap還要全程加鎖,還不如使用HashMap呢? 不完全是這樣。

ConcurrentHashMap提供了一些原子性的簡單複合邏輯方法,用好這些方法就可以發揮其威力。這就引申出程式碼中常見的另一個問題:在使用一些類庫提供的高階工具類時,開發人員可能還是按照舊的方式去使用這些新類,因為沒有使用其真實特性,所以無法發揮其威力。

案例

使用Map來統計Key出現次數的場景。 - 使用ConcurrentHashMap來統計,Key的範圍是10 - 使用最多10個併發,迴圈操作1000萬次,每次操作累加隨機的Key - 如果Key不存在的話,首次設定值為1。

show me code:
在這裡插入圖片描述

有了上節經驗,我們這直接鎖住Map,再做 - 判斷 - 讀取現在的累計值 - +1 - 儲存累加後值

這段程式碼在功能上的確毫無沒有問題,但卻無法充分發揮ConcurrentHashMap的效能,優化後:

在這裡插入圖片描述
ConcurrentHashMap的原子性方法computeIfAbsent做複合邏輯操作,判斷K是否存在V,若不存在,則把Lambda執行後結果存入Map作為V,即新建立一個LongAdder物件,最後返回V 因為computeIfAbsent返回的V是LongAdder,是個執行緒安全的累加器,可直接呼叫其increment累加。

這樣在確保執行緒安全的情況下達到極致效能,且程式碼行數驟減。

效能測試

  • 使用StopWatch測試兩段程式碼的效能,最後的斷言判斷Map中元素的個數及所有V的和是否符合預期來校驗程式碼正確性

在這裡插入圖片描述
效能測試結果比使用鎖效能提升至少5倍。

computeIfAbsent高效能之道

Java的Unsafe實現的CAS。 它在JVM層確保寫入資料的原子性,比加鎖效率高:

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

所以不要以為只要用了ConcurrentHashMap併發工具就是高效能的高併發程式。

辨明 computeIfAbsent、putIfAbsent

  • 當Key存在的時候,如果Value獲取比較昂貴的話,putIfAbsent就白白浪費時間在獲取這個昂貴的Value上(這個點特別注意)
  • Key不存在的時候,putIfAbsent返回null,小心空指標,而computeIfAbsent返回計算後的值
  • 當Key不存在的時候,putIfAbsent允許put null進去,而computeIfAbsent不能,之後進行containsKey查詢是有區別的(當然了,此條針對HashMap,ConcurrentHashMap不允許put null value進去)

文章末尾

歡迎各位大佬進群共同交流學習,我們的交流分享群:1149778920 暗號:CSDN
博主在這裡給大家整理了包括但不限於:JAVA基礎和進階類、Spring、Spring boot、Spring MVC、MyBatis、MySQL、JVM等各種資料有,免費分享給各位進群的小夥伴

在這裡插入圖片描述

相關文章