以最簡單的方式講HashMap

 

以最簡單的方式講HashMap

HashMap能夠說是面試中最常出現的名詞,此次頭條的一面,第一個問的問題就是HashMap。因此就讓咱們來探討下HashMap吧。html

實驗環境:JDK1.8java

首先先說一下,和JDK1.7相比,對HashMap作了一些優化,使得HashMap的性能更加的優化。面試

  1. HashMap的儲存結構數組

  2. HashMap中的Hash安全

  3. HashMap是怎麼保存數據的數據結構

  4. HashMap的擴容操做多線程

  5. HashMap的線程安全問題併發

HashMap的儲存結構

只有當咱們知道HashMap的儲存結構時,咱們纔可以明白HashMap的工做原理。app

jdk1.7的存儲結構

在JDK1.7中,HashMap採用的是數組【位桶】+單鏈表的數據結構性能

 


 

 

圖片來自這裏

jdk1.8的儲存結構

在JDK1.8中,與JDK1.7最不相同的地方就是,採用了紅黑樹進行儲存,採用的是數組【位桶】+鏈表+紅黑樹,當鏈表的長度超過某一閥值時,就會將鏈表轉換爲紅黑樹,這個閥值能夠本身設置,默認是8。

 


 

 

圖片來自這裏

Hash

首先先說HashMap中的hash。當咱們使用HashMap中的put(k,v)時,

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

首先咱們要根據key算出key的hash值。

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

這個hash值不只僅是經過Object中的hashCode的獲得的,還須要進行右移和^位異或。

HashMap保存數據

總所周知,HashMap默認的容量大小是16,那麼當咱們儲存一個值時,是怎麼判斷儲存的位置呢?

首先咱們須要明白幾個參數。在使用HashMap的時候咱們極可能會使用如下的構造參數:

public HashMap(int initialCapacity, float loadFactor) ;
  • initialCapacity:初始化容量默認是16
  • capacity:容量,經過initCapacity計算出一個大於或者等於initCapacity且爲2的冪的值
  • loadFactor:裝載因子,默認是0.75,根據它來肯定須要擴容的閥值。
  • threshold:閥值,capacity*loadFactor即爲閥值。
  1. 未產生hash衝突

    // n是HashMap的大小,Hash爲key的hash值,tab爲以下圖中的table,i表明儲存的位置
    int i;
    // 爲null表明此位置爲空的
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

     


     

    例如:當某一hash值與(n-1)相與的結果是3,那麼就將這個這個table的第3號的位置。

     

  2. 產生hash衝突

    可是若是當咱們獲得的hash值同樣或者說相與的結果的table位置已經存在一個值了,那麼咱們應該怎麼去儲存呢?

    • 當key與table[i]的全部key進行equals比較,若是相同則直接更新覆蓋value。

    • 假如key進行equals比較不相同,則進行元素的插入操做(在jdk1.7中是鏈表的插入,在jdk1.8中既有鏈表的插入操做也有紅黑樹的操做)。

HashMap保存數據的JKD1.8源代碼看源代碼可以更好的理解HashMap的put操做

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,則進行擴容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 假如桶中的元素是空的,則直接將元素放在桶中【使用(n - 1) & hash]判斷放的位置】
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 假如桶中已經存在這個元素
        else {
            Node<K,V> e; K k;
            // 假如桶中的第一個元素p的hash值,key與要存的值相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;// 使用e來記錄p
            // TreeNode 表明紅黑樹節點
            // 假如key不相等,則將元素放入紅黑樹節點中
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 假如p爲鏈表節點
            else {
                // 進行鏈表查找
                for (int binCount = 0; ; ++binCount) {
                    // 假如next爲空【表明達到鏈表末尾】
                    if ((e = p.next) == null) {
                        // 在末尾插入新的節點
                        p.next = newNode(hash, key, value, null);
                        // 若是鏈表長度達到閥值,則轉化爲紅黑樹
                        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))))
                        // 此時e爲鏈表中key相等的元素
                        break;
                    p = e;
                }
            }
            // e不爲nul,表明要相同的元素
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                // 若是onlyIfAbsent爲false或者舊值爲空,則進行更新
                // 在源碼中onlyIfAbsent默認是false
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 回調以容許LinkedHashMap過後操做
                afterNodeAccess(e);
                // 返回舊值
                return oldValue;
            }
        }
        // modeCount表明HashMap在結構上面被修改的次數
        ++modCount;
        // 加入大小大於閥值則進行擴容
        if (++size > threshold)
            resize();
        // 回調以容許LinkedHashMap過後操做
        afterNodeInsertion(evict);
        return null;
    }

HashMap的擴容操做

在HashMap中進行擴容操做是特別耗費時間的,由於隨着擴容,會從新進行一次hash分配,遍歷hash表中的全部元素,由於桶的大小【也就是數組長度n】變了,那麼(n - 1) & hash的值也會發生改變,因此咱們在編寫程序時應該儘可能避免resize,儘可能在新建HashMap對象的時候指令桶的長度【阿里巴巴開發手冊也是這樣推薦使用】。

