1. 程式人生 > >演算法---hash演算法原理(java中HashMap底層實現原理和原始碼解析)

演算法---hash演算法原理(java中HashMap底層實現原理和原始碼解析)

散列表(Hash table,也叫雜湊表),是依據關鍵碼值(Key value)而直接進行訪問的資料結構。也就是說,它通過把關鍵碼值對映到表中一個位置來訪問記錄,以加快查詢的速度。這個對映函式叫做雜湊函式,存放記錄的陣列叫做散列表。

 比如我們要儲存八十八個資料,我們為他申請了100個元素的地址空間,80/100=0.88,這個數字叫做負載因子.我們之所以這樣做是為了通過犧牲空間來換取時間,達到"高速儲存"的目的.我們基於一種結果儘可能隨機平均分佈的固定函式H為每一個元素安排儲存位置,這樣就能夠避免遍歷性質的線性搜尋,以達到高速存取。可是因為此隨機性,也必定導致一個問題就是衝突。所謂衝突,即兩個元素通過雜湊函式H得到的地址同樣,那麼這兩個元素稱為“同義詞”。

解決衝突是一個複雜問題。衝突主要取決於:
(1)雜湊函式,一個好的雜湊函式的值應儘可能平均分佈。
(2)處理衝突方法。
(3)負載因子的大小。太大不一定就好,並且浪費空間嚴重,負載因子和雜湊函式是聯動的。

  解決衝突的辦法:
     (1)線性探查法:衝突後,線性向前試探,找到近期的一個空位置。缺點是會出現堆積現象。存取時,可能不是同義詞的詞也位於探查序列,影響效率。
     (2)雙雜湊函式法:在位置d衝突後,再次使用還有一個雜湊函式產生一個與散列表桶容量m互質的數c,依次試探(d+n*c)%m,使探查序列跳躍式分佈。

影響產生衝突多少有下面三個因素:

  1. 雜湊函式是否均勻;

  2. 處理衝突的方法;

  3. 散列表的裝填因子。

  散列表的裝填因子定義為:α= 填入表中的元素個數 / 散列表的長度

  α是散列表裝滿程度的標誌因子。因為表長是定值,α與“填入表中的元素個數”成正比,所以,α越大,填入表中的元素較多,產生衝突的可能性就越大;α越小,填入表中的元素較少,產生衝突的可能性就越小。


HashMap陣列 (JDK8以前使用拉鍊法,JDK8以後使用紅黑樹)

在Java程式語言中,最基本的結構就是兩種,一種是陣列,一種是模擬指標(引用),所有的資料結構都可以用這兩個基本結構構造,HashMap也一樣。當程式試圖將多個 key-value 放入 HashMap 中時,以如下程式碼片段為例:

HashMap<String,Object> m=new HashMap<String,Object>(); 
m.put("a", "rrr1"); 
m.put("b", "tt9"); 
m.put("c", "tt8"); 
m.put("d", "g7"); 
m.put("e", "d6"); 

 HashMap 採用一種所謂的“Hash 演算法”來決定每個元素的儲存位置。當程式執行 map.put(String,Obect)方法 時,系統將呼叫String的 hashCode() 方法得到其 hashCode 值——每個 Java 物件都有 hashCode() 方法,都可通過該方法獲得它的 hashCode 值。得到這個物件的 hashCode 值之後,系統會根據該 hashCode 值來決定該元素的儲存位置。原始碼如下:

 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;  
            //判斷當前確定的索引位置是否存在相同hashcode和相同key的元素,如果存在相同的hashcode和相同的key的元素,那麼新值覆蓋原來的舊值,並返回舊值。  
            //如果存在相同的hashcode,那麼他們確定的索引位置就相同,這時判斷他們的key是否相同,如果不相同,這時就是產生了hash衝突。  
            //Hash衝突後,那麼HashMap的單個bucket裡儲存的不是一個 Entry,而是一個 Entry 鏈。  
            //系統只能必須按順序遍歷每個 Entry,直到找到想搜尋的 Entry 為止——如果恰好要搜尋的 Entry 位於該 Entry 鏈的最末端(該 Entry 是最早放入該 bucket 中),  
            //那系統必須迴圈到最後才能找到該元素。  
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
                V oldValue = e.value;  
                e.value = value;  
                return oldValue;  
            }  
        }  
        modCount++;  
        addEntry(hash, key, value, i);  
        return null;  
    }  

