刨死你係列——HashMap(jdk1.8)

本文的源碼是基於JDK1.8版本,在學習HashMap之前,先了解數組和鏈表的知識。

數組:
數組具有遍歷快,增刪慢的特點。數組在堆中是一塊連續的存儲空間,遍歷時數組的首地址是知道的(首地址=首地址+元素字節數 * 下標),所以遍歷快(數組遍歷的時間複雜度爲O(1) );增刪慢是因爲,當在中間插入或刪除元素時,會造成該元素後面所有元素地址的改變,所以增刪慢(增刪的時間複雜度爲O(n) )。

鏈表:
鏈表具有增刪快,遍歷慢的特點。鏈表中各元素的內存空間是不連續的,一個節點至少包含節點數據與後繼節點的引用,所以在插入刪除時,只需修改該位置的前驅節點與後繼節點即可,鏈表在插入刪除時的時間複雜度爲O(1)。但是在遍歷時,get(n)元素時,需要從第一個開始,依次拿到後面元素的地址,進行遍歷,直到遍歷到第n個元素(時間複雜度爲O(n) ),所以效率極低。

HashMap:
Hash表是一個數組+鏈表的結構,這種結構能夠保證在遍歷與增刪的過程中,如果不產生hash碰撞,僅需一次定位就可完成,時間複雜度能保證在O(1)。  在jdk1.7中,只是單純的數組+鏈表的結構,但是如果散列表中的hash碰撞過多時,會造成效率的降低,所以在JKD1.8中對這種情況進行了控制,當一個hash值上的鏈表長度大於8時,該節點上的數據就不再以鏈表進行存儲,而是轉成了一個紅黑樹。

hash碰撞:
hash是指,兩個元素通過hash函數計算出的值是一樣的,是同一個存儲地址。當後面的元素要插入到這個地址時,發現已經被佔用了,這時候就產生了hash衝突

hash衝突的解決方法:
開放定址法(查詢產生衝突的地址的下一個地址是否被佔用,直到尋找到空的地址),再散列法,鏈地址法等。hashmap採用的就是鏈地址法,jdk1.7中,當衝突時,在衝突的地址上生成一個鏈表,將衝突的元素的key,通過equals進行比較,相同即覆蓋,不同則添加到鏈表上,此時如果鏈表過長,效率就會大大降低,查找和添加操作的時間複雜度都爲O(n);但是在jdk1.8中如果鏈表長度大於8,鏈表就會轉化爲紅黑樹,下圖就是1.8版本的(圖片來源https://segmentfault.com/a/1190000012926722),時間複雜度也降爲了O(logn),性能得到了很大的優化。

下面通過源碼分析一下,HashMap的底層實現

首先,hashMap的主幹是一個Node數組(jdk1.7及之前爲Entry數組)每一個Node包含一個key與value的鍵值對,與一個next指向下一個node,hashMap由多個Node對象組成。

Node是HhaspMap中的一個靜態內部類 :

static class Node<K,V> implements Map.Entry<K,V> {
         final int 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; }
 
        //hashCode等其他代碼
     }

再看下hashMap中幾個重要的字段:

//默認初始容量爲16,0000 0001 左移4位 0001 0000爲16,主幹數組的初始容量爲16,而且這個數組
//必須是2的倍數(後面說爲什麼是2的倍數)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
 
//最大容量爲int的最大值除2
static final int MAXIMUM_CAPACITY = 1 << 30;
 
//默認加載因子爲0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
 
//閾值,如果主幹數組上的鏈表的長度大於8,鏈表轉化爲紅黑樹
 static final int TREEIFY_THRESHOLD = 8;
 
//hash表擴容後,如果發現某一個紅黑樹的長度小於6,則會重新退化爲鏈表
 static final int UNTREEIFY_THRESHOLD = 6;
 
//當hashmap容量大於64時,鏈表才能轉成紅黑樹
 static final int MIN_TREEIFY_CAPACITY = 64;
 
//臨界值=主幹數組容量*負載因子
int threshold;

HashMap的構造方法:

//initialCapacity爲初始容量,loadFactor爲負載因子
public HashMap(int initialCapacity, float loadFactor) {
        //初始容量小於0,拋出非法數據異常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //初始容量最大爲MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //負載因子必須大於0,並且是合法數字
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        
        this.loadFactor = loadFactor;
        //將初始容量轉成2次冪
        this.threshold = tableSizeFor(initialCapacity);
    }
 
    //tableSizeFor的作用就是,如果傳入A,當A大於0,小於定義的最大容量時,
  //  如果A是2次冪則返回A,否則將A轉化爲一個比A大且差距最小的2次冪。  
    //例如傳入7返回8,傳入8返回8,傳入9返回16
  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;
    }
 
 
    //調用上面的構造方法,自定義初始容量,負載因子爲默認的0.75
 public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
 
 
    //默認構造方法,負載因子爲0.75,初始容量爲DEFAULT_INITIAL_CAPACITY=16,初始容量在第一次put時纔會初始化
 public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
 
 
    //傳入一個MAP集合的構造方法
 public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

HashMap的put()方法

put 方法的源碼分析是本篇的一個重點,因爲通過該方法我們可以窺探到 HashMap 在內部是如何進行數據存儲的,所謂的數組+鏈表+紅黑樹的存儲結構是如何形成的,又是在何種情況下將鏈表轉換成紅黑樹來優化性能的。帶着一系列的疑問,我們看這個 put 方法:

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

也就是put方法調用了putVal方法,其中傳入一個參數位hash(key),我們首先來看看hash()這個方法。

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

此處如果傳入的int類型的值:①向一個Object類型賦值一個int的值時,會將int值自動封箱爲Integer。②integer類型的hashcode都是他自身的值,即h=key;h >>> 16爲無符號右移16位,低位擠走,高位補0;^ 爲按位異或,即轉成二進制後,相異爲1,相同爲0,由此可發現,當傳入的值小於  2的16次方-1 時,調用這個方法返回的值,都是自身的值。
然後再執行putVal方法:

//onlyIfAbsent是true的話,不要改變現有的值
//evict爲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爲空,長度爲0,調用resize方法,調整table的長度(resize方法在下圖中)
        if ((tab = table) == null || (n = tab.length) == 0)
            /* 這裏調用resize,其實就是第一次put時,對數組進行初始化。
               如果是默認構造方法會執行resize中的這幾句話:
               newCap = DEFAULT_INITIAL_CAPACITY;  新的容量等於默認值16
               newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);            
               threshold = newThr;   臨界值等於16*0.75
               Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 
               table = newTab; 將新的node數組賦值給table,然後return newTab
                
                如果是自定義的構造方法則會執行resize中的: 
                int oldThr = threshold;   
                newCap = oldThr;   新的容量等於threshold,這裏的threshold都是2的倍數,原因在    
                於傳入的數都經過tableSizeFor方法,返回了一個新值,上面解釋過
                float ft = (float)newCap * loadFactor; 
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE); 
                 threshold = newThr; 新的臨界值等於 (int)(新的容量*負載因子)
                Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
                table = newTab; return newTab;
            */
            n = (tab = resize()).length;  //將調用resize後構造的數組的長度賦值給n
        if ((p = tab[i = (n - 1) & hash]) == null) //將數組長度與計算得到的hash值比較
            tab[i] = newNode(hash, key, value, null);//位置爲空,將i位置上賦值一個node對象
        else {  //位置不爲空
            Node<K,V> e; K k;
            if (p.hash == hash &&  // 如果這個位置的old節點與new節點的key完全相同
                ((k = p.key) == key || (key != null && key.equals(k)))) 
                e = p;             // 則e=p
            else if (p instanceof TreeNode) // 如果p已經是樹節點的一個實例,既這裏已經是樹了
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {  //p與新節點既不完全相同,p也不是treenode的實例
                for (int binCount = 0; ; ++binCount) {  //一個死循環
                    if ((e = p.next) == null) {   //e=p.next,如果p的next指向爲null
                        p.next = newNode(hash, key, value, null);  //指向一個新的節點
                        if (binCount >= TREEIFY_THRESHOLD - 1) // 如果鏈表長度大於等於8
                            treeifyBin(tab, hash);  //將鏈表轉爲紅黑樹
                        break;
                    }
       if (e.hash == hash &&  //如果遍歷過程中鏈表中的元素與新添加的元素完全相同,則跳出循環
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e; //將p中的next賦值給p,即將鏈表中的下一個node賦值給p,
                           //繼續循環遍歷鏈表中的元素
                }
            }
            if (e != null) { //這個判斷中代碼作用爲:如果添加的元素產生了hash衝突,那麼調用                
                             //put方法時,會將他在鏈表中他的上一個元素的值返回
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)  //判斷條件成立的話,將oldvalue替換        
                //爲newvalue,返回oldvalue;不成立則不替換,然後返回oldvalue
                    e.value = value;
                afterNodeAccess(e);  //這個方法在後面說
                return oldValue;
            }
        }
        ++modCount;  //記錄修改次數
        if (++size > threshold)   //如果元素數量大於臨界值,則進行擴容
            resize();   //下面說
        afterNodeInsertion(evict);  
        return null;
    }

