集合系列—HashMap源碼分析

  前面我們已經分析了ArrayListLinkedList這兩個集合,我們知道ArrayList是基於數組實現的,LinkedList是基於鏈表實現的。它們各自有自己的優劣勢,例如ArrayList在定位查找元素時會優於LinkedList,而LinkedList在添加刪除元素時會優於ArrayList。而本篇介紹的HashMap綜合了二者的優勢,它的底層是基於哈希表實現的,如果不考慮哈希衝突的話,HashMap在增刪改查操作上的時間複雜度都能夠達到驚人的O(1)。我們先看看它所基於的哈希表的結構。


  從上圖中可以看到,哈希表是由數組和鏈表共同構成的一種結構,當然上圖是一個不好的示例,一個好的哈希函數應該要儘量平均元素在數組中的分佈,減少哈希衝突從而減小鏈表的長度。鏈表的長度越長,意味着在查找時需要遍歷的結點越多,哈希表的性能也就越差。接下來我們來看下HashMap的部分成員變量。


//默認初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//默認最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認加載因子, 指哈希表可以達到多滿的尺度
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//空的哈希表
static final Entry<?,?>[] EMPTY_TABLE = {};

//實際使用的哈希表
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//HashMap大小, 即HashMap存儲的鍵值對數量
transient int size;
//鍵值對的閾值, 用於判斷是否需要擴增哈希表容量
int threshold;
//加載因子
final float loadFactor;
//修改次數, 用於fail-fast機制
transient int modCount;
//使用替代哈希的默認閥值
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
//隨機的哈希種子, 有助於減少哈希碰撞的次數
transient int hashSeed = 0;

  由成員變量中看到,HashMap默認的初始容量爲16,默認的加載因子是0.75。而threshold是集合能夠存儲的鍵值對的閥值,默認是初始容量*加載因子,也就是16*0.75=12,當鍵值對要超過閥值時,意味着這時候的哈希表已處於飽和狀態,再繼續添加元素就會增加哈希衝突,從而使HashMap的性能下降。這時會觸發自動擴容機制,以保證HashMap的性能。我們還可以看到哈希表其實就是一個Entry數組,數組存放的每個Entry都是單向鏈表的頭結點。這個EntryHashMap的靜態內部類,來看看Entry的成員變量。

static class Entry<K,V> implements Map.Entry<K,V> {
   final K key;      //鍵
   V value;          //值
   Entry<K,V> next;  //下一個Entry的引用
   int hash;         //哈希碼
   
   ...               //省略下面代碼
}

  一個Entry實例就是一個鍵值對,裏面包含了keyvalue,同時每個Entry實例還有一個指向下一個Entry實例的引用。爲了避免重複計算,每個Entry實例還存放了對應的Hash碼。可以說Entry數組就是HashMap的核心,所有的操作都是針對這個數組來完成的。由於HashMap源碼比較長,不可能面面俱到的介紹它的所有方法,因此我們只抓住重點來進行介紹。接下來我們將以問題爲導向,針對下面幾個問題深入探究HashMap的內部機制。

1.HashMap在構造時做了哪些操作?

//構造器, 傳入初始化容量和加載因子
public HashMap(int initialCapacity, float loadFactor) {
   if (initialCapacity < 0) {
       throw new IllegalArgumentException("Illegal initial capacity: " 
       + initialCapacity);
   }
   //如果初始化容量大於最大容量, 就把它設爲最大容量
   if (initialCapacity > MAXIMUM_CAPACITY) {
       initialCapacity = MAXIMUM_CAPACITY;
   }
   //如果加載因子小於0或者加載因子不是浮點數, 則拋出異常
   if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
       throw new IllegalArgumentException("Illegal load factor: " 
       + loadFactor);
   }
   //設置加載因子
   this.loadFactor = loadFactor;
   //閾值爲初始化容量
   threshold = initialCapacity;
   init();
}
void init() {}

  所有的構造器都會調用到這個構造器,在這個構造器中我們看到除了對參數做一些校驗之外,它就做了兩件事,設置加載因子爲傳入的加載因子,設置閥值爲傳入的初始化大小。而init方法是空的,啥也沒做。注意,這時候並沒有根據傳入的初始化大小去新建一個Entry數組哦。那在什麼時候再去新建數組呢?繼續往下看。

