HashMap源碼閱讀筆記

HashMap源碼閱讀筆記

本文在此博客的內容上進行了部分修改,旨在加深筆者對HashMap的理解,暫不討論紅黑樹相關邏輯java

概述

  HashMap做爲常用到的類,大多時候都是隻知道大概原理,好比底層是由數組+鏈表+紅黑樹實現,使用HashMap存儲自定義類時須要重寫其hashCode和equals方法等等……但對其具體如何實現卻知之甚少,本文將做爲相似筆記的形式記錄筆者的源碼閱讀方式。(在JDK 1.7及其以前由數組加鏈表組成,正常狀況想咱們談論的均爲JDK 1.8及其以後的HashMap。須要注意的是HashMap非線程安全,在多線程下可能會引起多線程問題)node

構造方法分析

  HashMap 的構造方法很少,只有四個。HashMap 構造方法作的事情比較簡單,通常都是初始化一些重要變量,好比 loadFactor 和 threshold。而底層的數據結構則是延遲到插入鍵值對時再進行初始化。HashMap 相關構造方法以下:segmentfault

//構造方法1,也是使用最多的一種
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//構造方法2,能夠設置初始容量
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//構造方法3,能夠設置初始容量和負載因子
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);
}
//構造方法4,經過一個已有的map映射到新的map
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

  第二個方法調用了第三個方法,通常來講前兩個方法用的是比較多的,至於負載因子(loadFactor)通常使用默認的0.75就行。構造方法主要是進行一些參數的設置,下面說說初始化的這些參數。數組

初始容量,負載因子,閾值

  咱們在通常狀況下,都會使用無參構造方法建立 HashMap。但當咱們對時間和空間複雜度有要求的時候,使用默認值有時可能達不到咱們的要求,這個時候咱們就須要手動調參。在 HashMap 構造方法中,可供咱們調整的參數有兩個,一個是初始容量 initialCapacity,另外一個負載因子 loadFactor。經過這兩個設定這兩個參數,能夠進一步影響閾值大小。但初始閾值 threshold 僅由 initialCapacity 通過移位操做計算得出。他們的做用分別以下:安全

名稱 用途
initialCapacity HashMap 初始容量
loadFactor 負載因子
threshold 當前 HashMap 所能容納鍵值對數量的最大值,超過這個值,則需擴容

先介紹幾個常量值:數據結構

//The default initial capacity - MUST be a power of two.
//默認容量爲16,容量必須爲2的冪次方:16,32,64……
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/*The maximum capacity, used if a higher value is implicitly specified by either of the constructors with arguments. 
MUST be a power of two <= 1<<30.
*/
//最大容量爲2的30次冪
static final int MAXIMUM_CAPACITY = 1 << 30;

//The load factor used when none specified in constructor.
//默認負載因子爲0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//The next size value at which to resize (capacity * load factor).
// 閾值並無初始值,由於閾值 = 容量 * 負載因子
int threshold;

//樹化閾值,即桶元素達到8時而且桶數達到MIN_TREEIFY_CAPACITY(64)時開始樹化,把鏈表轉化爲紅黑樹
static final int TREEIFY_THRESHOLD = 8;

//紅黑樹拆分閾值,即擴容後若是數的節點小於6,則把紅黑樹轉化爲鏈表
static final int UNTREEIFY_THRESHOLD = 6;

/*
當哈希表中的容量達到這個值時,表中的桶才能進行樹形化,不然桶內元素太多時會優先擴容,而不是樹形化
這是由於當容量較小時桶內元素數更容易超過樹化閾值,此時應該優先選擇擴容而不是樹化,這樣能夠避免頻繁樹化。
*/
static final int MIN_TREEIFY_CAPACITY = 64;

  在上面的代碼中使用了位運算:<<,位運算是直接操做二進制位,<<表明向左移動,這裏只須要知道左移一位:a << 1等價於a * 2就好了(右移一位等價於除以2)。代碼中並無定義初始容量initialCapacity這個變量,由於initialCapacity只使用一次,而且能夠被threshold暫時代替(下面會有講到),所以並無必要浪費空間存儲該值。
  默認狀況下HashMap容量爲16,負載因子0.75,這裏並無默認閾值,緣由是閾值可由容量乘上負載因子計算而來(註釋中有說明),即threshold = capacity * loadFactor。但當你仔細看構造方法3時,會發現閾值並非由上面公式計算而來,而是經過一個方法算出來的。咱們來看看初始化 threshold 的方法長什麼樣的的,源碼以下:多線程

