從零學習HashMap

一、什麼是HashMap?

基於哈希表的 Map 接口的實現。此實現提供全部可選的映射操做,並容許使用 null 值和 null 鍵。(除了非同步和容許使用 null 以外,HashMap 類與 Hashtable 大體相同。)此類不保證映射的順序,特別是它不保證該順序恆久不變。 此實現假定哈希函數將元素適當地分佈在各桶之間,可爲基本操做(get 和 put)提供穩定的性能。迭代 collection 視圖所需的時間與 HashMap 實例的「容量」(桶的數量)及其大小(鍵-值映射關係數)成比例。因此,若是迭代性能很重要,則不要將初始容量設置得過高(或將加載因子設置得過低)。前端

1.一、什麼是哈希表?

先複習一下數據結構java

數組:採用一段連續的存儲單元來存儲數據。對於指定下標的查找,時間複雜度爲O(1);經過給定值進行查找,須要遍歷數組,逐一比對給定關鍵字和數組元素,時間複雜度爲O(n),固然,對於有序數組,則可採用二分查找,插值查找,斐波那契查找等方式,可將查找複雜度提升爲O(logn);對於通常的插入刪除操做,涉及到數組元素的移動,其平均複雜度也爲O(n)node

線性鏈表:對於鏈表的新增,刪除等操做(在找到指定操做位置後),僅需處理結點間的引用便可,時間複雜度爲O(1),而查找操做須要遍歷鏈表逐一進行比對,複雜度爲O(n)算法

二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操做,平均複雜度均爲O(logn)。後端

哈希表:(數組 + 鏈表)相比上述幾種數據結構,在哈希表中進行添加,刪除,查找等操做,性能十分之高,不考慮哈希衝突的狀況下(後面會探討下哈希衝突的狀況),僅需一次定位便可完成,時間複雜度爲O(1),接下來咱們就來看看哈希表是如何實現達到驚豔的常數階O(1)的。數組

1.二、哈希表如何工做?

它經過計算一個關於鍵值的函數,將所需查詢的數據映射到表中一個位置來訪問記錄,這加快了查找速度。這個映射函數叫作散列函數,存放記錄的數組叫作散列表。白話一點的說就是經過把Key經過一個固定的算法函數(hash函數)轉換成一個整型數字,而後就對該數字對數組的長度進行取餘,取餘結果就看成數組的下標,將value存儲在以該數字爲下標的數組空間裏。安全

當使用hash表查詢時,就是使用hash函數將key轉換成對應的數組下標,並定位到該下標的數組空間裏獲取value,這樣就充分利用到數組的定位性能進行數據定位。性能優化

  • key:咱們輸入待查找的值
  • value:咱們想要獲取的內容
  • hash值:key經過hash函數算出的值(對數組長度取模,即可獲得數組下標)
  • hash函數(散列函數):存在一種函數F,根據這個函數和查找關鍵字key,能夠直接肯定查找值所在位置,而不須要一個個遍歷比較。這樣就預先知道key在的位置,直接找到數據,提高效率。 即
  • 地址index=F(key) hash函數就是根據key計算出該存儲地址的位置,hash表就是基於hash函數創建的一種查找表。

1.三、什麼是哈希衝突?

當咱們對某個元素進行哈希運算,獲得一個存儲地址,而後要進行插入的時候,發現已經被其餘元素佔用了,其實這就是所謂的哈希衝突,也叫哈希碰撞。經過構造性能良好的hash函數,能夠減小衝突,但通常不可能徹底避免衝突,所以解決衝突是hash表的另外一個關鍵問題。 建立和查找hash表都會遇到衝突,兩種狀況下解決衝突的方法應該一致。bash

哈希衝突的解決方案:數據結構

開放定址法: 這種方法也稱再散列法,基本思想是:當關鍵字key的hash地址p=F(key)出現衝突時,以p爲基礎,產生另外一個hash地址p1,若是p1仍然衝突,再以p爲基礎,再產生另外一個hash地址p2,。。。知道找出一個不衝突的hash地址pi,而後將元素存入其中。

