看完這篇 HashMap,和麵試官扯皮就沒問題了

程式設計師cxuan發表於2020-06-23

HashMap 概述

如果你沒有時間細摳本文,可以直接看 HashMap 概述,能讓你對 HashMap 有個大致的瞭解

HashMap 是 Map 介面的實現,HashMap 允許空的 key-value 鍵值對,HashMap 被認為是 Hashtable 的增強版,HashMap 是一個非執行緒安全的容器,如果想構造執行緒安全的 Map 考慮使用 ConcurrentHashMap。HashMap 是無序的,因為 HashMap 無法保證內部儲存的鍵值對的有序性。

HashMap 的底層資料結構是陣列 + 連結串列的集合體,陣列在 HashMap 中又被稱為桶(bucket)。遍歷 HashMap 需要的時間損耗為 HashMap 例項桶的數量 + (key - value 對映) 的數量。因此,如果遍歷元素很重要的話,不要把初始容量設定的太高或者負載因子設定的太低。

HashMap 例項有兩個很重要的因素,初始容量和負載因子,初始容量指的就是 hash 表桶的數量,負載因子是一種衡量雜湊表填充程度的標準,當雜湊表中存在足夠數量的 entry,以至於超過了負載因子和當前容量,這個雜湊表會進行 rehash 操作,內部的資料結構重新 rebuilt。

注意 HashMap 不是執行緒安全的,如果多個執行緒同時影響了 HashMap ,並且至少一個執行緒修改了 HashMap 的結構,那麼必須對 HashMap 進行同步操作。可以使用 Collections.synchronizedMap(new HashMap) 來建立一個執行緒安全的 Map。

HashMap 會導致除了迭代器本身的 remove 外,外部 remove 方法都可能會導致 fail-fast 機制,因此儘量要用迭代器自己的 remove 方法。如果在迭代器建立的過程中修改了 map 的結構,就會丟擲 ConcurrentModificationException 異常。

下面就來聊一聊 HashMap 的細節問題。我們還是從面試題入手來分析 HashMap 。

HashMap 和 HashTable 的區別

我們上面介紹了一下 HashMap ,現在來介紹一下 HashTable

相同點

HashMap 和 HashTable 都是基於雜湊表實現的,其內部每個元素都是 key-value 鍵值對,HashMap 和 HashTable 都實現了 Map、Cloneable、Serializable 介面。

不同點

  • 父類不同:HashMap 繼承了 AbstractMap 類,而 HashTable 繼承了 Dictionary

  • 空值不同:HashMap 允許空的 key 和 value 值,HashTable 不允許空的 key 和 value 值。HashMap 會把 Null key 當做普通的 key 對待。不允許 null key 重複。

  • 執行緒安全性:HashMap 不是執行緒安全的,如果多個外部操作同時修改 HashMap 的資料結構比如 add 或者是 delete,必須進行同步操作,僅僅對 key 或者 value 的修改不是改變資料結構的操作。可以選擇構造執行緒安全的 Map 比如 Collections.synchronizedMap 或者是 ConcurrentHashMap。而 HashTable 本身就是執行緒安全的容器。

  • 效能方面:雖然 HashMap 和 HashTable 都是基於單連結串列的,但是 HashMap 進行 put 或者 get? 操作,可以達到常數時間的效能;而 HashTable 的 put 和 get 操作都是加了 synchronized 鎖的,所以效率很差。

  • 初始容量不同:HashTable 的初始長度是11,之後每次擴充容量變為之前的 2n+1(n為上一次的長度)

    而 HashMap 的初始長度為16,之後每次擴充變為原來的兩倍。建立時,如果給定了容量初始值,那麼HashTable 會直接使用你給定的大小,而 HashMap 會將其擴充為2的冪次方大小。

HashMap 和 HashSet 的區別

也經常會問到 HashMap 和 HashSet 的區別

HashSet 繼承於 AbstractSet 介面,實現了 Set、Cloneable,、java.io.Serializable 介面。HashSet 不允許集合中出現重複的值。HashSet 底層其實就是 HashMap,所有對 HashSet 的操作其實就是對 HashMap 的操作。所以 HashSet 也不保證集合的順序。

HashMap 底層結構

要了解一個類,先要了解這個類的結構,先來看一下 HashMap 的結構:

最主要的三個類(介面)就是 HashMap,AbstractMapMap 了,HashMap 我們上面已經在概述中簡單介紹了一下,下面來介紹一下 AbstractMap。

AbstractMap 類