/**
    * Returns a power of two size for the given target capacity.
    */
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;
}

  上面的代碼不太好理解,使用了無符號右移>>>和或|運算。具體位運算如何使用請百度,本文再也不贅述。該段代碼的做用是找到大於或等於 cap 的最小2的冪。能夠經過如下運算看出端倪:app

int cap = (int) Math.pow(2, 29) + 1;//2的29次冪+1
System.out.println(Integer.toBinaryString(cap));//輸出cap的二進制數
int n = cap - 1;
System.out.println(Integer.toBinaryString(n));
n |= n >>> 1;
System.out.println(Integer.toBinaryString(n));
n |= n >>> 2;
System.out.println(Integer.toBinaryString(n));
n |= n >>> 4;
System.out.println(Integer.toBinaryString(n));
n |= n >>> 8;
System.out.println(Integer.toBinaryString(n));
n |= n >>> 16;
System.out.println(Integer.toBinaryString(n));
n = n + 1;
System.out.println(Integer.toBinaryString(n));

輸出結果:框架

100000000000000000000000000001
100000000000000000000000000000
110000000000000000000000000000
111100000000000000000000000000
111111110000000000000000000000
111111111111111100000000000000
111111111111111111111111111111
1000000000000000000000000000000

這裏放一張圖解:
最小二次冪函數

  說完了初始閾值的計算過程,再來講說負載因子(loadFactor)。對於 HashMap 來講,負載因子是一個很重要的參數,該參數反應了 HashMap 桶數組的使用狀況(假設鍵值對節點均勻分佈在桶數組中)。經過調節負載因子,可以使 HashMap 時間和空間複雜度上有不一樣的表現。當咱們調低負載因子時,HashMap 所能容納的鍵值對數量變少。擴容時,從新將鍵值對存儲新的桶數組裏,鍵的鍵之間產生的碰撞會降低,鏈表長度變短。此時,HashMap 的增刪改查等操做的效率將會變高,這裏是典型的拿空間換時間。相反,若是增長負載因子(負載因子能夠大於1),HashMap 所能容納的鍵值對數量變多,空間利用率高,但碰撞率也高。這意味着鏈表長度變長,效率也隨之下降,這種狀況是拿時間換空間。至於負載因子怎麼調節,這個看使用場景了。通常狀況下,咱們用默認值就能夠了。

查找

  首先要知道內部的Node這個類,鏈表、二叉樹通常都是使用相似這種的類,沒什麼好說的。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;//節點的hash
    final K key;//節點的鍵
    V value;//節點的值
    Node<K,V> next;//節點的下一個節點
    //構造方法
    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }
    //下面這些沒什麼好說的
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    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;
    }
}

//這個table就是底層的數組,每一個節點能夠能夠經過其成員變量next造成鏈表
transient Node<K,V>[] table;

查找操做就是利用HashMap的原理:

  1. 找到元素所在桶
  2. 若是桶內是紅黑樹則調用紅黑樹的查找方法
  3. 若是桶內是鏈表則直接遍歷
//get()方法,經過key獲取value
public V get(Object key) {
    //聲明一個節點,調用內部方法獲取該key的節點,並返回其value
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//上面的方法並無調用key自己的hashCode()方法,而是使用了本身的靜態方法:
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//int類型爲32位,無符號右移16位表明取到了高16位的值
}
//這麼作的目的是使hashcode的高位也參與運算,增長隨機性