線性探測法

鏈地址法(拉鍊法,位桶法):將產生衝突的關鍵字的數據存儲在衝突hash地址的一個線性鏈表中。實現時,一種策略是散列表同一位置的全部衝突結果都是用棧存放的,新元素被插入到表的前端仍是後端徹底取決於怎樣方便。

二、HashMap的實現原理是什麼?1.7和1.8有哪些區別?

JDK1.7用的是頭插法,而JDK1.8及以後使用的都是尾插法,那麼他們爲何要這樣作呢?由於JDK1.7是用單鏈表進行的縱向延伸,當採用頭插法時會容易出現逆序且環形鏈表死循環問題。可是在JDK1.8以後是由於加入了紅黑樹使用尾插法,可以避免出現逆序且鏈表死循環的問題。

擴容後數據存儲位置的計算方式也不同:1. 在JDK1.7的時候是直接用hash值和須要擴容的二進制數進行&(這裏就是爲何擴容的時候爲啥必定必須是2的多少次冪的緣由所在,由於若是隻有2的n次冪的狀況時最後一位二進制數才必定是1,這樣能最大程度減小hash碰撞)(hash值 & length-1)。而在JDK1.8的時候直接用了JDK1.7的時候計算的規律,也就是擴容前的原始位置+擴容的大小值=JDK1.8的計算方式,而再也不是JDK1.7的那種異或的方法。可是這種方式就至關於只須要判斷Hash值的新增參與運算的位是0仍是1就直接迅速計算出了擴容後的儲存方式。

JDK1.7的時候使用的是數組+ 單鏈表的數據結構。可是在JDK1.8及以後時,使用的是數組+鏈表+紅黑樹的數據結構(當鏈表的深度達到8的時候,也就是默認閾值,就會自動擴容把鏈表轉成紅黑樹的數據結構來把時間複雜度從O(n)變成O(logN)提升了效率)

爲何當桶中鍵值對數量大於8才轉換成紅黑樹,數量小於6才轉換成鏈表?

由於紅黑樹的平均查找長度是log(n),長度爲8的時候,平均查找長度爲3,若是繼續使用鏈表,平均查找長度爲8/2=4,這纔有轉換爲樹的必要。鏈表長度若是是小於等於6,6/2=3,雖然速度也很快的,可是轉化爲樹結構和生成樹的時間並不會過短。還有選擇6和8,中間有個差值7能夠有效防止鏈表和樹頻繁轉換。

2.一、什麼是頭插法?

在頭結點(爲了操做方便,在單鏈表的第一個結點以前附加一個結點,稱爲頭結點。頭結點的數據域能夠存儲數據標題、表長等信息,也能夠不存儲任何信息,其指針域存儲第一個結點的首地址)H以後插入數據,其特色是讀入的數據順序與線性表的邏輯順序正好相反

2.二、什麼是尾插法?

將每次插入的新結點放在鏈表的尾部,尾插法就是要使後面插入的結點在前一個插入結點和NULL值之間。

2.三、1.8以後爲何改成尾插法?

主要是爲了安全,防止多線程下造成環化 由於resize的賦值方式,也就是使用了單鏈表的頭插入方式(1.8以前),同一位置上新元素總會被放在鏈表的頭部位置,在舊數組中同一條Entry鏈上的元素,經過從新計算索引位置後,有可能被放到了新數組的不一樣位置上。

就可能出現下面的狀況,你們發現問題沒有?

B的下一個指針指向了A

一旦幾個線程都調整完成,就可能出現環形鏈表

若是這個時候去取值,就出現了無限循環的狀態..

使用頭插會改變鏈表的上的順序,可是若是使用尾插,在擴容時會保持鏈表元素本來的順序,就不會出現鏈表成環的問題了

2.3.一、hashmap的擴容原理

何時進行擴容?也就是resize

