1. 程式人生 > >Java效能優化(8):改寫equals時總是要改寫hashCode

Java效能優化(8):改寫equals時總是要改寫hashCode

一個很常見的錯誤源於沒有改寫hashCode方法。在每個改寫了equals方法的類中,你必須也要改寫hashCode方法。如果不這樣做到話,就會違反Object.hashCode的通用約定,從而導致該類無法與所有基於雜湊值(hash)的集合類結合在一起正常運作,這樣的集合類包括hashMap、HashSet和Hashtable。

下面是hashcode約定的內容,來自java.lang.Object的規範:

  • 在一個應用程式執行期間,如果一個物件的equals方法比較所用到的資訊沒有被修改的話,那麼,對該物件呼叫hashCode方法多次,它必須始終如一地返回一個整數。在同一個應用程式的多次執行過程中,這個整數可以不同,即這個應用程式這次執行返回的整數與下一次執行返回的整數可以不一致。

  • 如果兩個物件根據equals(Object)方法是相等的,那麼呼叫這兩個物件中任意一個物件的hashCode方法必須產生同樣的整數結果。

  • 如果兩個物件根據equals(Object)方法是不相等的,那麼呼叫這兩個物件中任意一個物件的hashCode方法,不要求必須產生不同的整數結果。然而,程式設計師應該意識到這樣的事實,對於不相等的物件產生截然不同的整數結果,有可能提高散列表的效能。

因為沒有改寫hashCode而違反的關鍵約定是第二條:相等的物件必須有相等的hash code。根據一個類的equals方法,兩個截然不同的例項有可能在邏輯上是相等的,但是,根據Object類的hashCode方法,它們僅僅是兩個物件,沒有其他共同的地方。因此,物件hashCode方法返回兩個看起來是隨機的整數,而不是根據第二個約定要求的那樣,返回兩個相等的整數。

例如,考慮下面極為簡單的PhoneNumber類

public final class PhoneNumber {
    private final short areaCode;
    private final short exchange;
    private final short extension;

    private static void rangeCheck(int arg, int max, String name) {
        if (arg < 0 || arg > max)
            throw new IllegalArgumentException(name + ":"
+ arg); } public PhoneNumber(int areaCode, int exchange, int extension) { rangeCheck(areaCode, 999, "area code"); rangeCheck(exchange, 999, "exchange"); rangeCheck(extension, 9999, "extension"); this.areaCode = (short) areaCode; this.exchange = (short) exchange; this.extension = (short) extension; } public boolean equals(Object o) { if (o == this) return true; if (!(o instanceof PhoneNumber)) return false; PhoneNumber pn = (PhoneNumber) o; return pn.extension == extension && pn.exchange == exchange && pn.areaCode == areaCode; } }

假設你企圖將這個類與HashMap一起使用:

Map m = new HashMap();
m.put(new PhoneNumber(408,867,5309),"Jenny");

這時候,你可能會期望m.get(new PhoneNumber(408,867,5309))會返回”Jenny”,但是它實際上返回null。注意,這裡涉及到兩個PhoneNumber例項:第一個被用於插入到HashMap中,第二個例項與第一個相等,被用於檢索。由於PhoneNumber類沒有改寫hashCode方法,從而導致兩個相等的例項具有不相等的雜湊碼,違反了hashCode的約定。因此,put方法把Jenny的電話號碼物件存放在以雜湊桶中,而get方法會在另一個雜湊通中查詢她的電話號碼物件。要想修正這個問題非常簡單,只需為PhoneNumber類提供一個適當的hashCode方法即可。
編寫一個合法但並不好用的hashcode方法沒有任何價值,例如下面這個方法合法但永遠不應該被正式使用:

public int hashCode(){return 42;}

上面這個hashCode方法是合法的,因為相等的物件總是有同樣的雜湊碼。但它也極為惡劣,因為它使得每一個物件都具有同樣的雜湊碼。因此,每個物件都被對映到同一個雜湊桶中,從而散列表被退化為連結串列(linked list)。它使得本該線性時間執行的程式變成了平方執行時間,對於規模很大的散列表而言,這關係到列表能否正常工作。
一個好的雜湊函式通常傾向於”為不相等的物件產生不相等的雜湊碼”。這正是hashCode約定中第三條的含義。理想情況下,一個雜湊函式應該把一個集合中不相等的例項均勻地分佈到所有可能的雜湊值上。要想完全達到這種理想的情形是非常困難的,幸運的是,相對於這種理想情形並不太困難。下面給出一種簡單的”處方“:
1.把某個非零常數值,儲存在一個叫result的int型別的變數中。
2.對於物件中每一個關鍵域f,完成以下步驟:
a.為該域計算int型別的雜湊碼c:
i.如果該域是boolean型別,則計算(f?0:1)。
ii.如果該域是byte、char、short或int型別,則計算(int)f。
iii.如果該域是long型別,則計算(int)(f^(f>>32))。
iv.如果該域是float型別,則計算Float.floatToIntBits(f)。
v.如果該域是double型別,則計算Double.doubleToLongBits(f)得到一個long型別的值,然後按照步驟,2.a.iii,對該long型值計算雜湊值。
vi.如果該域是一個物件引用,並且該類的equals方法通過遞迴呼叫equals的方式來比較這個域,則同樣對這個域遞迴呼叫hashCode。如果要求一個更為複雜的比較,則為這個域計算一個”規範表示”,然後針對這個正規化表示呼叫hashCode。如果這個域的值為null,則返回0
vii.如果該域是一個數組,則把每一個元素當做單獨的域來處理。也就是說,遞迴地應用上述規則,對每個重要的元素計算一個雜湊碼,然後根據步驟2.b中的做法把這些雜湊值組合起來。
b.按照下面的公式,把步驟a中計算得到的雜湊碼c組合到result中:

result = 37*result+c;

3.返回result。
4.寫完了hashCode方法之後,問自己”是否相等的例項具有相等的雜湊碼”。如果不是的話,找出原因,並修正錯誤。

在雜湊碼的計算過程中,把冗餘域排除在外是可以接受的。換句話說,如果一個域的值可以根據參與計算的其他域值計算出來,則把這樣的與排除在外是可以接受的。對於在相等比較計算中沒有被用到的任何域,你要把它們排除在外,這是一個要求。如果不這樣做的話,可能會導致違反hashCode約定的第二條。

上面步驟1中用到了一個非零的初始值,對於步驟2.a中計算的雜湊值為0的那些初始域,它們會影響到雜湊值。如果步驟1中的初始值為0,則整個雜湊值將不受這些初始域的影響,從而會增加衝突的可能性。值17是任選的。

步驟2.b中的乘法部分使得雜湊值依賴於域的順序,如果一個類包含多個相似的域,那麼這樣的乘法運算會產生一個更好的雜湊函式。例如,如果String類也根據上面的步驟來建立雜湊函式,並且把乘法部分省去,則那些僅僅是字母順序不同的所有字串,都會有同樣的雜湊碼。之所以選擇37,是因為它是一個奇素數。如果乘數是偶數,並且乘法溢位的話,則資訊會丟失,因為與2相乘等價於移位運算,使用素數的好處並不是很明顯,但是習慣上使用素數來計算雜湊結果。
現在我們把這種方法用到PhoneNumber類中,它有三個關鍵域,都是short型別。根據上面的步驟,很直接地會得到下面的雜湊函式:

public int hashCode()
{
    int result = 17;
    result = 37 * result + areaCode;
    result = 37 * result + exchange;
    result = 37 * result + extension;
    return result;
}

因為這個方法返回結果是一個簡單的、確定的計算結果,它的輸入只是PhoneNumber例項中的三個關鍵域,所以,很清楚,相等的PhoneNumber會有相等的雜湊碼。實際上,對於PhoneNumber的hashcode實現而言,上面這個方法是非常合理的,等同於Java平臺庫1.4版本中的實現。它的做法非常簡單,速度也非常快,恰當地把不相等的電話號碼分散到不同的雜湊桶中。
如果一個類是非可變的,並且計算雜湊碼的代價也比較大,那麼你應該考慮把雜湊碼快取在物件內部,而不是每次請求的時候都重新計算雜湊碼。如果你覺得這種型別的大多數物件會被用做雜湊鍵,那麼你應該在例項被建立的時候就計算雜湊碼。否則的話,你可以選擇“遲緩初始化”雜湊碼,一直到hashCode被第一次呼叫的時候才初始化。現在尚不清楚我們的PhoneNumber類是否值得這樣處理,但可以通過它來說明這種方法如何實現:

private volatile int hashCode = 0;
public int hashCode()
{
    if(hashCode == 0)
    {
        int result = 17;
        result = 37 * result + areaCode;
        result = 37 * result + exchange;
        result = 37 * result + extension;
        hashCode = result;
    }
    return hashCode;

}

雖然本條目中前面給出的hashCode實現方法能夠獲得相對比較好的雜湊函式,但是它並不能產生最新的雜湊函式。不要試圖從雜湊碼計算中排除掉一個物件的關鍵部分以提高效能。雖然這樣得到的雜湊函式執行起來可能非常快,但是它的效果不見得會好,可能會導致散列表慢的根本不可用。特別是在實踐中,雜湊函式可能會面臨大量的例項,而且,在你選擇可以忽略掉的區域之中,這些例項仍然區別非常大。如果這樣的話,雜湊函式會把所有這些例項對映到非常少量的雜湊碼上,基於雜湊的集合將會表現出平方級的效能指標。