圖解HashMap(一)

HuYounger發表於2017-12-04

概述

HashMap是日常開發中經常會用到的一種資料結構,在介紹HashMap的時候會涉及到很多術語,比如時間複雜度O、雜湊(也叫雜湊)、雜湊演算法等,這些在大學課程裡都有教過,但是由於某種不可抗力又還給老師了,在深入學習HashMap之前先了解HashMap設計的思路以及以及一些重要概念,在後續分析原始碼的時候就能夠有比較清晰的認識。

HashMap是什麼

在回答這個問題之前先看個例子:小明打算從A市搬家到B市,拿了兩個箱子把自己的物品打包就出發了。圖解HashMap(一)

到了B市之後,他想拿手機給家裡報個平安,這時候問題來了,東西多了他忘記手機放在哪個箱子了?小明開始開啟1號箱子找手機,沒找到;再開啟2號箱子找,找到手機。當只有2個箱子的時候,東西又不多的情況下,他可能花個2分鐘就找到手機了,假如有20個箱子,每個箱子的東西又多又雜,那麼花的時間就多了。小明總結了下查詢耗時的原因,發現是因為這些東西放的沒有規律,如果他把每個箱子分個類別,比如定一個箱子專門放手機、電腦等電子裝置,有專門放衣服的箱子等等,那麼他找東西花的時間就可以大大縮短了。

其實HashMap也是用到這種思路,HashMap作為一種資料結構,像陣列和連結串列一樣用於常規的增刪改查,在存資料的時候(put)並不是隨便亂放,而是會先做一次類似“分類”的操作再儲存,一旦“分類”儲存之後,下次取(get)的時候就可以大大縮短查詢的時間。我們知道陣列在執行查、改的效率很高,而增、刪(不是尾部)的效率低,連結串列相反,HashMap則是把這兩者結合起來,看下HashMap的資料結構圖解HashMap(一)

從上面的結構可以看出,通常情況下HashMap是以陣列和連結串列的組合構成(Java8中將連結串列長度超過8的連結串列轉化成紅黑樹)。結合上面找手機的例子,我們簡單分析下HashMap存取操作的心路歷程。put存一個鍵值對的時候(比如存上圖蓋倫),先根據鍵值"分類","分類"一頓操作後告訴我們,蓋倫應該屬於14號坑,直接定位到14號坑。接下來有幾種情況:

  • 14號坑沒人,nice,直接存值;
  • 14號有人,也叫蓋倫,替換原來的攻擊值;
  • 14號有人,叫老王!插隊到老王前面去(單連結串列的頭插入方式,同一位置上新元素總會被放在連結串列的頭部位置)

get取的時候也需要傳鍵值,根據傳的鍵值來確定要找的是哪個"類別",比如找火男,"分類"一頓操作夠告訴我們火男屬於2號坑,於是我們直接定位到2號坑開始找,亞索不是…找到火男。

小結

HashMap是由陣列和連結串列組合構成的資料結構,Java8中連結串列長度超過8時會把長度超過8的連結串列轉化成紅黑樹;存取時都會根據鍵值計算出"類別"(hashCode),再根據"類別"定位到陣列中的位置並執行操作。

HashCode是什麼

還是舉個例子:一個工廠有500號人,下圖用兩種方案來儲存廠裡員工的信件。圖解HashMap(一)

左右各有27個信箱,左邊保安大哥存信的時候不做處理,想放哪個信箱就放哪個信箱,當員工去找信的時候,只好挨個信箱找,再挨個比對信箱裡信封的名字,萬一哥們臉黑,要找的放在最後一個信箱的最底下,悲劇…所以這種情況的時間複雜度為O(N);右邊採用HashCode的方式將27個信箱分類,分類的規則是名字首字母(第一個箱子放不寫名字的哥們),保安大哥將符合對應姓名的信件放在對應的信箱裡,這樣員工就不用挨個找了,只需要比對一個信箱裡的信件即可,大大提高了效率,這種情況的時間複雜度趨於一個常數O(1)。

