求求大家不要再問HashMap原理了....

前言

相信大多數朋友都使用過HashMap,面試也常常會被問到,但每每都回答的都不盡人意,確實,HashMap還算是比較複雜的一個數據結構,尤爲是在JDK1.8以後又引入了紅黑樹以後。本文就基於JDK1.8的HashMap源碼,帶你們將經常使用方法、重要屬性及相關方法進行分析,HashMap 源碼中可分析的點不少,本文很難一一覆蓋,請見諒。java

本文篇幅較長,請客官耐心觀看node

若是本文中有不正確的結論、說法,請你們提出和我討論,共同進步,謝謝。面試

1.原理

HashMap是基於hash算法實現的,也就是不一樣於數組,每次添加數據時,下標自增的操做,而是根據Key的hash值以及數組的長度計算出對應的下標,放入元素,那麼在查找的時候就直接可以定位到對應的元素,若是在沒有hash衝突的時候,時間複雜度基本就是O(1)了,引用一張圖大體總體看下HashMap的數據結構。算法

img

1.1 hash衝突

有朋友可能就會有疑惑了,那當元素愈來愈多的時候,就算經過hash算法計算,那萬一兩個元素計算出的下標同樣呢?那後面的元素往哪放?這裏採用的是鏈表的形式,當發生hash衝突的時候,第一個元素直接指向第二個元素,再有hash衝突元素時,直接插到鏈表尾部,這樣造成一條鏈。segmentfault

那麼若是衝突的元素不少,那麼鏈表豈不是會很長,由於咱們知道鏈表查詢是效率很低的,須要一個一個的遍歷,那麼在JDK1.8中,當鏈表長度超過必定閾值時,直接進行數據結構轉換,將鏈表轉化成紅黑樹,紅黑樹是一種平衡二叉樹,時間複雜度是O(logn),具體紅黑樹的原理就不分析了,不在此文章範圍內。數組

1.2 擴容

從上面分析,咱們也能夠看明白,HashMap的數據結構是由數組和鏈表(或樹形結構)組成,因此本質仍是由數組開始,咱們知道數組是須要提早知道容量的,好比初始位10,那麼當元素愈來愈多,由於下標範圍是0-9,因此hash衝突會愈來愈多,這樣造成不少鏈表或者樹,查詢時效率很是低,這時候就須要擴容了,也就是擴大原有數組的長度,至於擴多大,何時該擴容,下面分析源碼時,將一一給你們講解,可是咱們要注意的一點是,擴容是須要再次Hash的,爲何呢,由於hash算法是hash值取餘數組長度,因此必需要再次Hash肯定每一個元素的位置安全

1.3 hashCode

hash算法是基於key的hashcode方法的,hashcode是object的方法,每一個對象均可以進行復寫,這裏就衍生出一個問題,什麼類適合做爲更適合做爲HashMap的鍵?答案是String, Interger這樣的wrapper類,由於String是不可變的,也是final的,並且已經重寫了equals()和hashCode()方法了。其餘的wrapper類也有這個特色。不可變性是必要的,由於爲了要計算hashCode(),就要防止鍵值改變,若是鍵值在放入時和獲取時返回不一樣的hashcode的話,那麼就不能從HashMap中找到你想要的對象,並且比較安全,碰撞的概率就會小些,這樣就能提升HashMap的性能。數據結構

2 源碼

2.1 初始化

老規矩,先上構造方法老是沒錯的多線程

public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
	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個構造方法,咱們大多數基本用的就是第一個無參方法,其餘的幾個方法也是作一些初始化操做,主要關心這幾個變量:app

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

HashMap 初始容量是16,負載因子爲 0.75,可是有的朋友會細心發現,第一個構造方法,擺明就只是賦值了負載因子,初始容量和閾值都沒有被初始化,這裏先不解釋,後面擴容機制會告訴你答案,而後看最後一個構造函數,咱們能夠把初始容量和負載因子做爲值傳遞進來,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的冪,這裏引用一張圖解釋下,侵刪:

img

好比cap等於5,那麼最終返回的就是8,若是cap等於10,返回的就是16,這樣一說你們結合上面的應該能理解了。

2.1 插入

插入邏輯算是比較複雜的了,咱們先來看看put方法代碼:

public V put(K key, V value) {
        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
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //經過hash算法找到下標,若是對應的位置爲空,直接將數據放進去
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
           	//對應的位置不爲空,hash衝突 
            Node<K,V> e; K k;
            //判斷插入的key若是等於當前位置的key的話,先將 e 指向該鍵值對,後續覆蓋
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //若是桶中的引用類型爲 TreeNode,則調用紅黑樹的插入方法
            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) 
                            treeifyBin(tab, hash);
                        break;
                    }
                    //若是鏈表中包含該節點,賦值,後續覆蓋,跳出循環
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //判斷插入的是否存在HashMap中,上面e被賦值,不爲空,則說明存在,更新舊的鍵值對
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //當前HashMap鍵值對超過閾值時,進行擴容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
複製代碼

能夠看到主要邏輯在putVal()方法中,不清楚的能夠看下注釋,總結一下主要是幾個方面:

  • 若是當前table爲空,先進行初始化
  • 查找插入的鍵值對是否存在,存在的話,先進行賦值,後續將更新舊的鍵值對
  • 不存在,插入鏈表尾部,若是鏈表長度大於一個閾值,進行鏈表轉化樹的操做
  • 若是size大於一個閾值,進行擴容