這個抽象類是 Map 介面的骨幹實現,以求最大化的減少實現類的工作量。為了實現不可修改的 map,程式設計師僅需要繼承這個類並且提供 entrySet 方法的實現即可。它將會返回一組 map 對映的某一段。通常,返回的集合將在AbstractSet 之上實現。這個set不應該支援 add 或者 remove 方法,並且它的迭代器也不支援 remove 方法。

為了實現可修改的 map,程式設計師必須額外重寫這個類的 put 方法(否則就會丟擲UnsupportedOperationException),並且 entrySet.iterator() 返回的 iterator 必須實現 remove() 方法。

Map 介面

Map 介面定義了 key-value 鍵值對的標準。一個物件支援 key-value 儲存。Map不能包含重複的 key,每個鍵最多對映一個值。這個介面代替了Dictionary 類,Dictionary是一個抽象類而不是介面。

Map 介面提供了三個集合的構造器,它允許將 map 的內容視為一組鍵,值集合或一組鍵值對映。map的順序定義為map對映集合上的迭代器返回其元素的順序。一些map實現,像是TreeMap類,保證了map的有序性;其他的實現,像是HashMap,則沒有保證。

重要內部類和介面

Node 介面

Node節點是用來儲存HashMap的一個個例項,它實現了 Map.Entry介面,我們先來看一下 Map中的內部介面 Entry 介面的定義

Map.Entry

// 一個map 的entry 鏈,這個Map.entrySet()方法返回一個集合的檢視,包含類中的元素,
// 這個唯一的方式是從集合的檢視進行迭代,獲取一個map的entry鏈。這些Map.Entry鏈只在
// 迭代期間有效。
interface Entry<K,V> {
  K getKey();
  V getValue();
  V setValue(V value);
  boolean equals(Object o);
  int hashCode();
}

Node 節點會儲存四個屬性,hash值,key,value,指向下一個Node節點的引用

 // hash值
final int hash;
// 鍵
final K key;
// 值
V value;
// 指向下一個Node節點的Node型別
Node<K,V> next;

因為Map.Entry 是一條條entry 鏈連線在一起的,所以Node節點也是一條條entry鏈。構造一個新的HashMap例項的時候,會把這四個屬性值分為傳入

Node(int hash, K key, V value, Node<K,V> next) {
  this.hash = hash;
  this.key = key;
  this.value = value;
  this.next = next;
}

實現了 Map.Entry 介面所以必須實現其中的方法,所以 Node 節點中也包括上面的五個方法

KeySet 內部類

keySet 類繼承於 AbstractSet 抽象類,它是由 HashMap 中的 keyset() 方法來建立 KeySet 例項的,旨在對HashMap 中的key鍵進行操作,看一個程式碼示例

圖中把1, 2, 3這三個key 放在了HashMap中,然後使用 lambda 表示式迴圈遍歷 key 值,可以看到,map.keySet() 其實是返回了一個 Set 介面,KeySet() 是在 Map 介面中進行定義的,不過是被HashMap 進行了實現操作,來看一下原始碼就明白了

// 返回一個set檢視,這個檢視中包含了map中的key。
public Set<K> keySet() {
  // // keySet 指向的是 AbstractMap 中的 keyset
  Set<K> ks = keySet;
  if (ks == null) {
    // 如果 ks 為空,就建立一個 KeySet 物件
    // 並對 ks 賦值。
    ks = new KeySet();
    keySet = ks;
  }
  return ks;
}

所以 KeySet 類中都是對 Map中的 Key 進行操作的:

Values 內部類

Values 類的建立其實是和 KeySet 類很相似,不過 KeySet 旨在對 Map中的鍵進行操作,Values 旨在對key-value 鍵值對中的 value 值進行使用,看一下程式碼示例:

迴圈遍歷 Map中的 values值,看一下 values() 方法最終建立的是什麼:

public Collection<V> values() {
  // values 其實是 AbstractMap 中的 values
  Collection<V> vs = values;
  if (vs == null) {
    vs = new Values();
    values = vs;
  }
  return vs;
}

所有的 values 其實都儲存在 AbstractMap 中,而 Values 類其實也是實現了 Map 中的 Values 介面,看一下對 values 的操作都有哪些方法

其實是和 key 的操作差不多

EntrySet 內部類

上面提到了HashMap中分別有對 key、value 進行操作的,其實還有對 key-value 鍵值對進行操作的內部類,它就是 EntrySet,來看一下EntrySet 的建立過程:

點進去 entrySet() 會發現這個方法也是在 Map 介面中定義的,HashMap對它進行了重寫

