HashMap深度分析

Java菜分享發表於2018-09-22

這次主要是分析下HashMap的工作原理,為什麼我會拿這個東西出來分析,原因很簡單,以前我面試的時候,偶爾問起HashMap,99%的程式設計師都知道HashMap,基本都會用Hashmap,這其中不僅僅包括剛畢業的大學生,也包括已經工作5年,甚至是10年的程式設計師。HashMap涉及的知識遠遠不止put和get那麼簡單。本次的分析希望對於面試的人起碼對於面試官的問題有所應付

** 一、先來回憶下我的面試過程**

** 問:“你用過HashMap,你能跟我說說它嗎?”**

** 答:**“當然用過,HashMap是一種<key,value>的儲存結構,能夠快速將key的資料put方式儲存起來,然後很快的通過get取出來”,然後說“HashMap不是執行緒安全的, HashTable是執行緒安全的,通過synchronized實現的。HashMap取值非常快”等等。這個時候說明他已經很熟練使用HashMap的工具了。

問:“你知道HashMap 在put和get的時候是怎麼工作的嗎?”

答:“HashMap是通過key計算出Hash值,然後將這個Hash值對映到物件的引用上,get的時候先計算key的hash值,然後找到物件”。這個時候已經顯得不自信了。

問:“HashMap的key為什麼一般用字串比較多,能用其他物件,或者自定義的物件嗎?為什麼?”

答:“這個沒研究過,一般習慣用String。”

問:“你剛才提到HashMap不是執行緒安全的,你怎麼理解執行緒安全。原理是什麼?幾種方式避免執行緒安全的問題。”

答:“執行緒安全就是多個執行緒去訪問的時候,會對物件造成不是預期的結果,一般要加鎖才能執行緒安全。”

其實,問了以上那些問題,我基本能判定這個程式設計師的基本功了,一般技術中等,接下來的問題沒必要問了。

從我的個人角度來看,HashMap的面試問題能夠考察面試者的執行緒問題、Java記憶體模型問題、執行緒可見與不可變問題、Hash計算問題、連結串列結構問題、二進位制的&、|、<<、>>等問題。所以一個HashMap就能考驗一個人的技術功底了。

二、概念分析

1、HashMap的類圖結構

 此處的類圖是根據JDK1.6版本畫出來的。如下圖1:

202221148131465.png   圖(一)

2、HashMap儲存結構

** **HashMap的使用那麼簡單,那麼問題來了,它是怎麼儲存的,他的儲存結構是怎樣的,很多程式設計師都不知道,其實當你put和get的時候,稍稍往前一步,你看到就是它的真面目。其實簡單的說HashMap的儲存結構是由陣列和連結串列共同完成的。如圖:

210003116887371.png 從上圖可以看出HashMap是Y軸方向是陣列,X軸方向就是連結串列的儲存方式。大家都知道陣列的儲存方式在記憶體的地址是連續的,大小固定,一旦分配不能被其他引用佔用。它的特點是查詢快,時間複雜度是O(1),插入和刪除的操作比較慢,時間複雜度是O(n),連結串列的儲存方式是非連續的,大小不固定,特點與陣列相反,插入和刪除快,查詢速度慢。HashMap可以說是一種折中的方案吧。

3、HashMap基本原理

1、首先判斷Key是否為Null,如果為null,直接查詢Enrty[0],如果不是Null,先計算Key的HashCode,然後經過二次Hash。得到Hash值,這裡的Hash特徵值是一個int值。

2、根據Hash值,要找到對應的陣列啊,所以對Entry[]的長度length求餘,得到的就是Entry陣列的index。

3、找到對應的陣列,就是找到了所在的連結串列,然後按照連結串列的操作對Value進行插入、刪除和查詢操作。

4、HashMap概念介紹

變數 術語 說明 size 大小 HashMap的儲存大小 threshold 臨界值 HashMap大小達到臨界值,需要重新分配大小。 loadFactor 負載因子 HashMap大小負載因子,預設為75%。 modCount 統一修改 HashMap被修改或者刪除的次數總數。 Entry 實體 HashMap儲存物件的實際實體,由Key,value,hash,next組成。 5、HashMap初始化

預設情況下,大多數人都呼叫new HashMap()來初始化的,我在這裡分析new HashMap(int initialCapacity, float loadFactor)的建構函式,程式碼如下:

public HashMap(int initialCapacity, float loadFactor) {      // initialCapacity代表初始化HashMap的容量,它的最大容量是MAXIMUM_CAPACITY = 1 << 30。 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY;

     // loadFactor代表它的負載因子,預設是是DEFAULT_LOAD_FACTOR=0.75,用來計算threshold臨界值的。 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor);

    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;

    this.loadFactor = loadFactor;
    threshold = (int)(capacity * loadFactor);
    table = new Entry[capacity];
    init();
}
複製程式碼

