編程坑太多,Map 集合怎麼也有這麼多坑?一不小心又踩了好幾個!

點擊查看更多歷史文章

上一篇 List 踩坑文章中,我們提到幾個比較容易踩坑的點。作爲 List 集合好兄弟 Map,我們也是天天都在使用,一不小心也會踩坑。

今天我就來總結這些常見的坑,再撈自己一手,防止後續同學再繼續踩坑。

本文設計知識點如下:

不是所有的 Map 都能包含 null

這個踩坑經歷還是發生在實習的時候,那時候有這樣一段業務代碼,功能很簡單,從 XML 中讀取相關配置,存入 Map 中。

代碼示例如下:

那時候正好有個小需求,需要改動一下這段業務代碼。改動的過程中,突然想到 HashMap 併發過程可能導致死鎖的問題。

於是改動了一下這段代碼,將 HashMap 修改成了 ConcurrentHashMap

美滋滋提交了代碼,然後當天上線的時候,就發現炸了。。。

應用啓動過程發生 NPE 問題,導致應用啓動失敗。

根據異常日誌,很快就定位到了問題原因。由於 XML 某一項配置問題,導致讀取元素爲 null,然後元素置入到 ConcurrentHashMap 中,拋出了空指針異常。

這不科學啊! 之前 HashMap 都沒問題,都可以存在 null,爲什麼它老弟 ConcurrentHashMap 就不可以?

翻閱了一下 ConcurrentHashMap#put 方法的源碼,開頭就看到了對 KV 的判空校驗。

看到這裏,不知道你有沒有疑惑,爲什麼 ConcurrentHashMapHashMap 設計的判斷邏輯不一樣?

求助了下萬能的 Google,找到 Doug Lea 老爺子的回答:

來源:http://cs.oswego.edu/pipermail/concurrency-interest/2006-May/002485.html

總結一下:

  • null 會引起歧義,如果 value 爲 null,我們無法得知是值爲 null,還是 key 未映射具體值?
  • Doug Lea 並不喜歡 null,認爲 null 就是個隱藏的炸彈。

上面提到 Josh Bloch 正是 HashMap 作者,他與 Doug Lea 在 null 問題意見並不一致。

也許正是因爲這些原因,從而導致 ConcurrentHashMapHashMap 對於 null 處理並不一樣。

最後貼一下常用 Map 子類集合對於 null 存儲情況:

上面的實現類約束,都太不一樣,有點不好記憶。其實只要我們在加入元素之前,主動去做空指針判斷,不要在 Map 中存入 null,就可以從容避免上面問題。

自定義對象爲 key

先來看個簡單的例子,我們自定義一個 Goods 商品類,將其作爲 Key 存在 Map 中。

示例代碼如下:

上面代碼中,第二次我們加入一個相同的商品,原本我們期望新加入的值將會替換原來舊值。但是實際上這裏並沒有替換成功,反而又加入一對鍵值。

翻看一下 HashMap#put 的源碼:

以下代碼基於 JDK1.7

這裏首先判斷 hashCode 計算產生的 hash,如果相等,再判斷 equals 的結果。但是由於 Goods對象未重寫的hashCodeequals 方法,默認情況下 hashCode 將會使用父類對象 Object 方法邏輯。

Object#hashCode 是一個 native 方法,默認將會爲每一個對象生成不同 hashcode與內存地址有關),這就導致上面的情況。

所以如果需要使用自定義對象做爲 Map 集合的 key,那麼一定記得重寫hashCodeequals 方法。

然後當你爲自定義對象重寫上面兩個方法,接下去又可能踩坑另外一個坑。

使用 lombok 的 EqualsAndHashCode 自動重寫 hashCodeequals 方法。

上面的代碼中,當 Map 中置入自定義對象後,接着修改了商品金額。然後當我們想根據同一個對象取出 Map 中存的值時,卻發現取不出來了。