HashMap進行擴容時,會徹底新建一個桶,咱們從上面瞭解到桶就是數組,而數組是沒辦法自動擴容的,因此咱們須要用一個新的數組來代替前面的桶。而當HashMap進行擴容是,閥值會變成原來的兩倍容量也會變成原來的兩倍

首先咱們先講講JDK1.7中的resize(),JDK1.8有紅黑樹,仍是有點麻煩。

  1. JDK1.7 的rezise()
void resize(int newCapacity) {   //傳入新的容量 
    //table爲擴容前的Entry數組
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;  
    // 若是擴容前的數組大小若是已經達到最大(2^30) 
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        //修改閾值爲int的最大值(2^31-1),這樣之後就不會擴容了 
        threshold = Integer.MAX_VALUE;
        return;  
    }  

    // 新建一個Entry數組
    Entry[] newTable = new Entry[newCapacity];  
    //將數據轉移到新的Entry數組裏
    transfer(newTable);
    // 修改table的指向對象
    table = newTable;
    threshold = (int) (newCapacity * loadFactor);//修改閾值 
}

void transfer(Entry[] newTable) {  
    Entry[] src = table;                   //src引用了舊的Entry數組 
    int newCapacity = newTable.length;  
    // 遍歷舊的Entry數組 
    for (int j = 0; j < src.length; j++) { 
        Entry<K, V> e = src[j];
        // 若是此位置存在元素
        if (e != null) {  
            // for循環事後,舊的Entry數組就再也不引用任何對象
            src[j] = null;
            // 遍歷鏈表
            do {  
                // 得到鏈表中的下一個元素
                Entry<K, V> next = e.next;  
                // 從新計算數據保存位置
                int i = indexFor(e.hash, newCapacity);
                // 在jdk1.7中是頭部插入,此時e.next指向新的數組位置newTable[i]
                e.next = newTable[i];
                // 將newTable指向e
                newTable[i] = e;
                // 訪問下一個Entry鏈上的元素
                e = next;
            } while (e != null);  
        }  
    }  
}  
static int indexFor(int h, int length) {  
    return h & (length - 1);  
}
  1. JDK1.8 的rezise()
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    // 得到table的大小,並將其長度賦值給oldCap
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 閥值賦值
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 若是table不爲空
    if (oldCap > 0) {
        // 數組大小大於(2^30)
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 修改閾值爲int的最大值(2^31-1),這樣之後就不會擴容了 
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // newCap = oldCap << 1新的容量爲之前的兩倍
        // 當新的table長度沒有超過最致使,且之前的table長度大於16,則進行閥值更新
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 閥值擴大成兩倍
            newThr = oldThr << 1; // double threshold
    }
    // 若是table爲空,且閥值大於0
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 則新的容量大小爲閥值
        newCap = oldThr;
    
    // 假如table爲空切閥值小於等於0,則初始化閥值,和table
    else {               // zero initial threshold signifies using defaults
        // 新的table長度爲16
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 新的閥值爲負載因子【0.75】*16
        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;
    /* *以上都是進行初始化操做,目的是擴大容量,或則初始化HashMap *下面即是從新存放元素操做 */

    @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;
            // 假如oldTab[j]中含有元素
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 假如沒有下一個元素,也就是oldTab[j]中只有e一個元素
                if (e.next == null)
                    // 從新選擇空間
                    newTab[e.hash & (newCap - 1)] = e;
                // 假若有下一個元素,且該節點爲紅黑樹節點
                else if (e instanceof TreeNode)
                    // 將該節點進行rehash後,放到新的地方
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                /** * 在JDK1.8中不像JDK1.7同樣從新進行hash值計算,而是利用了一個規律: * 假如e.hash & oldCap爲0,那麼該元素的引索位置沒有變 * 假如e.hash & oldCap爲1,那麼該元素的引索位置爲原引索+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;
}

HashMap的線程安全問題

相信不少人都據說過HashMap線程不安全,可是HashMap爲何會產生線程安全問題呢?

  1. 多線程put()操做

設想一個場景,A線程正在進行put操做,它通過hash計算,以及鏈表查找,已經肯定了put的位置X,可是這時候cpu時間片到了,A線程不得不退出put操做的執行,這時候B線程得到了cpu時間片,在X的位置進行插入值,若是A線程再執行put操做就會覆蓋之前的值,此時數據就不一致了。

  1. 多線程resize()操做

當多個線程進行resize()操做時,假如table已經變成新數組,那麼下一個線程會使用已經被賦值過得的table作爲初始值進行操做。這樣可能就會出現死循環的操做。

至於怎麼避免HashMap的多線程安全問題,ConcurrentHashMap是一個好東西,至於它是怎麼解決併發的問題,咱們下次再聊。

HashMap其實並非很難,咱們主要是要理解它儲存元素的思想與方法。而經過源代碼,咱們可以更好的理解設計的理念

相關文章
相關標籤/搜索