搞懂 Java equals 和 hashCode 方法

像一隻狗發表於2018-04-04

搞懂 Java equals 和 hashCode 方法

分析完 Java List 容器的原始碼後,本來想直接進入 Set 和 Map 容器的原始碼分析,但是對於這兩種容器,內部儲存元素的方式的都是以鍵值對相關的,而元素如何存放,便與 equalshashCode 這兩個方法密切相關。所以在分析 Map 家族之前,需要深入瞭解下這兩個方法,而且這兩個方法在面試的時候也屬於極有可能考察的問題。

跟往常一樣,本文也儘可能結合面試題來重點講解下 equals 和 hashCode 的使用以及意義。

概述

首先 equalshashCode 兩個方法屬於 Object 基類的方法:

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

public native int hashCode();

複製程式碼

可以看出 equals 方法預設比較的是兩個物件的引用是否指向同一個記憶體地址。而 hashCode 這是一個 native 本地方法,其實預設的 hashCode 方法返回的就是物件對應的記憶體地址。

hasCode 方法的註釋這樣說的: This is typically implemented by converting the internal address of the object into an integer,

這一點我們通過 toString 方法也可以間接瞭解,我們都知道 toString 返回的是「類名@十六進位制記憶體地址」,由原始碼可以看出記憶體地址與 hashCode() 返回值相同。

