問題:兩個物件值相同(x.equals(y) == true),但是可能存在hashCode不同嗎?

跟著Mic學架構發表於2021-10-28

面試官的考察點

這道題仍然是考察JVM層面的基本知識,面試官認為,基本功紮實,才能寫出健壯性和穩定性很高的程式碼。

涉及到的技術知識

(x.equals(y)==true),這段程式碼,看起來非常簡單,但其實裡面還是涉及了一些底層知識點的,首先我們基於equals這個方法進行探索。

equals這個方法,在每個物件中都存在,以String型別為例,其方法定義如下

public boolean equals(Object anObject) {
  if (this == anObject) { 
    return true;
  }
  if (anObject instanceof String) { //判斷物件例項是否是String
    String anotherString = (String)anObject; //強轉成string型別
    int n = value.length;
    if (n == anotherString.value.length) { //如果兩個字串相等,那麼它們的長度自然相等。
      //遍歷兩個比較的字串,轉換為char型別逐個進行比較。
      char v1[] = value;  
      char v2[] = anotherString.value;
      int i = 0;
      while (n-- != 0) {
        if (v1[i] != v2[i]) //採用`==`進行判斷,如果不相同,則返回false
          return false;
        i++;
      }
      return true; //否則返回true。
    }
  }
  return false;
}

首先來分析第一段程式碼,判斷傳遞進來的這個物件和當前物件例項this是否相等,如果相等則返回true

  if (this == anObject) { 
    return true;
  }

==號的處理邏輯是怎麼實現的呢?

瞭解==判斷

在java語言中==操作符號,這個比較大家都知道,是基於引用物件的比較,具體其實還有一些其他的區別。

JVM會根據==兩邊相互比較的操作型別不同,在編譯時生成不同的指令。

  1. 對於boolean,byte、short、int、long這種整形運算元,會生成if_icmpne指令,該指令用於比較整形數值是否相等。關於if_icmpne指令可以參見:Chapter 4. The class File Format,它在Hotspot VM中的bytecodeInterpreter原始碼中的具體實現如下

    #define COMPARISON_OP(name, comparison)                                      
    CASE(_if_icmp##name): {                                                
      int skip = (STACK_INT(-2) comparison STACK_INT(-1))                
        ? (int16_t)Bytes::get_Java_u2(pc + 1) : 3;             
      address branch_pc = pc;                                            
      UPDATE_PC_AND_TOS(skip, -2);                                       
      DO_BACKEDGE_CHECKS(skip, branch_pc);                               
      CONTINUE;                                                          
    }
    

    可以看到實質是按照comparison表示式比較運算元棧中偏移量為-1和-2的兩個INT值。

  2. 如果運算元是物件的話,編譯器則會生成if_acmpne指令,與if_icmpne相比將i(int)改成了a(object reference)。這個指令在JVM規範中的表述:Chapter 4. The class File Format,它在Hotspot VM中相應的實現可參考:

    COMPARISON_OP(name, comparison)                                        
      CASE(_if_acmp##name): {                                                
      int skip = (STACK_OBJECT(-2) comparison STACK_OBJECT(-1))          
        ? (int16_t)Bytes::get_Java_u2(pc + 1) : 3;            
      address branch_pc = pc;                                            
      UPDATE_PC_AND_TOS(skip, -2);                                       
      DO_BACKEDGE_CHECKS(skip, branch_pc);                               
      CONTINUE;                                                          
    }
    

    STACK_OBJECT(-2) comparison STACK_OBJECT(-1)這一句即可看出比較的其實是運算元棧上兩個物件在堆中的指標。

對於JVM有一定了解得同學,必然知道((x==y)=true)這個判斷,如果xy的記憶體地址相同,那麼意味著就是同一個物件,因此直接返回true

因此從上面的分析中,得到的結論是,==判斷,比較的是兩個物件的記憶體地址,如果==返回true,說明記憶體地址相同。

String.equals原始碼

繼續分析equals中的原始碼,剩餘部分原始碼的實現邏輯是

  1. 比較兩個字串的長度是否相等,如果不相等,直接返回false
  2. 把兩個String型別轉換為char[]陣列,並且按照陣列順序逐步比較每一個char字元,如果不相等,同樣返回false