例子中右圖其實就是hashCode的一個實現,每個員工都有自己的hashCode,比如李四的hashCode是L,王五的hashCode是W(這取決於你的hash演算法怎麼寫),然後我們根據確定的hashCode值把信箱分類,hashCode匹配則存在對應信箱。在Java的Object中可以呼叫hashCode()方法獲取物件hashCode,返回一個int值。那麼會出現兩個物件的hashCode一樣嗎?答案是會的,就像上上個例子中蓋倫和老王的hashCode就一樣,這種情況網上有人稱之為"hash碰撞",出現這種所謂"碰撞"的處理上面已經介紹瞭解決思路,具體原始碼後續介紹。

小結

hashCode是一個物件的標識,Java中物件的hashCode是一個int型別值。通過hashCode來指定陣列的索引可以快速定位到要找的物件在陣列中的位置,之後再遍歷連結串列找到對應值,理想情況下時間複雜度為O(1),並且不同物件可以擁有相同的hashCode。

HashMap的時間複雜度

通過上面信箱找信的例子來討論下HashMap的時間複雜度,在使用hashCode之後可以直接定位到一個箱子,時間的耗費主要是在遍歷連結串列上,理想的情況下(hash演算法寫得很完美),連結串列只有一個節點,就是我們要的圖解HashMap(一)

那麼此時的時間複雜度為O(1),那不理想的情況下(hash演算法寫得很糟糕),比如上面信箱的例子,假設hash演算法計算每個員工都返回同樣的hashCode

圖解HashMap(一)

所有的信都放在一個箱子裡,此時要找信就要依次遍歷C信箱裡的信,時間複雜度不再是O(1),而是O(N),因此HashMap的時間複雜度取決於演算法的實現上,當然HashMap內部的機制並不像信箱這麼簡單,在HashMap內部會涉及到擴容、Java8中會將長度超過8的連結串列轉化成紅黑樹,這些都在後續介紹。

小結

HashMap的時間複雜度取決於hash演算法,優秀的hash演算法可以讓時間複雜度趨於常數O(1),糟糕的hash演算法可以讓時間複雜度趨於O(N)。

負載因子是什麼

我們知道HashMap中陣列長度是16(什麼?你說不知道,看下原始碼你就知道),假設我們用的是最優秀的hash演算法,即保證我每次往HashMap裡存鍵值對的時候,都不會重複,當hashmap裡有16個鍵值對的時候,要找到指定的某一個,只需要1次;

圖解HashMap(一)

之後繼續往裡面存值,必然會發生所謂的"hash碰撞"形成連結串列,當hashmap裡有32個鍵值對時,找到指定的某一個最壞情況要2次;當hashmap裡有128個鍵值對時,找到指定的某一個最壞情況要8次

圖解HashMap(一)

隨著hashmap裡的鍵值對越來越多,在陣列數量不變的情況下,查詢的效率會越來越低。那怎麼解決這個問題呢?只要增加陣列的數量就行了,鍵值對超過16,相應的就要把陣列的數量增加(HashMap內部是原來的陣列長度乘以2),這就是網上所謂的擴容,就算你有128個鍵值對,我們準備了128個坑,還是能保證"一個蘿蔔一個坑"。

圖解HashMap(一)

其實擴容並沒有那麼風光,就像ArrayList一樣,擴容是件很麻煩的事情,要建立一個新的陣列,然後把原來陣列裡的鍵值對"放"到新的陣列裡,這裡的"放"不像ArrayList那樣用原來的index,而是根據新表的長度重新計算hashCode,來保證在新表的位置,老麻煩了,所以同一個鍵值對在舊陣列裡的索引和新陣列中的索引通常是不一致的(火男:"我以前是3號,怎麼現在成了127號,給我個完美的解釋!"新表:"大清亡了,現在你得聽我的")。另外,我們也可以看出這是典型的以空間換時間的操作。