//主要步驟在這個方法裏
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 1. 定位鍵值對所在桶的位置,即若是這個鍵在表裏,它會在哪一個桶
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {//tab[(n - 1) & hash]這是個重點
        //總會檢查桶的第一個節點
        if (first.hash == hash && // always check first node
            ((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;
}

上面,定位桶所在的位置關鍵在這一步:

// index = (n - 1) & hash
first = tab[(n - 1) & hash]

  這裏,在n爲2的冪次方的時候,(n - 1) & hash等價於hash % n可是效率更高。舉個例子hash = 185,n = 16,計算過程以下:
(n - 1) & hash

  正好能夠獲取到hash對n的模。這裏正好解釋了爲何容量必須是2的冪次方。網上好多人說容量不是2的冪次方以後,是由於(n - 1) & hash這麼運算所得的值會衝突,而且會致使有些桶不能放元素了。但筆者不這麼認爲,由於假如容量不是2的冪次方以後,確定不能使用(n - 1) & hash這種運算方式了,只能使用模運算%,這樣就會致使效率低好多。因此空間必須是2的冪次方以後才能(n - 1) & hash這麼運算,最終獲得效率上的提高。
  可是上面的hash並不是key的hashCode方法,而是HashMap的靜態方法:

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

  這樣作有兩個好處,首先看一下上面求餘的計算圖,圖中的 hash 是由鍵的 hashCode 產生。計算餘數時,因爲 n 比較小,hash 只有低4位參與了計算,高位的計算能夠認爲是無效的。這樣致使了計算結果只與低位信息有關,高位數據沒發揮做用。爲了處理這個缺陷,咱們能夠上圖中的 hash 高4位數據與低4位數據進行異或運算,即 hash ^ (hash >>> 4)。經過這種方式,讓高位數據與低位數據進行異或,以此加大低位信息的隨機性,變相的讓高位數據參與到計算中。此時的計算過程以下:

  在 Java 中,hashCode 方法產生的 hash 是 int 類型,32 位寬。前16位爲高位,後16位爲低位,因此要右移16位。
  上面所說的是從新計算 hash 的一個好處,除此以外,從新計算 hash 的另外一個好處是能夠增長 hash 的複雜度。當咱們覆寫 hashCode 方法時,可能會寫出分佈性不佳的 hashCode 方法,進而致使 hash 的衝突率比較高。經過移位和異或運算,可讓 hash 變得更復雜,進而影響 hash 的分佈性。這也就是爲何 HashMap 不直接使用鍵對象原始 hash 的緣由了。

遍歷

  和查找查找同樣,遍歷操做也是你們使用頻率比較高的一個操做。對於 遍歷 HashMap,咱們通常都會用下面兩種方式:

//加強for循環體遍歷keySet
for(Object key : map.keySet()) {
    // do something
}

//遍歷entrySet
for(HashMap.Entry entry : map.entrySet()) {
    // do something
}

  加強型for循環的底層原理就是迭代器,所以上面的代碼至關於:

Set keys = map.keySet();
Iterator ite = keys.iterator();
while (ite.hasNext()) {
    Object key = ite.next();
    // do something
}

  你們在遍歷 HashMap 的過程當中會發現,屢次對 HashMap 進行遍歷時,遍歷結果順序都是一致的。但這個順序和插入的順序通常都是不一致的。爲何呢,這裏首先分析一下keySet的遍歷:

//keySet方法,返回的是一個內部KeySet類
public Set<K> keySet() {
    Set<K> ks = keySet;
    if (ks == null) {
        ks = new KeySet();
        keySet = ks;
    }
    return ks;
}

//內部的KeySet類
final class KeySet extends AbstractSet<K> {
    //大小
    public final int size()                 { return size; }
    //清空
    public final void clear()               { HashMap.this.clear(); }
    //注意這一步,返回的是下面的KeyIterator類的對象
    public final Iterator<K> iterator()     { return new KeyIterator(); }
    //包含
    public final boolean contains(Object o) { return containsKey(o); }
    //刪除
    public final boolean remove(Object key) {
        return removeNode(hash(key), key, null, false, true) != null;
    }
    //多線程迭代
    public final Spliterator<K> spliterator() {
        return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
    }
    //集合能夠調用forEach方法,參數爲Consumer函數式接口
    public final void forEach(Consumer<? super K> action) {
        Node<K,V>[] tab;
        if (action == null)
            throw new NullPointerException();
        if (size > 0 && (tab = table) != null) {
            int mc = modCount;
            for (Node<K, V> e : tab) {
                for (; e != null; e = e.next)
                    action.accept(e.key);
            }
            if (modCount != mc)
                throw new ConcurrentModificationException();
        }
    }
}
//內部類KeyIterator,繼承抽象內部類HashIterator
final class KeyIterator extends HashIterator
    implements Iterator<K> {
    public final K next() { return nextNode().key; }
}

//抽象內部類,下面有KeyIterator、ValueIterator、EntryIterator三個實現子類
abstract class HashIterator {
    //在當前已讀到的元素的下一個元素
    Node<K,V> next;        // next entry to return
    //當前已讀到的元素
    Node<K,V> current;     // current entry
    // 指望操做數,用於多線程狀況下,若是多個線程同時對 HashMap 進行讀寫,那麼這個指望操做數 expectedModCount 和 HashMap 的 modCount 就會不一致,這時候拋個異常出來,稱爲「快速失敗」
    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) 這行代碼時,有可能這時候  modCount 仍是等於 expectedModCount,當過了這一行代碼,modCount 有可能不等於 expectedModCoun,因此對於這個時候會有一個時差,或許會讀到有問題的數據
        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;
    }
    // 和外部 remove(Object key) 差很少,可是不會對 table 的元素進行重排,因此這個方法適合一邊迭代一邊刪除元素
    public final void remove() {
        Node<K,V> p = current;
        if (p == null)
            throw new IllegalStateException();
        //快速失敗
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        current = null;
        removeNode(p.hash, p.key, null, false, false);
        //操做完更新操做數
        expectedModCount = modCount;
    }
}

  如上面的源碼,遍歷全部的鍵時,首先要獲取鍵集合KeySet對象,而後再經過 KeySet 的迭代器KeyIterator進行遍歷。KeyIterator 類繼承自HashIterator類,核心邏輯也封裝在 HashIterator 類中。HashIterator 的邏輯並不複雜,在初始化時,HashIterator 先從桶數組中找到包含鏈表節點引用的桶。而後對這個桶指向的鏈表進行遍歷。遍歷完成後,再繼續尋找下一個包含鏈表節點引用的桶,找到繼續遍歷。找不到,則結束遍歷。舉個例子,假設咱們遍歷下圖的結構:

  HashIterator 在初始化時,會先遍歷桶數組,找到包含鏈表節點引用的桶,對應圖中就是3號桶。隨後由 nextNode 方法遍歷該桶所指向的鏈表。遍歷完3號桶後,nextNode 方法繼續尋找下一個不爲空的桶,對應圖中的7號桶。以後流程和上面相似,直至遍歷完最後一個桶。以上就是 HashIterator 的核心邏輯的流程,對應下圖:

經過這段代碼能夠驗證一下:

//建立一個HashMap並添加幾個Integer,注意Integer重寫了hashCode方法,Integer的HashCode等於其自己
HashMap<Integer, String> map = new HashMap<>(16);
map.put(7, "");
map.put(11, "");
map.put(43, "");
map.put(59, "");
map.put(19, "");
map.put(3, "");
map.put(35, "");

//建立一個list保存這些數以對比
List<Integer> nums = new ArrayList<>();
Collections.addAll(nums, 7, 11, 43, 59, 19, 3, 35);
//建立另一個list保存這些數的hashcode對16取餘
List<Integer> numsHash = new ArrayList<>();
for (Integer n : nums) {
    numsHash.add(n.hashCode() % 16);
}
System.out.println("Key:");
System.out.println(nums);
System.out.println("Key的哈希值對容量(16)取餘:");
System.out.println(numsHash);
System.out.println("在Map中的遍歷結果:");
System.out.println(map.keySet());

輸出結果:

Key:
[7, 11, 43, 59, 19, 3, 35]
Key的哈希值對容量(16)取餘:
[7, 11, 11, 11, 3, 3, 3]
在Map中的遍歷結果:
[19, 3, 35, 7, 11, 43, 59]

徹底一致

插入

  插入的大體流程實際上是比較簡單的:首先確定是先定位要插入的鍵值對屬於哪一個桶,定位到桶後,再判斷桶是否爲空。若是爲空,則將鍵值對存入便可。若是不爲空,則需將鍵值對接在鏈表最後一個位置,或者更新鍵值對。
可是真正的插入流程很是複雜,由於摻雜了桶的擴容以及鏈表的樹化等等。下面上源碼:

//put方法,把新值放入並返回舊值
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
//內部進行實現的方法,須要注意的是boolean onlyIfAbsent, boolean evict這兩個參數目前並無什麼用
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //這裏纔是初始化桶,HashMap在建立時並無直接初始化,而是延遲到放入元素時才進行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;//這裏tab = resize()進行了擴容
    //若是鍵所在的桶是空的就直接放入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        //使用e保存鍵和插入的鍵相同的節點
        Node<K,V> e; K k;
        //若是第一個節點的鍵和要放入的鍵相同,把e指向該節點
        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);
                    //鏈表長度是否達到樹化閾值,達到則樹化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);//樹化
                    break;
                }
                //條件爲 true,表示當前鏈表包含要插入的鍵值對,終止遍歷
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;//這一步是遍歷的關鍵,p指針後移
            }
        }
        //判斷要插入的鍵值對是否存在 HashMap 中,非空表明存在
        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;
}

