麪霸篇:Java 集合容器大滿貫(卷二)

麪霸篇,從面試角度作爲切入點提升大家的 Java 內功,所謂根基不牢,地動山搖

碼哥在 《Redis 系列》的開篇 Redis 爲什麼這麼快中說過:學習一個技術,通常只接觸了零散的技術點,沒有在腦海裏建立一個完整的知識框架和架構體系,沒有系統觀。這樣會很喫力,而且會出現一看好像自己會,過後就忘記,一臉懵逼。

我們需要一個系統觀,清晰完整的去學習技術,在「麪霸篇:Java 核心基礎大滿貫(卷一)」中,碼哥梳理了 Java 高頻核心知識點。

本篇將一舉攻破 Java 集合容器知識點,跟着「碼哥」一起來提綱挈領,梳理一個完整的 Java 容器開發技術能力圖譜,將基礎夯實。

點擊下方卡片,關注「碼哥字節」

集合容器概述

什麼是集合?

顧名思義,集合就是用於存儲數據的容器

集合框架是爲表示和操作集合而規定的一種統一的標準的體系結構。 任何集合框架都包含三大塊內容:對外的接口、接口的實現和對集合運算的算法

碼老溼,可以說下集合框架的三大塊內容具體指的是什麼嗎?

接口

面向接口編程,抽象出集合類型,使得我們可以在操作集合的時候不必關心具體實現,達到「多態」。

就好比密碼箱,我們只關心能打開箱子,存放東西,並且關閉箱子,至於怎麼加密咱們不關心。

接口實現

每種集合的具體實現,是重用性很高的數據結構。

算法

集合提供了數據存放以及查找、排序等功能,集合有很多種,也就是算法通常也是多態的,因爲相同的方法可以在同一個接口被多個類實現時有不同的表現

事實上,算法是可複用的函數。 它減少了程序設計的辛勞。

集合框架通過提供有用的數據結構和算法使你能集中注意力於你的程序的重要部分上,而不是爲了讓程序能正常運轉而將注意力於低層設計上。

集合的特點

  • 對象封裝數據,多個對象需要用集合存儲;
  • 對象的個數可以確定使用數組更高效,不確定個數的情況下可以使用集合,因爲集合是可變長度。

集合與數組的區別

  • 數組是固定長度的;集合可變長度的。
  • 數組可以存儲基本數據類型,也可以存儲引用數據類型;集合只能存儲引用數據類型。
  • 數組存儲的元素必須是同一個數據類型;集合存儲的對象可以是不同數據類型。

由於有多種集合容器,因爲每一個容器的自身特點不同,其實原理在於每個容器的內部數據結構不同。

集合容器在不斷向上抽取過程中,出現了集合體系。在使用一個體系的原則:參閱頂層內容。建立底層對象。

集合框架有哪些優勢

  • 容量自動增長擴容;
  • 提供高性能的數據結構和算法;
  • 可以方便地擴展或改寫集合,提高代碼複用性和可操作性。
  • 通過使用JDK自帶的集合類,可以降低代碼維護和學習新API成本。

有哪些常用的集合類

Java 容器分爲 Collection 和 Map 兩大類,Collection集合的子接口有Set、List、Queue三種子接口。

我們比較常用的是Set、List,Map接口不是 collection的子接口。

Collection集合主要有List和Set兩大接口

  • List:一個有序(元素存入集合的順序和取出的順序一致)容器,元素可以重複,可以插入多個null元素,元素都有索引。常用的實現類有 ArrayList、LinkedList 和 Vector。
  • Set:一個無序(存入和取出順序有可能不一致)容器,不可以存儲重複元素,只允許存入一個null元素,必須保證元素唯一性。
  • Set 接口常用實現類是 HashSet、LinkedHashSet 以及 TreeSet。

Map是一個鍵值對集合,存儲鍵、值和之間的映射。 Key無序,唯一;value 不要求有序,允許重複。

Map沒有繼承於 Collection 接口,從 Map 集合中檢索元素時,只要給出鍵對象,就會返回對應的值對象。

Map 的常用實現類:HashMap、TreeMap、HashTable、LinkedHashMap、ConcurrentHashMap

集合的底層數據結構

