1. 程式人生 > >刨死你係列——HashMap(jdk1.8)

刨死你係列——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

&n