“==”、equals() 和 hashCode()

概述

本篇博客簡單介紹一下 equals() 方法和 “==” 的區別,順帶解釋一下爲什麼在重寫 equals() 方法後還需要重寫 hashCode() 方法。


equals() 和 ==

在 java 代碼中,一般通過 == 判斷兩個對象的地址是否相等。如果比較的是常量數據,則比較他們的值是否相等。

equals() 是 Object 類方法,它的源碼實際上也是直接調用的 == :

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

也就是說,當我們調用 equals() 比較兩個對象是否相等時,實際上作用和 == 是相似的。

在實際的業務場景中,經常需要比較的不是對象地址,而是業務屬性。因此一般重寫 java 類的 equals() 方法來實現上述業務需求。下面我們看一個具體代碼:

class Node {
    private int num;

    public Node(int num) {
        this.num = num;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Node) {
            return ((Node) obj).num == num;
        }
        return false;
    }
}

@Test
public void test() {
    Node node1 = new Node(1);
    Node node2 = new Node(1);
    System.out.println("使用 '==' 比較:" + (node1 == node2));
    System.out.println("使用 equals() 比較:" + node1.equals(node2));
}

執行結果

使用 '==' 比較:false
使用 equals() 比較:true

從執行結果可以看出:"==" 因爲比較不同對象的地址返回 false,而 equals() 方法比較對象 num 屬性值返回 true。

像上述這種重寫 equals() 方法來比較對象屬性的方式已經很常見了,我們經常使用的 String 類就通過重寫 equals() 方法來比較對象具體的字符串值。


equals() 方法重寫規則

在重寫 equals() 方法時,一般我們需要遵守以下規則,否則將會帶來意想不到的異常:

  • 自反性:equals() 方法參數爲對象本身時必須返回 true。

  • 對稱性:a.equals(b) 返回 true 時,b.equals(a) 也必須返回 true,相反也是。

  • 傳遞性:a.equals(b) 返回 true,b.equals© 返回爲 true時,a.equals© 也必須返回 true

  • 連續性:在對象和方法參數沒有做任何改變時,equals() 方法返回結果必須一致

上述四條準則有一個共同的前提:參數對象非 NULL,如果參數爲 NULL,則必須返回false。值得一提的是,不存在 NULL 和 NULL 進行 equals() 方法判斷是否相等的情況。

一般情況下,重寫 equals() 方法爲根據屬性值判斷後,上述四條準則都不會出問題。但如果對象本身和參數對象存在父子級關係時,如果沒有做特殊處理,就很容易出現問題:

自反性一般不會出現問題,因爲對象和自己本身肯定不存在父子級關係。下面我們先看一個關於對稱性的反例:

class NodeSon extends Node {
    private int record;
    public NodeSon(int num, int record) {
        super(num);
        this.record = record;
    }
    @Override
    public boolean equals(Object obj) {
        if (obj instanceof NodeSon) {
            return super.equals(obj) && ((NodeSon) obj).record == record;
        }
        return false;
    }
}

@Test
public void test2() {
    Node node1 = new Node(1);
    NodeSon node2 = new NodeSon(1, 1);
    System.out.println(node1.equals(node2));
    System.out.println(node2.equals(node1));
}

執行結果

true
false

從輸出結果我們可以看出,node1 對象調用 equals() 方法時返回爲 ture,當我們反過來調用時,返回 false。

輸出這樣的結果其實也無可厚非,因爲 NodeSon 類作爲 Node 類的子類,它的對象可以向上轉型,而 Node 類對象就不能向下轉型爲 NodeSon 類對象,因爲它可能含有其他很多種子類。因此在通過關鍵字 instanceof 判斷對象類型是否相等時就已經返回 false,實際還沒有開始比較屬性。

假如現在的業務要求對象 equals() 方法不再受父子類型轉型的限制,它認爲這兩個類屬於同根,只要求比較屬性,也就是說要 equals() 方法在父子類間滿足對稱性。要解決該問題也比較簡單,只需要將子類的 equals() 方法改爲如下即可:

@Override
public boolean equals(Object obj) {
    if (obj instanceof NodeSon) {
        return super.equals(obj) && ((NodeSon) obj).record == record;
    }
    return super.equals(obj);
}

運行上述代碼後,兩者都返回 true。其實原理也很容易理解:在類型轉換失敗後,直接通過父類的方法進行判斷,這樣就不會因爲對象類型轉換出現對稱性問題。

雖然上述處理方式可以滿足對稱性,但不能滿足連續性:

@Test
public void test3() {
    Node node1 = new Node(1);
    NodeSon node2 = new NodeSon(1, 1);
    NodeSon node3 = new NodeSon(1, 2);
    System.out.println(node1.equals(node2));
    System.out.println(node1.equals(node3));
    System.out.println(node2.equals(node3));
}

執行結果

true
true
false

上述代碼中,通過 equals() 方法比較,node1 對象等於 node2 和 node3 對象。但 node2 和 node3 對象本身不相等,也就違背了 equals() 方法的連續性。