public boolean equals(Object anObject) {
  //省略
  if (anObject instanceof String) { //判斷物件例項是否是String
    String anotherString = (String)anObject; //強轉成string型別
    int n = value.length;
    if (n == anotherString.value.length) { //如果兩個字串相等,那麼它們的長度自然相等。
      //遍歷兩個比較的字串,轉換為char型別逐個進行比較。
      char v1[] = value;  
      char v2[] = anotherString.value;
      int i = 0;
      while (n-- != 0) {
        if (v1[i] != v2[i]) //採用`==`進行判斷,如果不相同,則返回false
          return false;
        i++;
      }
      return true; //否則返回true。
    }
  }
  return false;
}

==和equals

通過上面的分析,我們知道,在 Java 中比較兩個物件是否相等主要是通過 ==號,比較的是他們在記憶體中的存放地址。Object 類是 Java 中的超類,是所有類預設繼承的,如果一個類沒有重寫 Object 的 equals方法,那麼通過equals方法也可以判斷兩個物件是否相同,因為它內部就是通過==來實現的。

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

這裡的相同,是說比較的兩個物件是否是同一個物件,即在記憶體中的地址是否相等。而我們有時候需要比較兩個物件的內容是否相同,即類具有自己特有的“邏輯相等”概念,而不是想了解它們是否指向同一個物件。

例如比較如下兩個字串是否相同String a = "Hello"String b = new String("Hello"),這裡的相同有兩種情形,是要比較 a 和 b 是否是同一個物件(記憶體地址是否相同),還是比較它們的內容是否相等?這個具體需要怎麼區分呢?

如果使用 == 那麼就是比較它們在記憶體中是否是同一個物件,但是 String 物件的預設父類也是 Object,所以預設的equals方法比較的也是記憶體地址,所以我們要重寫 equals方法,正如 String 原始碼中所寫的那樣。

  1. 先比較記憶體地址
  2. 再比較value值
public boolean equals(Object anObject) {
  //省略
  if (anObject instanceof String) { //判斷物件例項是否是String
    String anotherString = (String)anObject; //強轉成string型別
    int n = value.length;
    if (n == anotherString.value.length) { //如果兩個字串相等,那麼它們的長度自然相等。
      //遍歷兩個比較的字串,轉換為char型別逐個進行比較。
      char v1[] = value;  
      char v2[] = anotherString.value;
      int i = 0;
      while (n-- != 0) {
        if (v1[i] != v2[i]) //採用`==`進行判斷,如果不相同,則返回false
          return false;
        i++;
      }
      return true; //否則返回true。
    }
  }
  return false;
}

這樣當我們 a == b時是判斷 a 和 b 是否是同一個物件,a.equals(b)則是比較 a 和 b 的內容是否相同,這應該很好理解。

JDK 中不止 String 類重寫了equals 方法,還有資料型別 Integer,Long,Double,Float等基本也都重寫了 equals 方法。所以我們在程式碼中用 Long 或者 Integer 做業務引數的時候,如果要比較它們是否相等,記得需要使用 equals 方法,而不要使用 ==

因為使用 ==號會有意想不到的坑出現,像這種資料型別很多都會在內部封裝一個常量池,例如 IntegerCache,LongCache 等等。當資料值在某個範圍內時會直接從常量池中獲取而不會去新建物件。

如果要使用==,可以將這些資料包裝型別轉換為基本型別之後,再通過==來比較,因為基本型別通過==比較的是數值,但是在轉換的過程中需要注意 NPE(NullPointException)的發生。

瞭解Class中的HashCode

回過頭再看一下面試題: 兩個物件值相同(x.equals(y) == true),但是可能存在hash code不同嗎?

這個結果返回true,假設xy是String型別,意味著它滿足兩個點。

  1. xy有可能是同一個記憶體地址。
  2. xy這兩個字串的值是相同的。

基於這兩個推斷,我們還沒辦法和hash code聯絡上,也就是說,這兩個物件的引用地址相同,和hashCode是否有關係?

在Java中,任何一個物件都是派生自Object,而在Object中,有一個native方法hashCode()

public native int hashCode();

為什麼要有hashCode?

對於包含容器結構的程式語言來說,基本上都會涉及到hashCode,它的主要作用是為了配合基於雜湊的集合一起工作,比如HashSet、HashTable、ConcurrentHashMap、HashMap等。

在這類的集合中新增元素時,首先需要判斷新增的元素是否存在(不允許存在重複元素),也許大多數人都會想到呼叫equals方法來逐個進行比較,這個方法確實可行。但是如果集合中已經存在一萬條資料或者更多的資料,如果採用equals方法去逐一遍歷每個元素的值進行比較,效率會非常低。