說了這麼多,那負載因子是個什麼東西?負載因子其實就是規定什麼時候擴容。上面我們說預設hashmap陣列大小為16,存的鍵值對數量超過16則進行擴容,好像沒什麼毛病。然而HashMap中並不是等陣列滿了(達到16)才擴容,它會存在一個閥值(threshold),只要hashmap裡的鍵值對大於等於這個閥值,那麼就要進行擴容。閥值的計算公式:

閥值 = 當前陣列長度✖負載因子

hashmap中預設負載因子為0.75,預設情況下第一次擴容判斷閥值是16 ✖ 0.75 = 12;所以第一次存鍵值對的時候,在存到第13個鍵值對時就需要擴容了;或者另外一種理解思路:假設當前存到第12個鍵值對:12 / 16 = 0.75,13 / 16 = 0.8125(大於0.75需要擴容) 。肯定會有人有疑問,我要這鐵棒有何用?不,我要這負載因子有何用?直接規定超過陣列長度再擴容不就行了,還省得每次擴容之後還要重新計算新的閥值,Google說取0.75是一個比較好的權衡,當然我們可以自己修改,HashMap初識化時可以指定陣列大小和負載因子,你完全可以改成1。

public HashMap(int initialCapacity, float loadFactor)
複製程式碼

我的理解是這負載因子就像人的飯量,有的人吃要7分飽,有的人要10分飽,穩妥起見預設讓我們7.5分飽。

小結

在陣列大小不變的情況下,存放鍵值對越多,查詢的時間效率會降低,擴容可以解決該問題,而負載因子決定了什麼時候擴容,負載因子是已存鍵值對的數量和總的陣列長度的比值。預設情況下負載因子為0.75,我們可在初始化HashMap的時候自己修改。

hash與Rehash

hash和rehash的概念其實上面已經分析過了,每次擴容後,轉移舊錶鍵值對到新表之前都要重新rehash,計算鍵值對在新表的索引。如下圖火男這個鍵值對被存進hashmap到後面擴容,會經過hash和rehash的過程

圖解HashMap(一)

第一次hash可以理解成'"分類"',方便後續取、改等操作可以快速定位到具體的"坑"。那麼為什麼要進行rehash,按照之前元素在陣列中的索引直接賦值,例如火男之前3號坑,現在跑到30號坑。

圖解HashMap(一)

個人理解是,在未擴容前,可以看到如13號鏈的長度是3,為了保證我們每次查詢的時間複雜度O趨於O(1),理想的情況是"一個蘿蔔一個坑",那麼現在"坑"多了,原來"3個蘿蔔一個坑"的情況現在就能有效的避免了。

原始碼分析

Java7原始碼分析

先看下Java7裡的HashMap實現,有了上面的分析,現在在原始碼中找具體的實現。

//HashMap裡的陣列
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//Entry物件,存key、value、hash值以及下一個節點
static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
}
//預設陣列大小,二進位制1左移4位為16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//負載因子預設值
static final float DEFAULT_LOAD_FACTOR = 0.75f; 
//當前存的鍵值對數量
transient int size; 
//閥值 = 陣列大小 * 負載因子
int threshold;
//負載因子變數
final float loadFactor;

//預設new HashMap陣列大小16,負載因子0.75
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//可以指定陣列大小和負載因子
public HashMap(int initialCapacity, float loadFactor) {
    //省略一些邏輯判斷
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    //空方法
    init();
}
複製程式碼

以上就是HashMap的一些先決條件,接著看平時put操作的程式碼實現,put的時候會遇到3種情況上面已分析過,看下Java7程式碼:

public V put(K key, V value) {
        //陣列為空時建立陣列
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //key為空單獨對待
        if (key == null)
            return putForNullKey(value);
        //①根據key計算hash值
        int hash = hash(key);
        //②根據hash值和當前陣列的長度計算在陣列中的索引
        int i = indexFor(hash, table.length);
        //遍歷整條連結串列
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //③情況1.hash值和key值都相同的情況,替換之前的值
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                //返回被替換的值
                return oldValue;
            }
        }

        modCount++;
        //③情況2.坑位沒人,直接存值或發生hash碰撞都走這
        addEntry(hash, key, value, i);
        return null;
    }
