Java容器系列-HashMap源碼分析

HashMap 實現了 Map 接口。HashMap 使用的很普遍,但不是線程安全的,若是在多線程中使用,必須須要額外提供同步機制(多線程狀況下推薦使用 ConCurrentHashMap)。java

HashMap 的類圖相對簡單,主要就是繼承了 AbstractMap,有一點須要注意,雖然沒有實現 Iterable 接口,但 HashMap 自己仍是實現了迭代器的功能。數組

本文基於 JDK1.8緩存

成員變量及常量

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

每個鍵值對都是使用 Node 來存儲,這是一個單鏈表的數據結構。每一個桶上能夠經過鏈表來存儲多個鍵值對。微信

常量

HashMap 中用到的常量及其意義以下:數據結構

// 初始容量(桶的個數) 2^4 
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
// 最大容量(桶的個數) 2^30
static 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 * loadFactor
int threshold;
// 裝載因子
final float loadFactor;
複製代碼

table 是一個 Node 數組,length 一般是 2^n,但也能夠爲 0。函數

初始化

HashMap 的初始化其實就只幹了兩件事:源碼分析

  • 肯定 threadhold 的值
  • 肯定 loadFactor 的值

用戶能夠經過傳入初始的容量和裝載因子。HashMap 的容量老是 2^n,若是傳入的參數不是 2^n,也會被轉化成 2^npost

// 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) 返回的值必定是 2^n-1,因此最後返回的值必定是 2^n,感興趣的能夠去驗證一下。

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

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

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

擴容機制

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

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

  • 肯定桶的個數
  • 肯定 threshold 的值
  • 將全部的元素移到新的桶中

參數說明

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

擴容流程以下:

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

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

這樣設置表明這個若是桶的個數達到了最大容量,就不會再進行擴容操做了。

具體實現

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

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

Map key 是否能夠爲null value 是否能夠爲 null
HashMap
Hashtable
ConcurrentHashMap
TreeMap

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

HashMap 也支持 fail-fast 機制。

hash 方法

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

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 必定是 2^x,因此 (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 的不少操做就沒法保持 O(1) 的操做時間。

極端狀況下,全部的鍵值對在一個桶中。那麼 get、remove 等操做的時間複雜度度就都是 O(n)。HashMap 的解決方法是用紅黑樹來替代鏈表,紅黑樹查詢的時間複雜度穩定在 O(logn)

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

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

// HashMap.treeifyBin() method
final 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。

原文

相關文章

關注微信公衆號,聊點其餘的

相關文章
相關標籤/搜索