此時hashCode方法的作用就體現出來了,當集合要新增新的物件時,先呼叫這個物件的hashCode方法,得到對應的hashcode值,實際上在HashMap的具體實現中會用一個table儲存已經存進去的物件的hashcode值:

  1. 如果table中沒有該hashcode值,它就可以直接存進去,不用再進行任何比較了;

  2. 如果存在該hashcode值, 就呼叫它的equals方法與新元素進行比較,相同的話就不存了,不相同就雜湊其它的地址,所以這裡存在一個衝突解決的問題,這樣一來實際呼叫equals方法的次數就大大降低了.

說通俗一點:Java中的hashCode方法就是根據一定的規則將與物件相關的資訊(比如物件的儲存地址,物件的欄位等)對映成一個數值,這個數值稱作為雜湊值。下面這段程式碼是java.util.HashMap的中put方法的具體實現:

 public V put(K key, V value) {
   if (key == null)
     return putForNullKey(value);
   int hash = hash(key.hashCode());
   int i = indexFor(hash, table.length);
   for (Entry<K,V> e = table[i]; e != null; e = e.next) {
     Object k;
     if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
       V oldValue = e.value;
       e.value = value;
       e.recordAccess(this);
       return oldValue;
     }
   }
 
   modCount++;
   addEntry(hash, key, value, i);
   return null;
 }

所以通過hashCode,減少了查詢比較的次數,優化了查詢的效率同時也就減少了查詢的時間。

hashCode方法的實現

下面是hashCode這個方法的完整註釋說明。

 /**
     * Returns a hash code value for the object. This method is
     * supported for the benefit of hash tables such as those provided by
     * {@link java.util.HashMap}.
     * <p>
     * The general contract of {@code hashCode} is:
     * <ul>
     * <li>Whenever it is invoked on the same object more than once during
     *     an execution of a Java application, the {@code hashCode} method
     *     must consistently return the same integer, provided no information
     *     used in {@code equals} comparisons on the object is modified.
     *     This integer need not remain consistent from one execution of an
     *     application to another execution of the same application.
     * <li>If two objects are equal according to the {@code equals(Object)}
     *     method, then calling the {@code hashCode} method on each of
     *     the two objects must produce the same integer result.
     * <li>It is <em>not</em> required that if two objects are unequal
     *     according to the {@link java.lang.Object#equals(java.lang.Object)}
     *     method, then calling the {@code hashCode} method on each of the
     *     two objects must produce distinct integer results.  However, the
     *     programmer should be aware that producing distinct integer results
     *     for unequal objects may improve the performance of hash tables.
     * </ul>
     * <p>
     * As much as is reasonably practical, the hashCode method defined by
     * class {@code Object} does return distinct integers for distinct
     * objects. (This is typically implemented by converting the internal
     * address of the object into an integer, but this implementation
     * technique is not required by the
     * Java&trade; programming language.)
     *
     * @return  a hash code value for this object.
     * @see     java.lang.Object#equals(java.lang.Object)
     * @see     java.lang.System#identityHashCode
     */
public native int hashCode();

從註釋的描述可以知道,hashCode 方法返回該物件的雜湊碼值。它可以為像 HashMap 這樣的雜湊表有益。Object 類中定義的 hashCode 方法為不同的物件返回不同的整形值。具有迷惑異議的地方就是
This is typically implemented by converting the internal address of the object into an integer
這一句,意為通常情況下實現的方式是將物件的內部地址轉換為整形值。

如果你不深究就會認為它返回的就是物件的記憶體地址,我們可以繼續看看它的實現,但是因為這裡是 native 方法所以我們沒辦法直接在這裡看到內部是如何實現的。native 方法本身非 java 實現,如果想要看原始碼,只有下載完整的 jdk 原始碼,Oracle 的 JDK 是看不到的,OpenJDK 或其他開源 JRE 是可以找到對應的 C/C++ 程式碼。我們在 OpenJDK 中找到 Object.c 檔案,可以看到hashCode 方法指向 JVM_IHashCode 方法來處理。

static JNINativeMethod methods[] = {
    {"hashCode",    "()I",                    (void *)&JVM_IHashCode},
    {"wait",        "(J)V",                   (void *)&JVM_MonitorWait},
    {"notify",      "()V",                    (void *)&JVM_MonitorNotify},
    {"notifyAll",   "()V",                    (void *)&JVM_MonitorNotifyAll},
    {"clone",       "()Ljava/lang/Object;",   (void *)&JVM_Clone},
};