// 返回一個 set 檢視,此檢視包含了 map 中的key-value 鍵值對
public Set<Map.Entry<K,V>> entrySet() {
  Set<Map.Entry<K,V>> es;
  return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

如果 es 為空建立一個新的 EntrySet 例項,EntrySet 主要包括了對key-value 鍵值對對映的方法,如下

HashMap 1.7 的底層結構

JDK1.7 中,HashMap 採用位桶 + 連結串列的實現,即使用連結串列來處理衝突,同一 hash 值的連結串列都儲存在一個陣列中。但是當位於一個桶中的元素較多,即 hash 值相等的元素較多時,通過 key 值依次查詢的效率較低。它的資料結構如下

HashMap 底層資料結構就是一個 Entry 陣列,Entry 是 HashMap 的基本組成單元,每個 Entry 中包含一個 key-value 鍵值對。

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

而每個 Entry 中包含 hash, key ,value 屬性,它是 HashMap 的一個內部類

static class Entry<K,V> implements Map.Entry<K,V> {
  final K key;
  V value;
  Entry<K,V> next;
  int hash;
  
  Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
  }
  ...
}

所以,HashMap 的整體結構就像下面這樣

HashMap 1.8 的底層結構

與 JDK 1.7 相比,1.8 在底層結構方面做了一些改變,當每個桶中元素大於 8 的時候,會轉變為紅黑樹,目的就是優化查詢效率,JDK 1.8 重寫了 resize() 方法。

HashMap 重要屬性

初始容量

HashMap 的預設初始容量是由 DEFAULT_INITIAL_CAPACITY 屬性管理的。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

HashMaap 的預設初始容量是 1 << 4 = 16, << 是一個左移操作,它相當於是

最大容量

HashMap 的最大容量是

static final int MAXIMUM_CAPACITY = 1 << 30;

這裡是不是有個疑問?int 佔用四個位元組,按說最大容量應該是左移 31 位,為什麼 HashMap 最大容量是左移 30 位呢?因為在數值計算中,最高位也就是最左位的 是代表著符號為,0 -> 正數,1 -> 負數,容量不可能是負數,所以 HashMap 最高位只能移位到 2 ^ 30 次冪。

預設負載因子

HashMap 的預設負載因子是

static final float DEFAULT_LOAD_FACTOR = 0.75f;

float 型別所以用 .f 為單位,負載因子是和擴容機制有關,這裡大致提一下,後面會細說。擴容機制的原則是當 HashMap 中儲存的數量 > HashMap 容量 * 負載因子時,就會把 HashMap 的容量擴大為原來的二倍。

HashMap 的第一次擴容就在 DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12 時進行。

樹化閾值

HashMap 的樹化閾值是

static final int TREEIFY_THRESHOLD = 8;

在進行新增元素時,當一個桶中儲存元素的數量 > 8 時,會自動轉換為紅黑樹(JDK1.8 特性)。

連結串列閾值

HashMap 的連結串列閾值是

static final int UNTREEIFY_THRESHOLD = 6;

在進行刪除元素時,如果一個桶中儲存元素數量 < 6 後,會自動轉換為連結串列

擴容臨界值

static final int MIN_TREEIFY_CAPACITY = 64;

這個值表示的是當桶陣列容量小於該值時,優先進行擴容,而不是樹化

節點陣列

HashMap 中的節點陣列就是 Entry 陣列,它代表的就是 HashMap 中 陣列 + 連結串列 資料結構中的陣列。

transient Node<K,V>[] table;

Node 陣列在第一次使用的時候進行初始化操作,在必要的時候進行 resize,resize 後陣列的長度擴容為原來的二倍。

鍵值對數量

在 HashMap 中,使用 size 來表示 HashMap 中鍵值對的數量。

修改次數

在 HashMap 中,使用 modCount 來表示修改次數,主要用於做併發修改 HashMap 時的快速失敗 - fail-fast 機制。

擴容閾值

在 HashMap 中,使用 threshold 表示擴容的閾值,也就是 初始容量 * 負載因子的值。

threshold 涉及到一個擴容的閾值問題,這個問題是由 tableSizeFor 原始碼解決的。我們先看一下它的原始碼再來解釋

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

程式碼中涉及一個運算子 |= ,它表示的是按位或,啥意思呢?你一定知道 a+=b 的意思是 a=a+b,那麼 **同理:a |= b 就是 a = a | b **,也就是雙方都轉換為二進位制,來進行與操作。如下圖所示

我們上面採用了一個比較大的數字進行擴容,由上圖可知 2^29 次方的陣列經過一系列的或操作後,會算出來結果是 2^30 次方。