上面的問題主要是因爲 get 方法是根據對象 的 hashcode 計算產生的 hash 值取定位內部存儲位置。

當我們修改了金額字段後,導致 Goods 對象 hashcode 產生的了變化,從而導致 get 方法無法獲取到值。

通過上面兩種情況,可以看到使用自定義對象作爲 Map 集合 key,還是挺容易踩坑的。

所以儘量避免使用自定義對象作爲 Map 集合 key,如果一定要使用,記得重寫 hashCodeequals 方法。另外還要保證這是一個不可變對象,即對象創建之後,無法再修改裏面字段值。

錯用 ConcurrentHashMap 導致線程不安全

之前的文章『每天都在用 Map,這些核心技術你知道嗎?』我們說過 HashMap 是一個線程不安全的容器,多線程環境爲了線程安全,我們需要使用 ConcurrentHashMap代替。

但是不要認爲使用了 ConcurrentHashMap 一定就能保證線程安全,在某些錯誤的使用場景下,依然會造成線程不安全。

上面示例代碼,我們原本期望輸出 1001,但是運行幾次,得到結果都是小於 1001

深入分析這個問題原因,實際上是因爲第一步與第二步是一個組合邏輯,不是一個原子操作。

ConcurrentHashMap 只能保證這兩步單的操作是個原子操作,線程安全。但是並不能保證兩個組合邏輯線程安全,很有可能 A 線程剛通過 get 方法取到值,還未來得及加 1,線程發生了切換,B 線程也進來取到同樣的值。

這個問題同樣也發生在其他線程安全的容器,比如 Vector等。

上面的問題解決辦法也很簡單,加鎖就可以解決,不過這樣就會使性能大打折扣,所以不太推薦。

我們可以使用 AtomicInteger 解決以上的問題。

List 集合這些坑,Map 中也有

上一篇文章中我們提過,Arrays#asListList#subList 返回 List 將會與原集合互相影響,且可能並不支持 add 等方法。同樣的,這些坑爹的特性在 Map 中也存在,一不小心,將會再次掉坑。

Map 接口除了支持增刪改查功能以外,還有三個特有的方法,能返回所有 key,返回所有的 value,返回所有 kv 鍵值對。

// 返回 key 的 set 視圖
Set<K> keySet()// 返回所有 value   Collection 視圖
Collection<V> values();
// 返回 key-value 的 set 視圖
Set<Map.Entry<K, V>> entrySet();

這三個方法創建返回新集合,底層其實都依賴的原有 Map 中數據,所以一旦 Map 中元素變動,就會同步影響返回的集合。

另外這三個方法返回新集合,是不支持的新增以及修改操作的,但是卻支持 clear、remove 等操作。

示例代碼如下:

所以如果需要對外返回 Map 這三個方法產生的集合,建議再來個套娃。

new ArrayList<>(map.values());

最後再簡單提一下,使用 foreach 方式遍歷新增/刪除 Map 中元素,也將會和 List 集合一樣,拋出 ConcurrentModificationException

總結

從上面文章可以看到不管是 List 提供的方法返回集合,還是 Map 中方法返回集合,底層實際還是使用原有集合的元素,這就導致兩者將會被互相影響。所以如果需要對外返回,請使用套娃大法,這樣讓別人用的也安心。

第二, Map 各個實現類對於 null 的約束都不太一樣,這裏建議在 Map 中加入元素之前,主動進行空指針判斷,提前發現問題。

第三,慎用自定義對象作爲 Map 中的 key,如果需要使用,一定要重寫 hashCodeequals 方法,並且還要保證這是個不可變對象。

第三,ConcurrentHashMap 是線程安全的容器,但是不要思維定勢,不要片面認爲使用 ConcurrentHashMap 就會線程安全。

最後(關注,點贊,轉發三連)

你在使用 Map 的過程還踩過什麼坑,歡迎留言討論。

我是樓下小黑哥,我們下篇文章再見~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章