JVM_IHashCode方法實現在 jvm.cpp中的定義為:

JVM_ENTRY(jint, JVM_IHashCode(JNIEnv* env, jobject handle))  
  JVMWrapper("JVM_IHashCode");  
  // as implemented in the classic virtual machine; return 0 if object is NULL  
  return handle == NULL ? 0 : ObjectSynchronizer::FastHashCode (THREAD, JNIHandles::resolve_non_null(handle)) ;  
JVM_END 

這裡是一個三目表示式,真正計算獲得 hashCode 值的是ObjectSynchronizer::FastHashCode,它具體的實現在synchronizer.cpp中,擷取部分關鍵程式碼片段。

intptr_t ObjectSynchronizer::FastHashCode (Thread * Self, oop obj) {
  if (UseBiasedLocking) {
  
  //省略程式碼片段
  
  // Inflate the monitor to set hash code
  monitor = ObjectSynchronizer::inflate(Self, obj);
  // Load displaced header and check it has hash code
  mark = monitor->header();
  assert (mark->is_neutral(), "invariant") ;
  hash = mark->hash();
  if (hash == 0) {
    hash = get_next_hash(Self, obj);
    temp = mark->copy_set_hash(hash); // merge hash code into header
    assert (temp->is_neutral(), "invariant") ;
    test = (markOop) Atomic::cmpxchg_ptr(temp, monitor, mark);
    if (test != mark) {
      // The only update to the header in the monitor (outside GC)
      // is install the hash code. If someone add new usage of
      // displaced header, please update this code
      hash = test->hash();
      assert (test->is_neutral(), "invariant") ;
      assert (hash != 0, "Trivial unexpected object/monitor header usage.");
    }
  }
  // We finally get the hash
  return hash;
}

從以上程式碼片段中可以發現,實際計算hashCode的是 get_next_hash,該方法的部分程式碼定義如下。

static inline intptr_t get_next_hash(Thread * Self, oop obj) {
  intptr_t value = 0 ;
  if (hashCode == 0) {
     // This form uses an unguarded global Park-Miller RNG,
     // so it's possible for two threads to race and generate the same RNG.
     // On MP system we'll have lots of RW access to a global, so the
     // mechanism induces lots of coherency traffic.
     value = os::random() ;
  } else
  if (hashCode == 1) {
     // This variation has the property of being stable (idempotent)
     // between STW operations.  This can be useful in some of the 1-0
     // synchronization schemes.
     intptr_t addrBits = cast_from_oop<intptr_t>(obj) >> 3 ;
     value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ;
  } else
  if (hashCode == 2) {
     value = 1 ;            // for sensitivity testing
  } else
  if (hashCode == 3) {
     value = ++GVars.hcSequence ;
  } else
  if (hashCode == 4) {
     value = cast_from_oop<intptr_t>(obj) ;
  } else {
     // Marsaglia's xor-shift scheme with thread-specific state
     // This is probably the best overall implementation -- we'll
     // likely make this the default in future releases.
     unsigned t = Self->_hashStateX ;
     t ^= (t << 11) ;
     Self->_hashStateX = Self->_hashStateY ;
     Self->_hashStateY = Self->_hashStateZ ;
     Self->_hashStateZ = Self->_hashStateW ;
     unsigned v = Self->_hashStateW ;
     v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
     Self->_hashStateW = v ;
     value = v ;
  }

  value &= markOopDesc::hash_mask;
  if (value == 0) value = 0xBAD ;
  assert (value != markOopDesc::no_hash, "invariant") ;
  TEVENT (hashCode: GENERATE) ;
  return value;
}

get_next_hash的方法中我們可以看到,如果從0開始算的話,這裡提供了6種計算hash值的方案,有自增序列,隨機數,關聯記憶體地址等多種方式,其中官方預設的是最後一種,即隨機數生成。可以看出hashCode也許和記憶體地址有關係,但不是直接代表記憶體地址的,具體需要看虛擬機器版本和設定。

上面的整體描述還是比較複雜,直接說結論:

  1. 一個物件的hashCode,預設情況下如果沒有重寫,則由JVM中的get_next_hash方法來生成,這個生成的方式不一定和記憶體地址有關,預設是用隨機數生成。兩個不同的物件,他們生成的hashCode可能會相同,如果存在這個問題,就是所謂的hash衝突,在HashMap中,解決hash衝突的方法是鏈式定址法。

  2. 使用==這個表示式判斷,如果返回true,意味著兩個物件的hashCode一定相同。

問題解答

問題:兩個物件值相同(x.equals(y) == true),但是可能存在hashCode不同嗎?

基於上面背景知識的梳理,再來回答這個問題,就有思路了。

理論情況下,x.equals(y)==true,如果沒有重寫equals這個方法,這兩個物件的記憶體地址是是相同的,也就意味著hashCode必然也相等。

那有沒有可能hashCode不同呢? 如果一定要做,也是可以實現的,我們來看下面這個例子。

public class App 
{
    public static void main( String[] args ) {
        A a = new A();
        B b = new B();
        System.out.println(a.equals(b));
        System.out.println(a.hashCode() + "," + b.hashCode());
    }
}
class A {
    @Override
    public boolean equals(Object obj) {
        return true;
    }
}

class B {
}

執行結果如下

true
692404036,1554874502

從結果可以看到,equals返回true,但是hashCode不同。

雖然我們模擬了這個可能性,但是原則上是錯誤的,因為這樣違反了hashCode的通用規定,可能會導致該類無法結合所有基於雜湊集合一起正常工作,比如HashMap、HashSet等。

public class App {
    public static void main( String[] args ) {
        Person p1=new Person("mic",18);
        Person p2=new Person("mic",18);

        HashMap<Person,String> hashMap=new HashMap<>();
        hashMap.put(p1,"mic");
        System.out.println(hashMap.get(p2));
    }
}
class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

   //省略getter/setter

    @Override
    public boolean equals(Object obj) {
        if(this==obj){
            return true;
        }
        if(obj instanceof Person){
            if(this.getName()==((Person) obj).getName()&&this.getAge()==((Person) obj).getAge()){
                return true;
            }
        }
        return false;
    }
}