所以擴容後的陣列長度是原來的 2 倍。

負載因子

loadFactor 表示負載因子,它表示的是 HashMap 中的密集程度。

HashMap 建構函式

在 HashMap 原始碼中,有四種建構函式,分別來介紹一下

  • 帶有初始容量 initialCapacity負載因子 loadFactor 的建構函式
public HashMap(int initialCapacity, float loadFactor) {
  if (initialCapacity < 0)
    throw new IllegalArgumentException("Illegal initial capacity: " +
                                       initialCapacity);
  if (initialCapacity > MAXIMUM_CAPACITY)
    initialCapacity = MAXIMUM_CAPACITY;
  if (loadFactor <= 0 || Float.isNaN(loadFactor))
    throw new IllegalArgumentException("Illegal load factor: " +
                                       loadFactor);
  this.loadFactor = loadFactor;
  // 擴容的閾值
  this.threshold = tableSizeFor(initialCapacity);
}

初始容量不能為負,所以當傳遞初始容量 < 0 的時候,會直接丟擲 IllegalArgumentException 異常。如果傳遞進來的初始容量 > 最大容量時,初始容量 = 最大容量。負載因子也不能小於 0 。然後進行陣列的擴容,這個擴容機制也非常重要,我們後面進行探討

  • 只帶有 initialCapacity 的建構函式
public HashMap(int initialCapacity) {
  this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

最終也會呼叫到上面的建構函式,不過這個預設的負載因子就是 HashMap 的預設負載因子也就是 0.75f

  • 無引數的建構函式
public HashMap() {
  this.loadFactor = DEFAULT_LOAD_FACTOR;
}

預設的負載因子也就是 0.75f

  • 帶有 map 的建構函式
public HashMap(Map<? extends K, ? extends V> m) {
  this.loadFactor = DEFAULT_LOAD_FACTOR;
  putMapEntries(m, false);
}

帶有 Map 的建構函式,會直接把外部元素批量放入 HashMap 中。

講一講 HashMap put 的全過程

我記得剛畢業一年去北京面試,一家公司問我 HashMap put 過程的時候,我支支吾吾答不上來,後面痛下決心好好整。以 JDK 1.8 為基準進行分析,後面也是。先貼出整段程式碼,後面會逐行進行分析。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
  Node<K,V>[] tab; Node<K,V> p; int n, i;
  // 如果table 為null 或者沒有為 table 分配記憶體,就resize一次
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  // 指定hash值節點為空則直接插入,這個(n - 1) & hash才是表中真正的雜湊
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  // 如果不為空
  else {
    Node<K,V> e; K k;
    // 計算表中的這個真正的雜湊值與要插入的key.hash相比
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    // 若不同的話,並且當前節點已經在 TreeNode 上了
    else if (p instanceof TreeNode)
      // 採用紅黑樹儲存方式
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    // key.hash 不同並且也不再 TreeNode 上,在連結串列上找到 p.next==null
    else {
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          // 在表尾插入
          p.next = newNode(hash, key, value, null);
          // 新增節點後如果節點個數到達閾值,則進入 treeifyBin() 進行再次判斷
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        // 如果找到了同 hash、key 的節點,那麼直接退出迴圈
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        // 更新 p 指向下一節點
        p = e;
      }
    }
    // map中含有舊值,返回舊值
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }
  }
  // map調整次數 + 1
  ++modCount;
  // 鍵值對的數量達到閾值,需要擴容
  if (++size > threshold)
    resize();
  afterNodeInsertion(evict);
  return null;
}

首先看一下 putVal 方法,這個方法是 final 的,如果你自已定義 HashMap 繼承的話,是不允許你自己重寫 put 方法的,然後這個方法涉及五個引數

  • hash -> put 放在桶中的位置,在 put 之前,會進行 hash 函式的計算。
  • key -> 引數的 key 值
  • value -> 引數的 value 值
  • onlyIfAbsent -> 是否改變已經存在的值,也就是是否進行 value 值的替換標誌
  • evict -> 是否是剛建立 HashMap 的標誌

在呼叫到 putVal 方法時,首先會進行 hash 函式計算應該插入的位置

public V put(K key, V value) {
  return putVal(hash(key), key, value, false, true);
}

雜湊函式的原始碼如下