Collection

  1. List

    • ArrayList:Object 數組;
    • Vector:Object 數組;
    • LinkedList:雙向循環鏈表;
  2. Set

    • HashSet:唯一,無序。基於 HashMap 實現,底層採用 HashMap 保存數據。

      它不允許集合中有重複的值,當我們提到HashSet時,第一件事情就是在將對象存儲在HashSet之前,要先確保對象重寫equals()和hashCode()方法,這樣才能比較對象的值是否相等,以確保set中沒有儲存相等的對象。

      如果我們沒有重寫這兩個方法,將會使用這個方法的默認實現。

    • LinkedHashSet: LinkedHashSet 繼承與 HashSet,底層使用 LinkedHashMap 來保存所有元素。

    • TreeSet(有序,唯一): 紅黑樹(自平衡的排序二叉樹。)

Map

  • HashMap:JDK1.8之前HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要爲了解決哈希衝突而存在的(“拉鍊法”解決衝突)。

    JDK1.8以後在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(默認爲8)時,將鏈表轉化爲紅黑樹,以減少搜索時間。

  • LinkedHashMap:LinkedHashMap 繼承自 HashMap,所以它的底層仍然是基於拉鍊式散列結構即由數組和鏈表或紅黑樹組成

    內部還有一個雙向鏈表維護鍵值對的順序,每個鍵值對既位於哈希表中,也位於雙向鏈表中

    LinkedHashMap支持兩種順序插入順序 、 訪問順序。

    • 插入順序:先添加的在前面,後添加的在後面。修改操作不影響順序
    • 訪問順序:所謂訪問指的是get/put操作,對一個鍵執行get/put操作後,其對應的鍵值對會移動到鏈表末尾,所以最末尾的是最近訪問的,最開始的是最久沒有被訪問的,這就是訪問順序。
  • HashTable: 數組+鏈表組成的,數組是 HashMap 的主體,鏈表則是主要爲了解決哈希衝突而存在的

  • TreeMap: 紅黑樹(自平衡的排序二叉樹)

集合的 fail-fast 快速失敗機制

Java 集合的一種錯誤檢測機制,當多個線程對集合進行結構上的改變的操作時,有可能會產生 fail-fast 機制。

原因:迭代器在遍歷時直接訪問集合中的內容,並且在遍歷過程中使用一個 modCount 變量。

集合在被遍歷期間如果內容發生變化,就會改變modCount的值。

每當迭代器使用hashNext()/next()遍歷下一個元素之前,都會檢測modCount變量是否爲expectedmodCount值,是的話就返回遍歷;否則拋出異常,終止遍歷。

解決辦法:

  1. 在遍歷過程中,所有涉及到改變modCount值得地方全部加上synchronized。
  2. 使用CopyOnWriteArrayList來替換ArrayList

Collection 接口

List 接口

Itertator 是什麼

Iterator 接口提供遍歷任何 Collection 的接口。我們可以從一個 Collection 中使用迭代器方法來獲取迭代器實例。

迭代器取代了 Java 集合框架中的 Enumeration,迭代器允許調用者在迭代過程中移除元素。

List<String> list = new ArrayList<>();
Iterator<String> it = list. iterator();
while(it. hasNext()){
  String obj = it. next();
  System. out. println(obj);
}

如何邊遍歷邊移除 Collection 中的元素?

Iterator<Integer> it = list.iterator();
while(it.hasNext()){
   *// do something*
   it.remove();
}

一種最常見的錯誤代碼如下:

for(Integer i : list){
   list.remove(i)
}

運行以上錯誤代碼會報 ConcurrentModificationException 異常

如何實現數組和 List 之間的轉換?

  • 數組轉 List:使用 Arrays. asList(array) 進行轉換。
  • List 轉數組:使用 List 自帶的 toArray() 方法。

ArrayList 和 LinkedList 的區別是什麼?

  • 數據結構實現:ArrayList 是動態數組的數據結構實現,而 LinkedList 是雙向鏈表的數據結構實現。
  • 隨機訪問效率:ArrayList 比 LinkedList 在隨機訪問的時候效率要高,因爲 LinkedList 是線性的數據存儲方式,所以需要移動指針從前往後依次查找。
  • 增加和刪除效率:在非首尾的增加和刪除操作,LinkedList 要比 ArrayList 效率要高,因爲 ArrayList 增刪操作要影響數組內的其他數據的下標。
  • 內存空間佔用:LinkedList 比 ArrayList 更佔內存,因爲 LinkedList 的節點除了存儲數據,還存儲了兩個引用,一個指向前一個元素,一個指向後一個元素。
  • 線程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保證線程安全;

