Java容器系列-HashMap源碼分析

HashMap 實現了 Map 接口。HashMap 使用的很廣泛,但不是線程安全的,如果在多線程中使用,必須需要額外提供同步機制(多線程情況下推薦使用 ConCurrentHashMap)。

Java容器系列-HashMap源碼分析

 

HashMap 的類圖相對簡單,主要就是繼承了 AbstractMap,有一點需要注意,雖然沒有實現 Iterable 接口,但 HashMap 本身還是實現了迭代器的功能。

本文基於 JDK1.8

成員變量及常量

HashMap 是一個 Node[] 數組,每一個下標稱之爲一個  。

每一個鍵值對都是使用 Node 來存儲,這是一個單鏈表的數據結構。每個桶上可以通過鏈表來存儲多個鍵值對。

常量

HashMap 中用到的常量及其意義如下:

// 初始容量(桶的個數) 2^4 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 最大容量(桶的個數) 2^30static final int MAXIMUM_CAPACITY = 1 << 30;// 默認的裝載因子(load factor),除非特殊原因,否則不建議修改static final float DEFAULT_LOAD_FACTOR = 0.75f;// 單個桶上的元素個數大於這個值從鏈表轉成樹(樹化操作)static final int TREEIFY_THRESHOLD = 8;// 單個桶上元素少於這個值從樹轉成鏈表static final int UNTREEIFY_THRESHOLD = 6;// 只有桶的個數大於這個值時,樹化操作纔會真正執行static final int MIN_TREEIFY_CAPACITY = 64;

成員變量

HashMap 中用到的成員變量如下:

// HashMap 中的 table,也就是桶transient Node<K,V>[] table;// 緩存所有的鍵值對 transient Set<Map.Entry<K,V>> entrySet;// 鍵值對的個數transient int size;// HashMap 被修改的次數,用於 fail-fast 檢查transient int modCount;// 進行 resize 操作的臨界值,threshold = capacity * loadFactorint threshold;// 裝載因子final float loadFactor;

table 是一個 Node 數組, length 通常是 ,但也可以爲 0。

初始化

HashMap 的初始化其實就只幹了兩件事:

  • 確定 threadhold 的值
  • 確定 loadFactor 的值

用戶可以通過傳入初始的容量和裝載因子。HashMap 的容量總是 ,如果傳入的參數不是 ,也會被轉化成 :

// HashMap.tableSizeFor()int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

Integer.numberOfLeadingZeros() 返回一個 int 類型(32位)在二進制表達下最後一個非零數字前面零的個數。比如 2:

0000 0000 0000 0000 0000 0000 0000 010

所以 Integer.numberOfLeadingZeros(3) 返回 30。

-1 在用二進制表示爲:

1111 1111 1111 1111 1111 1111 1111 1111

>>> 表示無符號右移,-1 右移 30 位則得到:

0000 0000 0000 0000 0000 0000 0000 011

得到 3。

所以經過了 -1 >>> Integer.numberOfLeadingZeros(cap - 1) 返回的值一定是 ,所以最後返回的值一定是 ,感興趣的可以去驗證一下。

HashMap 在初始化的時候也可以接受一個 Map 對象,然後把傳入的 Map 對象中的元素放入當前的容器中。

除了傳入 Map 對象的實例化方式,都不會實際去創建桶數組,這是一種延遲初始化的方式,在插入第一個鍵值對的時候,會調用 resize() 方法去初始化桶。

下面來詳細看看 resize() 操作。

擴容機制

與 ArrayList 不同,HashMap 沒有手動擴容的過程,只會根據容器當前的情況自動擴容。

擴容操作由 resize() 方法來完成,擴容操作主要幹三件事:

  • 確定桶的個數
  • 確定 threshold 的值
  • 將所有的元素移到新的桶中

參數說明

  • oldCap: 擴容前桶的個數
  • oldThr: 擴容前 threshold 的值
  • newCap: 擴容後桶的個數
  • newThr: 擴容後 threshold 的值

擴容流程如下:

Java容器系列-HashMap源碼分析

 

擴容時會新建一個 Node(桶)數組,然後把原容器中的鍵值對重新作 hash 操作,然後放到新的桶中。