static final int hash(Object key) {
  int h;
  return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

首先先來理解一下 hash 函式的計算規則

Hash 函式

hash 函式會根據你傳遞的 key 值進行計算,首先計算 key 的 hashCode 值,然後再對 hashcode 進行無符號右移操作,最後再和 hashCode 進行異或 ^ 操作。

>>>: 無符號右移操作,它指的是 無符號右移,也叫邏輯右移,即若該數為正,則高位補0,而若該數為負數,則右移後高位同樣補0 ,也就是不管是正數還是負數,右移都會在空缺位補 0 。

在得到 hash 值後,就會進行 put 過程。

首先會判斷 HashMap 中的 Node 陣列是否為 null,如果第一次建立 HashMap 並進行第一次插入元素,首先會進行陣列的 resize,也就是重新分配,這裡還涉及到一個 resize() 擴容機制原始碼分析,我們後面會介紹。擴容完畢後,會計算出 HashMap 的存放位置,通過使用 ( n - 1 ) & hash 進行計算得出。

然後會把這個位置作為陣列的下標作為存放元素的位置。如果不為空,那麼計算表中的這個真正的雜湊值與要插入的 key.hash 相比。如果雜湊值相同,key-value 不一樣,再判斷是否是樹的例項,如果是的話,那麼就把它插入到樹上。如果不是,就執行尾插法在 entry 鏈尾進行插入。

會根據桶中元素的數量判斷是連結串列還是紅黑樹。然後判斷鍵值對數量是否大於閾值,大於的話則進行擴容。

擴容機制

在 Java 中,陣列的長度是固定的,這意味著陣列只能儲存固定量的資料。但在開發的過程中,很多時候我們無法知道該建多大的陣列合適。好在 HashMap 是一種自動擴容的資料結構,在這種基於變長的資料結構中,擴容機制是非常重要的。

在 HashMap 中,閾值大小為桶陣列長度與負載因子的乘積。當 HashMap 中的鍵值對數量超過閾值時,進行擴容。HashMap 中的擴容機制是由 resize() 方法來實現的,下面我們就來一次認識下。(貼出中文註釋,便於複製)

final Node<K,V>[] resize() {
  Node<K,V>[] oldTab = table;
  // 儲存old table 的大小
  int oldCap = (oldTab == null) ? 0 : oldTab.length;
  // 儲存擴容閾值
  int oldThr = threshold;
  int newCap, newThr = 0;
  if (oldCap > 0) {
    // 如果old table資料已達最大,那麼threshold也被設定成最大
    if (oldCap >= MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return oldTab;
    }
    // 左移擴大二倍,
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
      // 擴容成原來二倍
      newThr = oldThr << 1; // double threshold
  }
  // 如果oldThr                                                                                                                                               !> 0
  else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = oldThr;
  // 如果old table <= 0 並且 儲存的閾值 <= 0
  else {               // zero initial threshold signifies using defaults
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  }
  // 如果擴充閾值為0
  if (newThr == 0) {
    // 擴容閾值為 初始容量*負載因子
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
              (int)ft : Integer.MAX_VALUE);
  }
  // 重新給負載因子賦值
  threshold = newThr;
  // 獲取擴容後的陣列
  @SuppressWarnings({"rawtypes","unchecked"})
  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  table = newTab;
  // 如果第一次進行table 初始化不會走下面的程式碼
  // 擴容之後需要重新把節點放在新擴容的陣列中
  if (oldTab != null) {
    for (int j = 0; j < oldCap; ++j) {
      Node<K,V> e;
      if ((e = oldTab[j]) != null) {
        oldTab[j] = null;
        if (e.next == null)
          newTab[e.hash & (newCap - 1)] = e;
        else if (e instanceof TreeNode)
          // 重新對映時,需要對紅黑樹進行拆分
          ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        else { // preserve order
          Node<K,V> loHead = null, loTail = null;
          Node<K,V> hiHead = null, hiTail = null;
          Node<K,V> next;
          // 遍歷連結串列,並將連結串列節點按原順序進行分組
          do {
            next = e.next;
            if ((e.hash & oldCap) == 0) {
              if (loTail == null)
                loHead = e;
              else
                loTail.next = e;
              loTail = e;
            }
            else {
              if (hiTail == null)
                hiHead = e;
              else
                hiTail.next = e;
              hiTail = e;
            }
          } while ((e = next) != null);
          // 將分組後的連結串列對映到新桶中
          if (loTail != null) {
            loTail.next = null;
            newTab[j] = loHead;
          }
          if (hiTail != null) {
            hiTail.next = null;
            newTab[j + oldCap] = hiHead;
          }
        }
      }
    }
  }
  return newTab;
}

擴容機制原始碼比較長,我們耐心點進行拆分