複製程式碼

先看上面key為空的情況(上面畫圖的時候總要在第一格留個空key的鍵值對),執行 putForNullKey() 方法單獨處理,會把該鍵值對放在index0,所以HashMap中是允許key為空的情況。再看下主流程:

步驟①.根據鍵值算出hash值 — > hash(key)

步驟②.根據hash值和當前陣列的長度計算在陣列中的索引 — > indexFor(hash, table.length)

    static int indexFor(int h, int length) {
        //hash值和陣列長度-1按位與操作,聽著費勁?其實相當於h%length;取餘數(取模運算)
        //如:h = 17,length = 16;那麼算出就是1
        //&運算的效率比%要高
        return h & (length-1);
    }
複製程式碼

步驟③情況1.hash值和key值都相同,替換原來的值,並將被替換的值返回。

步驟③情況2.坑位沒人或發生hash碰撞 — > addEntry(hash, key, value, i)

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //當前hashmap中的鍵值對數量超過閥值
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //擴容為原來的2倍
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            //計算在新表中的索引
            bucketIndex = indexFor(hash, table.length);
        }
        //建立節點
        createEntry(hash, key, value, bucketIndex);
    }
複製程式碼

如果put的時候超過閥值,會呼叫 resize() 方法將陣列大小擴大為原來的2倍,並且根據新表的長度計算在新表中的索引(如之前17%16 =1,現在17%32=17),看下resize方法

    void resize(int newCapacity) { //傳入新的容量
        //獲取舊陣列的引用
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        //極端情況,當前鍵值對數量已經達到最大
        if (oldCapacity == MAXIMUM_CAPACITY) {
            //修改閥值為最大直接返回
            threshold = Integer.MAX_VALUE;
            return;
        }
        //步驟①根據容量建立新的陣列
        Entry[] newTable = new Entry[newCapacity];
        //步驟②將鍵值對轉移到新的陣列中
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        //步驟③將新陣列的引用賦給table
        table = newTable;
        //步驟④修改閥值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

複製程式碼

上面的重點是步驟②,看下它具體的轉移操作

    void transfer(Entry[] newTable, boolean rehash) {
        //獲取新陣列的長度
        int newCapacity = newTable.length;
        //遍歷舊陣列中的鍵值對
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //計算在新表中的索引,併到新陣列中
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
複製程式碼

這段for迴圈的遍歷會使得轉移前後鍵值對的順序顛倒(Java7和Java8的區別),畫個圖就清楚了,假設石頭的key值為5,蓋倫的key值為37,這樣擴容前後兩者還是在5號坑。第一次:

圖解HashMap(一)

第二次

圖解HashMap(一)

最後再看下建立節點的方法

    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }
複製程式碼

建立節點時,如果找到的這個坑裡面沒有存值,那麼直接把值存進去就行了,然後size++;如果是碰撞的情況,

圖解HashMap(一)

前面說的以單連結串列頭插入的方式就是這樣(蓋倫:”老王已被我一腳踢開!“),總結一下Java7 put流程圖

圖解HashMap(一)

相比put,get操作就沒這麼多套路,只需要根據key值計算hash值,和陣列長度取模,然後就可以找到在陣列中的位置(key為空同樣單獨操作),接著就是遍歷連結串列,原始碼很少就不分析了。

Java8原始碼分析

基本思路是一樣的

//定義長度超過8的連結串列轉化成紅黑樹
static final int TREEIFY_THRESHOLD = 8;
//換了個馬甲還是認識你!!!
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
}
複製程式碼

看下Java8 put的原始碼