上面程式中用到了一個重要的內部介面:Map.Entry,每個 Map.Entry 其實就是一個 key-value 對。從上面程式中可以看出:當系統決定儲存 HashMap 中的 key-value 對時,完全沒有考慮 Entry 中的 value,僅僅只是根據 key 來計算並決定每個 Entry 的儲存位置。這也說明了前面的結論:我們完全可以把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的儲存位置之後,value 隨之儲存在那裡即可.HashMap程式經過我改造,我故意的構造出了hash衝突現象,因為HashMap的初始大小16,但是我在hashmap裡面放了超過16個元素,並且我遮蔽了它的resize()方法。不讓它去擴容。這時HashMap的底層陣列Entry[]   table結構如下: 

Hashmap裡面的bucket出現了單鏈表的形式,散列表要解決的一個問題就是雜湊值的衝突問題,通常是兩種方法:連結串列法和開放地址法。連結串列法就是將相同hash值的物件組織成一個連結串列放在hash值對應的槽位;開放地址法是通過一個探測演算法,當某個槽位已經被佔據的情況下繼續查詢下一個可以使用的槽位。java.util.HashMap採用的連結串列法的方式,連結串列是單向連結串列。形成單鏈表的核心程式碼如下:
 

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);  
bsp; 

上面方法的程式碼很簡單,但其中包含了一個設計:系統總是將新新增的 Entry 物件放入 table 陣列的 bucketIndex 索引處——如果 bucketIndex 索引處已經有了一個 Entry 物件,那新新增的 Entry 物件指向原有的 Entry 物件(產生一個 Entry 鏈),如果 bucketIndex 索引處沒有 Entry 物件,也就是上面程式程式碼的 e 變數是 null,也就是新放入的 Entry 物件指向 null,也就是沒有產生 Entry 鏈。

       HashMap裡面沒有出現hash衝突時,沒有形成單鏈表時,hashmap查詢元素很快,get()方法能夠直接定位到元素,但是出現單鏈表後,單個bucket 裡儲存的不是一個 Entry,而是一個 Entry 鏈,系統只能必須按順序遍歷每個 Entry,直到找到想搜尋的 Entry 為止——如果恰好要搜尋的 Entry 位於該 Entry 鏈的最末端(該 Entry 是最早放入該 bucket 中),那系統必須迴圈到最後才能找到該元素。

       當建立 HashMap 時,有一個預設的負載因子(load factor),其預設值為 0.75,這是時間和空間成本上一種折衷:增大負載因子可以減少 Hash 表(就是那個 Entry 陣列)所佔用的記憶體空間,但會增加查詢資料的時間開銷,而查詢是最頻繁的的操作(HashMap 的 get() 與 put() 方法都要用到查詢);減小負載因子會提高資料查詢的效能,但會增加 Hash 表所佔用的記憶體空間。

三、HashMap原始碼分析

 

       1、關鍵屬性

  先看看HashMap類中的一些關鍵屬性:

1 transient Entry[] table;//儲存元素的實體陣列
2  
3 transient int size;//存放元素的個數
4  
5 int threshold; //臨界值   當實際大小超過臨界值時,會進行擴容threshold = 載入因子*容量
6 
7  final float loadFactor; //載入因子
8  
9 transient int modCount;//被修改的次數

2、構造方法

