HashMap是如何工作的

大多數Java程序員都會用到Map類特別是Hash Map,HashMap雖然實現很簡單,但是在存取數據上確有很強的優勢。但是有多少開發人員知道HashMap是如何工作的呢?幾天前,我閱讀了大量篇幅的java.util.HashMap的源碼(Java 7 和 Java 8)就是爲了對他的數據結構基礎有一定的瞭解。在這篇文章中,我將解釋java.util.HashMap(包括Java 8)在內的HashMap的性能,內存,已經在使用中遇到的問題。

  1、內存模型
Java中HashMap類,實現了Map

static class Entry<K,V> implements Map.Entry<K,V>{
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
}

一個HashMap把數據存儲在多個entry對象組成的鏈表中(也稱爲桶或者箱)。所有的這些鏈表都被註冊在一個Entery對象組成的數組鏈表中,默認的容量爲16。

從圖片中我們可以看出,HashMap內存中是一串可以爲空的entry組成的鏈表。這些Entry中的每一個都可以指向另外一個Entry組成的linkedList。
 所有擁有相同哈希值的key被放到相同的桶中。不同哈希值的Key最終可能被放到相同的桶中。
 當我們調用put(K key,V value) 或者是 get(Object key)時,函數首先根據Key計算出哈希值,找到索引鏈表中這個Key對應的位置,然後,通過迭代的方式遍歷桶中的元素,找到Key值相等的對象(使用equals() 方法比較Key)。
 當我們調用get()方法時,方法會返回一個entry的集合(如果entry存在)。
 當我們調用put(Key key,Value value)方法時,如果Entry已經存在,則使用新的值替換它,如果不存在,則在Linked list的頭部創建一個新的Entry(依據key,value的值)。
 在map中一個桶(linked list)的形成主要分爲三個步驟:
  (1) 通過key獲取一個hashCode值
  (2) 重新計算key的hashCode值防止所有的數據都放在同一個桶內。
  (3) 新的hashCode值會與數組的長度length - 1做與運算(hashCode & (length - 1)),相當於對數組長度進行取模運算。使產生的hashCode值不會超出數組的長度。
 下面是Java8,Java7中有關hashCode的源碼:

// the "rehash" function in JAVA 7 that takes the hashcode of the key
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
// the "rehash" function in JAVA 8 that directly takes the key
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
// the function that returns the index from the rehashed hash
static int indexFor(int h, int length) {
    return h & (length-1);
}

 爲了更高的工作效率,內部數組應該是2的次冪。讓我們看一下這是爲什麼:
 想像一下,一個長度爲17的數組,實際上要做運算的數字是16(length - 1),它的二進制表示爲 00……00010000,所以任何hashCode與16做“&運算“後的結果只有0或16。也就是說長度是17的數組中只用到了兩個,碰撞發生的機率很大,效率也會很差的。
 但是如果你選擇了2的次冪作爲數組的長度,例如16,按位與元算(length - 1)之後。可以輸出0-15中的任意一個數。顯然這樣的利用率,是非常高的,碰撞的機率相對來說減小了不少。
 這就是爲什麼map中數組的長度都是2的次冪,對於開發者來說這種機制是透明的。如果他定義了一個HashMap的長度爲37,Map將自動計算出下次下次要擴展的數組長度爲64。
 2、自動擴展
 獲取索引之後,函數(get,put,remove)會逐個查看鏈表中是否已經存在了相同Key的Entry。如果沒有擴展這種機制將會帶來性能上的問題,因爲函數需要遍歷整個鏈表才能確定是否存在要找的Entry.即使在最好的方案中,每個LinkedList中都將有125000個entry,所以每個get(),remove(),put()方法,都會帶來125000個entry的操作。爲了避免這種情況,HashMap具有自動擴大內部數組的能力來使LinkedList保持儘量的短。
 當你創建一個HashMap時,你可以通過構造函數來初始化容量和負載因子。
 public HashMap(int initialCapacity,float loadFactor)
 如果你沒有聲明,那麼初始化的容量爲16,負載因子爲0.75,初始化容量代表了內部數組的長度。
 每次當你使用put()方法在Map中新增Key/value組合時,函數需要堅持是否需要擴展內部數組的長度。爲了做到這個,Map中存了兩個數據。
  map的總容量:它代表了HashMap中entry的個數,這個數量在每次新增或刪除entry時會發生改變。
  一個上限門檻(threshold):它等於內部數組的個數乘以負載因子,它在每次自動擴展內存之後會更新。
 在執行put()函數之前,也就是新增Entry之前,首先比較size與threshold的大小,如果size大於threshold那麼數組要擴展爲原來的一倍。當新的數組已經生成完之後,返回索引的函數發生了改變,新生成的數組要創建是原來兩倍的entry,並且重新分配原來的entry到新的HashMap中去。
 擴展HashMap中數組的目的是,減少每個桶(linkedList)的長度,從而減少put(),remove()和get()方法的花費的時間。所有擁有相同key的Entry在新的HashMap中還會在同一個桶中,但是之前在同一個桶中擁有不同key的Entry,可能會被分配到不同的桶中。
這裏寫圖片描述
 上面的圖片很好的展示了數組擴展前後HashMap的變化,從圖中可以看出每個桶的長度都有所減小,所以整體的put(),get(),remove()效率都有所提升。
注:HashMap只有增大數組的方法,但是沒有縮小數組的方法
  線程安全
 如果你已經知道了HashMap,並且你知道了它是線程不安全的,但是爲什麼呢?舉個栗子,想象一下一個寫的線程往Map中寫數據,一個讀的線程從數據庫中讀數據,爲什麼它不能工作呢?
 因爲map在自動擴展期間,如果一個線程想get或put一個對象,Map可能使用老的索引值,所以不能在新的桶中找到對應的entey。
 最壞的情況是兩個線程同時調用put方法,讓map同時自動擴展,當兩個線程同時修改鏈表時,map最終可能生成一個內部循環的列表。如果嘗試在這樣的鏈表中獲取一個數據,get()方法將永遠不會結束。

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