2.HashMap添加鍵值對時會進行什麼操作?

//放置key-value鍵值對到HashMap中
public V put(K key, V value) {
   //如果哈希表沒有初始化就進行初始化
   if (table == EMPTY_TABLE) {
       //初始化哈希表
       inflateTable(threshold);
   }
   if (key == null) {
       return putForNullKey(value);
   }
   //計算key的hash碼
   int hash = hash(key);
   //根據hash碼定位在哈希表的位置
   int i = indexFor(hash, table.length);
   for (Entry<K,V> e = table[i]; e != null; e = e.next) {
       Object k;
       //如果對應的key已經存在, 就替換它的value值, 並返回原先的value值
       if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
           V oldValue = e.value;
           e.value = value;
           e.recordAccess(this);
           return oldValue;
       }
   }
   modCount++;
   //如果沒有對應的key就添加Entry到HashMap中
   addEntry(hash, key, value, i);
   //添加成功返回null
   return null;
}

  看到,在添加鍵值對時會先檢查哈希表是否是個空表,如果是空表就進行初始化。之後再進行後續操作,調用哈希函數計算傳入的keyHash碼。根據hash碼定位到Entry數組的指定槽位,然後對該槽位的單向鏈表進行遍歷,如果傳入的已經存在了就進行替換操作,否則就新建一個Entry添加到哈希表中。

3. 哈希表是怎樣初始化的?

//初始化哈希表, 會對哈希表容量進行膨脹, 因爲有可能傳入的容量不是2的冪
private void inflateTable(int toSize) {
   //哈希表容量必須是2的次冪
   int capacity = roundUpToPowerOf2(toSize);
   //設置閥值, 這裏一般是取capacity*loadFactor
   threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
   //新建指定容量的哈希表
   table = new Entry[capacity];
   //初始化哈希種子
   initHashSeedAsNeeded(capacity);
}

  上面我們知道,在構造HashMap時不會新建Entry數組,而是在put操作時檢查當前哈希表是否是個空表,如果是空表就調用inflateTable方法進行初始化。上面貼出了這個方法的代碼,可以看到方法內部會重新計算Entry數組的容量,因爲在構造HashMap時傳入的初始化大小可能不是2的冪,因此要將這個數轉換成2的冪再去根據新的容量新建Entry數組。初始化哈希表時再次重新設置閥值,閥值一般是capacity*loadFactor。此外,在初始化哈希表時還會去初始化哈希種子(hashSeed),這個hashSeed用於優化哈希函數,默認爲0是不使用替代哈希算法,但是也可以自己去設置hashSeed的值,以達到優化效果。具體下面會講到。

4.HashMap在什麼時候判斷是否要擴容,以及它是怎樣擴容的?

//添加Entry方法, 先判斷是否要擴容
void addEntry(int hash, K key, V value, int bucketIndex) {
   //如果HashMap的大小大於閥值並且哈希表對應槽位的值不爲空
   if ((size >= threshold) && (null != table[bucketIndex])) {
     //因爲HashMap的大小大於閥值, 表明即將發生哈希衝突, 所以進行擴容
       resize(2 * table.length);
       hash = (null != key) ? hash(key) : 0;
       bucketIndex = indexFor(hash, table.length);
   }
   //在這裏表明HashMap的大小沒有超過閥值, 所以不需要擴容
   createEntry(hash, key, value, bucketIndex);
}