下面看看HashMap的幾個構造方法:

 1	public HashMap(int initialCapacity, float loadFactor) {
 2         //確保數字合法
 3         if (initialCapacity < 0)
 4             throw new IllegalArgumentException("Illegal initial capacity: " +
 5                                               initialCapacity);
 6         if (initialCapacity > MAXIMUM_CAPACITY)
 7             initialCapacity = MAXIMUM_CAPACITY;
 8         if (loadFactor <= 0 || Float.isNaN(loadFactor))
 9             throw new IllegalArgumentException("Illegal load factor: " +
10                                               loadFactor);
11 
12         // Find a power of 2 >= initialCapacity
13         int capacity = 1;   //初始容量
14         while (capacity < initialCapacity)   //確保容量為2的n次冪,使capacity為大於initialCapacity的最小的2的n次冪
15             capacity <<= 1;
16 
17         this.loadFactor = loadFactor;
18         threshold = (int)(capacity * loadFactor);
19         table = new Entry[capacity];
20        init();
21    }
22 
23     public HashMap(int initialCapacity) {
24         this(initialCapacity, DEFAULT_LOAD_FACTOR);
25    }
26 
27     public HashMap() {
28         this.loadFactor = DEFAULT_LOAD_FACTOR;
29         threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
30         table = new Entry[DEFAULT_INITIAL_CAPACITY];
31        init();
32     }

我們可以看到在構造HashMap的時候如果我們指定了載入因子和初始容量的話就呼叫第一個構造方法,否則的話就是用預設的。預設初始容量為16,預設載入因子為0.75。我們可以看到上面程式碼中13-15行,這段程式碼的作用是確保容量為2的n次冪,使capacity為大於initialCapacity的最小的2的n次冪,至於為什麼要把容量設定為2的n次冪,我們等下再看。

 

重點分析下HashMap中用的最多的兩個方法put和get

       3、儲存資料

  下面看看HashMap儲存資料的過程是怎樣的,首先看看HashMap的put方法:
 

public V put(K key, V value) {
     // 若“key為null”,則將該鍵值對新增到table[0]中。
         if (key == null) 
            return putForNullKey(value);
     // 若“key不為null”,則計算該key的雜湊值,然後將其新增到該雜湊值對應的連結串列中。
         int hash = hash(key.hashCode());
     //搜尋指定hash值在對應table中的索引
         int i = indexFor(hash, table.length);
     // 迴圈遍歷Entry陣列,若“該key”對應的鍵值對已經存在,則用新的value取代舊的value。然後退出!
         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))) { //如果key相同則覆蓋並返回舊值
                  V oldValue = e.value;
                 e.value = value;
                 e.recordAccess(this);
                 return oldValue;
              }
         }
     //修改次數+1
         modCount++;
     //將key-value新增到table[i]處
     addEntry(hash, key, value, i);
     return null;
}

上面程式中用到了一個重要的內部介面:Map.Entry,每個 Map.Entry 其實就是一個 key-value 對。從上面程式中可以看出:當系統決定儲存 HashMap 中的 key-value 對時,完全沒有考慮 Entry 中的 value,僅僅只是根據 key 來計算並決定每個 Entry 的儲存位置。這也說明了前面的結論:我們完全可以把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的儲存位置之後,value 隨之儲存在那裡即可。

我們慢慢的來分析這個函式,第2和3行的作用就是處理key值為null的情況,我們看看putForNullKey(value)方法:

 1 private V putForNullKey(V value) {
 2         for (Entry<K,V> e = table[0]; e != null; e = e.next) {
 3             if (e.key == null) {   //如果有key為null的物件存在,則覆蓋掉
 4                 V oldValue = e.value;
 5                 e.value = value;
 6                 e.recordAccess(this);
 7                 return oldValue;
 8            }
 9        }
10         modCount++;
11         addEntry(0, null, value, 0); //如果鍵為null的話,則hash值為0
12         return null;
13     }
 

注意:如果key為null的話,hash值為0,物件儲存在陣列中索引為0的位置。即table[0]

我們再回去看看put方法中第4行,它是通過key的hashCode值計算hash碼,下面是計算hash碼的函式:

1  //計算hash值的方法 通過鍵的hashCode來計算
2     static int hash(int h) {
3         // This function ensures that hashCodes that differ only by
4         // constant multiples at each bit position have a bounded
5         // number of collisions (approximately 8 at default load factor).
6         h ^= (h >>> 20) ^ (h >>> 12);
7         return h ^ (h >>> 7) ^ (h >>> 4);
8     }

得到hash碼之後就會通過hash碼去計算出應該儲存在陣列中的索引,計算索引的函式如下:

1     static int indexFor(int h, int length) { //根據hash值和陣列長度算出索引值
2         return h & (length-1);  //這裡不能隨便算取,用hash&(length-1)是有原因的,這樣可以確保算出來的索引是在陣列大小範圍內,不會超出
3     }

 

這個我們要重點說下,我們一般對雜湊表的雜湊很自然地會想到用hash值對length取模(即除法雜湊法),Hashtable中也是這樣實現的,這種方法基本能保證元素在雜湊表中雜湊的比較均勻,但取模會用到除法運算,效率很低,HashMap中則通過h&(length-1)的方法來代替取模,同樣實現了均勻的雜湊,但效率要高很多,這也是HashMap對Hashtable的一個改進。

 

    接下來,我們分析下為什麼雜湊表的容量一定要是2的整數次冪。首先,length為2的整數次冪的話,h&(length-1)就相當於對length取模,這樣便保證了雜湊的均勻,同時也提升了效率;其次,length為2的整數次冪的話,為偶數,這樣length-1為奇數,奇數的最後一位是1,這樣便保證了h&(length-1)的最後一位可能為0,也可能為1(這取決於h的值),即與後的結果可能為偶數,也可能為奇數,這樣便可以保證雜湊的均勻性,而如果length為奇數的話,很明顯length-1為偶數,它的最後一位是0,這樣h&(length-1)的最後一位肯定為0,即只能為偶數,這樣任何hash值都只會被雜湊到陣列的偶數下標位置上,這便浪費了近一半的空間,因此,length取2的整數次冪,是為了使不同hash值發生碰撞的概率較小,這樣就能使元素在雜湊表中均勻地雜湊。
 根據上面 put 方法的原始碼可以看出,當程式試圖將一個key-value對放入HashMap中時,程式首先根據該 key 的 hashCode() 返回值決定該 Entry 的儲存位置:如果兩個 Entry 的 key 的 hashCode() 返回值相同,那它們的儲存位置相同。如果這兩個 Entry 的 key 通過 equals 比較返回 true,新新增 Entry 的 value 將覆蓋集合中原有 Entry 的 value,但key不會覆蓋。如果這兩個 Entry 的 key 通過 equals 比較返回 false,新新增的 Entry 將與集合中原有 Entry 形成 Entry 鏈,而且新新增的 Entry 位於 Entry 鏈的頭部——具體說明繼續看 addEntry() 方法的說明。
 

1 void addEntry(int hash, K key, V value, int bucketIndex) {
2         Entry<K,V> e = table[bucketIndex]; //如果要加入的位置有值,將該位置原先的值設定為新entry的next,也就是新entry連結串列的下一個節點
3         table[bucketIndex] = new Entry<>(hash, key, value, e);
4         if (size++ >= threshold) //如果大於臨界值就擴容
5             resize(2 * table.length); //以2的倍數擴容
6     }

 

引數bucketIndex就是indexFor函式計算出來的索引值,第2行程式碼是取得陣列中索引為bucketIndex的Entry物件,第3行就是用hash、key、value構建一個新的Entry物件放到索引為bucketIndex的位置,並且將該位置原先的物件設定為新物件的next構成連結串列。

  第4行和第5行就是判斷put後size是否達到了臨界值threshold,如果達到了臨界值就要進行擴容,HashMap擴容是擴為原來的兩倍。

4、調整大小

resize()方法如下:

 重新調整HashMap的大小,newCapacity是調整後的單位

 1     void resize(int newCapacity) {
 2         Entry[] oldTable = table;
 3         int oldCapacity = oldTable.length;
 4         if (oldCapacity == MAXIMUM_CAPACITY) {
 5             threshold = Integer.MAX_VALUE;
 6             return;
 7        }
 8 
 9         Entry[] newTable = new Entry[newCapacity];
10         transfer(newTable);//用來將原先table的元素全部移到newTable裡面
11         table = newTable;  //再將newTable賦值給table
12         threshold = (int)(newCapacity * loadFactor);//重新計算臨界值
13     }

 

新建了一個HashMap的底層陣列,上面程式碼中第10行為呼叫transfer方法,將HashMap的全部元素新增到新的HashMap中,並重新計算元素在新的陣列中的索引位置

 

