Java集合(6)之 HashMap 源碼解析

HashMap 在平常開發中很是經常使用,它基於哈希表實現,以 key-value 形式存儲。本文經過 JDK1.8 的源碼,分析一下 HashMap 的內部結構和實現原理。html

HashMap 概述

JDK1.7 以前,HashMap 底層由數組 + 鏈表實現,也就是鏈表散列。當向 HashMap 中添加一個鍵值對時,首先計算 keyhash 值,以此肯定插入數組中的位置,但可能會碰撞衝突,將其轉換爲鏈表存儲。java

而從 JDK1.8 開始,增長了紅黑樹,由數組 + 鏈表 + 紅黑樹實現,當鏈表長度超過 8 時,鏈表轉換爲紅黑樹以提升性能。它的存儲方式以下:node

定義屬性

靜態常量

HashMap 的幾個靜態常量以下:算法

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {

    // 默認初始容量爲 16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  
    
    // 最大容量爲 2^30
    static final int MAXIMUM_CAPACITY = 1 << 30;        
    
    // 默認負載因子爲 0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;     
    
    // 默認鏈表中元素大於 8 時轉爲紅黑樹
    static final int TREEIFY_THRESHOLD = 8;             
    
    // 擴容時,鏈表中元素小於這個值就會還原爲鏈表
    static final int UNTREEIFY_THRESHOLD = 6;    
    
    // 數組的容量大於 64 時才容許被樹形化
    static final int MIN_TREEIFY_CAPACITY = 64;
    ···
}
複製代碼

重要變量

下面是 HashMap 中幾個重要的變量:數組

transient Node<K,V>[] table; // 存儲元素數組
transient Set<Map.Entry<K,V>> entrySet; // 緩存 entry 返回的 Set 
transient int size; // 鍵值對個數
transient int modCount; // 內部結構修改次數
int threshold; // 臨界值
final float loadFactor; // 負載因子
複製代碼

Node<K,V>[] table緩存

Node<K,V>[] table 數組用來存儲具體的元素,是 HashMap 底層數組和鏈表的組成元素。在第一次使用時初始化(默認初始化容量爲 16),並在必要的時候進行擴容。安全

通常來講,因爲素數致使衝突的機率較小,因此哈希表數組大小爲素數。但 JavaHashMap 中採用很是規設計,數組的長度老是 2n 次方,這樣作能夠在取模和擴容時作優化,同時也能減小碰撞衝突。app

NodeHashMap 的一個內部類,實現了 Map.Entry 接口,本質上就是一個映射(鍵值對)。它的實現以下:性能

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;   // 用來定位數組索引位置
    final K key;      // 鍵
    V value;          // 值
    Node<K,V> next;   // 指向鏈表的下一個結點

    Node(int hash, K key, V value, Node<K,V> next) { ··· }
    public final K getKey() { ··· }
    public final V getValue() { ··· }
    public final String toString() { ··· }
    
    // 重寫了 hashCode 和 equals 方法
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
    public final V setValue(V newValue) { ··· }
    
    public final boolean equals(Object o) { 
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}
複製代碼

entrySet優化

entrySet 用於緩存 entrySet() 方法返回的 Set。後面會詳細分析。

size

sizeHashMap 中鍵值對的數量。注意,鍵值對的數量 size 和哈希表數組的長度 capacity不一樣。

modCount

modCount 用於記錄 HashMap 內部結構發生變化的次數,用於使用迭代器遍歷集合時修改內部結構,而快速失敗。須要注意的是,這裏指的是結構發生變化,例如增長或刪除一個鍵值對或者擴容,可是修改鍵值對的值不屬於結構變化。

threshold 和 loadFactor

thresholdHashMap 能容納的最大鍵值對個數,loadFactor 是負載因子,默認爲 0.75。有以下等式(capacity 是數組容量):

threshold = capacity * loadFactor;
複製代碼

能夠得出,在數組長度定義好以後,負載因子越大,所能容納鍵值對越多。若是存儲元素個數大於 threshold,就要進行擴容,擴容後的容量是以前的兩倍。

TreeNode

當鏈表長度超過 8(閾值)時,將鏈表轉換爲紅黑樹存儲,以提升查找的效率。下面是 TreeNode 的定義:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // 父節點
    TreeNode<K,V> left;    //左子樹
    TreeNode<K,V> right;   //右子樹
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;      //顏色屬性
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
    // 返回當前節點的根節點
    final TreeNode<K,V> root() {
        for (TreeNode<K,V> r = this, p;;) {
            if ((p = r.parent) == null)
                return r;
            r = p;
        }
    }
    ······
}
複製代碼

構造方法