我們以 if...else if...else 邏輯進行拆分,上面程式碼主要做了這幾個事情

  • 判斷 HashMap 中的陣列的長度,也就是 (Node<K,V>[])oldTab.length() ,再判斷陣列的長度是否比最大的的長度也就是 2^30 次冪要大,大的話直接取最大長度,否則利用位運算 <<擴容為原來的兩倍

  • 如果陣列長度不大於0 ,再判斷擴容閾值 threshold 是否大於 0 ,也就是看有無外部指定的擴容閾值,若有則使用,這裡需要說明一下 threshold 何時是 oldThr > 0 ,因為 oldThr = threshold ,這裡其實比較的就是 threshold,因為 HashMap 中的每個構造方法都會呼叫 HashMap(initCapacity,loadFactor) 這個構造方法,所以如果沒有外部指定 initialCapacity,初始容量使用的就是 16,然後根據 this.threshold = tableSizeFor(initialCapacity); 求得 threshold 的值。

  • 否則,直接使用預設的初始容量和擴容閾值,走 else 的邏輯是在 table 剛剛初始化的時候。

然後會判斷 newThr 是否為 0 ,筆者在剛開始研究時發現 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 一直以為這是常量做乘法,怎麼會為 0 ,其實不是這部分的問題,在於上面邏輯判斷中的擴容操作,可能會導致位溢位

導致位溢位的示例:oldCap = 2^28 次冪,threshold > 2 的三次方整數次冪。在進入到 float ft = (float)newCap * loadFactor; 這個方法是 2^28 * 2^(3+n) 會直接 > 2^31 次冪,導致全部歸零。

在擴容後需要把節點放在新擴容的陣列中,這裡也涉及到三個步驟

  • 迴圈桶中的每個 Node 節點,判斷 Node[i] 是否為空,為空直接返回,不為空則遍歷桶陣列,並將鍵值對對映到新的桶陣列中。

  • 如果不為空,再判斷是否是樹形結構,如果是樹形結構則按照樹形結構進行拆分,拆分方法在 split 方法中。

  • 如果不是樹形結構,則遍歷連結串列,並將連結串列節點按原順序進行分組。

講一講 get 方法全過程

我們上面講了 HashMap 中的 put 方法全過程,下面我們來看一下 get 方法的過程,

public V get(Object key) {
  Node<K,V> e;
  return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
  Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

  // 找到真實的元素位置
  if ((tab = table) != null && (n = tab.length) > 0 &&
      (first = tab[(n - 1) & hash]) != null) {
    // 總是會check 一下第一個元素
    if (first.hash == hash && // always check first node
        ((k = first.key) == key || (key != null && key.equals(k))))
      return first;

    // 如果不是第一個元素,並且下一個元素不是空的
    if ((e = first.next) != null) {

      // 判斷是否屬於 TreeNode,如果是 TreeNode 例項,直接從 TreeNode.getTreeNode 取
      if (first instanceof TreeNode)
        return ((TreeNode<K,V>)first).getTreeNode(hash, key);

      // 如果還不是 TreeNode 例項,就直接迴圈陣列元素,直到找到指定元素位置
      do {
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          return e;
      } while ((e = e.next) != null);
    }
  }
  return null;
}

來簡單介紹下吧,首先會檢查 table 中的元素是否為空,然後根據 hash 算出指定 key 的位置。然後檢查連結串列的第一個元素是否為空,如果不為空,是否匹配,如果匹配,直接返回這條記錄;如果匹配,再判斷下一個元素的值是否為 null,為空直接返回,如果不為空,再判斷是否是 TreeNode 例項,如果是 TreeNode 例項,則直接使用 TreeNode.getTreeNode 取出元素,否則執行迴圈,直到下一個元素為 null 位置。

getNode 方法有一個比較重要的過程就是 (n - 1) & hash,這段程式碼是確定需要查詢的桶的位置的,那麼,為什麼要 (n - 1) & hash 呢?

n 就是 HashMap 中桶的數量,這句話的意思也就是說 (n - 1) & hash 就是 (桶的容量 - 1) & hash

// 為什麼 HashMap 的檢索位置是 (table.size - 1) & hash
public static void main(String[] args) {

  Map<String,Object> map = new HashMap<>();

  // debug 得知 1 的 hash 值算出來是 49
  map.put("1","cxuan");
  // debug 得知 1 的 hash 值算出來是 50
  map.put("2","cxuan");
  // debug 得知 1 的 hash 值算出來是 51
  map.put("3","cxuan");

}

那麼每次算完之後的 (n - 1) & hash ,依次為

也就是 tab[(n - 1) & hash] 算出的具體位置。