綜合來說,在需要頻繁讀取集合中的元素時,更推薦使用 ArrayList,而在插入和刪除操作較多時,更推薦使用 LinkedList。

爲什麼 ArrayList 的 elementData 加上 transient 修飾?

ArrayList 中的數組定義如下:

private transient Object[] elementData;

ArrayList 的定義:

public class ArrayList<E> extends AbstractList<E>
     implements List<E>, RandomAccess, Cloneable, java.io.Serializable

ArrayList 實現了 Serializable 接口,這意味着 ArrayList 支持序列化。

transient 的作用是說不希望 elementData 數組被序列化。

每次序列化時,先調用 defaultWriteObject() 方法序列化 ArrayList 中的非 transient 元素,然後遍歷 elementData,只序列化已存入的元素,這樣既加快了序列化的速度,又減小了序列化之後的文件大小。

介紹下CopyOnWriteArrayList?

CopyOnWriteArrayList是ArrayList的線程安全版本,也是大名鼎鼎的copy-on-write(COW,寫時複製)的一種實現。

在讀操作時不加鎖,跟ArrayList類似;在寫操作時,複製出一個新的數組,在新數組上進行操作,操作完了,將底層數組指針指向新數組。

適合使用在讀多寫少的場景。例如add(Ee)方法的操作流程如下:使用ReentrantLock加鎖,拿到原數組的length,使用Arrays.copyOf方法從原數組複製一個新的數組(length+1),將要添加的元素放到新數組的下標length位置,最後將底層數組指針指向新數組。

List、Set、Map三者的區別?

  • List(對付順序的好幫手):存儲的對象是可重複的、有序的。
  • Set(注重獨一無二的性質):存儲的對象是不可重複的、無序的。
  • Map(用Key來搜索的專業戶):存儲鍵值對(key-value),不能包含重複的鍵(key),每個鍵只能映射到一個值。

Set 接口

說一下 HashSet 的實現原理?

  • HashSet底層原理完全就是包裝了一下HashMap
  • HashSet的唯一性保證是依賴與hashCode()equals()兩個方法,所以存入對象的時候一定要自己重寫這兩個方法來設置去重的規則。
  • HashSet 中的元素都存放在 HashMap key 上面,而value 中的值都是統一的一個 private static final Object PRESENT = new Object();

hashCode()與equals()的相關規定

  1. 如果兩個對象相等,則 hashcode 一定也是相同的
  2. 兩個對象相等,對兩個 equals 方法返回 true
  3. 兩個對象有相同的 hashcode 值,它們也不一定是相等的
  4. 綜上,equals方法被覆蓋過,則hashCode方法也必須被覆蓋
  5. hashCode()的默認行爲是對堆上的對象產生獨特值。如果沒有重寫hashCode(),則該class的兩個對象無論如何都不會相等(即使這兩個對象指向相同的數據)。

==與equals的區別

  1. == 是判斷兩個變量或實例是不是指向同一個內存空間 equals 是判斷兩個變量或實例所指向的內存空間的值是不是相同
  2. == 是指對內存地址進行比較 equals() 是對字符串的內容進行比較
  3. ==指引用是否相同, equals() 指的是值是否相同。

Queue

BlockingQueue是什麼?

Java.util.concurrent.BlockingQueue 是一個隊列,在進行檢索或移除一個元素的時候,線程會等待隊列變爲非空;

當在添加一個元素時,線程會等待隊列中的可用空間。

BlockingQueue接口是Java集合框架的一部分,主要用於實現生產者-消費者模式。

Java提供了幾種 BlockingQueue 的實現,比如ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueueSynchronousQueue等。

在 Queue 中 poll()和 remove()有什麼區別?

  • 相同點:都是返回第一個元素,並在隊列中刪除返回的對象。
  • 不同點:如果沒有元素 poll()會返回 null,而 remove()會直接拋出 NoSuchElementException 異常。

Map 接口

Map 整體結構如下所示:

Hashtable 比較特別,作爲類似 Vector、Stack 的早期集合相關類型,它是擴展了 Dictionary 類的,類結構上與 HashMap 之類明顯不同。

HashMap 等其他 Map 實現則是都擴展了 AbstractMap,裏面包含了通用方法抽象。

不同 Map 的用途,從類圖結構就能體現出來,設計目的已經體現在不同接口上。