出現這種結果其實也無可厚非,因爲 NodeSon 類中引入新的屬性,而 node2 對象和 node3 對象的新屬性值也不相同,因此返回 false 也屬實正常。

總結一下,當出現以下兩種場景時容易不滿足 equal() 特性:

  • 父類子類混合比較

  • 子類引入新屬性時,根據父類做橋接但新熟悉不相等時

對於上述連續性問題,我認爲沒有解決方案。當然你可以通過以下特殊處理讓輸出結果都爲 false。這樣從輸出結果來看是滿足連續性問題了, 但沒有任何實際價值。

    class NodeSonNoError {
        Node node;
        int count;

        public NodeSonNoError(int num, int count) {
            this.node = new Node(num);
            this.count = count;
        }

        public boolean equals(Object obj) {
            if (obj instanceof NodeSonNoError) {
                NodeSonNoError temp = (NodeSonNoError) obj;
                return node.equals(temp.node) && count == temp.count;
            }
            return false;
        }
    }

通過上述類運行會發現結果都是 false,也就實現了連續性和自反性。不過我覺得大可不必,這種類在實際業務場景中起不到任何作用。


深入理解 equals() 重寫規則

有了上面的基礎,我們再來看看如果 equals() 方法不遵守規則時,會帶來哪些異常:

class Node {
    @Override
    public boolean equals(Object obj) {
        return obj instanceof Node;
    }
}
class NodeSon extends Node {
    @Override
    public boolean equals(Object obj) {
        return obj instanceof NodeSon;
    }
}
@Test
public void test() {
    List<Node> list = new ArrayList<>();
    Node node1 = new Node();
    Node node2 = new NodeSon();
    System.out.println("只添加對象 node2 時:");
    list.add(node2);
    System.out.println("集合是否包含node1:" + list.contains(node1));
    System.out.println("集合是否包含node2:" + list.contains(node2));
    list.clear();
    System.out.println("只添加對象 node1 時:");
    list.add(node1);
    System.out.println("集合是否包含node1:" + list.contains(node1));
    System.out.println("集合是否包含node2:" + list.contains(node2));
}

執行結果

只添加對象 node2 時:
集合是否包含node1:true
集合是否包含node2:true
只添加對象 node1 時:
集合是否包含node1:true
集合是否包含node2:false

從輸出結果可以看出,當只添加對象 node1 時,輸出結果正常。如果只添加 node2 對象,那麼集合會認爲兩個對象都被添加。這顯然是有問題的,下面我們來看一下 contains() 方法的源碼。

public boolean contains(Object o) {
    return indexOf(o) >= 0;
}

public int indexOf(Object o) {
    if (o == null) {
        for (int i = 0; i < size; i++)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = 0; i < size; i++)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}

從源碼可以看出,最終在集合中遍歷通過 equals() 方法判斷,如果 equals() 方法返回 true,就說明集合中存在當前元素。而根據前面的介紹,node2 對象是根據 Node 類子類創建的,而且我們重寫之後的 equals() 方法直接根據對象類型判斷,因此當集合添加 node2 對象時,會認爲 node1 對象也被添加。而 node1 對象作爲父類,不能向下轉型,因此集合不會因爲 node1 對象判斷 node2 對象也在集合中。

造成上述結果的根本原因是由於我們在重寫 equals() 方法時,沒有保證對稱性。實際上除了 List 之外,其他很多集合也會在 equals() 重寫不滿足上述特性出出現問題。


equals() 和 hashCode()

hashCode() 也是一個 Object 類方法,根據它獲取對象的 hash 值。該方法經常被用在 Map 集合中,通過它快速定位對象處於哪個散列,方便後面操作。可以抽象的把 hashCode() 方法的作用理解爲目錄,通過目錄就可以大概知道想要查詢的內容屬於哪一頁,根據頁數快速定位。

在正式介紹 equals() 和 hashCode() 方法前,我們先通過一個簡答的案例來了解 hashCode() 方法:

@Test
public void test() {
    String s1 = "test";
    String s2 = "test";
    StringBuffer stringBuffer1 = new StringBuffer(s1);
    StringBuffer stringBuffer2 = new StringBuffer(s2);
    System.out.println("s1 對象的 hash 值:" + s1.hashCode());
    System.out.println("s2 對象的 hash 值:" + s2.hashCode());
    System.out.println("stringBuffer1 對象的 hash 值:" + stringBuffer1.hashCode());
    System.out.println("stringBuffer2 對象的 hash 值:" + stringBuffer2.hashCode());
}

執行結果

s1 對象的 hash 值:3556498
s2 對象的 hash 值:3556498
stringBuffer1 對象的 hash 值:1688376486
stringBuffer2 對象的 hash 值:2114664380

從輸出結果我們會發現 String 對象值相同時,它們的 hash 值也是相同的。而 StringBuffer 對象的 hash 值就相對比較無序。

導致這種結果的原因其實也比較簡單,因爲 String 對象重寫了 hashCode() 方法。下面我們看一下重寫之後的 String 類的 hashCode() 方法源碼:

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