大體流程以下:

  1. 當桶數組 table 爲空時,經過擴容的方式初始化 table
  2. 查找要插入的鍵值對是否已經存在,存在的話根據條件判斷是否用新值替換舊值
  3. 若是不存在,則將鍵值對鏈入鏈表中,並根據鏈表長度決定是否將鏈表轉爲紅黑樹
  4. 判斷鍵值對數量是否大於閾值,大於的話則進行擴容操做

仍是比較容易理解的。

擴容

  在 Java 中,數組的長度是固定的,這意味着數組只能存儲固定量的數據。但在開發的過程當中,不少時候咱們沒法知道該建多大的數組合適。建小了不夠用,建大了用不完,形成浪費。若是咱們能實現一種變長的數組,並按需分配空間就行了。好在,咱們不用本身實現變長數組,Java 集合框架已經實現了變長的數據結構。好比 ArrayList 和 HashMap。對於這類基於數組的變長數據結構,擴容是一個很是重要的操做。下面就來聊聊 HashMap 的擴容機制。
  在詳細分析以前,先來講一下擴容相關的背景知識:
  在 HashMap 中,桶數組的長度均是2的冪,閾值大小爲桶數組長度與負載因子的乘積。當 HashMap 中的鍵值對數量超過閾值時,進行擴容。
  HashMap 的擴容機制與其餘變長集合的套路不太同樣,HashMap 按當前桶數組長度的2倍進行擴容,閾值也變爲原來的2倍(若是計算過程當中,閾值溢出歸零,則按閾值公式從新計算)。擴容以後,要從新計算鍵值對的位置,並把它們移動到合適的位置上去。以上就是 HashMap 的擴容大體過程,接下來咱們來看看具體的實現:

// 擴容方法
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 若是 table 不爲空,代表已經初始化過了
    if (oldCap > 0) {
        // 當 table 容量超過容量最大值,則再也不擴容
        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; // double threshold
    } else if (oldThr > 0) // initial capacity was placed in threshold
        /*
         * 初始化時,將 threshold 的值賦值給 newCap,
         * HashMap 使用 threshold 變量暫時保存 initialCapacity 參數的值
         */ 
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        /*
         * 調用無參構造方法時,桶數組容量爲默認容量,
         * 閾值爲默認容量與默認負載因子乘積
         */
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // newThr 爲 0 時,按閾值計算公式進行計算
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 建立新的桶數組,桶數組的初始化也是在這裏完成的
    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;
                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;
}

上面的源碼總共作了3件事,分別是:

  1. 計算新桶數組的容量 newCap 和新閾值 newThr
  2. 根據計算出的 newCap 建立新的桶數組,桶數組 table 也是在這裏進行初始化的
  3. 將鍵值對節點從新映射到新的桶數組裏。若是節點是 TreeNode 類型,則須要拆分成黑樹。若是是普通節點,則節點按原順序進行分組。

  上面列的三點中,建立新的桶數組就一行代碼,不用說了。接下來,來講說第一點和第三點,先說說 newCap 和 newThr 計算過程。該計算過程對應 resize 源碼的第一和第二個條件分支,以下:

// 第一個條件分支
if ( oldCap > 0) {
    // 嵌套條件分支
    if (oldCap >= MAXIMUM_CAPACITY) {...}
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY) {...}
} 
else if (oldThr > 0) {...}
else {...}

// 第二個條件分支
if (newThr == 0) {...}

  經過這兩個條件分支對不一樣狀況進行判斷,進而算出不一樣的容量值和閾值。它們所覆蓋的狀況以下:

條件 覆蓋狀況 備註
oldCap > 0 桶數組 table 已經被初始化
oldThr > 0 threshold > 0,且桶數組未被初始化 調用 HashMap(int) 和 HashMap(int, float) 構造方法時會產生這種狀況,此種狀況下 newCap = oldThr,newThr 在第二個條件分支中算出
oldCap == 0 && oldThr == 0 桶數組未被初始化,且 threshold 爲 0 調用 HashMap() 構造方法會產生這種狀況。

  這裏把oldThr > 0狀況單獨拿出來講一下。在這種狀況下,會將 oldThr 賦值給 newCap,等價於newCap = threshold = tableSizeFor(initialCapacity)。咱們在初始化時傳入的 initialCapacity 參數通過 threshold 中轉最終賦值給了 newCap。這也就解答了前面提的一個疑問:initialCapacity 參數沒有被保存下來,那麼它怎麼參與桶數組的初始化過程的呢?
嵌套分支:

條件 覆蓋狀況 備註
oldCap >= 230 桶數組容量大於或等於最大桶容量 230 這種狀況下再也不擴容
newCap < 2^30 && oldCap > 16 新桶數組容量小於最大值,且舊桶數組容量大於 16 該種狀況下新閾值 newThr = oldThr << 1,移位可能會致使溢出

  這裏簡單說明一下移位致使的溢出狀況,當 loadFactor小數位爲 0,整數位可被2整除且大於等於8時,在某次計算中就可能會致使 newThr 溢出歸零。見下圖:

位移溢出

分支二:

條件 覆蓋狀況 備註
newThr == 0 第一個條件分支未計算 newThr 或嵌套分支在計算過程當中致使 newThr 溢出歸零

  說完 newCap 和 newThr 的計算過程,接下來再來分析一下鍵值對節點從新映射的過程。

  在 JDK 1.8 中,從新映射節點須要考慮節點類型。對於樹形節點,需先拆分成黑樹再映射。對於鏈表類型節點,則需先對鏈表進行分組,而後再映射。須要的注意的是,分組後,組內節點相對位置保持不變。關於紅黑樹拆分的邏輯將會放在下一小節說明,先來看看鏈表是怎樣進行分組映射的。

  咱們都知道往底層數據結構中插入節點時,通常都是先經過模運算計算桶位置,接着把節點放入桶中便可。事實上,咱們能夠把從新映射看作插入操做。在 JDK 1.7 中,也確實是這樣作的。但在 JDK 1.8 中,則對這個過程進行了必定的優化,邏輯上要稍微複雜一些。在詳細分析前,咱們先來回顧一下 hash 求餘的過程:
hash

  上圖中,桶數組大小 n = 16,hash1 與 hash2 不相等。但由於只有後4位參與求餘,因此結果相等。當桶數組擴容後,n 由16變成了32,對上面的 hash 值從新進行映射:

  擴容後,參與模運算的位數由4位變爲了5位。因爲兩個 hash 第5位的值是不同,因此兩個 hash 算出的結果也不同。上面的計算過程並不難理解,繼續往下分析。

  假設咱們上圖的桶數組進行擴容,擴容後容量 n = 16,從新映射過程以下:

  依次遍歷鏈表,並計算節點 hash & oldCap 的值。以下圖所示

  若是值爲0,將 loHead 和 loTail 指向這個節點。若是後面還有節點 hash & oldCap 爲0的話,則將節點鏈入 loHead 指向的鏈表中,並將 loTail 指向該節點。若是值爲非0的話,則讓 hiHead 和 hiTail 指向該節點。完成遍歷後,可能會獲得兩條鏈表,此時就完成了鏈表分組:

  最後再將這兩條連接存放到相應的桶中,完成擴容。以下圖:

  從上圖能夠發現,從新映射後,兩條鏈表中的節點順序並未發生變化,仍是保持了擴容前的順序。以上就是 JDK 1.8 中 HashMap 擴容的代碼講解。另外再補充一下,JDK 1.8 版本下 HashMap 擴容效率要高於以前版本。若是你們看過 JDK 1.7 的源碼會發現,JDK 1.7 爲了防止因 hash 碰撞引起的拒絕服務攻擊,在計算 hash 過程當中引入隨機種子。以加強 hash 的隨機性,使得鍵值對均勻分佈在桶數組中。在擴容過程當中,相關方法會根據容量判斷是否須要生成新的隨機種子,並從新計算全部節點的 hash。而在 JDK 1.8 中,則經過引入紅黑樹替代了該種方式。從而避免了屢次計算 hash 的操做,提升了擴容效率。

刪除

  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 &&
        // 1. 定位桶位置
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 若是鍵的值與鏈表第一個節點相等,則將 node 指向該節點
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {  
            // 若是是 TreeNode 類型,調用紅黑樹的查找邏輯定位待刪除節點
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 2. 遍歷鏈表,找到待刪除節點
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        // 3. 刪除節點,並修復鏈表或紅黑樹
        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;
}

  上面的代碼並不複雜,不難理解,這裏就很少說了。

table變量

  若是你們細心閱讀 HashMap 的源碼,會發現桶數組 table 被申明爲 transient。transient 表示易變的意思,在 Java 中,被該關鍵字修飾的變量不會被默認的序列化機制序列化。咱們再回到源碼中,考慮一個問題:桶數組 table 是 HashMap 底層重要的數據結構,不序列化的話,別人還怎麼還原呢?

  這裏簡單說明一下吧,HashMap 並無使用默認的序列化機制,而是經過實現readObject/writeObject兩個方法自定義了序列化的內容。這樣作是有緣由的,試問一句,HashMap 中存儲的內容是什麼?不用說,你們也知道是鍵值對。因此只要咱們把鍵值對序列化了,咱們就能夠根據鍵值對數據重建 HashMap。有的朋友可能會想,序列化 table 不是能夠一步到位,後面直接還原不就好了嗎?這樣一想,倒也是合理。但序列化 talbe 存在着兩個問題:

  1. table 多數狀況下是沒法被存滿的,序列化未使用的部分,浪費空間
  2. 同一個鍵值對在不一樣 JVM 下,所處的桶位置多是不一樣的,在不一樣的 JVM 下反序列化 table 可能會發生錯誤。

  以上兩個問題中,第一個問題比較好理解,第二個問題解釋一下。HashMap 的get/put/remove等方法第一步就是根據 hash 找到鍵所在的桶位置,但若是鍵沒有覆寫 hashCode 方法,計算 hash 時最終調用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不一樣的 JVM 下,可能會有不一樣的實現,產生的 hash 可能也是不同的。也就是說同一個鍵在不一樣平臺下可能會產生不一樣的 hash,此時再對在同一個 table 繼續操做,就會出現問題。
  綜上所述,你們應該能明白 HashMap 不序列化 table 的緣由了


直接閱讀源碼仍是有些難度的,結合這篇博客勉強把源碼讀完了,感受源代碼寫的好厲害(* ̄3 ̄)╭


參考HashMap源碼分析

相關文章
相關標籤/搜索