前言
前面兩節內容我們詳細講解了Hashtable算法和源碼分析,針對散列函數始終逃脫不掉hashCode的計算,本節我們將詳細分析hashCode和equals,同時您將會看到本節內容是從《Effective Java》學習整理而來(吐槽一句,這本書中文版翻譯的真垃圾),對於《Effective Java》這本書很有學習價值,但是我不會像其他童鞋一樣,直接從這本書講解一個系列,我所采用的是學習到對應地方然后參考不同java經典書籍進行總結,循序漸進式這樣效果更佳,好了,我們開始吧。
equals
翻看《Effective Java》關於equals這一節內容,直接拋出重寫equals必須遵守的如下五大約定,當我看到這幾大特性時,頓時驚呆了,這不就是大學線代講解矩陣時的特點么,學以致用原來是這么個道理。
1、自反性:對於非空的對象x,x.equals(x)必須返回true.
2、對稱性:對於非空的對象x和y,若x.equals(y)等於true時,那么y.equals(x)也必須返回true.
3、傳遞性:對於非空的對象x、y和z,如果x.equals(y)和y.equals(z)等於true時,那么x.equals(z)也必須返回true
4、一致性:對於非空的對象x和y,如果利用equals判斷對象的信息沒有被修改時,無論調用多少次,那么x.equals(y)要么為true,要么為false
5、對於非空的對象x,x.equals(null)必須返回false
關於第一點很好理解,非空對象自身引用必須相等,對於第二點書中所給的例子則是將重寫對象比較某個字符串時不區分大小寫,但是字符串對象是區分大小寫,如此這樣將導致對稱不一致問題,對於第三點則是繼承時注意equals的傳遞性,第4點則強調多次調用通過equals判斷的恆等性,最后一點更好理解如若不判斷則會拋出空指針異常。那么我們實際在重寫equals時可將以下幾點作為模板來使用就可以啦。
1、使用“==”判斷兩個對象是否引用相同
2、使用instanceof操作符來檢查參數類型是否相同
3、若類型相同,則將參數轉換為正確的類型
4、比較對象中每個值是否都相等,若全部相等則返回true,否則為false
如上幾點模板來自《Effective Java》對重寫equals的總結,當然我們可以從重寫字符串對象中的equals找到如上影子,字符串對象的equals方法如下:
public boolean equals(Object anObject) { // 判斷對象引用是否相等,相等直接返回 if (this == anObject) { return true; } //判斷對象參數類型是否正確 if (anObject instanceof String) { //若參數類型相同,則轉換為對應的參數類型 String anotherString = (String)anObject; int n = value.length; //比較參數對象中的所有值是否相等 if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } } return false; }
好了,到這里我們講解完了equals,還是比較簡單,那么重寫equals時為何一定要重寫hashCode呢?主要原因在於:這是通用約定,如果是基於散列的集合比較HashMap或者HashSet等,存儲對象地址需要通過散列函數計算hashCode,如若不這樣做將會出現意想不到的問題。那么意想不到的問題是什么呢?
hashCode
下面我們用一個例子來講解為何重寫equals時一定要重寫hashCode。
public class Person { int age; String name; public Person(int age, String name) { this.age = age; this.name = name; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj instanceof Person) { Person p = (Person) obj; return (this.age == p.age && this.name == p.name); } return false; } }
如上我們給出一個Person對象,然后帶有年齡和名稱兩個屬性,重寫時判斷年齡和名稱相等即可認為為同一人,下面我們在控制台進行如下操作,然后我們看看將會打印出什么結果呢。
Person p1 = new Person(12, "Jeffcky"); Person p2 = new Person(12, "Jeffcky"); Hashtable hashtable = new Hashtable(); hashtable.put(p1, "v1"); System.out.println(hashtable.get(p2));
不難理解,因為Hashtable對象存儲地址是基於hashCode,但是上述我們沒有重寫hashCode,所以我們實例化對象p2時,即使重寫了equals兩個對象相等,結果獲取p2的值肯定是獲取不到的,因為hashCode不等,接下來我們重寫hashCode
@Override public int hashCode() { return (31 * Integer.valueOf(this.age).hashCode() + name.hashCode()); }
我們看到字符串對象重寫了hashCode,因為字符串用的很頻繁,同時我們極有可能在散列集合中用到。下面我們來看看字符串對象的hashCode實現方式。
上圖標記出的就是計算字符串的hashCode核心即散列函數,從上看出通過字符串中每一個字符的ASCII碼來計算,同時我們也可再拓展下看源碼數值類型的hashCode就是其本身。上述計算方式最終我們數學進行歸納出計算方法為:
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
比如我們計算字符串【AC】的hashCode,根據如上計算公式則是
65*31^(2-1) + 67*31^(2-2) = 2082
在《Effective Java》中提到之所以選擇31的原因是:它是一個奇素數,如果乘數是偶數,並且乘法溢出的話,信息就會丟失,因為2相乘等價於移位運算。使用素數的好處並不很明顯,但是習慣使用素數來計算散列結果。我嚴重懷疑是不是翻譯的人理解錯了意思,對於書中給出選擇素數的原因無法讓人折服,這里我來講解我個人的想法。
散列函數為什么要使用質數
選擇31的原因是因為它是質數(素數),而不是因為它是奇數。當我們插入一個元素到哈希表中時,哈希如何識別需要將元素存儲在哪個存儲桶中(Bucket)呢?這是一個重要的問題,使得強制性要求哈希能夠在恆定時間內告訴我們將值存儲在哪個存儲桶中,以便能夠快速檢索。我們能想到的是傻瓜式操作方式即循環遍歷比較,這種順序搜索將直接導致哈希性能惡化,直接取決哈希表所包含值的數量。換句話說,這將具有線性性能成本(O(N)),隨着鍵(N)的數量越來越大,性能可想而知。另一個復雜之處是我們要處理的值的實際類型。若我們要處理字符串和其他復雜類型,檢查或比較本身的數量將導致成本又將變得很高。基於以上敘述,所以我們至少需要解決兩個問題,其一是便於快速檢索而非順序檢索,其二是解決復雜類型值的比較。解決此問題的簡單方法是希望出現一種將復雜值分解為易於使用的鍵或哈希的方法,實現此過程的最簡單方法是生成唯一編號,該數字必須是唯一的,因為我們要區分一個值和另一個值。質數是唯一數字,它們的獨特之處在於,由於使用了素數來構成素數,因此素數與任何其他數字的乘積具有的最大可能的唯一性(不像素數本身那樣唯一),質數的此屬性在哈希函數中使用可減少沖突次數(或碰撞)。例如使用4 * 8,則它比諸如3 * 5的質數乘積更有可能發生沖突,32可以通過1 * 32或2 * 16或4 * 8或2 ^ 5等計算得到,但3*5 只能以1 * 15或3 * 5得到15。
總結
本文我們詳細討論了hashCode和equals,以及分析了在散列函數中使用質數的原因,這里還存在一節內容留到學習虛擬機時再補上,通過分析虛擬機源碼了解hashCode具體實現,下一節我們將進入學習分析HashMap源碼,感謝您的閱讀,我們下節見。