HashMap 的容量有上限,爲 ,也就是 1073741824,桶的個數不會超過這個數,threshold 的最大值是 2147483647,是最大容量的兩倍少1。

這樣設置代表這個如果桶的個數達到了最大容量,就不會再進行擴容操作了。

具體實現

Java容器系列-HashMap源碼分析

 

HashMap 的結構圖如上,每個桶都是一個鏈表的頭結點,對於 hash 值相同(哈希衝突)的 key,會放在同一個桶上。這也是 HashMap 解決哈希衝突的方法稱之爲 拉鍊法 。在 JDK1.8 以後,在插入鍵值對時,使用的是 尾插法 ,而不再是頭插法。

HashMap 與 Hashtable 的功能大致上一致。HashMap 的 key 和 value 都可以爲 null。下面是主流 Map 的鍵值對是否可以爲 null 的對比:

Mapkey 是否可以爲nullvalue 是否可以爲 nullHashMap是是Hashtable否否ConcurrentHashMap否否TreeMap否是

HashMap 不是線程安全的。在多線程環境中,需要使用額外的同步機制,比如使用 Map m = Collections.synchronizedMap(new HashMap(...)); 。

HashMap 也支持 fail-fast 機制。

hash 方法

hash 方法對 HashMap 非常重要,直接會影響到性能。鍵值對插入位置由 hash 方法來決定。假設 hash 方法可以讓元素在桶上均勻分佈,基本操作如 get 和 put 操作就是常量操作時間( )。

hash 方法需要有兩個特點:

  • 計算的結果需要足夠隨機
  • 計算量不能太大

HashMap 中具體實現如下:

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

>>> 是無符號右移操作,上面已經說到。假設現在有個 key 是 "name",在我電腦上計算出來的值是:3373707,轉變成二進制就是:

0000 0000 0011 0011 0111 1010 1000 1011

右移 16 位後:

0000 0000 0000 0000 0000 0000 0011 0011

然後進行 異或 運算:

0000 0000 0011 0011 0111 1010 1011 1000

最後拿這個值與 HashMap 的長度減 1 進行與操作,因爲 n 一定是 ,所以 (n-1) 的二進制全部是由 1 組成,下面這個操作相當於取 hash 值的後幾位:

index = (n - 1) & hash

index 就是鍵值對的插入位置。

hash() 函數其實就是用來使鍵值對的插入位置足夠隨機,稱之爲 擾動函數 ,如果對具體的策略感興趣,可以參考這篇 文章 。

注:Object.hashcode() 是一個本地方法,返回對象的內存地址。Object.equals() 方法默認比較對象的內存地址,如果某個類修改了 equals 方法,那麼 hashcode 方法也需要修改,要讓 equals 和 hascode 的行爲是一致的。否在在查找鍵值對的過程中就會出現 equals 結果是 true, hashcode 卻不一樣,這樣就無法找到鍵值對。

容量和裝載因子

使用 HashMap 時,有兩個參數會影響它的性能: 初始容量 和 裝載因子 。

容量是指 HashMap 中桶的個數,初始容量是在創建實例時候所初始化桶的個數。

裝載因子用來決定擴容的 時機 ,進行擴容操作時,會把桶的數量設爲原來的 兩倍 ,容器中所有的元素都會重新分配位置,擴容的代價很大,應該儘可能減少擴容操作。

裝載因子的默認值是 0.75,這是權衡 時間性能 和 空間開銷 的一個值。裝載因子設置的越大,那麼空間的開銷就會降低,但查找等操作的性能就會下降,反之亦然。

在初始化 HashMap 的時候,初始容量和裝載因子的值必須仔細衡量,以便儘可能減少擴容操作,如果沒有特殊的情況,使用默認的參數就可以。

遍歷 HashMap 所需的時間與容器的容量(桶的個數)及元素的數量成正比。如果迭代的時間性能很重要,就不要把 初始容量 設置的太大,也不要把 裝載因子 設置的很小。

樹化操作

在講解具體的方法前,需要了解 HashMap 中一個重要的內部操作: 樹化 。