HashMap 的遍歷方式

HashMap 的遍歷,也是一個使用頻次特別高的操作

HashMap 遍歷的基類是 HashIterator,它是一個 Hash 迭代器,它是一個 HashMap 內部的抽象類,它的構造比較簡單,只有三種方法,hasNext 、 remove 和 nextNode 方法,其中 nextNode 方法是由三種迭代器實現的

這三種迭代器就就是

  • KeyIterator ,對 key 進行遍歷
  • ValueIterator,對 value 進行遍歷
  • EntryIterator, 對 Entry 鏈進行遍歷

雖然說看著迭代器比較多,但其實他們的遍歷順序都是一樣的,構造也非常簡單,都是使用 HashIterator 中的 nextNode 方法進行遍歷

final class KeyIterator extends HashIterator
        implements Iterator<K> {
        public final K next() { return nextNode().key; }
    }

final class ValueIterator extends HashIterator
  implements Iterator<V> {
  public final V next() { return nextNode().value; }
}

final class EntryIterator extends HashIterator
  implements Iterator<Map.Entry<K,V>> {
  public final Map.Entry<K,V> next() { return nextNode(); }
}

HashIterator 中的遍歷方式

abstract class HashIterator {
  Node<K,V> next;        // 下一個 entry 節點
  Node<K,V> current;     // 當前 entry 節點
  int expectedModCount;  // fail-fast 的判斷標識
  int index;             // 當前槽

  HashIterator() {
    expectedModCount = modCount;
    Node<K,V>[] t = table;
    current = next = null;
    index = 0;
    if (t != null && size > 0) { // advance to first entry
      do {} while (index < t.length && (next = t[index++]) == null);
    }
  }

  public final boolean hasNext() {
    return next != null;
  }

  final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    if (modCount != expectedModCount)
      throw new ConcurrentModificationException();
    if (e == null)
      throw new NoSuchElementException();
    if ((next = (current = e).next) == null && (t = table) != null) {
      do {} while (index < t.length && (next = t[index++]) == null);
    }
    return e;
  }

  public final void remove() {...}
}

next 和 current 分別表示下一個 Node 節點和當前的 Node 節點,HashIterator 在初始化時會遍歷所有的節點。下面我們用圖來表示一下他們的遍歷順序

你會發現 nextNode() 方法的遍歷方式和 HashIterator 的遍歷方式一樣,只不過判斷條件不一樣,構造 HashIterator 的時候判斷條件是有沒有連結串列,桶是否為 null,而遍歷 nextNode 的判斷條件變為下一個 node 節點是不是 null ,並且桶是不是為 null。

HashMap 中的移除方法

HashMap 中的移除方法也比較簡單了,原始碼如下

public V remove(Object key) {
  Node<K,V> e;
  return (e = removeNode(hash(key), key, null, false, true)) == null ?
    null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
  Node<K,V>[] tab; Node<K,V> p; int n, index;
  if ((tab = table) != null && (n = tab.length) > 0 &&
      (p = tab[index = (n - 1) & hash]) != null) {
    Node<K,V> node = null, e; K k; V v;
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      node = p;
    else if ((e = p.next) != null) {
      if (p instanceof TreeNode)
        node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
      else {
        do {
          if (e.hash == hash &&
              ((k = e.key) == key ||
               (key != null && key.equals(k)))) {
            node = e;
            break;
          }
          p = e;
        } while ((e = e.next) != null);
      }
    }
    if (node != null && (!matchValue || (v = node.value) == value ||
                         (value != null && value.equals(v)))) {
      if (node instanceof TreeNode)
        ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
      else if (node == p)
        tab[index] = node.next;
      else
        p.next = node.next;
      ++modCount;
      --size;
      afterNodeRemoval(node);
      return node;
    }
  }
  return null;
}

remove 方法有很多,最終都會呼叫到 removeNode 方法,只不過傳遞的引數值不同,我們拿 remove(object) 來演示一下。

首先會通過 hash 來找到對應的 bucket,然後通過遍歷連結串列,找到鍵值相等的節點,然後把對應的節點進行刪除。

關於 HashMap 的面試題

HashMap 的資料結構

JDK1.7 中,HashMap 採用位桶 + 連結串列的實現,即使用連結串列來處理衝突,同一 hash 值的連結串列都儲存在一個陣列中。但是當位於一個桶中的元素較多,即 hash 值相等的元素較多時,通過 key 值依次查詢的效率較低。

所以,與 JDK 1.7 相比,JDK 1.8 在底層結構方面做了一些改變,當每個桶中元素大於 8 的時候,會轉變為紅黑樹,目的就是優化查詢效率。