//對哈希表進行擴容
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, initHashSeedAsNeeded(newCapacity));
   //將當前哈希表設置爲新的哈希表
   table = newTable;
   //更新哈希表閾值
   threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

  在調用put方法添加一個鍵值對時,如果集合中沒有存在的key就去調用addEntry方法新建一個Entry。看到上面貼出的addEntry代碼,在新建一個Entry之前會先判斷當前集合元素的大小是否超過了閥值,如果超過了閥值就調用resize進行擴容。傳入的新的容量是原來哈希表的兩倍,在resize方法內部會新建一個容量爲原先的2倍的Entry數組。然後將舊的哈希表裏面的元素全部遷移到新的哈希表,其中可能會進行再哈希,根據initHashSeedAsNeeded方法計算的值來確定是否進行再哈希。完成哈希表的遷移之後,將當前哈希表替換爲新的,最後再根據新的哈希表容量來重新計算HashMap的閥值。

4.爲什麼Entry數組的大小必須爲2的冪?

//返回哈希碼對應的數組下標
static int indexFor(int h, int length) {
   return h & (length-1);
}

    indexFor方法是根據hash碼來計算出在數組中對應的下標。我們可以看到在這個方法內部使用了與(&)操作符。與操作是對兩個操作數進行位運算,如果對應的兩個位都爲1,結果才爲1,否則爲0。與操作經常會用於去除操作數的高位值,例如:01011010 & 00001111 = 00001010。我們繼續回到代碼中,看看h&(length-1)做了些什麼。


   已知傳入的lengthEntry數組的長度,我們知道數組下標是從0開始計算的,所以數組的最大下標爲length-1。如果length2的冪,那麼length-1的二進制位後面都爲1。這時h&(length-1)的作用就是去掉了h的高位值,只留下h的低位值來作爲數組的下標。由此可以看到Entry數組的大小規定爲2的冪就是爲了能夠使用這個算法來確定數組的下標。

6.哈希函數是怎樣計算Hash碼的?

//生成hash碼的函數
final int hash(Object k) {
   int h = hashSeed;
   //key是String類型的就使用另外的哈希算法
   if (0 != h && k instanceof String) {
       return sun.misc.Hashing.stringHash32((String) k);
   }
   h ^= k.hashCode();
   //擾動函數
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);
}

     hash方法的最後兩行是真正計算hash值的算法,計算hash碼的算法被稱爲擾動函數,所謂的擾動函數就是把所有東西雜糅到一起,可以看到這裏使用了四個向右移位運算。目的就是將h的高位值與低位值混合一下,以此增加低位值的隨機性。在上面我們知道定位數組的下標是根據hash碼的低位值來確定的。keyhash碼是通過hashCode方法來生成的,而一個糟糕的hashCode方法生成的hash碼的低位值可能會有很大的重複。爲了使得hash碼在數組上映射的比較均勻,擾動函數就派上用場了,把高位值的特性糅合進低位值,增加低位值的隨機性,從而使散列分佈的更加鬆散,以此提高性能。下圖舉了個例子幫助理解。

7. 替代哈希是怎麼回事?

我們看到hash方法中首先會將hashSeed賦值給h。這個hashSeed就是哈希種子,它是一個隨機的值,作用就是幫助優化哈希函數。hashSeed默認是0,也就是默認不使用替代哈希算法。那麼什麼時候使用hashSeed呢?首先需要設置開啓替代哈希,在系統屬性中設置jdk.map.althashing.threshold的值,在系統屬性中這個值默認是-1,當它是-1的時候使用替代哈希的閥值爲Integer.MAX_VALUE。這也意味着可能你永遠也不會使用替代哈希了。當然你可以把這個閥值設小一點,這樣當集合元素達到閥值後就會生成一個隨機的hashSeed。以此增加hash函數的隨機性。爲什麼要使用替代哈希呢?當集合元素達到你設定的閥值之後,意味着哈希表已經比較飽和了,出現哈希衝突的可能性就會大大增加,這時對再添加進來的元素使用更加隨機的散列函數能夠使後面添加進來的元素更加隨機的分佈在散列表中。

注:以上分析全部基於JDK1.7,不同版本之間會有較大的改動,讀者需要注意。








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