HashMap 使用拉鍊法來解決哈希衝突問題。多個鍵值對被分配到同一個桶的時候,是以鏈表的方式連接起來。但這樣會面臨一個問題,如果鏈表過長,那麼 HashMap 的很多操作就無法保持 的操作時間。

極端情況下,所有的鍵值對在一個桶中。那麼 get、remove 等操作的時間複雜度度就都是 。HashMap 的解決方法是用 紅黑樹 來替代鏈表,紅黑樹查詢的時間複雜度穩定在 。

HashMap 在單個桶的的元素的個數超過 8(TREEIFY_THRESHOLD) 且桶的個數大於 64(MIN_TREEIFY_CAPACITY) 時,會把桶後面的鏈表轉成樹(類似於 TreeMap ),這個操作稱之爲樹化操作。

需要注意的是,當單個桶上的元素超過了8個,但桶的個數少於 64 時,不會進行樹化操作,而是會進行 擴容 操作,代碼如下:

// HashMap.treeifyBin() methodfinal void treeifyBin(Node<K,V>[] tab, int hash) {    int n, index; Node<K,V> e;    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)        resize();    // other code...}

樹化的過程是把鏈表的所有節點都替換成 TreeNode,然後再組成一棵紅黑樹(紅黑樹的具體構建過程可以查看這篇 文章 )。而且在鏈表轉成樹的過程中,每個節點之間的相對關係不會變化,通過節點的 next 變量來保持這個關係。

當樹上的節點樹少於 6(UNTREEIFY_THRESHOLD) 時,樹結構會重新轉化成鏈表。把樹的每一個節點換成鏈表的節點,通過 next 重新組成一個鏈表:

// HashMap.ubtreeify()final Node<K,V> untreeify(HashMap<K,V> map) {    Node<K,V> hd = null, tl = null;    for (Node<K,V> q = this; q != null; q = q.next) {        Node<K,V> p = map.replacementNode(q, null);        if (tl == null)            hd = p;        else            tl.next = p;            tl = p;    }    return hd;}

即使遇到極端情況(所有的鍵值對在一個桶上),樹化操作也會保證 HashMap 的性能也不會退化太多。

增刪改查操作

get 方法:get 方法的實際操作是使用 getNode 方法來完成的。

// HashMap.getNode()final Node<K,V> getNode(int hash, Object key) {    // 首先檢查容器是否爲 null 以及 key 在容器中是否存在    if ((tab = table) != null && (n = tab.length) > 0 &&        (first = tab[(n - 1) & hash]) != null) {        // 找到相應的桶,從第一個節點開始查找,如果第一個節點不是要找的,後續節點就分成鏈表或者紅黑樹進行查找        if (first.hash == hash &&            ((k = first.key) == key || (key != null && key.equals(k))))            return first;        if ((e = first.next) != null) {            // 如果鏈表已經轉成了紅黑樹,則在紅黑樹中查找            if (first instanceof TreeNode)               return ((TreeNode<K,V>)first).getTreeNode(hash, key);            do {                // 如果不是樹,則在鏈表中查找                if (e.hash == hash &&                   ((k = e.key) == key || (key != null && key.equals(k))))                   return e;            } while ((e = e.next) != null);        }    }}

put 方法:用於插入或者更新鍵值對,實際使用的是 HashMap.putVal() 方法來實現。如果是第一次插入鍵值對,會觸發 擴容 操作。

