1. 程式人生 > >你所不知道的Java之HashCode

你所不知道的Java之HashCode

以下內容為作者辛苦原創,版權歸作者所有,如轉載演繹請在“光變”微信公眾號留言申請,轉載文章請在開始處顯著標明出處。

之所以寫HashCode,是因為平時我們總聽到它。但你真的瞭解hashcode嗎?它會在哪裡使用?它應該怎樣寫?

相信閱讀完本文,能讓你看到不一樣的hashcode。

使用hashcode的目的在於:使用一個物件查詢另一個物件。對於使用雜湊的資料結構,如HashSet、HashMap、LinkedHashSet、LinkedHashMap,如果沒有很好的覆寫鍵的hashcode()和equals()方法,那麼將無法正確的處理鍵。

請對以下程式碼中Person

覆寫hashcode()方法,看看會發生什麼?

// 覆寫hashcode
@Override
public int hashCode() {
    return age;
}

@Test
public void testHashCode() {
    Set<Person> people = new HashSet<Person>();
    Person person = null;
    for (int i = 0; i < 3 ; i++) {
        person = new Person("name-" + i, i);
        people.add(person);
    }
    person.age = 100;
    System.out.println(people.contains(person));
    people.add(person);
    System.out.println(people.size());
}

執行結果並不是預期的true3,而是false4!改變person.age後HashSet無法找到person這個物件了,可見覆寫hahcode對HashSet的儲存和查詢造成了影響。

那麼hashcode是如何影響HashSet的儲存和查詢呢?又會造成怎樣的影響呢?

HashSet的內部使用HashMap實現,所有放入HashSet中的集合元素都會轉為HashMap的key來儲存。HashMap使用散列表來儲存,也就是陣列+連結串列+紅黑樹(JDK1.8增加了紅黑樹部分)。
儲存結構簡圖如下:

HashMap儲存結構簡圖

陣列的預設長度為16,數組裡每個元素儲存的是一個連結串列的頭結點。組成連結串列的結點結構如下:

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

每一個Node都儲存了一個hash----鍵物件的hashcode,如果鍵沒有按照任何特定順序儲存,查詢時通過equals()逐一與每一個數組元素進行比較,那麼時間複雜度為O(n),陣列長度越大,效率越低。

所以瓶頸在於鍵的查詢速度,如何通過鍵來快速的定位到儲存位置呢?

HashMap將鍵的hash值與陣列下標建立對映,通過鍵物件的hash函式生成一個值,以此作為陣列的下標,這樣我們就可以通過鍵來快速的定位到儲存位置了。如果hash函式設計的完美的話,陣列的每個位置只有較少的值,那麼在O(1)的時間我們就可以找到需要的元素,從而不需要去遍歷連結串列。這樣就大大提高了查詢速度。

那麼HashMap根據hashcode是如何得到陣列下標呢?可以拆分為以下幾步:

  • 第一步:h = key.hashCode()
  • 第二步:h ^ (h >>> 16)
  • 第三步:(length - 1) & hash

分析

第一步是得到key的hashcode值;

第二步是將鍵的hashcode的高16位異或低16位(高位運算),這樣即使陣列table的length比較小的時候,也能保證高低Bit都參與到Hash的計算中,同時不會有太大的開銷;

第三步是hash值和陣列長度進行取模運算,這樣元素的分佈相對來說比較均勻。當length總是2的n次方時,h & (length-1)運算等價於對length取模,這樣模運算轉化為位移運算速度更快。

但是,HashMap預設陣列初始化容量大小為16。當陣列長度遠小於鍵的數量時,不同的鍵可能會產生相同的陣列下標,也就是發生了雜湊衝突!

對於雜湊衝突有開放定址法、鏈地址法、公共溢位區法等解決方案。

開放定址法就是一旦發生衝突,就尋找下一個空的雜湊地址。過程可用下式描述:

fi(key) = (f(key) + di) mod m (di=1,2,3,...,m-1)

例如鍵集合為{12,67,56,16,25,37,22,29,15,47,48,34},表長n = 12,取f(key) = key mod 12

前5個計算都沒有衝突,直接存入。如表所示