上述代碼中,hash 是 String 對象的 hash 值,value 是保存字符串的字符數組。從代碼可以很清晰的看出,String 對象的 hash 值是根據字符串算出來的,因此當字符值相同時,計算出來的 hash 值也相同。

Object 類的 hashCode() 方法是 native 類型的,它的值根據對象的地址計算,也就是說如果這個 Object 對象所在內存地址沒有發生變化,它的 hash 值也不會發生變化。

關於 hashCode() 方法的作用顯而易見:儘可能讓對象散開一點提高效率

怎麼理解這句話呢:假設現在存在 hashMap 集合,長度爲10,現在我們要用它添加100個元素。如果我們的 hashCode() 方法計算出的結果儘可能均勻,這100個對象將會被平均分配到這10個槽點,後面無論是查詢、修改、刪除操作都只需要遍歷十個左右的對象即可。如果 hashCode() 方法不均勻,所有的對象都被分配到第一個槽點,那麼無論我們做任何操作,都需要遍歷所有的對象。關於 hashMap 集合的原理,後面我們出博客專門介紹。

從上面的解釋可以看出,hashCode() 方法的主要作用是將對象元素分類,方便查詢等操作。既然涉及到查詢,一定存在判斷是否相等操作,而判斷是否相等的方法上面我們介紹了很多,即 equals()。下面我們看一下 Set 集合中是如何判斷對象是否存在:

public boolean contains(Object o) {
    return map.containsKey(o);
}

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}

從源碼可以清晰的看出,在 Set 集合中,要判斷一個對象是否存在,首先根據 hash 值定位到槽點,然後在槽點集合中根據 equals() 方法判斷是否存在。

也就是是說,要想在 Set() 集合中判斷對象是否存在,還需要首先擁有相同的 hash 值。這也是爲什麼一般重寫 equals() 方法後,還需要重寫 hashCode() 方法的主要原因。和equals() 方法相同,下面我們總結下 hashCode() 方法需要遵守的約定:

  • equals() 返回 true 的兩個對象,他們的 hashCode() 結果必須相同

  • equals() 返回 false 的兩個對象,他們的 hashCode() 結果可以不相等

和 equals() 方法類似,如果一個類重寫完 hashCode() 方法後沒有滿足上述條例,那麼就會在 Map 集合中出現奇奇怪怪的異常信息。這裏我使用Set方便驗證,看過源碼的同學一定知道,Set底層用的就是Map,只不過沒有存 value 值。

class Node {
    int num;
    public Node(int num) {
        this.num = num;
    }
    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Node) {
            return ((Node) obj).num == num;
        }
        return false;
    }
}

@Test
public void test2() {
    HashSet<Node> set = new HashSet<>();
    Node node1 = new Node(1);
    Node node2 = new Node(1);
    set.add(node1);
    System.out.println("node1.equals(node2) ->" + node1.equals(node2));
    System.out.println("set 集合是否包含node1:" + set.contains(node1));
    System.out.println("set 集合是否包含node2:" + set.contains(node2));
}

執行結果

node1.equals(node2) ->true
set 集合是否包含node1:true
set 集合是否包含node2:false

從輸出結果可以看出,雖然 node1 對象和 node2 對象是相等的(根據equals()方法判斷),但是 set 集合只認爲它包含有 node1 對象,而不包含有 node2 對象。

造成這種現象的原因很簡單,因爲 node1 對象和 node2 對象的 hashCode() 值是不相等的。因此首先會被定位到不同的槽位,如果槽位都不相同,還沒有到 equals() 那一步已經返回 false。

要解決上述問題很簡單,只需要重寫該類的 hashCode() 方法即可。重寫的方法有很多種,這裏我列出最簡單也相對比較均衡並且和對象屬性有關的一種:

@Override
public int hashCode() {
    return new Integer(num).hashCode();
}

將 Node 類的 hashCode() 方法重寫爲如上後,再次運行代碼:

執行結果

node1.equals(node2) ->true
set 集合是否包含node1:true
set 集合是否包含node2:true

從輸出結果可以看出,這次沒有再出現上面的問題。


重寫 equals() 方法的建議

  1. 判斷前在方法中將參數對象強轉爲相應類型的對象
  2. 判斷是否同意對象時用“==”,直接比較地址
  3. 判斷參數對象是否爲NULL,如果是直接返回 false
  4. 存在父子類關係時,如果業務上這兩個類是相同的,用 instanceof 方法判斷,否則通過 getClass() 判斷是否同一類型
  5. 根據屬性比較時,基礎類型比較用 “==”,對象類型用 equals() ,兩這都返回 true 時才返回 true。
  6. 子類重寫 equals() 方法時,根據業務場景選擇是否在類型不匹配時調用 super.equals(xxx),返回 true時,必須先調用 super.equals(xxx)。
  7. 重寫 equals() 方法後,一定不能忘記重寫 hashCode(),否則在 Map 集合中容易出錯。

參考:
https://blog.csdn.net/javazejian/article/details/51348320
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章