HashMap 的實現原理?

在 JDK 1.7 中 HashMap 是以數組加鏈表的形式組成的,JDK 1.8 之後新增了紅黑樹的組成結構,當鏈表大於 8 並且容量大於 64 時,鏈表結構會轉換成紅黑樹結構。

HashMap 基於 Hash 算法實現的:

  1. 當我們往Hashmap 中 put 元素時,利用 key 的 hashCode 重新 hash 計算出當前對象的元素在數組中的下標。
  2. 存儲時,如果出現 hash 值相同的 key,此時有兩種情況。
    • 如果 key 相同,則覆蓋原始值;
    • 如果 key 不同(出現衝突),則將當前的 key-value 放入鏈表中
  3. 獲取時,直接找到 hash 值對應的下標,在進一步判斷 key 是否相同,從而找到對應值。
  4. 理解了以上過程就不難明白 HashMap 是如何解決 hash 衝突的問題,核心就是使用了數組的存儲方式,然後將衝突的key的對象放入鏈表中,一旦發現衝突就在鏈表中做進一步的對比。

JDK1.7 VS JDK1.8 比較

JDK1.8主要解決或優化了一下問題:

  1. resize 擴容優化
  2. 引入了紅黑樹,目的是避免單條鏈表過長而影響查詢效率,紅黑樹算法請參考
  3. 解決了多線程死循環問題,但仍是非線程安全的,多線程時可能會造成數據丟失問題。
不同 JDK 1.7 JDK 1.8
存儲結構 數組 + 鏈表 數組 + 鏈表 + 紅黑樹
初始化方式 單獨函數:inflateTable() 直接集成到了擴容函數resize()
hash值計算方式 擾動處理 = 9次擾動 = 4次位運算 + 5次異或運算 擾動處理 = 2次擾動 = 1次位運算 + 1次異或運算
存放數據的規則 無衝突時,存放數組;衝突時,存放鏈表 無衝突時,存放數組;衝突 & 鏈表長度 < 8:存放單鏈表;衝突 & 鏈表長度 > 8:樹化並存放紅黑樹
插入數據方式 頭插法(先講原位置的數據移到後1位,再插入數據到該位置) 尾插法(直接插入到鏈表尾部/紅黑樹)
擴容後存儲位置的計算方式 全部按照原來方法進行計算(即hashCode ->> 擾動函數 ->> (h&length-1)) 按照擴容後的規律計算(即擴容後的位置=原位置 or 原位置 + 舊容量)

如何有效避免哈希碰撞

主要是因爲如果使用hashCode取餘,那麼相當於參與運算的只有hashCode的低位,高位是沒有起到任何作用的。

所以我們的思路就是讓 hashCode 取值出的高位也參與運算,進一步降低hash碰撞的概率,使得數據分佈更平均,我們把這樣的操作稱爲擾動,在JDK 1.8中的hash()函數如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 與自己右移16位進行異或運算(高低位異或)
}

HashMap的put方法的具體流程?

當我們put的時候,首先計算 keyhash值,這裏調用了 hash方法,hash方法實際是讓key.hashCode()key.hashCode()>>>16進行異或操作,高16bit補0,一個數和0異或不變,所以 hash 函數大概的作用就是:高16bit不變,低16bit和高16bit做了一個異或,目的是減少碰撞

①.判斷鍵值對數組table[i]是否爲空或爲null,否則執行resize()進行擴容;

②.根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向⑥,如果table[i]不爲空,轉向③;

③.判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則轉向④,這裏的相同指的是hashCode以及equals;

④.判斷table[i] 是否爲treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向⑤;

⑤.遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;

⑥.插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,如果超過,進行擴容。

HashMap的擴容操作是怎麼實現的?

①.在jdk1.8中,resize方法是在hashmap中的鍵值對大於閥值時或者初始化時,就調用resize方法進行擴容;

②.每次擴展的時候,都是擴展2倍;

③.擴展後Node對象的位置要麼在原位置,要麼移動到原偏移量兩倍的位置。

在1.7中,擴容之後需要重新去計算其Hash值,根據Hash值對其進行分發.

但在1.8版本中,則是根據在同一個桶的位置中進行判斷(e.hash & oldCap)是否爲0,0 -表示還在原來位置,否則就移動到原數組位置 + oldCap。

重新進行 hash 分配後,該元素的位置要麼停留在原始位置,要麼移動到原始位置+增加的數組大小這個位置上。