當HashMap中的元素越來越多的時候,hash衝突的機率也就越來越高,因為陣列的長度是固定的。所以為了提高查詢的效率,就要對HashMap的陣列進行擴容,陣列擴容這個操作也會出現在ArrayList中,這是一個常用的操作,而在HashMap陣列擴容之後,最消耗效能的點就出現了:原陣列中的資料必須重新計算其在新陣列中的位置,並放進去,這就是resize。

   那麼HashMap什麼時候進行擴容呢?當HashMap中的元素個數超過陣列大小*loadFactor時,就會進行陣列擴容,loadFactor的預設值為0.75,這是一個折中的取值。也就是說,預設情況下,陣列大小為16,那麼當HashMap中元素個數超過16*0.75=12的時候,就把陣列的大小擴充套件為 2*16=32,即擴大一倍,然後重新計算每個元素在陣列中的位置,擴容是需要進行陣列複製的,複製陣列是非常消耗效能的操作,所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的效能。


 5、資料讀取

 

1.public V get(Object key) {   
2.    if (key == null)   
3.        return getForNullKey();   
4.    int hash = hash(key.hashCode());   
5.    for (Entry<K,V> e = table[indexFor(hash, table.length)];   
6.        e != null;   
7.        e = e.next) {   
8.        Object k;   
9.        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))   
10.            return e.value;   
11.    }   
12.    return null;   
13.}  

 有了上面儲存時的hash演算法作為基礎,理解起來這段程式碼就很容易了。從上面的原始碼中可以看出:從HashMap中get元素時,首先計算key的hashCode,找到陣列中對應位置的某一元素,然後通過key的equals方法在對應位置的連結串列中找到需要的元素。

6、HashMap的效能引數:

 

   HashMap 包含如下幾個構造器:

   HashMap():構建一個初始容量為 16,負載因子為 0.75 的 HashMap。

   HashMap(int initialCapacity):構建一個初始容量為 initialCapacity,負載因子為 0.75 的 HashMap。

   HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子建立一個 HashMap。

   HashMap的基礎構造器HashMap(int initialCapacity, float loadFactor)帶有兩個引數,它們是初始容量initialCapacity和載入因子loadFactor。

   initialCapacity:HashMap的最大容量,即為底層陣列的長度。

   loadFactor:負載因子loadFactor定義為:散列表的實際元素數目(n)/ 散列表的容量(m)。

   負載因子衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用連結串列法的散列表來說,查詢一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查詢效率的降低;如果負載因子太小,那麼散列表的資料將過於稀疏,對空間造成嚴重浪費。

   HashMap的實現中,通過threshold欄位來判斷HashMap的最大容量:
 

threshold = (int)(capacity * loadFactor);  

 結合負載因子的定義公式可知,threshold就是在此loadFactor和capacity對應下允許的最大元素數目,超過這個數目就重新resize,以降低實際的負載因子。預設的的負載因子0.75是對空間和時間效率的一個平衡選擇。當容量超出此最大容量時, resize後的HashMap容量是容量的兩倍
 

JDK8新增的紅黑樹:

shixinzhang

HashMap 在 JDK 1.8 中新增的操作:桶的樹形化 treeifyBin()
在Java 8 中,如果一個桶中的元素個數超過 TREEIFY_THRESHOLD(預設是 8 ),就使用紅黑樹來替換連結串列,從而提高速度。

這個替換的方法叫 treeifyBin() 即樹形化。
 

//將桶內所有的 連結串列節點 替換成 紅黑樹節點
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //如果當前雜湊表為空,或者雜湊表中元素的個數小於 進行樹形化的閾值(預設為 64),就去新建/擴容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //如果雜湊表中的元素個數超過了 樹形化閾值,進行樹形化
        // e 是雜湊表中指定位置桶裡的連結串列節點,從第一個開始
        TreeNode<K,V> hd = null, tl = null; //紅黑樹的頭、尾節點
        do {
            //新建一個樹形節點,內容和當前連結串列節點 e 一致
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null) //確定樹頭節點
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);  
        //讓桶的第一個元素指向新建的紅黑樹頭結點,以後這個桶裡的元素就是紅黑樹而不是連結串列了
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}


    TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}