public String toString() {
   return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
複製程式碼

面試題目: hashCode 方法返回的是物件的記憶體地址麼? 答: Object 基類的 hashCode 方法預設返回物件的記憶體地址,但是在一些場景下我們需要覆寫 hashCode 函式,比如需要使用 Map 來存放物件的時候,覆寫後 hashCode 就不是物件的記憶體地址了。

equals 詳解

equals 方法既然是基類 Object 的方法,我們建立的所有的物件都擁有這個方法,並有權利去重寫這個方法。該方法返回一個 boolean 值,代表比較的兩個物件是否相同,這裡的相同的條件由重寫 equals 方法的類來解決。比如我們都知道 :

String str1 = "abc";
String str2 = "abc";
str1.equals(str2);//true
複製程式碼

顯然 String 類一定重寫了 equals 方法否則兩個 String 物件記憶體地址肯定不同。我們簡單看下 String 類的 equals 方法:

 public boolean equals(Object anObject) {
   //首先判斷兩個物件的記憶體地址是否相同
   if (this == anObject) {
       return true;
   }
   // 判斷連個物件是否屬於同一型別。
   if (anObject instanceof String) {
       String anotherString = (String)anObject;
       int n = value.length;
       //長度相同的情況下逐一比較 char 陣列中的每個元素是否相同
       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 方法已經不單單是呼叫 this==obj來判斷物件是否相同了。事實上所有 Java 定義好的一些現有的引用資料型別都重寫了該方法。當我們自己定義引用資料型別的時候我們應該依照什麼原則去判定兩個物件是否相同,這就需要我們自己來根據業務需求來把握。但是我們都需要遵循以下規則:

  • 自反性(reflexive)。對於任意不為 null 的引用值 x,x.equals(x) 一定是 true。

  • 對稱性(symmetric)。對於任意不為 null 的引用值 x 和 y ,當且僅當x.equals(y)是 true 時,y.equals(x)也是true。

  • 傳遞性(transitive)。對於任意不為 null 的引用值x、y和z,如果 x.equals(y) 是 true,同時 y.equals(z) 是 true,那麼x.equals(z)一定是 true。

  • 一致性(consistent)。對於任意不為null的引用值x和y,如果用於equals比較的物件資訊沒有被修改的話,多次呼叫時 x.equals(y) 要麼一致地返回 true 要麼一致地返回 false。

  • 對於任意不為 null 的引用值 x,x.equals(null) 返回 false。

equals vs ==

說到 equals 怎麼能不說 == ,其實兩個在初學 Java 的時候給新手還是帶來了蠻多困惑的。對於這兩個的區別需要看比較的物件是什麼樣的型別。

我們都知道 Java 資料型別可分為 基本資料型別 和 引用資料型別。基本資料型別包括 byte, short, int , long , float , double , boolen ,char 八種。對於基本資料型別 == 操作符判斷的是左右兩邊變數的值:

int a = 10;
int b = 10;
float c = 10.0f;
//以下輸出結果均為 true
System.out.println("(a == b) = " + (a == b));
System.out.println("(b == c) = " + (b == c));
複製程式碼

而對於引用資料型別 == 操作符判斷就是等號兩邊的指向的物件的記憶體地址是否相同。也就是說通過 == 判斷的兩個引用資料型別變數,如果相同,則他們指向的肯定是同一個物件。

EntryClass entryClass1 = new EntryClass(1);
EntryClass entryClass2 = new EntryClass(1);
EntryClass entryClass3 = entryClass1;
 
 // (entryClass1 == entryClass2) = false   
System.out.println(" (entryClass1 == entryClass2) = " + (entryClass1 == entryClass2));
// (entryClass1 == entryClass3) = true
System.out.println(" (entryClass1 == entryClass3) = " + (entryClass1 == entryClass3));
複製程式碼

equals 與 == 操作符的區別總結如下:

  1. 若 == 兩側都是基本資料型別,則判斷的是左右兩邊運算元據的值是否相等

  2. 若 == 兩側都是引用資料型別,則判斷的是左右兩邊運算元的記憶體地址是否相同。若此時返回 true , 則該操作符作用的一定是同一個物件。

  3. Object 基類的 equals 預設比較兩個物件的記憶體地址,在構建的物件沒有重寫 equals 方法的時候,與 == 操作符比較的結果相同。

  4. equals 用於比較引用資料型別是否相等。在滿足equals 判斷規則的前體系,兩個物件只要規定的屬性相同我們就認為兩個物件是相同的。

hashCode 方法

hashCode 方法並沒有 equals 方法使用的那麼頻繁,說道 hashCode 方法就不得不結合 Java 的 Map 容器,類似於 HashMap 這種使用了雜湊演算法容器會根據物件的hashCode返回值來初步確定物件在容器中的位置,然後內部再根據一定的 hash 演算法來實現元素的存取。

hash 法簡介

hash 演算法,又被成為雜湊演算法,基本上,雜湊演算法就是將物件本身的鍵值,通過特定的數學函式運算或者使用其他方法,轉化成相應的資料儲存地址的。而雜湊法所使用的數學函式就被稱為 『雜湊函式』又可以稱之為雜湊函式。

說了這麼多定義的東西,那這個 hash 演算法究竟是幹什麼用的呢 ?我們可以通過一個例子來說明:

如果我們要在存放了的元素{0,4,6,9,28} 的陣列中找到數值等於 6 的值的索引我們會怎麼做?我們是不是需要遍歷一遍陣列才能拿到對應的索引。在陣列較大的時候這往往是低效率的。

如果我們能在陣列存放的時候就按一定的規則放入元素,在我們想找某個元素的時候在根據之前定好的規則,就可以很快的得到我們想要的結果了。換句話說之前我們在陣列中存放元素的順序可能是依照新增順序進行的,但是如果我們是按照一種既定的數學函式運算得到要放入元素的值,和陣列角標的對映關係的話。那麼我們在想取某個值的元素的時候就使用對映關係就可以找到對應的角標了。

在常見的 hash 函式中有一種最簡單的方法交「除留餘數法」,操作方法就是將要存入資料除以某個常數後,使用餘數作為索引值。 下面看個例子:

將 323 ,458 ,25 ,340 ,28 ,969, 77 使用「除留餘數法」儲存在長度為11的陣列中。我們假設上邊說的某個常數即為陣列長度11。 每個數除以11以後存放的位置如下圖所示:

搞懂 Java equals 和 hashCode 方法

試想一下我們現在想要拿到 77 在陣列中的位置,是不是隻需要 arr[77%11] = 77 就可以了。

但是上述簡單的 hash 演算法,缺點也是很明顯的,比如 77 和 88 對 11 取餘數得到的值都是 0,但是角標為 0 位置已經存放了 77 這個資料,那88就不知道該去哪裡了。上述現象在雜湊法中有個名詞叫碰撞:

碰撞:若兩個不同的資料經過相同雜湊函式運算後,得到相同的結果,那麼這種現象就做碰撞。

於是在設計 hash 函式的時候我們就要儘可能做到:

  1. 降低碰撞的可能性
  2. 儘量將要存入的元素經過 hash 函式運算後的結果,儘量能夠均勻的分佈在指定的容器(我們在稱之為桶)。

hashCode 方法 與 hash 演算法的關係

其實 Java 中的有所的物件又擁有 hashCode 方法其實就是一種 hash 演算法,只是有的類覆寫好提供給我們了,有些就需要我們手動去覆寫。比如我們可以看一下 String 提供給我們的 hashCode 演算法:

public int hashCode() {
   int h = hash;//預設是0
   if (h == 0 && value.length > 0) {
       char val[] = value;
        // 字串轉化的 char 陣列中每一個元素都參與運算
       for (int i = 0; i < value.length; i++) {
           h = 31 * h + val[i];
       }
       hash = h;
   }
   return h;
}
複製程式碼

前文說了 hashCode 方法與 java 中使用雜湊表的集合類息息相關,我們拿 Set 來舉例,我們都知道 Set 中是不允許存放重複的元素的。那麼我們憑藉什麼來判斷已有的 Set 集合中是否有何要存入的元素重複的元素呢?有人可能會說我們可以通過 equals 來判斷兩個元素是否相同。那麼問題又來,如果 Set 中已經有 10000個元素了,那麼之後在存入一個元素豈不是要呼叫 10000 次 equals 方法。顯然這不合理,效能低到令人髮指。那要怎麼辦才能保證即高效又不重複呢?答案就在於 hashCode 這個函式。

經過之前的分析我們知道 hash 演算法是使用特定的運算來得到資料的儲存位置的,那麼 hashCode 方法就充當了這個特定的函式運算。這裡我們可以簡單認為呼叫 hashCode 方法後得到數值就是元素的儲存位置(其實集合內部還做了進一步的運算,以保證儘可能的均勻分佈在桶內)。

當 Set 需要存放一個元素的時候,首先會呼叫 hashCode 方法去檢視對應的地址上有沒有存放元素,如果沒有則表示 Set 中肯定沒有相同的元素,直接存放在對應位置就好,但是如果 hashCode 的結果相同,即發生了碰撞,那麼我們在進一步呼叫該位置元素的 equals 方法與要存放的元素進行比較,如果相同就不存了,如果不相同就需要進一步雜湊其它的地址。這樣我們就可以儘可能高效的保證了無重複元素的方法。

面試題: hashCode 方法的作用和意義 答: 在 Java 中 hashCode 的存在主要是用於提高容器查詢和儲存的快捷性,如 HashSet, Hashtable,HashMap 等,hashCode是用來在雜湊儲存結構中確定物件的儲存地址的,

hashCode 和 equals 方法的關係

翻看Object 類對於 equals 方法的註釋上有這這麼一條:

請注意,當這個方法被重寫時,通常需要覆蓋{@code hashCode}方法,以便維護{@code hashCode}方法的一般契約,該方法宣告相等物件必須具有相等的雜湊碼.

可以看到如果我們出於某種原因複寫了 equals 方法我們需要按照約定去覆寫 hashCode 方法,並且使用 equals 比較相同的物件,必須擁有相等的雜湊碼。

Object 對於 hashCode 方法也有幾條要求:

  1. 在 Java 應用程式執行期間,在對同一物件多次呼叫 hashCode 方法時,必須一致地返回相同的整數,前提是將物件進行 equals 比較時所用的資訊沒有被修改。從某一應用程式的一次執行到同一應用程式的另一次執行,該整數無需保持一致。
  2. 如果根據 equals(Object) 方法,兩個物件是相等的,那麼對這兩個物件中的每個物件呼叫 hashCode 方法都必須生成相同的整數結果。
  1. 如果根據 equals(java.lang.Object) 方法,兩個物件不相等,那麼對這兩個物件中的任一物件上呼叫 hashCode 方法 不要求 一定生成不同的整數結果。但是,程式設計師應該意識到,為不相等的物件生成不同整數結果可以提高雜湊表的效能。  

結合 equals 方法的,我們可以做出如下總結:

  1. 呼叫 equals 返回 true 的兩個物件必須具有相等的雜湊碼。

  2. 如果兩個物件的 hashCode 返回值相同,呼叫它們 equals 方法不一返回 true 。

我們先來看下第一個結論:呼叫 equals 返回 true 的兩個物件必須具有相等的雜湊碼。為什麼這麼要求呢?比如我們還拿 Set 集合舉例,Set 首先會呼叫物件的 hashCode 方法尋找物件的儲存位置,如果兩個相同的物件呼叫 hashCode 方法得到的結果不同,那麼造成的後果就是 Set 中儲存了相同的元素,而這樣的結果肯定是不對的。所以就要求 呼叫 equals 返回 true 的兩個物件必須具有相等的雜湊碼

那麼第二條為什麼 hashCode 返回值相同,兩個物件卻不一定相同呢?這是因為,目前沒有完美的 hash 演算法能夠完全的避免 「雜湊碰撞」,既然碰撞是無法完全避免的所以兩個不相同的物件總有可能得到相同的雜湊值。所以我們只能儘可能的保證不同的物件的 hashCode 不相同。事實上,對於 HashMap 在儲存鍵值對的時候,就會發生這樣的情況,在 JDK 1.7 之前,HashMap 對鍵的雜湊值碰撞的處理方式,就是使用所謂的‘拉鍊法’。 具體實現會在之後分析 HashMap 的時候說到。

總結

本文總結了 equals 方法和 hashCode 方法的作用和意義。並學習了在覆寫這兩個方法的時候需要注意的要求。需要注意的是,關於這兩個方法在面試的時候還是很有可能被問及的所以,我們至少要明白:

  1. hashCode 返回值不一定物件的儲存地址,比如發生雜湊碰撞的時候。
  2. 呼叫 equals 返回 true 的兩個物件必須具有相等的雜湊碼。
  3. 如果兩個物件的 hashCode 返回值相同,呼叫它們 equals 方法不一返回 true 。

相關文章