在上述程式碼中,重寫了equals方法,但是沒有重寫hashCode方法,當呼叫Person類的hashCodo方法時,預設就是呼叫父類Object的hashCode方法,根據隨機數返回一個整數值。在equals方法中,我們是根據nameage進行判斷兩個物件是否相等。

main方法中構建了兩個物件p1p2,我們用HashMap儲存儲存,將物件作為key。把p1存入到hashMap中,再通過p2來獲取,在原則上,由於p1和p2相等,所以理論上是能夠拿到結果的,但是實際執行結果如下:

null

Process finished with exit code 0

熟知`HashMap 原理的同學應該知道,HashMap 是由陣列 + 連結串列的結構組成,這樣的結果就是因為它們 hashCode 不相等,所以放在了陣列的不同下標,當我們根據 Key 去查詢的時候結果就為 null。

得到的結果我們肯定不滿意,這裡的 p1p2 雖然記憶體地址不同,但是它們的邏輯內容相同,我們認為它們應該是相同的。

為了避免這類問題的存在,所以約定了一條原則重寫equals方法的同時也需要重寫hashCode方法。這時一種通用約定,這個約定包含以下幾個方面。

  • 方法都必須始終返回同一個值。
  • 如果兩個物件根據equals方法比較是相等的,那麼呼叫這兩個物件中的hashCode方法都必須產生同樣的整數結果。
  • 如果兩個物件根據equals方法比較是不相等的,那麼呼叫者兩個物件中的hashCode方法,則不一定要求hashCode方法必須產生不同的結果。但是給不相等的物件產生不同的整數雜湊值,是有可能提高雜湊表(hash table)的效能。

從理論上來說如果重寫了equals方法而沒有重寫hashCode方法則違背了上述約定的第二條,相等的物件必須擁有相等的雜湊值。但是規則是大家默契的約定,如果我們就喜歡不走尋常路,在重寫了 equals方法後沒有覆蓋hashCode方法,就會造成嚴重的後果。

問題總結

綜合分析下來,對於該問題的正確解答如下

  1. 如果兩個物件值相同,有可能存在不同的hashCode。具體的實現方法是,只重寫equals方法,不重寫hashCode
  2. 這種處理方式會存在風險,在實際開發中,必須遵循重寫equals方法的同時也需要重寫hashCode方法這一原則。否則在Java雜湊集合類操作中,會存在null的問題。

關注[跟著Mic學架構]公眾號,獲取更多精品原創

相關文章