public V put(K key, V value) {
    //根據key計算hashreturn 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;
        //步驟1.陣列為空或陣列長度為0,則擴容(咦,看到不一樣咯)
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //步驟2.根據hash值和陣列長度計算在陣列中的位置
        //如果"坑"裡沒人,直接建立Node並存值
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //步驟3."坑"裡有人,且hash值和key值都相等,先獲取引用,後面會用來替換值
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //步驟4.該鏈是紅黑樹
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //步驟5.該鏈是連結串列
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        //步驟5.1注意這個地方跟Java7不一樣,是插在連結串列尾部!!!
                        p.next = newNode(hash, key, value, null);
                        //連結串列長度超過8,轉化成紅黑樹
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //步驟5.2連結串列中已存在且hash值和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;
        //步驟6.鍵值對數量超過閥值,擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
複製程式碼

通過上面註釋分析,對比和Java7的區別,Java8一視同仁,管你key為不為空的統一處理,多了一步連結串列長度的判斷以及轉紅黑樹的操作,並且比較重要的一點,新增Node是插在尾部而不是頭部!!!。當然上面的主角還是擴容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;
    if (oldCap > 0) {
        //極端情況,舊陣列爆滿了
        if (oldCap >= MAXIMUM_CAPACITY) {
            //閥值改成最大,放棄治療直接返回舊陣列
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //擴容咯,這裡採用左移運算左移1位,也就是舊陣列*2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //同樣新閥值也是舊閥值*2
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //初始化在這裡
    else {               // zero initial threshold signifies using defaults
        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;
    @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;
                //這個鏈只有一個節點,根據新陣列長度計算在新表中的位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //紅黑樹的處理
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //連結串列長度大於1,小於8的情況,下面高能,單獨拿出來分析
                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;
}
複製程式碼

可以看到,Java8把初始化陣列和擴容全寫在resize方法裡了,但是思路還是一樣的,擴容後要轉移,轉移要重新計算在新表中的位置,上面程式碼最後一塊高能可能不太好理解,剛開始看的我一臉懵逼,看了一張美團部落格的分析圖才豁然開朗,在分析前先捋清楚思路

下面我們講解下JDK1.8做了哪些優化。經過觀測可以發現,我們使用的是2次冪的擴充套件(指長度擴為原來2倍),所以,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。看下圖可以明白這句話的意思,n為table的長度,圖(a)表示擴容前的key1(5)和key2(21)兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的雜湊與高位運算結果。

圖解HashMap(一)

圖a中key1(5)和key(21)計算出來的都是5,元素在重新計算hash之後,因為n變為2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:

圖解HashMap(一)

圖b中計算後key1(5)的位置還是5,而key2(21)已經變成了21,因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”。

有了上面的分析再回來看下原始碼

else { // preserve order
    //定義兩條鏈
    //原來的hash值新增的bit為0的鏈,頭部和尾部
    Node<K,V> loHead = null, loTail = null;
    //原來的hash值新增的bit為1的鏈,頭部和尾部
    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;
    }
}
複製程式碼

為了更清晰明瞭,還是舉個例子,下面的表定義了鍵和它們的hash值(陣列長度為16時,它們都在5號坑)

Key Hash
石頭 5
蓋倫 5
蒙多 5
妖姬 21
狐狸 21
日女 21

假設一個hash演算法剛好算出來的的儲存是這樣的,在存第13個元素時要擴容

圖解HashMap(一)

那麼流程應該是這樣的(只關注5號坑鍵值對的情況),第一次:

圖解HashMap(一)

第二次:

圖解HashMap(一)

省略中間幾次,第六次

圖解HashMap(一)

兩條鏈找出來後,最後轉移一波,大功告成。

    //擴容前後位置不變的鏈
    if (loTail != null) {
        loTail.next = null;
        newTab[j] = loHead;
    }
    //擴容後位置加上原陣列長度的鏈
    if (hiTail != null) {
        hiTail.next = null;
        newTab[j + oldCap] = hiHead;
    }
複製程式碼
圖解HashMap(一)

總結下Java8 put流程圖

圖解HashMap(一)

對比

1.發生hash衝突時,Java7會在連結串列頭部插入,Java8會在連結串列尾部插入

2.擴容後轉移資料,Java7轉移前後連結串列順序會倒置,Java8還是保持原來的順序

3.關於效能對比可以參考美團技術部落格,引入紅黑樹的Java8大程度得優化了HashMap的效能

感謝

講的很詳細的外國小哥

美團技術部落格

相關文章