那麼重點固然就是擴容方法了,看看具體實現:

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;
            }
            //經過位運算,計算出新的容量以及新的閾值,2倍計算
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
       //使用 threshold 變量暫時保存 initialCapacity 參數的值
        else if (oldThr > 0) 
            newCap = oldThr;
        else {
            //這裏就能回答上面的初始化的問題了,調用空的構造函數時的賦值
            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 {
                        //遍歷整個鏈表,從新hash,根據新的下標從新分組
                        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;
    }
複製代碼

代碼稍微長了點,你們耐心點看下邏輯,總結也就幾點

  • 判斷當前oldTab長度是否爲空,若是爲空,則進行初始化桶數組,也就回答了空構造函數初始化爲何沒有對容量和閾值進行輔助,若是不爲空,則進行位運算,左移一位,2倍運算。
  • 擴容,建立一個新容量的數組,遍歷舊的數組:
    • 若是節點爲空,直接賦值插入
    • 若是節點爲紅黑樹,則須要進行進行拆分操做
    • 若是爲鏈表,根據hash算法進行從新計算下標,將鏈表進行拆分分組

這裏主要說明下鏈表拆分是什麼意思,咱們知道下標計算是hash&(n-1),假如原始數組長度爲16,進行求餘計算:那麼n-1也就是15,對應二進制 0000 1111,這時候分別有2個hash值分別爲:1101 1100和1110 1100,計算能夠獲得,獲得的下標都是0000 1100,也就是12,若是進行擴容以後呢?長度變成32,n-1也就對應 0001 1111,2個hash再次進行計算獲得的就是 0001 1100 和 0000 1100,一個下標仍是12,而另外一個則是28了

能夠看到擴容後,參與模運算的位數由4位變爲了5位,因此對應得出來的值天然就不同了,相信你們也應該理解了

2.2 查找

相對於複雜的插入操做,查找的邏輯相對就相對簡單點了,代碼以下:

public V get(Object key) {
        Node<K,V> e;
        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;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //定位下標,若是第一個節點是所要查找的值,直接返回
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;          
            if ((e = first.next) != null) {
                //若是第一個節點是TreeNode類型,去遍歷紅黑樹
                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;
    }
複製代碼

上面也提到了,經過(n - 1) & hash 便可算出在數組中的位置,這裏簡單解釋一下。HashMap 中桶數組的大小 length 老是2的冪,此時,(n - 1) & hash 等價於對 length 取餘。但取餘的計算效率沒有位運算高,因此(n - 1) & hash也是一個小的優化

還有一個計算hash值得方法

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

能夠看到,這裏的hash並非用原有對象的hashcode最爲最終的hash值,而是作了必定位運行,具體緣由我的想法以下:

由於若是(n-1)的值過小的話(n - 1) & hash的值就徹底依靠hash的低位值,好比n-1爲0000 1111,那麼最終的值就徹底依賴於hash值的低4位了,這樣的話hash的高位就玩徹底失去了做用,h ^ (h >>> 16),經過這種方式,讓高位數據與低位數據進行異或,也是變相的加大了hash的隨機性,這樣就不單純的依賴對象的hashcode方法了。

2.3 刪除

有了前面一些鋪墊,刪除操做也並不複雜

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 &&
            (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 {
                    //遍歷鏈表,找到該節點
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            //經過節點類型進行刪除操做
            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;
    }
複製代碼

相信有了以前的基礎,這裏理解就不困難了,具體實現就很少說了,有興趣的朋友能夠深刻源碼看下

3.總結

大體分析就到一段落了,這裏總結下幾個問題,但願可以幫助到你們一些面試過程。

  • HashMap底層數據結構由數組+鏈表+紅黑樹實現(JDK1.8),經過鍵key通過擾動函數擾動後獲得hash值,而後再經過hash & (length - 1)代替取模的方式進行元素定位,查找效率最好狀況是O(1)
  • Hash衝突是指不一樣對象的hashCode經過hash算法後得出了相同定位的下標,這時候採用鏈地址法,會將此元素插入至此位置鏈表的最後一位,造成單鏈表。當存在位置的鏈表長度 大於等於 8 而且當前數組容量超過64時,HashMap會將鏈表 轉變爲 紅黑樹,這裏要說明一點,每每後者的條件會被大多數人忽略,當桶數組容量比較小時,鍵值對節點 hash 的碰撞率可能會比較高,進而致使鏈表長度較長。這個時候應該優先擴容,而不是立馬樹化。畢竟高碰撞率是由於桶數組容量較小引發的,這個是主因。容量小時,優先擴容能夠避免一些列的沒必要要的樹化過程。
  • HashMap的容量是2的n次方,有利於提升計算元素存放位置時的效率,也下降了hash衝突的概率,從上面代碼分析咱們也能看出來,就算傳遞進來一個不是2次方的數,內部也會經過位運算找到大於或等於 cap 的最小2的冪,來設置給容器。
  • 在使用HashMap的時候,儘可能的選擇不可變的對象做爲key,避免對象的改變引發hash的變化,致使數據的不許確性
  • HashMap是非線程安全的,在多線程的操做下會存在異常狀況,可使用HashTable或者ConcurrentHashMap進行代替

參考文章

JDK 源碼中 HashMap 的 hash 方法原理是什麼?

HashMap 源碼詳解分析

請幫頂 / 評論點贊!由於你的鼓勵是我寫做的最大動力!

相關文章
相關標籤/搜索