// HashMap.putVal() 刪減了部分代碼final V putVal(int hash, K key, V value, boolean onlyIfAbsent,                   boolean evict) {    Node<K,V>[] tab; Node<K,V> p; int n, i;    // 如果是第一次插入鍵值對,首先會進行擴容操作    if ((tab = table) == null || (n = tab.length) == 0)        n = (tab = resize()).length;    // 如果一個桶的還沒有插入鍵值對,則對第一個節點進行初始化    if ((p = tab[i = (n - 1) & hash]) == null)        tab[i] = newNode(hash, key, value, null);    else {        Node<K,V> e; K k;        if (p.hash == hash &&            ((k = p.key) == key || (key != null && key.equals(k))))            e = p;        // 如果是紅黑樹的結構,則按照紅黑樹的方式插入或者更新節點        else if (p instanceof TreeNode)            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);        // 否則按照鏈表的方式插入或者更新節點        else {            for (int binCount = 0; ; ++binCount) {                if ((e = p.next) == null) {                   // 如果沒有找到鍵值對,則新建一個節點,把鍵值對插入                   p.next = newNode(hash, key, value, null);                   // 如果鏈表的長度大於等於 8,就會嘗試進行樹化操作                   if (binCount >= TREEIFY_THRESHOLD - 1)                        treeifyBin(tab, hash);                        break;                }                // 如果找到了 key,則跳出循環                if (e.hash == hash &&                   ((k = e.key) == key || (key != null && key.equals(k))))                   break;                p = e;            }        }        // 如果 key 已經存在,則把 value 更新爲新的 value        if (e != null) {            V oldValue = e.value;           if (!onlyIfAbsent || oldValue == null)               e.value = value;            return oldValue;        }    }    // fail-fast 版本號更新    ++modCount;    // 如果容器中元素的數量大於擴容臨界值,則進行擴容    if (++size > threshold)        resize();    return null;}

remove 方法的實現與 get 方法類似。

clear 方法會將 map 中所有的桶都置爲 null 來清空鍵值對。

其他的操作都是組合這幾個基本的操作來完成。

JDK8 的新特性

在 JDK8 中,Map 中增加了一些新的方法,HashMap 對這些方法都進行了重寫,加入了對 fail-fast 機制的支持。

這些方法是用上面的增刪改查方法來實現的。

getOrDefault 方法,在值不存在的時候,返回一個默認值:

HashMap map = new HashMap<>();map.put("name", "xiaomi");map.getOrDefault("gender","genderNotExist"); // genderNotExist

forEach 方法,遍歷 map 中的鍵值對,可以接收 lambda 表達式:

HashMap<String, Object> map = new HashMap<>();map.put("name", "xiaomi");map.forEach((k, v) -> System.out.println(k +":"+ v));

putIfAbsent 方法,只有在 key 不存在時纔會插入鍵值對:

HashMap<String, Object> map = new HashMap<>();map.put("name", "xiaomi");map.putIfAbsent("gender", "man");

computeIfAbsent 方法用來簡化一些操作,下面方法1和方法2功能一樣,都是在 key 不存在的情況下,通過某些處理後然後把鍵值對插入 map:

HashMap<String, Object> map = new HashMap<>();map.put("name", "xiaomi");// 方法1:Integer age = (Integer)map.get("key");if (age == null) {    age = 18;    map.put("key", age);}// 方法2:map.computeIfAbsent("age",  k -> {return 18;});

computeIfPresent 方法則是在鍵值對存在的情況下,對鍵值對進行處理,然後再更新 map,下面方法1和方法2功能完全一樣:

HashMap<String, Object> map = new HashMap<>();map.put("name", "xiaomi");// 方法1:Integer age = (Integer)map.get("key");Integer age = 18 + 4;map.put("key", age);// 方法2:map.computeIfPresent("age", (k,v) -> {return 18 + 4;});

merge 方法用來對相同的 key 的 value 進行合併,以下方法1和方法2的功能一致:

HashMap<String, Object> map = new HashMap<>();map.put("name", "xiaomi");// 方法1:Integer age = (Integer)map.get("key");age += 14;map.put("key", age);// 方法2:map.merge("age", 18, (oldVal, newVal) -> {return (Integer)oldVal + (Integer)newVal;});

其他功能

HashMap 同樣也實現了迭代功能,HashMap 中有三個具體 Iterator 的實現:

  • KeyIterator: 遍歷 map 的 key
  • ValueIterator: 遍歷 map 的 value
  • EntryIterator: 同時遍歷 map 的 key 和 value

但是這個三個迭代器都不會直接使用,而是通過調用 HashMap 方法來間接獲取。

  • KeyIterator 通過 HashMap.keySet() 方法獲取並使用
  • ValueIterator 通過 HashMap.vlaues() 方法獲取並使用
  • EntryIterator 通過 HashMap.entrySet() 方法獲取並使用

Spliterator 的實現與迭代器的類似,分別對於 key、value 和 key + value 分別實現了 Spliterator。

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