由上面的程式碼可以看出,初始化的時候需要知道初始化的容量大小,因為在後面要通過按位與的Hash演算法計算Entry陣列的索引,那麼要求Entry的陣列長度是2的N次方。

6、HashMap中的Hash計算和碰撞問題

HashMap的hash計算時先計算hashCode(),然後進行二次hash。程式碼如下:

// 計算二次Hash
int hash = hash(key.hashCode());

// 通過Hash找陣列索引 int i = indexFor(hash, table.length); 先不忙著學習HashMap的Hash演算法,先來看看JDK的String的Hash演算法。程式碼如下:

/** * Returns a hash code for this string. The hash code for a * String object is computed as *

* s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
* 
* using int arithmetic, where s[i] is the * ith character of the string, n is the length of * the string, and ^ indicates exponentiation. * (The hash value of the empty string is zero.) * * @return a hash code value for this object. */ public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}
複製程式碼

從JDK的API可以看出,它的演算法等式就是s[0]31^(n-1) + s[1]31^(n-2) + ... + s[n-1],其中s[i]就是索引為i的字元,n為字串的長度。這裡為什麼有一個固定常量31呢,關於這個31的討論很多,基本就是優化的數字,主要參考Joshua Bloch's Effective Java的引用如下:

The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort of optimization automatically.

大體意思是說選擇31是因為它是一個奇素數,如果它做乘法溢位的時候,資訊會丟失,而且當和2做乘法的時候相當於移位,在使用它的時候優點還是不清楚,但是它已經成為了傳統的選擇,31的一個很好的特性就是做乘法的時候可以被移位和減法代替的時候有更好的效能體現。例如31i相當於是i左移5位減去i,即31i == (i<<5)-i。現代的虛擬記憶體系統都使用這種自動優化。

現在進入正題,HashMap為什麼還要做二次hash呢? 程式碼如下:

static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } 回答這個問題之前,我們先來看看HashMap是怎麼通過Hash查詢陣列的索引的。

/** * Returns index for hash code h. */ static int indexFor(int h, int length) { return h & (length-1); } 其中h是hash值,length是陣列的長度,這個按位與的演算法其實就是h%length求餘,一般什麼情況下利用該演算法,典型的分組。例如怎麼將100個數分組16組中,就是這個意思。應用非常廣泛。

既然知道了分組的原理了,那我們看看幾個例子,程式碼如下:

    int h=15,length=16;
    System.out.println(h & (length-1));
    h=15+16;
    System.out.println(h & (length-1));
    h=15+16+16;
    System.out.println(h & (length-1));
    h=15+16+16+16;
    System.out.println(h & (length-1));
複製程式碼

執行結果都是15,為什麼呢?我們換算成二進位制來看看。

System.out.println(Integer.parseInt("0001111", 2) & Integer.parseInt("0001111", 2));

System.out.println(Integer.parseInt("0011111", 2) & Integer.parseInt("0001111", 2));

System.out.println(Integer.parseInt("0111111", 2) & Integer.parseInt("0001111", 2));

System.out.println(Integer.parseInt("1111111", 2) & Integer.parseInt("0001111", 2)); 這裡你就發現了,在做按位與操作的時候,後面的始終是低位在做計算,高位不參與計算,因為高位都是0。這樣導致的結果就是隻要是低位是一樣的,高位無論是什麼,最後結果是一樣的,如果這樣依賴,hash碰撞始終在一個陣列上,導致這個陣列開始的連結串列無限長,那麼在查詢的時候就速度很慢,又怎麼算得上高效能的啊。所以hashmap必須解決這樣的問題,儘量讓key儘可能均勻的分配到陣列上去。避免造成Hash堆積。

回到正題,HashMap怎麼處理這個問題,怎麼做的二次Hash。

static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } 這裡就是解決Hash的的衝突的函式,解決Hash的衝突有以下幾種方法:

  1. 開放定址法 線性探測再雜湊,二次探測再雜湊,偽隨機探測再雜湊)  2. 再雜湊法
  2. 鏈地址法
  3. 建立一 公共溢位區

而HashMap採用的是鏈地址法,這幾種方法在以後的部落格會有單獨介紹,這裡就不做介紹了。

7、HashMap的put()解析

以上說了一些基本概念,下面該進入主題了,HashMap怎麼儲存一個物件的,程式碼如下:

/** * Associates the specified value with the specified key in this map. * If the map previously contained a mapping for the key, the old * value is replaced. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with key, or * null if there was no mapping for key. * (A null return can also indicate that the map * previously associated null with key.) */ public V put(K key, V value) { if (key == null) return putForNullKey(value); int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } }

    modCount++;
    addEntry(hash, key, value, i);
    return null;
}
複製程式碼

從程式碼可以看出,步驟如下:

(1) 首先判斷key是否為null,如果是null,就單獨呼叫putForNullKey(value)處理。程式碼如下:

/** * Offloaded version of put for null keys */ private V putForNullKey(V value) { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(0, null, value, 0); return null; } 從程式碼可以看出,如果key為null的值,預設就儲存到table[0]開頭的連結串列了。然後遍歷table[0]的連結串列的每個節點Entry,如果發現其中存在節點Entry的key為null,就替換新的value,然後返回舊的value,如果沒發現key等於null的節點Entry,就增加新的節點。

(2) 計算key的hashcode,再用計算的結果二次hash,通過indexFor(hash, table.length);找到Entry陣列的索引i。

(3) 然後遍歷以table[i]為頭節點的連結串列,如果發現有節點的hash,key都相同的節點時,就替換為新的value,然後返回舊的value。

(4) modCount是幹嘛的啊? 讓我來為你解答。眾所周知,HashMap不是執行緒安全的,但在某些容錯能力較好的應用中,如果你不想僅僅因為1%的可能性而去承受hashTable的同步開銷,HashMap使用了Fail-Fast機制來處理這個問題,你會發現modCount在原始碼中是這樣宣告的。

volatile關鍵字宣告瞭modCount,代表了多執行緒環境下訪問modCount,根據JVM規範,只要modCount改變了,其他執行緒將讀到最新的值。其實在Hashmap中modCount只是在迭代的時候起到關鍵作用。

private abstract class HashIterator implements Iterator { Entry<K,V> next; // next entry to return int expectedModCount; // For fast-fail int index; // current slot Entry<K,V> current; // current entry

    HashIterator() {
        expectedModCount = modCount;
        if (size > 0) { // advance to first entry
            Entry[] t = table;
            while (index < t.length && (next = t[index++]) == null)
                ;
        }
    }

    public final boolean hasNext() {
        return next != null;
    }

    final Entry<K,V> nextEntry() {
複製程式碼

        // 這裡就是關鍵 if (modCount != expectedModCount) throw new ConcurrentModificationException(); Entry<K,V> e = next; if (e == null) throw new NoSuchElementException();

        if ((next = e.next) == null) {
            Entry[] t = table;
            while (index < t.length && (next = t[index++]) == null)
                ;
        }
    current = e;
        return e;
    }

    public void remove() {
        if (current == null)
            throw new IllegalStateException();
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        Object k = current.key;
        current = null;
        HashMap.this.removeEntryForKey(k);
        expectedModCount = modCount;
    }

}
複製程式碼

使用Iterator開始迭代時,會將modCount的賦值給expectedModCount,在迭代過程中,通過每次比較兩者是否相等來判斷HashMap是否在內部或被其它執行緒修改,如果modCount和expectedModCount值不一樣,證明有其他執行緒在修改HashMap的結構,會丟擲異常。

所以HashMap的put、remove等操作都有modCount++的計算。

(5) 如果沒有找到key的hash相同的節點,就增加新的節點addEntry(),程式碼如下:

void addEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<K,V>(hash, key, value, e); if (size++ >= threshold) resize(2 * table.length); } 這裡增加節點的時候取巧了,每個新新增的節點都增加到頭節點,然後新的頭節點的next指向舊的老節點。

(6) 如果HashMap大小超過臨界值,就要重新設定大小,擴容,見第9節內容。

8、HashMap的get()解析

理解上面的put,get就很好理解了。程式碼如下:

public V get(Object key) { if (key == null) return getForNullKey(); int hash = hash(key.hashCode()); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null; } 別看這段程式碼,它帶來的問題是巨大的,千萬記住,HashMap是非執行緒安全的,所以這裡的迴圈會導致死迴圈的。為什麼呢?當你查詢一個key的hash存在的時候,進入了迴圈,恰恰這個時候,另外一個執行緒將這個Entry刪除了,那麼你就一直因為找不到Entry而出現死迴圈,最後導致的結果就是程式碼效率很低,CPU特別高。一定記住。

9、HashMap的size()解析

HashMap的大小很簡單,不是實時計算的,而是每次新增加Entry的時候,size就遞增。刪除的時候就遞減。空間換時間的做法。因為它不是執行緒安全的。完全可以這麼做。效力高。

9、HashMap的reSize()解析

當HashMap的大小超過臨界值的時候,就需要擴充HashMap的容量了。程式碼如下:

void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
複製程式碼

從程式碼可以看出,如果大小超過最大容量就返回。否則就new 一個新的Entry陣列,長度為舊的Entry陣列長度的兩倍。然後將舊的Entry[]複製到新的Entry[].程式碼如下:

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 { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } } 在複製的時候陣列的索引int i = indexFor(e.hash, newCapacity);重新參與計算。

至此,HashMap還有一些迭代器的程式碼,這裡不一一做介紹了,在JDK1.7版本中HashMap也做了一些升級,具體有Hash因子的參與。

今天差不多完成了HashMap的原始碼解析,下一步將會分析ConcurrencyHashMap的原始碼。ConcurrencyHashMap彌補了HashMap執行緒不安全、HashTable效能低的缺失。是目前高效能的執行緒安全的HashMap類。

相關文章