美團面試拷打:ConcurrentHashMap 為何不能插入 null?HashMap 為何可以?

2023-08-29 06:02:02

週末的時候,有一位小夥伴提了一些關於 ConcurrentHashMap 的問題,都是他最近面試遇到的。原提問如下:

整個提問看著非常複雜,其實歸納來說就是兩個問題:

  1. ConcurrentHashMap 為什麼 key 和 value 不能為 null?
  2. ConcurrentHashMap 能保證複合操作的原子性嗎?

下面我會以此提供這兩個問題的詳細答案,希望對你有幫助。

ConcurrentHashMap 為什麼 key 和 value 不能為 null?

ConcurrentHashMap 的 key 和 value 不能為 null 主要是為了避免二義性。null 是一個特殊的值,表示沒有物件或沒有參照。如果你用 null 作為鍵,那麼你就無法區分這個鍵是否存在於 ConcurrentHashMap 中,還是根本沒有這個鍵。同樣,如果你用 null 作為值,那麼你就無法區分這個值是否是真正儲存在 ConcurrentHashMap 中的,還是因為找不到對應的鍵而返回的。

拿 get 方法取值來說,返回的結果為 null 存在兩種情況:

  • 值沒有在集合中 ;
  • 值本身就是 null。

這也就是二義性的由來。

具體可以參考 ConcurrentHashMap 原始碼分析

多執行緒環境下,存在一個執行緒操作該 ConcurrentHashMap 時,其他的執行緒將該 ConcurrentHashMap 修改的情況,所以無法通過 containsKey(key) 來判斷否存在這個鍵值對,也就沒辦法解決二義性問題了。

與此形成對比的是,HashMap 可以儲存 null 的 key 和 value,但 null 作為鍵只能有一個,null 作為值可以有多個。如果傳入 null 作為引數,就會返回 hash 值為 0 的位置的值。單執行緒環境下,不存在一個執行緒操作該 HashMap 時,其他的執行緒將該 HashMap 修改的情況,所以可以通過 contains(key)來做判斷是否存在這個鍵值對,從而做相應的處理,也就不存在二義性問題。

也就是說,多執行緒下無法正確判定鍵值對是否存在(存在其他執行緒修改的情況),單執行緒是可以的(不存在其他執行緒修改的情況)。

如果你確實需要在 ConcurrentHashMap 中使用 null 的話,可以使用一個特殊的靜態空物件來代替 null。

public static final Object NULL = new Object();

最後,再分享一下 ConcurrentHashMap 作者本人 (Doug Lea)對於這個問題的回答:

The main reason that nulls aren't allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can't be accommodated. The main one is that if map.get(key) returns null, you can't detect whether the key explicitly maps to null vs the key isn't mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.

翻譯過來之後的,大致意思還是單執行緒下可以容忍歧義,而多執行緒下無法容忍。

ConcurrentHashMap 能保證複合操作的原子性嗎?

ConcurrentHashMap 是執行緒安全的,意味著它可以保證多個執行緒同時對它進行讀寫操作時,不會出現資料不一致的情況,也不會導致 JDK1.7 及之前版本的 HashMap 多執行緒操作導致死迴圈問題。但是,這並不意味著它可以保證所有的複合操作都是原子性的,一定不要搞混了!

複合操作是指由多個基本操作(如putgetremovecontainsKey等)組成的操作,例如先判斷某個鍵是否存在containsKey(key),然後根據結果進行插入或更新put(key, value)。這種操作在執行過程中可能會被其他執行緒打斷,導致結果不符合預期。

例如,有兩個執行緒 A 和 B 同時對 ConcurrentHashMap 進行復合操作,如下:

// 執行緒 A
if (!map.containsKey(key)) {
map.put(key, value);
}
// 執行緒 B
if (!map.containsKey(key)) {
map.put(key, anotherValue);
}

如果執行緒 A 和 B 的執行順序是這樣:

  1. 執行緒 A 判斷 map 中不存在 key
  2. 執行緒 B 判斷 map 中不存在 key
  3. 執行緒 B 將 (key, anotherValue) 插入 map
  4. 執行緒 A 將 (key, value) 插入 map

那麼最終的結果是 (key, value),而不是預期的 (key, anotherValue)。這就是複合操作的非原子性導致的問題。

那如何保證 ConcurrentHashMap 複合操作的原子性呢?

ConcurrentHashMap 提供了一些原子性的複合操作,如 putIfAbsentcomputecomputeIfAbsentcomputeIfPresentmerge等。這些方法都可以接受一個函數作為引數,根據給定的 key 和 value 來計算一個新的 value,並且將其更新到 map 中。

上面的程式碼可以改寫為:

// 執行緒 A
map.putIfAbsent(key, value);
// 執行緒 B
map.putIfAbsent(key, anotherValue);

或者:

// 執行緒 A
map.computeIfAbsent(key, k -> value);
// 執行緒 B
map.computeIfAbsent(key, k -> anotherValue);

很多同學可能會說了,這種情況也能加鎖同步呀!確實可以,但不建議使用加鎖的同步機制,違背了使用 ConcurrentHashMap 的初衷。在使用 ConcurrentHashMap 的時候,儘量使用這些原子性的複合操作方法來保證原子性。