HashMap 的 put 過程

大致過程如下,首先會使用 hash 方法計算物件的雜湊碼,根據雜湊碼來確定在 bucket 中存放的位置,如果 bucket 中沒有 Node 節點則直接進行 put,如果對應 bucket 已經有 Node 節點,會對連結串列長度進行分析,判斷長度是否大於 8,如果連結串列長度小於 8 ,在 JDK1.7 前會使用頭插法,在 JDK1.8 之後更改為尾插法。如果連結串列長度大於 8 會進行樹化操作,把連結串列轉換為紅黑樹,在紅黑樹上進行儲存。

HashMap 為啥執行緒不安全

HashMap 不是一個執行緒安全的容器,不安全性體現在多執行緒併發對 HashMap 進行 put 操作上。如果有兩個執行緒 A 和 B ,首先 A 希望插入一個鍵值對到 HashMap 中,在決定好桶的位置進行 put 時,此時 A 的時間片正好用完了,輪到 B 執行,B 執行後執行和 A 一樣的操作,只不過 B 成功把鍵值對插入進去了。如果 A 和 B 插入的位置(桶)是一樣的,那麼執行緒 A 繼續執行後就會覆蓋 B 的記錄,造成了資料不一致問題。

還有一點在於 HashMap 在擴容時,因 resize 方法會形成環,造成死迴圈,導致 CPU 飆高。

HashMap 是如何處理雜湊碰撞的

HashMap 底層是使用位桶 + 連結串列實現的,位桶決定元素的插入位置,位桶是由 hash 方法決定的,當多個元素的 hash 計算得到相同的雜湊值後,HashMap 會把多個 Node 元素都放在對應的位桶中,形成連結串列,這種處理雜湊碰撞的方式被稱為鏈地址法。

其他處理 hash 碰撞的方式還有 開放地址法、rehash 方法、建立一個公共溢位區這幾種方法。

HashMap 是如何 get 元素的

首先會檢查 table 中的元素是否為空,然後根據 hash 算出指定 key 的位置。然後檢查連結串列的第一個元素是否為空,如果不為空,是否匹配,如果匹配,直接返回這條記錄;如果匹配,再判斷下一個元素的值是否為 null,為空直接返回,如果不為空,再判斷是否是 TreeNode 例項,如果是 TreeNode 例項,則直接使用 TreeNode.getTreeNode 取出元素,否則執行迴圈,直到下一個元素為 null 位置。

HashMap 和 HashTable 有什麼區別

見上

HashMap 和 HashSet 的區別

見上

HashMap 是如何擴容的

HashMap 中有兩個非常重要的變數,一個是 loadFactor ,一個是 threshold ,loadFactor 表示的就是負載因子,threshold 表示的是下一次要擴容的閾值,當 threshold = loadFactor * 陣列長度時,陣列長度擴大位原來的兩倍,來重新調整 map 的大小,並將原來的物件放入新的 bucket 陣列中。

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

這道題我想了幾天,之前和群裡小夥伴們探討每日一題的時候,問他們為什麼 length%hash == (n - 1) & hash,它們說相等的前提是 length 的長度 2 的冪次方,然後我回了一句難道 length 還能不是 2 的冪次方嗎?其實是我沒有搞懂因果關係,因為 HashMap 的長度是 2 的冪次方,所以使用餘數來判斷在桶中的下標。如果 length 的長度不是 2 的冪次方,小夥伴們可以舉個例子來試試

例如長度為 9 時候,3 & (9-1) = 0,2 & (9-1) = 0 ,都在 0 上,碰撞了;

這樣會增大 HashMap 碰撞的機率。

HashMap 執行緒安全的實現有哪些

因為 HashMap 不是一個執行緒安全的容器,所以併發場景下推薦使用 ConcurrentHashMap ,或者使用執行緒安全的 HashMap,使用 Collections 包下的執行緒安全的容器,比如說

Collections.synchronizedMap(new HashMap());

還可以使用 HashTable ,它也是執行緒安全的容器,基於 key-value 儲存,經常用 HashMap 和 HashTable 做比較就是因為 HashTable 的資料結構和 HashMap 相同。

上面效率最高的就是 ConcurrentHashMap。

後記

文章並沒有敘述太多關於紅黑樹的構造、包含新增、刪除、樹化等過程,一方面是自己能力還達不到,一方面是關於紅黑樹的描述太過於佔據篇幅,紅黑樹又是很大的一部分內容,所以會考慮放在後面的紅黑樹進行講解。

相關文章