陣列下標
0 12
1 25
2  
3  
4 16
5  
6  
7 67
8 56
9  
10  
11  

key = 37時,f(37) = 1,與25的位置衝突。應用公式f(37) = (f(37) + 1) mod 12 = 2,所以37存入陣列下標為2的位置。如表所示

陣列下標
0 12
1 25
2 37
3  
4 16
5  
6  
7 67
8 56
9  
10  
11  

到了key = 48,與12所在的0衝突了。繼續往下找,發現一直到f(48) = (f(48) + 6) mod 12 = 6時才有空位。如表所示

陣列下標
0 12
1 25
2 37
3  
4 16
5 29
6 48
7 67
8 56
9  
10 22
11 47

所以在解決衝突的時候還會出現48和37衝突的情況,也就是出現了堆積,無論是查詢還是存入效率大大降低。

鏈地址法解決衝突的做法是:如果雜湊表空間為[0~m-1],設定一個由m個指標分量組成的一維陣列Array[m], 凡雜湊地址為i的資料元素都插入到頭指標為Array[i]的連結串列中。

它的基本思想是:為每個Hash值建立一個單鏈表,當發生衝突時,將記錄插入到連結串列中。如圖所示:

鏈地址法

連結串列的好處表現在:

  1. remove操作時效率高,只維護指標的變化即可,無需進行移位操作
  2. 重新雜湊時,原來散落在同一個槽中的元素可能會被散落在不同的地方,對於陣列需要進行移位操作,而連結串列只需維護指標。
    但是,這也帶來了需要遍歷單鏈表的效能損耗。

公共溢位法就是我們為所有衝突的鍵單獨放一個公共的溢位區存放。
例如前面例子中{37,48,34}有衝突,將他們存入溢位表。如圖所示。

公共溢位法

在查詢時,先與基本表進行比對,如果相等則查詢成功,如果不等則在溢位表中進行順序查詢。公共溢位法適用於衝突資料很少的情況。

HashMap解決衝突採取的是鏈地址法。整體流程圖(暫不考慮擴容)如下:

HashMap儲存流程簡圖

理解了hashcode和雜湊衝突即解決方案後,我們如何設計自己的hashcode()
方法呢?

Effective Java一書中對覆寫hashcode()給出以下指導:

  • 給int變數result賦予某個非零常量值

  • 為物件內每個有意義的域f計算一個int雜湊碼c

域型別 計算
boolean c = (f ? 0 : 1)
byte、char、short、int c = (int)f
long c = (int)(f ^ (f >>> 32))
float c = Float.floatToIntBits(f)
double long l = Double.doubleToIntLongBits(f)
  c = (int)(l ^ (l >>> 32))
Object c = f.hashcode()
陣列 每個元素應用上述規則
boolean c = (f ? 0 : 1)
boolean c = (f ? 0 : 1)
  • 合併計算得到雜湊碼 result = 37 * result + c

現代IDE通過點選右鍵上下文選單可以自動生成hashcode方法,比如通過IDEA生成的hashcode如下:

@Override
public int hashCode() {
    int result = name != null ? name.hashCode() : 0;
    result = 31 * result + age;
    return result;
}

但是在企業級程式碼中,最好使用第三方庫如Apache commons來生成hashocde方法。使用第三方庫的優勢是可以反覆驗證嘗試程式碼。下面程式碼顯示瞭如何使用Apache Commons hash code 為一個自定義類構建生成hashcode。

public int hashCode(){
    HashCodeBuilder builder = new HashCodeBuilder();
    builder.append(mostSignificantMemberVariable);
    ........................
    builder.append(leastSignificantMemberVariable);
    return builder.toHashCode();
}

如程式碼所示,最重要的簽名成員變數應該首先傳遞然後跟隨的是沒那麼重要的成員變數。

總結

通過上述分析,我們設計hashcode()應該注意的是:

  • 無論何時,對同一個物件呼叫hashcode()都應該生成同樣的值。
  • hashcode()儘量使用物件內有意義的識別資訊。
  • 好的hashcode()應該產生分佈均勻的雜湊值。

感謝覺醒飛鳥的寶貴建議和辛苦校對。



作者:光變
連結:https://www.jianshu.com/p/e183f75d0289
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授