有幾個因素:

  • Capacity:HashMap當前長度。
  • DEFAULT_LOAD_FACTOR:負載因子,默認值0.75f。

  • DEFAULT_INITIAL_CAPACITY

    • 爲啥用位運算?不直接寫16?

      位移運算符 效率高

    • 爲啥選擇16呢?

      若是兩個元素不相同,可是hash函數的值相同,這兩個元素就是一個碰撞

      由於把任意長度的字符串變成固定長度的字符串,因此存在一個hash對應多個字符串的狀況,因此碰撞必然存在

      爲了減小hash值的碰撞,須要實現一個儘可能均勻分佈的hash函數,在HashMap中經過利用key的hashcode值,來進行位運算 公式:index = e.hash & (newCap - 1)

      舉個例子: 1.計算"book"的hashcode 十進制 : 3029737 二進制 : 101110001110101110 1001

      2.HashMap長度是默認的16,length - 1的結果 十進制 : 15 二進制 : 1111

      3.把以上兩個結果作與運算 101110001110101110 1001 & 1111 = 1001 1001的十進制 : 9,因此 index=9

      hash算法最終獲得的index結果,取決於hashcode值的最後幾位

      爲了推斷HashMap的默認長度爲何是16 如今,咱們假設HashMap的長度是10,重複剛纔的運算步驟: hashcode : 101110001110101110 1001 length - 1 : 1001 index : 1001

      再換一個hashcode 101110001110101110 1111 試試: hashcode : 101110001110101110 1111 length - 1 : 1001 index : 1001

      從結果能夠看出,雖然hashcode變化了,可是運算的結果都是1001,也就是說,當HashMap長度爲10的時候,有些index結果的出現概率 會更大而有些index結果永遠不會出現(好比0111),這樣就不符合hash均勻分佈的原則.

      在使用是2的冪的數字的時候,Length-1的值是全部二進制位全爲1,這種狀況下,index的結果等同於HashCode後幾位的值。只要輸入的HashCode自己分佈均勻,Hash算法的結果就是均勻的。能夠下降hash碰撞的概率。

言歸正傳,到底何時進行擴容呢,假定默認長度就是16,負載因子0.75f,16 * 0.75 = 12, 那麼在put第13個的時候就會進行resize。

2.3.二、hashmap擴容分爲兩步

  • 擴容:建立一個新的Entry空數組,長度是原數組的2倍。

  • ReHash:遍歷原Entry數組,把全部的Entry從新Hash到新數組。

    • 爲何要從新Hash呢,不直接複製過去?

      由於長度擴大之後,Hash的規則也隨之改變。

      Hash的公式---> index = HashCode(Key) & (Length - 1)

2.3.三、拓展:重寫equals方法的時候須要重寫hashCode方法?

由於在java中,全部的對象都是繼承於Object類。Ojbect類中有兩個方法equals、hashCode,這兩個方法都是用來比較兩個對象是否相等的。在未重寫equals方法咱們是繼承了object的equals方法,那裏的 equals是比較兩個對象的內存地址。

== 比較的是兩個對象的地址
複製代碼

在重寫equals的方法的時候,必須注意重寫hashCode方法,同時還要保證經過equals判斷相等的兩個對象,調用hashCode方法要返回一樣的整數值。而若是equals判斷不相等的兩個對象,其hashCode能夠相同(只不過會發生哈希衝突,應儘可能避免)。

在結合HashMap說一下,HashMap是經過key的hashCode去尋找index的,那index同樣就造成鏈表了,也就是說」張三「和」李四「的index多是同樣的,在一個鏈表上的。咱們去get的時候,他就是根據key去hash而後計算出index,找到了index,那我怎麼找到具體的」張三「和」李四「呢?就是用到了equals方法!雖然它們的hashCode同樣,可是他們並不相等。

三、JDK1.8中HashMap性能優化之紅黑樹

3.一、什麼是紅黑樹?