任何類都可以作爲 Key 麼?

可以使用任何類作爲 Map 的 key,然而在使用之前,需要考慮以下幾點:

  • 如果類重寫了 equals() 方法,也應該重寫 hashCode() 方法。

  • 類的所有實例需要遵循與 equals() 和 hashCode() 相關的規則。

  • 如果一個類沒有使用 equals(),不應該在 hashCode() 中使用它。

  • 用戶自定義 Key 類最佳實踐是使之爲不可變的,這樣 hashCode() 值可以被緩存起來,擁有更好的性能。

    不可變的類也可以確保 hashCode() 和 equals() 在未來不會改變,這樣就會解決與可變相關的問題了。

爲什麼HashMap中String、Integer這樣的包裝類適合作爲K?

String、Integer等包裝類的特性能夠保證Hash值的不可更改性和計算準確性,能夠有效的減少Hash碰撞的機率。

  1. 都是final類型,即不可變性,保證key的不可更改性,不會存在獲取hash值不同的情況
  2. 內部已重寫了equals()hashCode()等方法,遵守了HashMap內部的規範(不清楚可以去上面看看putValue的過程),不容易出現Hash值計算錯誤的情況;

HashMap爲什麼不直接使用hashCode()處理後的哈希值直接作爲table的下標?

hashCode()方法返回的是int整數類型,其範圍爲-(2 ^ 31)~(2 ^ 31 - 1),約有40億個映射空間,而HashMap的容量範圍是在16(初始化默認值)~2 ^ 30,HashMap通常情況下是取不到最大值的,並且設備上也難以提供這麼多的存儲空間,從而導致通過hashCode()計算出的哈希值可能不在數組大小範圍內,進而無法匹配存儲位置;

HashMap 的長度爲什麼是2的冪次方

爲了能讓 HashMap 存取高效,儘量較少碰撞,也就是要儘量把數據分配均勻,每個鏈表/紅黑樹長度大致相同。這個實現就是把數據存到哪個鏈表/紅黑樹中的算法。

這個算法應該如何設計呢?

我們首先可能會想到採用 % 取餘的操作來實現。

但是,重點來了:取餘(%)操作中如果除數是2的冪次則等價於與其除數減一的與(&)操作(也就是說 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。

並且採用二進制位操作 &,相對於 % 能夠提高運算效率,這就解釋了 HashMap 的長度爲什麼是2的冪次方。

那爲什麼是兩次擾動呢?

答:這樣就是加大哈希值低位的隨機性,使得分佈更均勻,從而提高對應數組存儲下標位置的隨機性&均勻性,最終減少Hash衝突,兩次就夠了,已經達到了高位低位同時參與運算的目的;

HashMap 和 ConcurrentHashMap 的區別

  1. ConcurrentHashMap對整個桶數組進行了分割分段(Segment),每一個分段上都用lock鎖進行保護,相對於HashTable的synchronized鎖的粒度更精細了一些,併發性能更好,而HashMap沒有鎖機制,不是線程安全的。(JDK1.8之後ConcurrentHashMap啓用了一種全新的方式實現,利用 synchronized + CAS算法。)
  2. HashMap的鍵值對允許有null,但是ConCurrentHashMap都不允許。

ConcurrentHashMap 實現原理

JDK1.7

首先將數據分爲一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據時,其他段的數據也能被其他線程訪問。

在JDK1.7中,ConcurrentHashMap採用Segment + HashEntry的方式進行實現,結構如下:

一個 ConcurrentHashMap 裏包含一個 Segment 數組。

Segment 的結構和HashMap類似,是一種數組和鏈表結構,一個 Segment 包含一個 HashEntry 數組,每個 HashEntry 是一個鏈表結構的元素,每個 Segment 守護着一個HashEntry數組裏的元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得對應的 Segment的鎖。

  1. 該類包含兩個靜態內部類 HashEntry 和 Segment ;前者用來封裝映射表的鍵值對,後者用來充當鎖的角色;
  2. HashEntry 內部使用 volatile 的 value 字段來保證可見性,get 操作需要保證的是可見性,所以並沒有什麼同步邏輯。
  3. Segment 是一種可重入的鎖 ReentrantLock,每個 Segment 守護一個HashEntry 數組裏得元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得對應的 Segment 鎖。

get 操作需要保證的是可見性,所以並沒有什麼同步邏輯


public V get(Object key) {
        Segment<K,V> s; // manually integrate access methods to reduce overhead
        HashEntry<K,V>[] tab;
        int h = hash(key.hashCode());
       //利用位操作替換普通數學運算
       long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        // 以Segment爲單位,進行定位
        // 利用Unsafe直接進行volatile access
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) {
           //省略
          }
        return null;
    }

