從源碼角度分析hashCode和equals, 再也不背hashCode和equals的覆寫規則了:)

原創文章, 轉載請私信. 訂閱號 tastejava 學習加思考, 仔細品味java之美

什麼是hashCode和equals

hashCode和equals都是Object對象中的方法, 也就Java中是所有對象都默認擁有這兩個方法. 方法的作用正如其名, hashCode用於返回當前對象的hash值, equals方法用於比較兩個對象是否相等.

hashCode和equals默認實現

Object類中hashCode和equals的源代碼分別如下所示:

/**
 1. As much as is reasonably practical, the hashCode method defined by
 2. class {@code Object} does return distinct integers for distinct
 3. objects. (This is typically implemented by converting the internal
 4. address of the object into an integer, but this implementation
 5. technique is not required by the
 6. Java™ programming language.)
 7. 大致意思是不同的對象調用此方法返回一個不同的整數, 這個整數通常與對象內存地址有關係.
*/
public native int hashCode();

可以看到hashCode的默認實現是一個本地方法, 雖然看不到具體實現邏輯, 但是可以通過方法註釋瞭解到, 默認的hashCode方法返回值與對象內存地址有關, 即hashCode返回值用內存地址作爲邏輯依據.

public boolean equals(Object obj) {
    return (this == obj);
}

equals默認邏輯比較簡單, 兩個對象內存地址相同返回true, 否則返回false, 即equals默認邏輯也是用內存地址作爲邏輯依據的.

爲什麼需要覆寫這兩個方法

上面看到兩個方法默認邏輯都是與對象內存地址有關, 那麼爲什麼自定義類需要覆寫兩個方法呢. 其實這是兩個問題

  1. 爲什麼對象需要用equals比較相等而不能直接用=操作符比較兩個對象相等.
  2. 爲什麼自定義對象可能進行hash操作(比如當做HashMap的key, 比如要存入HashSet)時要覆寫兩個方法.

第一個問題我們都已經很熟悉了, 假設我們有一個自定義類HashKey, 類中包含idNum和name兩個字段.

public class HashKey {
    /**
     * 姓名
     */
    private String name;

    /**
     * id
     */
    private String idNum;
    
    // 此處忽略了兩個參數的構造器
}

我們實例化了兩個HashKey的對象, hashKeyOne和hashKeyTwo, 這兩個對象name和idNum字段值相同, 也就是在業務上兩個對象相等, 但是此時用equals方法和==操作符比較兩個對象結果都是false. 因爲兩個對象內存地址不同, ==操作符和equals默認邏輯自然返回false.

HashKey hashKeyOne = new HashKey("小明", "220");
HashKey hashKeyTwo = new HashKey("小明", "220");
// 默認equals邏輯下, res1值爲false
boolean res1 = hashKeyOne.equals(hashKeyTwo);
// 兩個對象內存地址不同, res2值也爲false
boolean res2 = hashKeyOne == hashKeyTwo;

所以我們通常覆寫equals方法, 如果類中各字段值相同, 那麼業務上兩個對象就相等. (也有可能是其他業務邏輯, 比如只要id字段相等那麼兩個對象就想=相等) .
**即用equals比較兩個對象相等是爲了比較兩個對象業務上是否相等.**這就是著名的對象一定要用equals比較相等, 例如基本類型的包裝類, JDK已經覆寫了equals可以比較業務上相等(數字類型業務相等就是表示的數字值相等).
那麼爲什麼還要覆寫hashCode方法呢, 比較兩個對象業務相等時我們也沒有用到hashCode方法啊. 這要看我們自定義類的實例是否會遇到hash相關操作, 比如自定義對象當做HashMap的key時. HashMap部分源碼如下:

// HashMap put方法中關鍵邏輯.
// 判斷當前新增key是否與原位置本身存在的key對象相同, 
// 如果只是hash碰撞導致hash值一樣, 但是equals判斷業務上不是相同對象, 
// 那麼就在此處形成鏈表, 保存新增key和value. 
// 如果的確是一樣的key對象, 那麼用新的value替換掉之前存在key對應的value
if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

我們都知道HashMap或者其他Hash容器都是通過新增對象hashCode值來決定對象存放位置.(當然也通過hashCode來達到高效查找. 不需要遍歷所有元素直接通過hashCode算出來元素放在哪了)
從源代碼邏輯可以看到, 重寫hashCode方法主要是爲了可能遇到的Hash操作做準備的, 前面重寫equals方法已經實現了判斷對象業務上相等. 但是HashMap中判斷key相等的前提是hashCode值相等, 即有兩個對象通過hashCode算出的存儲位置相同, 然後纔會調用equals進一步確定兩個對象相不相等, 不相等用鏈表存儲, 相等直接替換到最新值.
所以重寫了equals後, 自定義類的多個實例已經能通過equals方法判斷是否業務上相等, 但是在實例可能遇到hash操作時, 還需要重寫hashCode方法.

覆寫的規則和原理

很多資料告訴我們要背下來hashCode和equals的特性並正確覆寫. 這是不對的. 從HashMap源碼中可以看到爲什麼兩個不同的獨立方法需要有一些規則.

  1. 不同對象hashCode可能相同, equals不一定相同
  2. 相同對象hashCode和equals一定都相同
    第一條規則其實是hash函數的特性, 覆寫hashCode方法要儘可能的保證不同對象產生不同hash值, 也就是hash函數結果分佈均勻, 這樣纔是一個好的hash函數實現. 但是再好的函數也會有hash碰撞的時候, 也就是不同對象產生了相同hash值
    第二條規則是因爲hash容器判斷hashCode和equals都相等, 纔是相等對象, 爲了正確使用hash相關容器, 需要覆寫兩個方法並滿足第二條規則

怎麼寫出一個優秀的hashCode方法

一個優秀的hashCode方法要保證hash結果分佈均勻, 避免HashMap等hash容器產生鏈表, 降低效率. 但是想要實現一個優秀的hash算法並不簡單, 這裏我推薦把自定義類業務上相等涉及的字段生成一個字符串, 然後返回字符串的hashCode結果. JDK已經把String中的hashCode方法做到了很優秀.

是否應該所有自定義類都覆寫兩個方法

上面我們已經說過了爲什麼要覆蓋這兩個方法, 但是往往從事開發有一段時間的人也會有疑惑, 平時沒有重寫過自定義類這兩個方法, 代碼也沒有出錯呀. 那是因爲我們沒有用到自定義對象當做HashMap的key, 或者沒有把自定義對象存入HashSet的邏輯. 當我們用String當做HashMap的key時是邏輯正確的, 因爲String內部已經重寫了hashCode和equals兩個方法.
那麼是否所有自定義類都應該覆寫兩個方法呢, 從防禦性編程角度來看, 是的. 這一點我之前有一個用友出身的同事做的特別好, 每個他寫的自定義類都會重寫equals和hashCode, toString方法.

最佳實踐

每個自定義類都覆寫equals, hashCode和toString等方法, 簡單對象中還要生成每個字段的getter個setter. 阿里Java開發手冊提倡把這些業務意義很低的方法放到類尾部, 比private方法還要靠後. 然而這樣的策略依舊造成了源代碼的膨脹, 並且不能保證每個團隊成員遵守規則.
仔細感受, 這些覆寫方法都是重複的邏輯, 有沒有更簡單的實現方式呢? 我目前的最佳實踐是使用Lombok, @Data註解自動生成getter, setter, 自動重寫hashCode和equals等方法. 如果只需要覆寫hashCode和equals, 那麼我們可以用lombok的@EqualsAndHashCode註解. 不瞭解Lombok的同學自行了解一下吧.

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