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

碼哥位元組發表於2021-11-18

麵霸篇,從面試角度作為切入點提升大家的 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 方法進行累加。

相關文章