魔鬼面試官必問:ConcurrentHashMap 線程安全嗎?
沒啥深入實踐的理論系同學,在使用併發工具時,總是認爲把HashMap
改爲ConcurrentHashMap
,就完美解決併發了呀。或者使用寫時複製的CopyOnWriteArrayList
,性能更佳呀!技術言論雖然自由,但面對魔鬼面試官時,我們更在乎的是這些真的正確嗎?
1 線程重用導致用戶信息錯亂
生產環境中,有時獲取到的用戶信息是別人的。查看代碼後,發現是使用了ThreadLocal
緩存獲取到的用戶信息。
ThreadLocal
適用於變量在線程間隔離,而在方法或類間共享的場景。若用戶信息的獲取比較昂貴(比如從 DB 查詢),則在ThreadLocal
中緩存比較合適。問題來了,爲什麼有時會出現用戶信息錯亂?
1.1 案例
使用 ThreadLocal 存放一個 Integer 值,代表需要在線程中保存的用戶信息,初始 null。先從 ThreadLocal 獲取一次值,然後把外部傳入的參數設置到 ThreadLocal 中,模擬從當前上下文獲取用戶信息,隨後再獲取一次值,最後輸出兩次獲得的值和線程名稱。
1.2 bug 重現
在配置文件設置 Tomcat 參數 - 工作線程池最大線程數設爲 1,這樣始終是同一線程在處理請求:
server.tomcat.max-threads=1
先讓用戶 1 請求接口,第一、第二次獲取到用戶 ID 分別是 null 和 1,符合預期
用戶 2 請求接口,bug 復現!第一、第二次獲取到用戶 ID 分別是 1 和 2,顯然第一次獲取到了用戶 1 的信息,因爲 Tomcat 線程池重用了線程。兩次請求線程都是同一線程:http-nio-45678-exec-1
。
寫業務代碼時,首先要理解代碼會跑在什麼線程上:
-
Tomcat 服務器下跑的業務代碼,本就運行在一個多線程環境(否則接口也不可能支持這麼高的併發),並不能認爲沒有顯式開啓多線程就不會有線程安全問題
-
線程創建較昂貴,所以 Web 服務器會使用線程池處理請求,線程會被重用。使用類似 ThreadLocal 工具存放數據時,需注意在代碼運行完後,顯式清空設置的數據。
1.3 解決方案
在 finally 代碼塊顯式清除 ThreadLocal 中數據。即使新請求過來,使用了之前的線程,也不會獲取到錯誤的用戶信息。修正後代碼:
ThreadLocal 利用獨佔資源的解決線程安全問題,若就是要資源在線程間共享怎麼辦?就需要用到線程安全的容器。使用了線程安全的併發工具,並不代表解決了所有線程安全問題。
1.4 ThreadLocalRandom 可將其實例設置到靜態變量,在多線程下重用嗎?
current() 的時候初始化一個初始化種子到線程,每次 nextseed 再使用之前的種子生成新的種子:
UNSAFE.putLong(t = Thread.currentThread(), SEED,
r = UNSAFE.getLong(t, SEED) + GAMMA);
如果你通過主線程調用一次 current 生成一個 ThreadLocalRandom 實例保存,那麼其它線程來獲取種子的時候必然取不到初始種子,必須是每一個線程自己用的時候初始化一個種子到線程。可以在 nextSeed 設置一個斷點看看:
UNSAFE.getLong(Thread.currentThread(),SEED);
2 ConcurrentHashMap 真的安全嗎?
我們都知道 ConcurrentHashMap 是個線程安全的哈希表容器,但它僅保證提供的原子性讀寫操作線程安全。
2.1 案例
有個含 900 個元素的 Map,現在再補充 100 個元素進去,這個補充操作由 10 個線程併發進行。
開發人員誤以爲使用 ConcurrentHashMap 就不會有線程安全問題,於是不加思索地寫出了下面的代碼:在每一個線程的代碼邏輯中先通過 size 方法拿到當前元素數量,計算 ConcurrentHashMap 目前還需要補充多少元素,並在日誌中輸出了這個值,然後通過 putAll 方法把缺少的元素添加進去。
爲方便觀察問題,我們輸出了這個 Map 一開始和最後的元素個數。
訪問接口
分析日誌輸出可得:
-
初始大小 900 符合預期,還需填充 100 個元素
-
worker13 線程查詢到當前需要填充的元素爲 49,還不是 100 的倍數
-
最後 HashMap 的總項目數是 1549,也不符合填充滿 1000 的預期
2.2 bug 分析
ConcurrentHashMap 就像是一個大籃子,現在這個籃子裏有 900 個桔子,我們期望把這個籃子裝滿 1000 個桔子,也就是再裝 100 個桔子。有 10 個工人來幹這件事兒,大家先後到崗後會計算還需要補多少個桔子進去,最後把桔子裝入籃子。
ConcurrentHashMap 這籃子本身,可以確保多個工人在裝東西進去時,不會相互影響干擾,但無法確保工人 A 看到還需要裝 100 個桔子但是還未裝時,工人 B 就看不到籃子中的桔子數量。你往這個籃子裝 100 個桔子的操作不是原子性的,在別人看來可能會有一個瞬間籃子裏有 964 個桔子,還需要補 36 個桔子。
ConcurrentHashMap 對外提供能力的限制:
-
使用不代表對其的多個操作之間的狀態一致,是沒有其他線程在操作它的。如果需要確保需要手動加鎖
-
諸如 size、isEmpty 和 containsValue 等聚合方法,在併發下可能會反映 ConcurrentHashMap 的中間狀態。因此在併發情況下,這些方法的返回值只能用作參考,而不能用於流程控制。顯然,利用 size 方法計算差異值,是一個流程控制
-
諸如 putAll 這樣的聚合方法也不能確保原子性,在 putAll 的過程中去獲取數據可能會獲取到部分數據
2.3 解決方案
整段邏輯加鎖:
只有一個線程查詢到需補 100 個元素,其他 9 個線程查詢到無需補,最後 Map 大小 1000
既然使用 ConcurrentHashMap 還要全程加鎖,還不如使用 HashMap 呢?不完全是這樣。
ConcurrentHashMap 提供了一些原子性的簡單複合邏輯方法,用好這些方法就可以發揮其威力。這就引申出代碼中常見的另一個問題:在使用一些類庫提供的高級工具類時,開發人員可能還是按照舊的方式去使用這些新類,因爲沒有使用其真實特性,所以無法發揮其威力。
3 知己知彼,百戰百勝
3.1 案例
使用 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
累加。
這樣在確保線程安全的情況下達到極致性能,且代碼行數驟減。
3.2 性能測試
使用 StopWatch 測試兩段代碼的性能,最後的斷言判斷 Map 中元素的個數及所有 V 的和是否符合預期來校驗代碼正確性
性能測試結果:
比使用鎖性能提升至少 5 倍。
3.3 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 進去)
3.4 CopyOnWriteArrayList 之殤
再比如一段簡單的非 DB 操作的業務邏輯,時間消耗卻超出預期時間,在修改數據時操作本地緩存比回寫 DB 慢許多。原來是有人使用了CopyOnWriteArrayList
緩存大量數據,而該業務場景下數據變化又很頻繁。
CopyOnWriteArrayList
雖然是一個線程安全版的 ArrayList,但其每次修改數據時都會複製一份數據出來,所以只適用讀多寫少或無鎖讀場景。
所以一旦使用CopyOnWriteArrayList
,一定是因爲場景適宜而非炫技。
CopyOnWriteArrayList V.S 普通加鎖 ArrayList 讀寫性能
測試併發寫性能
測試結果:高併發寫,CopyOnWriteArray 比同步 ArrayList 慢百倍
測試併發讀性能
測試結果:高併發讀(100 萬次 get 操作),CopyOnWriteArray 比同步 ArrayList 快 24 倍
高併發寫時,CopyOnWriteArrayList 爲何這麼慢呢?因爲其每次 add 時,都用 Arrays.copyOf 創建新數組,頻繁 add 時內存申請釋放性能消耗大。
4 總結
4.1 Don't !!!
-
不要只會用併發工具,而不熟悉線程原理
-
不要覺得用了併發工具,就怎麼都線程安全
-
不熟悉併發工具的優化本質,就難以發揮其真正性能
-
不要不結合當前業務場景,就隨意選用併發工具,可能導致系統性能更差
4.2 Do !!!
-
認真閱讀官方文檔,理解併發工具適用場景及其各 API 的用法,並自行測試驗證,最後再使用
-
併發 bug 本就不易復現, 多自行進行性能壓力測試
參考
- https://zhuanlan.zhihu.com/p/201333611
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/si5vFuVvoXUEEUWT_edpUQ