註釋已經很詳細了,咱們說一下這個初始化的問題

//如果 table 還未被初始化,那麼初始化它
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

resize()擴容機制,單元素如何散列到新的數組中,鏈表中的元素如何散列到新的數組中,紅黑樹中的元素如何散列到新的數組中?

//上圖中說了默認構造方法與自定義構造方法第一次執行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) {   //當容量超過最大值時,臨界值設置爲int最大值
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY) //擴容容量爲2倍,臨界值爲2倍
                newThr = oldThr << 1;
        }
        else if (oldThr > 0) // 不執行
            newCap = oldThr;
        else {                // 不執行
            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;    //將新的臨界值賦值賦值給threshold
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;   //新的數組賦值給table
 
        //擴容後,重新計算元素新的位置
        if (oldTab != null) {   //原數組
            for (int j = 0; j < oldCap; ++j) {   //通過原容量遍歷原數組
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {   //判斷node是否爲空,將j位置上的節點
                //保存到e,然後將oldTab置爲空,這裏爲什麼要把他置爲空呢,置爲空有什麼好處嗎??
                //難道是吧oldTab變爲一個空數組,便於垃圾回收?? 這裏不是很清楚
                    oldTab[j] = null;
                    if (e.next == null)          //判斷node上是否有鏈表
                        newTab[e.hash & (newCap - 1)] = e; //無鏈表,確定元素存放位置,
//擴容前的元素地址爲 (oldCap - 1) & e.hash ,所以這裏的新的地址只有兩種可能,一是地址不變,
//二是變爲 老位置+oldCap
                    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;
 
                      
/* 這裏如果判斷成立,那麼該元素的地址在新的數組中就不會改變。因爲oldCap的最高位的1,在e.hash對應的位上爲0,所以擴容後得到的地址是一樣的,位置不會改變 ,在後面的代碼的執行中會放到loHead中去,最後賦值給newTab[j];
如果判斷不成立,那麼該元素的地址變爲 原下標位置+oldCap,也就是lodCap最高位的1,在e.hash對應的位置上也爲1,所以擴容後的地址改變了,在後面的代碼中會放到hiHead中,最後賦值給newTab[j + oldCap]
             舉個栗子來說一下上面的兩種情況:
            設:oldCap=16 二進制爲:0001 0000
                oldCap-1=15 二進制爲:0000 1111
                e1.hash=10 二進制爲:0000 1010
                e2.hash=26 二進制爲:0101 1010
            e1在擴容前的位置爲:e1.hash & oldCap-1  結果爲:0000 1010 
            e2在擴容前的位置爲:e2.hash & oldCap-1  結果爲:0000 1010 
            結果相同,所以e1和e2在擴容前在同一個鏈表上,這是擴容之前的狀態。
            
    現在擴容後,需要重新計算元素的位置,在擴容前的鏈表中計算地址的方式爲e.hash & oldCap-1
    那麼在擴容後應該也這麼計算呀,擴容後的容量爲oldCap*2=32 0010 0000 newCap=32,新的計算
    方式應該爲
    e1.hash & newCap-1 
    即:0000 1010 & 0001 1111 
    結果爲0000 1010與擴容前的位置完全一樣。
    e2.hash & newCap-1 
    即:0101 1010 & 0001 1111 
    結果爲0001 1010,爲擴容前位置+oldCap。
    而這裏卻沒有e.hash & newCap-1 而是 e.hash & oldCap,其實這兩個是等效的,都是判斷倒數第五位
    是0,還是1。如果是0,則位置不變,是1則位置改變爲擴容前位置+oldCap。
            再來分析下loTail loHead這兩個的執行過程(假設(e.hash & oldCap) == 0成立):
            第一次執行:
            e指向oldTab[j]所指向的node對象,即e指向該位置上鍊表的第一個元素
            loTail爲空,所以loHead指向與e相同的node對象,然後loTail也指向了同一個node對象。
            最後,在判斷條件e指向next,就是指向oldTab鏈表中的第二個元素
            第二次執行:
            lotail不爲null,所以lotail.next指向e,這裏其實是lotail指向的node對象的next指向e,
            也可以說是,loHead的next指向了e,就是指向了oldTab鏈表中第二個元素。此時loHead指向        
            的node變成了一個長度爲2的鏈表。然後lotail=e也就是指向了鏈表中第二個元素的地址。
            第三次執行:
            與第二次執行類似,loHead上的鏈表長度變爲3,又增加了一個node,loTail指向新增的node
               ......
            hiTail與hiHead的執行過程與以上相同,這裏就不再做解釋了。
            由此可以看出,loHead是用來保存新鏈表上的頭元素的,loTail是用來保存尾元素的,直到遍            
            歷完鏈表。   這是(e.hash & oldCap) == 0成立的時候。
            (e.hash & oldCap) == 0不成立的情況也相同,其實就是把oldCap遍歷成兩個新的鏈表,
            通過loHead和hiHead來保存鏈表的頭結點,然後將兩個頭結點放到newTab[j]與 
            newTab[j+oldCap]上面去      
*/
                              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;   //尾節點的next設置爲空
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;   //尾節點的next設置爲空
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

有關JDK1.7擴容出現的死循環的問題:

/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable) {
 Entry[] src = table;
 int newCapacity = newTable.length;
 for (int j = 0; j < src.length; j++) {
   Entry<K,V> e = src[j];
   if (e != null) {
       src[j] null;
       do {
           // B線程執行到這裏之後就暫停了
           Entry<K,V> next = e.next;
           int i = indexFor(e.hash, newCapacity);
           e.next = newTable[i];
           newTable[i] = e;
           e = next;
       } while (e != null);
   }
 }
}

併發下的Rehash

  1)假設我們有兩個線程。我用紅色和淺藍色標註了一下。我們再回頭看一下我們的 transfer代碼中的這個細節:

do {

    Entry<K,V> next = e.next; // <--假設線程一執行到這裏就被調度掛起了

    int i = indexFor(e.hash, newCapacity);

    e.next = newTable[i];

    newTable[i] = e;

    e = next;

} while (e != null);

而我們的線程二執行完成了。於是我們有下面的這個樣子。

注意,因爲Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash後,指向了線程二重組後的鏈表。我們可以看到鏈表的順序被反轉後。

2)線程一被調度回來執行。

  • 先是執行 newTalbe[i] = e;
  • 然後是e = next,導致了e指向了key(7),
  • 而下一次循環的next = e.next導致了next指向了key(3)

3)一切安好。