而對於 put 操作,首先是通過二次哈希避免哈希衝突,然後以 Unsafe 調用方式,直接獲取相應的 Segment,然後進行線程安全的 put 操作:


 public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        // 二次哈希,以保證數據的分散性,避免哈希衝突
        int hash = hash(key.hashCode());
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }

其核心邏輯實現在下面的內部方法中:


final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            // scanAndLockForPut會去查找是否有key相同Node
            // 無論如何,確保獲取鎖
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        // 更新已有value...
                    }
                    else {
                        // 放置HashEntry到特定位置,如果超過閾值,進行rehash
                        // ...
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

JDK1.8

JDK1.8中,放棄了Segment臃腫的設計,取而代之的是採用Node + CAS + Synchronized來保證併發安全進行實現

synchronized只鎖定當前鏈表或紅黑二叉樹的首節點,這樣只要hash不衝突,就不會產生併發,效率又提升N倍。

  • 總體結構上,它的內部存儲和 HashMap 結構非常相似,同樣是大的桶(bucket)數組,然後內部也是一個個所謂的鏈表結構(bin),同步的粒度要更細緻一些。

  • 其內部仍然有 Segment 定義,但僅僅是爲了保證序列化時的兼容性而已,不再有任何結構上的用處。

  • 因爲不再使用 Segment,初始化操作大大簡化,修改爲 lazy-load 形式,這樣可以有效避免初始開銷,解決了老版本很多人抱怨的這一點。

  • 數據存儲利用 volatile 來保證可見性。

  • 使用 CAS 等操作,在特定場景進行無鎖併發操作。

  • 使用 Unsafe、LongAdder 之類底層手段,進行極端情況的優化。

另外,需要注意的是,“線程安全”這四個字特別容易讓人誤解,因爲ConcurrentHashMap 只能保證提供的原子性讀寫操作是線程安全的。

誤區

我們來看一個使用 Map 來統計 Key 出現次數的場景吧,這個邏輯在業務代碼中非常常見。

開發人員誤以爲使用了 ConcurrentHashMap 就不會有線程安全問題,於是不加思索地寫出了下面的代碼:

  • 在每一個線程的代碼邏輯中先通過 containsKey方法判斷可以 是否存在。
  • key 存在則 + 1,否則初始化 1.

// 共享數據
ConcurrentHashMap<String, Long> freqs = new ConcurrentHashMap<>(ITEM_COUNT);

public void normaluse(String key) throws InterruptedException {
    
      if (freqs.containsKey(key)) {
        //Key存在則+1
        freqs.put(key, freqs.get(key) + 1);
      } else {
        //Key不存在則初始化爲1
        freqs.put(key, 1L);
      }
}

大錯特錯啊朋友們,需要注意 ConcurrentHashMap 對外提供的方法或能力的限制

  • 使用了 ConcurrentHashMap,不代表對它的多個操作之間的狀態是一致的,是沒有其他線程在操作它的,如果需要確保需要手動加鎖。

  • 諸如 size、isEmpty 和 containsValue 等聚合方法,在併發情況下可能會反映 ConcurrentHashMap 的中間狀態。

    因此在併發情況下,這些方法的返回值只能用作參考,而不能用於流程控制。

    顯然,利用 size 方法計算差異值,是一個流程控制。

  • 諸如 putAll 這樣的聚合方法也不能確保原子性,在 putAll 的過程中去獲取數據可能會獲取到部分數據。

正確寫法:

//利用computeIfAbsent()方法來實例化LongAdder,然後利用LongAdder來進行線程安全計數 
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
  • 使用 ConcurrentHashMap 的原子性方法 computeIfAbsent 來做複合邏輯操作,判斷 Key 是否存在 Value,如果不存在則把 Lambda 表達式運行後的結果放入 Map 作爲 Value,也就是新創建一個 LongAdder 對象,最後返回 Value。

  • 由於 computeIfAbsent 方法返回的 Value 是 LongAdder,是一個線程安全的累加器,因此可以直接調用其 increment 方法進行累加。

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