HashMap 主要提供了四種構造方法:

1). 構造一個默認初始容量 16 和默認加載因子 0.75 的空 HashMap

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}
複製代碼

2). 構造一個指定的初始容量和默認加載因子 0.75 的空 HashMap

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製代碼

3). 構造一個指定的初始容量和加載因子的空 HashMap

public HashMap(int initialCapacity, float loadFactor) {
    // check
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
複製代碼

4). 使用給定的 map 構造一個新 HashMap

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
複製代碼

基本方法

HashMap 內部功能實現不少,這裏主要從 hash 方法、put 方法、get 方法、resize 方法和 entrySet 方法進行分析。

hash 方法

HashMap 中,增刪改查都須要用 hash 算法來計算元素在數組中的位置,因此 hash 算法是否均勻高效,對性能影響很大。看一下它的實現:

static final int hash(Object key) {
    int h;
    // 優化了高位運算算法
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// tab[i = (n - 1) & hash] 取模
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    ···
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    ···
}
複製代碼

hash 算法計算對象的保存位置,分爲三步:取 keyhashCode 值、高位運算、取模運算。

因爲取模元素消耗較大,HashMap 中用了一個很巧妙的方法,利用的就是底層數組長度老是 2n 次方。經過 hash & (table.length - 1) 就能夠獲得對象的保存位置,相較於對 length 取模效率更高。

JDK1.8 中優化了高位運算的算法,經過 hashCode 的高 16 位異或低 16 位實現。下面舉例說明,ntable 的長度:

put 方法

來看一下 HashMapput 方法:

public V put(K key, V value) {
    // 調用 hash 計算 key 的哈希值
    return putVal(hash(key), key, value, false, true);
}
複製代碼
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 爲空或長度爲 0,則調用 resize 進行擴容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 根據 key 的 hash 計算數組索引值,若是當前位置爲 null,則直接建立新節點插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // table[i] 不爲空
        Node<K,V> e; K k;
        // 若是 table[i] 的首元素和傳入的 key 相等(hashCode 和 equals),則直接覆蓋,這裏允許 key 和 value 爲 null
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 判斷 table[i] 是否爲 treeNode,即 table[i] 是否爲紅黑樹,若是是則在樹中插入
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 不然遍歷鏈表
        else {
            for (int binCount = 0; ; ++binCount) {
                // 若是 key 不存在
                if ((e = p.next) == null) {
                    // 則新建一個結點
                    p.next = newNode(hash, key, value, null);
                    // 若是長度大於8,則轉爲紅黑樹處理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 若是 key 已經存在,則直接覆蓋
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 內部結構發生變化
    ++modCount;
    // 若是超過最大容量就擴容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}
複製代碼

下面是 put 方法的幾個步驟::

  • 判斷哈希表數組 table[] 爲空或者長度爲 0,若是是則調用 resize() 進行擴容;
  • 經過 hash & (table.length - 1) 計算插入的數組索引值,若是當前位置爲 null,則直接建立節點插入
  • 判斷 table[i] 的首個元素是否和 key 相等(hashCodeequals),若是相等則直接覆蓋 value
  • 判斷 table[i] 是否爲 treeNode,即 table[i] 是不是紅黑樹,若是是紅黑樹,則直接在樹中插入鍵值對;
  • 不然遍歷鏈表,若是 key 不存在,則直接建立節點插入,並判斷鏈表長度是否大於 8,若是是紅黑樹則轉爲紅黑樹處理;若是遍歷中發現 key 已經存在,則直接覆蓋便可;
  • 插入成功後,判斷實際存在鍵值對是否超過了最大容量,若是是則進行擴容;

HashMapput 方法能夠經過下圖理解:

get 方法

來看一下 HashMapget 方法:

public V get(Object key) {
    Node<K,V> e;
    // 調用 getNode 方法,若是經過 key 獲取的 Node 爲 null,則返回 null;不然返回 node.value
    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;
    // 若是數組不爲空,數組長度大於 0
    // 經過 hash & (length - 1) 計算數組的索引值,而且對應的位置不爲 null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 若是桶中第一個元素與 key 相等,則直接返回
        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);
        }
    }
    return null;
}
複製代碼

resize 方法

下面來分析一下 resize 方法的源碼:

final Node<K,V>[] resize() {
    // 保存原先的數組、容量、臨界值
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 若是擴容前容量 > 0
    if (oldCap > 0) {
        // 若是數組大小已經達到最大 2^30,則修改閾值爲最大值 2^31-1,之後也就不會再擴容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 若是沒有超過最大值,就擴充爲原來的 2 倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; 
    } 
    else if (oldThr > 0) // 若是擴容前容量 <= 0,舊臨界值 > 0
        // 將數組的新容量設置爲 舊數組擴容的臨界值
        newCap = oldThr;
    else { // 容量 <= 0,舊臨界值 <= 0 
        // 不然設置爲默認值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 計算新的臨界值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    // 建立新的 table,容量爲 newCap
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 遍歷舊哈希表的每一個桶,將舊哈希表中的桶複製到新的哈希表中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 若是舊桶中只有一個 node
                if (e.next == null)
                    // 則將 oldTab[j] 放入新哈希表中 e.hash & (newCap - 1) 的位置
                    newTab[e.hash & (newCap - 1)] = e;
                // 若是舊桶中爲紅黑樹,則轉換處理
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 
                    Node<K,V> loHead = null, loTail = null; // 將下標不變的節點組織成一條鏈表
                    Node<K,V> hiHead = null, hiTail = null; // 將下標增長 oldCapaciry 的節點組織成另外一條鏈表
                    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 {
                            // 原索引 + oldCap
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到新數組中
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引 + oldCap 放到新數組中
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
複製代碼

resize 方法在擴容時,因爲每次數組的長度變爲原先的 2 倍,因此元素要麼在原位置,要麼在「原始位置 + 原數組長度」的位置。經過計算 e.hash & oldCap 來判斷是否須要移動。

看下圖,ntable 的長度,圖 (a) 爲擴容前的 key1key2 肯定索引位置的示例,圖 (b) 爲擴容後的 key1key2 肯定索引位置的示例,其中 key1(hash1)key1 對應的哈希與高位運算的結果:

元素在從新計算 hash 後,由於 n 變爲 2 倍,那麼 n - 1mask 的範圍(紅色)在高位多 1bit,所以新的 index 就會這樣變化:

所以,在擴容時,只需看看原來的 hash 值新增的 bit 位是 1 仍是 0,若是是 0,索引不變,不然變成 "原索引 + oldCapacity",能夠看看下圖 16 擴充爲 32 的示意圖:

entrySet 方法

HashMap 的一種遍歷方式就是使用 entrySet 方法返回的迭代器進行遍歷。先來看一下 entrySet 方法:

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
複製代碼

能夠看到,若是緩存 map 中鍵值對的 Set 不爲 null,則直接返回,不然會建立一個 EntrySet 對象。

EntrySet 類的 iterator 方法會返回一個 EntryIterator 迭代器對象,另外還有兩個迭代器 KeyIteratorValueIterator

final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> {
    public final Map.Entry<K,V> next() { return 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; }
}
複製代碼

它們三個都繼承自 HashIterator,分別用於鍵遍歷、值遍歷、鍵值對遍歷,它們都重寫了 Iteratornext 方法,其中調用了 HashIteratornextNode 方法。

HashIterator 是一個抽象類,實現了迭代器的大部分方法:

abstract class HashIterator {
    Node<K,V> next;        // next entry to return
    Node<K,V> current;     // current entry
    int expectedModCount;  // for fast-fail
    int index;             // current slot

    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() { ··· }
}
複製代碼

能夠看出 HashIterator 迭代器的默認構造器中,將 current 設置爲 null,而後循環在數組中查找不爲 null 的桶, 讓 next 指向第一個桶中的第一個節點 Node

在遍歷時,next 方法會調用 nextNode() 方法,這個方法首先把 next 賦給 e 以稍後返回,並把 e 賦給 current。而後判斷 next 是否爲空,若是不爲空,返回 e 便可。

若是爲空,就在數組中繼續查找不爲空的桶,找到後退出循環,最後返回 e。這樣就能都遍歷出來了。

小結

HashMap 的特色主要有:

  • HashMap 根據鍵的 hashCode 值來存儲數據,大多數狀況下能夠直接定位它的值,於是訪問速度很快。
  • HashMap 不保證插入的順序。
  • 擴容是一個特別耗能的操做,在使用 HashMap 時,最好估算 map 的大小,初始化時給定一個大體的數值,避免進行頻繁的擴容。
  • threshold = capacity * loadFactor; 若是存儲元素個數大於 threshold,就要進行擴容,擴容後的容量是以前的兩倍。
  • 默認的負載因子 0.75 是時間和空間之間的一個平衡,通常不建議修改。
  • HashMapkeyvalue 容許爲 null,最多容許一條記錄的鍵爲 null,容許多條記錄的值爲 null
  • 它是非線程安全的。若是須要線程安全,可使用 CollectionssynchronizedMap 方法使 HashMap 具備線程安全的能力,或使用 ConcurrentHashMap

參考資料

相關文章
相關標籤/搜索