線程一接着工作。把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移。

4)環形鏈接出現。

e.next = newTable[i] 導致  key(3).next 指向了 key(7)

注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。

 

 於是,當我們的線程一調用到,HashTable.get(11)時,悲劇就出現了——Infinite Loop。

因爲HashMap本來就不支持併發。要併發就用ConcurrentHashmap

HashMap的get()方法

public V get(Object key) {
    Node<K,V> e;
    //直接調用了getNode()
    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;
      //先判斷數組是否爲空,長度是否大於0,那個node節點是否存在
     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) {
             //如果是紅黑樹,去紅黑樹找
             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;
 }

這裏關於first = tab[(n - 1) & hash]

這裏通過(n - 1)& hash即可算出桶的在桶數組中的位置,可能有的朋友不太明白這裏爲什麼這麼做,這裏簡單解釋一下。HashMap 中桶數組的大小 length 總是2的冪,此時,(n - 1) & hash 等價於對 length 取餘。但取餘的計算效率沒有位運算高,所以(n - 1) & hash也是一個小的優化。舉個例子說明一下吧,假設 hash = 185,n = 16。計算過程示意圖如下

 

 在上面源碼中,除了查找相關邏輯,還有一個計算 hash 的方法。這個方法源碼如下:

/**
 * 計算鍵的 hash 值
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

看這個方法的邏輯好像是通過位運算重新計算 hash,那麼這裏爲什麼要這樣做呢?爲什麼不直接用鍵的 hashCode 方法產生的 hash 呢?大家先可以思考一下,我把答案寫在下面。

這樣做有兩個好處,我來簡單解釋一下。我們再看一下上面求餘的計算圖,圖中的 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 的原因了。


 

由於個人能力問題,先學習這些,數據結構這個大山,我一定要刨平它。

基於jdk1.7版本的HashMap

https://www.jianshu.com/p/dde9b12343c1

參考博客:

https://www.cnblogs.com/wenbochang/archive/2018/02/22/8458756.html

https://segmentfault.com/a/1190000012926722

https://blog.csdn.net/pange1991/article/details/82377980

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章