紅黑樹也叫自平衡二叉查找樹或者平衡二叉B樹。二叉樹不用多說,二叉查找樹(Binary Search Tree),(又:二叉搜索樹,二叉排序樹)它或者是一棵空樹,或者是具備下列性質的二叉樹: 若它的左子樹不空,則左子樹上全部結點的值均小於它的根結點的值; 若它的右子樹不空,則右子樹上全部結點的值均大於它的根結點的值; 它的左、右子樹也分別爲二叉排序樹。

時間複雜度爲O(log n)

高度h <= log2(n+1)

3.1.一、紅黑樹的主要特性:  

  • 每一個節點要麼是黑色,要麼是紅色。(節點非黑即紅)  
  • 根節點是黑色。  
  • 每一個葉子節點(NIL)是黑色。   
  • 若是一個節點是紅色的,則它的子節點必須是黑色的。(也就是說父子節點不能同時爲紅色)  
  • 從一個節點到該節點的子孫節點的全部路徑上包含相同數目的黑節點。(這一點是平衡的關鍵)
  • 若它的左子樹不空,則左子樹上全部結點的值均小於它的根結點的值; 若它的右子樹不空,則右子樹上全部結點的值均大於它的根結點的值

HashMap中的代碼

TreeNode繼承自LinkedHashMap中的內部類——LinkedHashMap.Entry,而這個內部類又繼承自Node,因此算是Node的子類。parent用來指向它的父節點,left指向左孩子,right指向右孩子,prev則指向前一個節點(原鏈表中的前一個節點),注意,這些字段跟Entry,Node中的字段同樣,是使用默認訪問權限的,因此子類能夠直接使用父類的屬性。

3.1.二、紅黑樹的左旋和右旋:  

將節點以某個節點爲中心向左或者向右進行旋轉操做以保持二叉樹的平衡

左旋:

咱們要對節點A執行左旋的操做,那咱們就須要執行接下來的幾個操做:

①將A的右子樹設置爲D;

②若是D不爲空,則將D的父節點設置爲A;

③將C的父節點設置爲A的父節點;

④若是A的父節點爲空,則將C設置爲root節點,若是A爲父節點的左子樹,則將C設置爲A父節點的左子樹,若是A爲父節點的右子樹,則將C設置爲A父節點的右子樹;

⑤將C的左子樹設置爲A;

⑥將A的父節點設置爲C。

執行完成後的樹形結構以下圖:

動圖展現:

右旋:

①將A的左子樹設置爲E;

②若是E不爲空,則將E的父節點設置爲A;

③將B的父節點設置爲A的父節點,若是A的父節點爲空,則將B設置爲root節點,若是A爲父節點的左子樹,則將B設置爲A父節點的左子樹,若是A爲父節點的右子樹,則將B設置爲A父節點的右子樹;

④將B的右子樹設置爲A;

⑤將A的父節點設置爲B。

執行完成後的樹形結構以下圖:

動圖展現:

四、源碼分析

4.一、插入(put)

插入的時候作了哪些動做?

找了一個牛X的流程圖

① 判斷鍵值對數組table[i]是否爲空或爲null,不然執行resize()進行擴容;

② 根據鍵值key計算hash值獲得插入的數組索引i,若是table[i]==null,直接新建節點添加,轉向⑥,若是table[i]不爲空,轉向③;

③ 判斷table[i]的首個元素是否和key同樣,若是相同直接覆蓋value,不然轉向④,這裏的相同指的是hashCode以及equals;

④ 判斷table[i] 是否爲treeNode,即table[i] 是不是紅黑樹,若是是紅黑樹,則直接在樹中插入鍵值對,不然轉向⑤;

⑤ 遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操做,不然進行鏈表的插入操做;遍歷過程當中若發現key已經存在直接覆蓋value便可;

⑥ 插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,若是超過,進行擴容。

源碼:

HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put("a",1); 

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;
        // 步驟①:tab爲空則建立
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 步驟②:計算index,並對null作處理(n 爲數組長度)
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K, V> e;
            K k;
            // 步驟③:節點key存在,直接覆蓋value
            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);
                        //鏈表長度大於8轉換爲紅黑樹進行處理
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;

                    }
                    // key已經存在直接覆蓋value
                    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;

    }
複製代碼

index計算方式:

index = hashCode(key) & (當前table長度length - 1)
複製代碼

擴容:

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) {
        // 若是容量大於容器最大值
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 閥值設爲int最大值
            threshold = Integer.MAX_VALUE;
            // 返回老的數組,再也不擴充
            return oldTab;
        }// 若是老的容量*2 小於最大容量而且老的容量大於等於默認容量
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 新的閥值也再老的閥值基礎上*2
            newThr = oldThr << 1; // double threshold
    }// 若是老的閥值大於0
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 新容量等於老閥值
        newCap = oldThr;
    else {  // 若是容量是0,閥值也是0,認爲這是一個新的數組,使用默認的容量16和默認的閥值12           
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 若是新的閥值是0,從新計算閥值
    if (newThr == 0) {
        // 使用新的容量 * 負載因子(0.75)
        float ft = (float)newCap * loadFactor;
        // 若是新的容量小於最大容量 且 閥值小於最大 則新閥值等於剛剛計算的閥值,不然新閥值爲 int 最大值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    } 
    // 將新閥值賦值給當前對象的閥值。
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        // 建立一個Node 數組,容量是新數組的容量(新容量要麼是老的容量,要麼是老容量*2,要麼是16)
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    // 將新數組賦值給當前對象的數組屬性
    table = newTab;
    // 若是老的數組不是null
    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)
                    // 調用紅黑樹 的split 方法,傳入當前對象,新數組,當前下標,老數組的容量,目的是將樹的數據從新散列到數組中
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 若是既不是樹,next 節點也不爲空,則是鏈表,注意,這裏將優化鏈表從新散列(java 8 的改進)
                  // Java8 以前,這裏曾是併發操做會出現環狀鏈表的狀況,可是Java8 優化了算法。此bug再也不出現,但併發時仍然不建議HashMap
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 這裏的判斷須要引出一些東西:oldCap 假如是16,那麼二進制爲 10000,擴容變成 100000,也就是32.
                        // 當舊的hash值 與運算 10000,結果是0的話,那麼hash值的右起第五位確定也是0,那麼該於元素的下標位置也就不變。
                        if ((e.hash & oldCap) == 0) {
                            // 第一次進來時給鏈頭賦值
                            if (loTail == null)
                                loHead = e;
                            else
                                // 給鏈尾賦值
                                loTail.next = e;
                            // 重置該變量
                            loTail = e;
                        }
                        // 若是不是0,那麼就是1,也就是說,若是原始容量是16,那麼該元素新的下標就是:原下標 + 16(10000b)
                        else {
                            // 同上
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 理想狀況下,可將原有的鏈表拆成2組,提升查詢性能。
                    if (loTail != null) {
                        // 銷燬實例,等待GC回收
                        loTail.next = null;
                        // 置入bucket中
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}


複製代碼

4.二、查詢(get)

HashMap 的查找操做比較簡單,

  • 先定位鍵值對所在的哈希桶數組的位置

  • table[index]的首個元素是否和key同樣(hash、equals都相等),若是相同則返回該value

  • 若是不相同判斷首個元素的類型,而後再對鏈表或紅黑樹進行查找。

源碼:

HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put("a",1);
hashMap.get("a");


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;
    // 1. 定位鍵值對所在桶的位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //2.哈希桶的首個元素是否和key同樣(hash、equals都相等),若是相同則返回該value
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // 3. 若是 first 是 TreeNode 類型,則調用黑紅樹查找方法
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                
            // 4. 對鏈表進行查找
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
複製代碼

有時間後續在更詳細的分析源碼部分。

相關文章
相關標籤/搜索