天天看點

HashMap實作原理

摘要

HashMap是Java程式員使用頻率最高的用于映射(鍵值對)處理的資料類型。随着JDK(Java Developmet Kit)版本的更新,JDK1.8對HashMap底層的實作進行了優化,例如引入紅黑樹的資料結構和擴容的優化等。本文結合JDK1.7和JDK1.8的差別,深入探讨HashMap的結構實作和功能原理。

簡介

Java為資料結構中的映射定義了一個接口java.util.Map,此接口主要有四個常用的實作類,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,類繼承關系如下圖所示:

HashMap實作原理

下面針對各個實作類的特點做一些說明:

(1) HashMap:它根據鍵的hashCode值存儲資料,大多數情況下可以直接定位到它的值,因而具有很快的通路速度,但周遊順序卻是不确定的。 HashMap最多隻允許一條記錄的鍵為null,允許多條記錄的值為null。HashMap非線程安全,即任一時刻可以有多個線程同時寫HashMap,可能會導緻資料的不一緻。如果需要滿足線程安全,可以用 Collections的synchronizedMap方法使HashMap具有線程安全的能力,或者使用ConcurrentHashMap。

(2) Hashtable:Hashtable是遺留類,很多映射的常用功能與HashMap類似,不同的是它承自Dictionary類,并且是線程安全的,任一時間隻有一個線程能寫Hashtable,并發性不如ConcurrentHashMap,因為ConcurrentHashMap引入了分段鎖。Hashtable不建議在新代碼中使用,不需要線程安全的場合可以用HashMap替換,需要線程安全的場合可以用ConcurrentHashMap替換。

(3) LinkedHashMap:LinkedHashMap是HashMap的一個子類,儲存了記錄的插入順序,在用Iterator周遊LinkedHashMap時,先得到的記錄肯定是先插入的,也可以在構造時帶參數,按照通路次序排序。

(4) TreeMap:TreeMap實作SortedMap接口,能夠把它儲存的記錄根據鍵排序,預設是按鍵值的升序排序,也可以指定排序的比較器,當用Iterator周遊TreeMap時,得到的記錄是排過序的。如果使用排序的映射,建議使用TreeMap。在使用TreeMap時,key必須實作Comparable接口或者在構造TreeMap傳入自定義的Comparator,否則會在運作時抛出java.lang.ClassCastException類型的異常。

對于上述四種Map類型的類,要求映射中的key是不可變對象。不可變對象是該對象在建立後它的哈希值不會被改變。如果對象的哈希值發生變化,Map對象很可能就定位不到映射的位置了。

通過上面的比較,我們知道了HashMap是Java的Map家族中一個普通成員,鑒于它可以滿足大多數場景的使用條件,是以是使用頻度最高的一個。下文我們主要結合源碼,從存儲結構、常用方法分析、擴容以及安全性等方面深入講解HashMap的工作原理。

内部實作

搞清楚HashMap,首先需要知道HashMap是什麼,即它的存儲結構-字段;其次弄明白它能幹什麼,即它的功能實作-方法。下面我們針對這兩個方面詳細展開講解。

存儲結構-字段

從結構實作來講,HashMap是數組+連結清單+紅黑樹(JDK1.8增加了紅黑樹部分)實作的,如下如所示。

HashMap實作原理

這裡需要講明白兩個問題:資料底層具體存儲的是什麼?這樣的存儲方式有什麼優點呢?

(1) 從源碼可知,HashMap類中有一個非常重要的字段,就是 Node[] table,即哈希桶數組,明顯它是一個Node的數組。我們來看Node[JDK1.8]是何物。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;    //用來定位數組索引位置
        final K key;
        V value;
        Node<K,V> next;   //連結清單的下一個node

        Node(int hash, K key, V value, Node<K,V> next) { ... }
        public final K getKey(){ ... }
        public final V getValue() { ... }
        public final String toString() { ... }
        public final int hashCode() { ... }
        public final V setValue(V newValue) { ... }
        public final boolean equals(Object o) { ... }
}      

Node是HashMap的一個内部類,實作了Map.Entry接口,本質是就是一個映射(鍵值對)。上圖中的每個黑色圓點就是一個Node對象。

(2) HashMap就是使用哈希表來存儲的。哈希表為解決沖突,可以采用開放位址法和鍊位址法等來解決問題,Java中HashMap采用了鍊位址法。鍊位址法,簡單來說,就是數組加連結清單的結合。在每個數組元素上都一個連結清單結構,當資料被Hash後,得到數組下标,把資料放在對應下标元素的連結清單上。例如程式執行下面代碼:

map.put("美團","小美");      

  系統将調用"美團"這個key的hashCode()方法得到其hashCode 值(該方法适用于每個Java對象),然後再通過Hash算法的後兩步運算(高位運算和取模運算,下文有介紹)來定位該鍵值對的存儲位置,有時兩個key會定位到相同的位置,表示發生了Hash碰撞。當然Hash算法計算結果越分散均勻,Hash碰撞的機率就越小,map的存取效率就會越高。  

  如果哈希桶數組很大,即使較差的Hash算法也會比較分散,如果哈希桶數組數組很小,即使好的Hash算法也會出現較多碰撞,是以就需要在空間成本和時間成本之間權衡,其實就是在根據實際情況确定哈希桶數組的大小,并在此基礎上設計好的hash算法減少Hash碰撞。那麼通過什麼方式來控制map使得Hash碰撞的機率又小,哈希桶數組(Node[] table)占用空間又少呢?答案就是好的Hash算法和擴容機制。

  在了解Hash和擴容流程之前,我們得先了解下HashMap的幾個字段。從HashMap的預設構造函數源碼可知,構造函數就是對下面幾個字段進行初始化,源碼如下:

   int threshold;             // 所能容納的key-value對極限 
     final float loadFactor;    // 負載因子
     int modCount;  
     int size;      

  首先,Node[] table的初始化長度length(預設值是16),Load factor為負載因子(預設值是0.75),threshold是HashMap所能容納的最大資料量的Node(鍵值對)個數。threshold = length * Load factor。也就是說,在數組定義好長度之後,負載因子越大,所能容納的鍵值對個數越多。

  結合負載因子的定義公式可知,threshold就是在此Load factor和length(數組長度)對應下允許的最大元素數目,超過這個數目就重新resize(擴容),擴容後的HashMap容量是之前容量的兩倍。預設的負載因子0.75是對空間和時間效率的一個平衡選擇,建議大家不要修改,除非在時間和空間比較特殊的情況下,如果記憶體空間很多而又對時間效率要求很高,可以降低負載因子Load factor的值;相反,如果記憶體空間緊張而對時間效率要求不高,可以增加負載因子loadFactor的值,這個值可以大于1。

  size這個字段其實很好了解,就是HashMap中實際存在的鍵值對數量。注意和table的長度length、容納最大鍵值對數量threshold的差別。而modCount字段主要用來記錄HashMap内部結構發生變化的次數,主要用于疊代的快速失敗。強調一點,内部結構發生變化指的是結構發生變化,例如put新鍵值對,但是某個key對應的value值被覆寫不屬于結構變化。

在HashMap中,哈希桶數組table的長度length大小必須為2的n次方(一定是合數),這是一種非正常的設計,正常的設計是把桶的大小設計為素數。相對來說素數導緻沖突的機率要小于合數,具體證明可以參考http://blog.csdn.net/liuqiyao_01/article/details/14475159,Hashtable初始化桶大小為11,就是桶大小設計為素數的應用(Hashtable擴容後不能保證還是素數)。HashMap采用這種非正常設計,主要是為了在取模和擴容時做優化,同時為了減少沖突,HashMap定位哈希桶索引位置時,也加入了高位參與運算的過程。

這裡存在一個問題,即使負載因子和Hash算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響HashMap的性能。于是,在JDK1.8版本中,對資料結構做了進一步的優化,引入了紅黑樹。而當連結清單長度太長(預設超過8)時,連結清單就轉換為紅黑樹,利用紅黑樹快速增删改查的特點提高HashMap的性能,其中會用到紅黑樹的插入、删除、查找等算法。本文不再對紅黑樹展開讨論,想了解更多紅黑樹資料結構的工作原理可以參考http://blog.csdn.net/v_july_v/article/details/6105630。

----------------------

功能實作-方法

HashMap的内部功能實作很多,本文主要從根據key擷取哈希桶數組索引位置、put方法的詳細執行、擴容過程三個具有代表性的點深入展開講解。

1. 确定哈希桶數組索引位置

不管增加、删除、查找鍵值對,定位到哈希桶數組的位置都是很關鍵的第一步。前面說過HashMap的資料結構是數組和連結清單的結合,是以我們當然希望這個HashMap裡面的元素位置盡量分布均勻些,盡量使得每個位置上的元素數量隻有一個,那麼當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,不用周遊連結清單,大大優化了查詢的效率。HashMap定位數組索引位置,直接決定了hash方法的離散性能。先看看源碼的實作(方法一+方法二):

方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 為第一步 取hashCode值
     // h ^ (h >>> 16)  為第二步 高位參與運算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) {  //jdk1.7的源碼,jdk1.8沒有這個方法,但是實作原理一樣的
     return h & (length-1);  //第三步 取模運算
}      

這裡的Hash算法本質上就是三步:取key的hashCode值、高位運算、取模運算。

對于任意給定的對象,隻要它的hashCode()傳回值相同,那麼程式調用方法一所計算得到的Hash碼值總是相同的。我們首先想到的就是把hash值對數組長度取模運算,這樣一來,元素的分布相對來說是比較均勻的。但是,模運算的消耗還是比較大的,在HashMap中是這樣做的:調用方法二來計算該對象應該儲存在table數組的哪個索引處。

這個方法非常巧妙,它通過h & (table.length -1)來得到該對象的儲存位,而HashMap底層數組的長度總是2的n次方,這是HashMap在速度上的優化。當length總是2的n次方時,h& (length-1)運算等價于對length取模,也就是h%length,但是&比%具有更高的效率。

在JDK1.8的實作中,優化了高位運算的算法,通過hashCode()的高16位異或低16位實作的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、品質來考慮的,這麼做可以在數組table的length比較小的時候,也能保證考慮到高低Bit都參與到Hash的計算中,同時不會有太大的開銷。

轉自:https://zhuanlan.zhihu.com/p/21673805