我們可能經常聽到說重寫equals方法必須重寫hashcode方法,這是為什么呢?java中所有的類都是Object的子類,直接上object源碼
/* * Copyright (c) 1994, 2012, Oracle and/or its affiliates. All rights reserved. * ORACLE PROPRIETARY/CONFIDENTIAL. Use is subject to license terms. * */ package java.lang; /** * Class {@code Object} is the root of the class hierarchy. * Every class has {@code Object} as a superclass. All objects, * including arrays, implement the methods of this class. * * @author unascribed * @see java.lang.Class * @since JDK1.0 */ public class Object { private static native void registerNatives(); static { registerNatives(); } public final native Class<?> getClass(); public native int hashCode(); public boolean equals(Object obj) { return (this == obj); } protected native Object clone() throws CloneNotSupportedException; public String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); } public final native void notify(); public final native void notifyAll(); public final native void wait(long timeout) throws InterruptedException; public final void wait(long timeout, int nanos) throws InterruptedException { if (timeout < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (nanos < 0 || nanos > 999999) { throw new IllegalArgumentException( "nanosecond timeout value out of range"); } if (nanos > 0) { timeout++; } wait(timeout); } public final void wait() throws InterruptedException { wait(0); } protected void finalize() throws Throwable { } }
首先來復習下hash算法和hashmap
在一個長度為n(假設是10000)的線性表(假設是ArrayList)里,存放着無序的數字;如果我們要找一個指定的數字,就不得不通過從頭到尾依次遍歷來查找,這樣的平均查找次數是n除以2(這里是5000)。
我們再來觀察Hash表(這里的Hash表純粹是數據結構上的概念,和Java無關)。它的平均查找次數接近於1,代價相當小,關鍵是在Hash表里,存放在其中的數據和它的存儲位置是用Hash函數關聯的。
我們假設一個Hash函數是x*x%5。當然實際情況里不可能用這么簡單的Hash函數,我們這里純粹為了說明方便,而Hash表是一個長度是11的線性表。如果我們要把6放入其中,那么我們首先會對6用Hash函數計算一下,結果是1,所以我們就把6放入到索引號是1這個位置。同樣如果我們要放數字7,經過Hash函數計算,7的結果是4,那么它將被放入索引是4的這個位置。這個效果如下圖所示。
這樣做的好處非常明顯。比如我們要從中找6這個元素,我們可以先通過Hash函數計算6的索引位置,然后直接從1號索引里找到它了。
不過我們會遇到“Hash值沖突”這個問題。比如經過Hash函數計算后,7和8會有相同的Hash值,對此Java的HashMap對象采用的是”鏈地址法“的解決方案。效果如下圖所示。
具體的做法是,為所有Hash值是i的對象建立一個同義詞鏈表。假設我們在放入8的時候,發現4號位置已經被占,那么就會新建一個鏈表結點放入8。同樣,如果我們要找8,那么發現4號索引里不是8,那會沿着鏈表依次查找。
雖然我們還是無法徹底避免Hash值沖突的問題,但是Hash函數設計合理,仍能保證同義詞鏈表的長度被控制在一個合理的范圍里。這里講的理論知識並非無的放矢,大家能在后文里清晰地了解到重寫hashCode方法的重要性。
舉一個簡單例子
當我們用HashMap存入自定義的類時,如果不重寫這個自定義類的equals和hashCode方法,得到的結果會和我們預期的不一樣。我們來看WithoutHashCode.java這個例子。
在其中的第2到第18行,我們定義了一個Key類;在其中的第3行定義了唯一的一個屬性id。當前我們先注釋掉第9行的equals方法和第16行的hashCode方法。
1 import java.util.HashMap; 2 class Key { 3 private Integer id; 4 public Integer getId() 5 {return id; } 6 public Key(Integer id) 7 {this.id = id; } 8 //故意先注釋掉equals和hashCode方法 9 // public boolean equals(Object o) { 10 // if (o == null || !(o instanceof Key)) 11 // { return false; } 12 // else 13 // { return this.getId().equals(((Key) o).getId());} 14 // } 15 16 // public int hashCode() 17 // { return id.hashCode(); } 18 } 19 20 public class WithoutHashCode { 21 public static void main(String[] args) { 22 Key k1 = new Key(1); 23 Key k2 = new Key(1); 24 HashMap<Key,String> hm = new HashMap<Key,String>(); 25 hm.put(k1, "Key with id is 1"); 26 System.out.println(hm.get(k2)); 27 } 28 }
在main函數里的第22和23行,我們定義了兩個Key對象,它們的id都是1,就好比它們是兩把相同的都能打開同一扇門的鑰匙。
在第24行里,我們通過泛型創建了一個HashMap對象。它的鍵部分可以存放Key類型的對象,值部分可以存儲String類型的對象。
在第25行里,我們通過put方法把k1和一串字符放入到hm里; 而在第26行,我們想用k2去從HashMap里得到值;這就好比我們想用k1這把鑰匙來鎖門,用k2來開門。這是符合邏輯的,但從當前結果看,26行的返回結果不是我們想象中的那個字符串,而是null。
原因有兩個—沒有重寫。第一是沒有重寫hashCode方法,第二是沒有重寫equals方法。
當我們往HashMap里放k1時,首先會調用Key這個類的hashCode方法計算它的hash值,隨后把k1放入hash值所指引的內存位置。
關鍵是我們沒有在Key里定義hashCode方法。這里調用的仍是Object類的hashCode方法(所有的類都是Object的子類),而Object類的hashCode方法返回的hash值其實是k1對象的內存地址(假設是1000)。
如果我們隨后是調用hm.get(k1),那么我們會再次調用hashCode方法(還是返回k1的地址1000),隨后根據得到的hash值,能很快地找到k1。
但我們這里的代碼是hm.get(k2),當我們調用Object類的hashCode方法(因為Key里沒定義)計算k2的hash值時,其實得到的是k2的內存地址(假設是2000)。由於k1和k2是兩個不同的對象,所以它們的內存地址一定不會相同,也就是說它們的hash值一定不同,這就是我們無法用k2的hash值去拿k1的原因。
當我們把第16和17行的hashCode方法的注釋去掉后,會發現它是返回id屬性的hashCode值,這里k1和k2的id都是1,所以它們的hash值是相等的。
我們再來更正一下存k1和取k2的動作。存k1時,是根據它id的hash值,假設這里是100,把k1對象放入到對應的位置。而取k2時,是先計算它的hash值(由於k2的id也是1,這個值也是100),隨后到這個位置去找。
但結果會出乎我們意料:明明100號位置已經有k1,但第26行的輸出結果依然是null。其原因就是沒有重寫Key對象的equals方法。
HashMap是用鏈地址法來處理沖突,也就是說,在100號位置上,有可能存在着多個用鏈表形式存儲的對象。它們通過hashCode方法返回的hash值都是100。
當我們通過k2的hashCode到100號位置查找時,確實會得到k1。但k1有可能僅僅是和k2具有相同的hash值,但未必和k2相等(k1和k2兩把鑰匙未必能開同一扇門),這個時候,就需要調用Key對象的equals方法來判斷兩者是否相等了。
由於我們在Key對象里沒有定義equals方法,系統就不得不調用Object類的equals方法。由於Object的固有方法是根據兩個對象的內存地址來判斷,所以k1和k2一定不會相等,這就是為什么依然在26行通過hm.get(k2)依然得到null的原因。
為什么重寫equals方法一般必須重寫hashcode方法
首先來看下上文提到的integer的源碼
@Override public int hashCode() { return Integer.hashCode(value); } public static int hashCode(int value) { return value; } public boolean equals(Object obj) { if (obj instanceof Integer) { return value == ((Integer)obj).intValue(); } return false; }
如Integer類中equals方法和hashcode方法均被重寫,Integer類中的hashcode方法就是返回它本身的值,equals方法比較的是它本身的值是否相等。
而equals方法必須要滿足以下幾個特性
1.自反性:x.equals(x) == true,自己和自己比較相等
2.對稱性:x.equals(y) == y.equals(x),兩個對象調用equals的的結果應該一樣
3.傳遞性:如果x.equals(y) == true y.equals(z) == true 則 x.equals(z) == true,x和y相等,y和z相等,則x和z相等
4.一致性 : 如果x對象和y對象有成員變量num1和num2,其中重寫的equals方法只有num1參加了運算,則修改num2不影響x.equals(y)的值
而這時如果某個類沒有重寫hashcode方法的話,equals判斷兩個值相等,但是hashcode的值不相等,如String類,這樣就會造成歧義