天天看點

對Java中HashCode方法的深入思考

前言

最近在學習 Go 語言,Go 語言中有指針對象,一個指針變量指向了一個值的記憶體位址。學習過 C 語言的猿友應該都知道指針的概念。Go 語言文法與 C 相近,可以說是類 C 的程式設計語言,是以 Go 語言中有指針也是很正常的。我們可以通過将取位址符

&

放在一個變量前使用就會得到相應變量的記憶體位址。

package main

import "fmt"

func main() {
   var a int= 20   /* 聲明實際變量 */
   var ip *int        /* 聲明指針變量 */

   ip = &a  /* 指針變量的存儲位址 */
   fmt.Printf("a 變量的位址是: %x\n", &a  )

   /* 指針變量的存儲位址 */
   fmt.Printf("ip 變量儲存的指針位址: %x\n", ip )
   /* 使用指針通路值 */
   fmt.Printf("*ip 變量的值: %d\n", *ip )
}           

因為本人主要開發語言是 Java,是以我就聯想到 Java 中沒有指針,那麼 Java 中如何擷取變量的記憶體位址呢?

如果能擷取變量的記憶體位址那麼就可以清晰的知道兩個對象是否是同一個對象,如果兩個對象的記憶體位址相等那麼無疑是同一個對象反之則是不同的對象。

很多人說對象的 HashCode 方法傳回的就是對象的記憶體位址,包括我在《Java核心程式設計·卷I》的第5章内容中也發現說是 HashCode 其值就是對象的記憶體位址。

對Java中HashCode方法的深入思考

但是 HashCode 方法真的是記憶體位址嗎?回答這個問題前我們先回顧下一些基礎知識。

==和equals

在 Java 中比較兩個對象是否相等主要是通過

==

号,比較的是他們在記憶體中的存放位址。Object 類是 Java 中的超類,是所有類預設繼承的,如果一個類沒有重寫 Object 的

equals

方法,那麼通過

equals

方法也可以判斷兩個對象是否相同,因為它内部就是通過

==

來實作的。

//Indicates whether some other object is "equal to" this one.
public boolean equals(Object obj) {
    return (this == obj);
}           

Tips:這裡額外解釋個疑惑

我們學習 Java 的時候知道,Java 的繼承是單繼承,如果所有的類都繼承了 Object 類,那麼為何建立一個類的時候還可以extend其他的類?

這裡涉及到直接繼承和間接繼承的問題,當建立的類沒有通過關鍵字

extend

顯示繼承指定的類時,類預設的直接繼承了Object,A --> Object。當建立的類通過關鍵字

extend

顯示繼承指定的類時,則它間接的繼承了Object類,A --> B --> Object。

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

例如比較如下兩個字元串是否相同

String a = "Hello"

String b = new String("Hello")

,這裡的相同有兩種情形,是要比較 a 和 b 是否是同一個對象(記憶體位址是否相同),還是比較它們的内容是否相等?這個具體需要怎麼區分呢?

如果使用

==

那麼就是比較它們在記憶體中是否是同一個對象,但是 String 對象的預設父類也是 Object,是以預設的

equals

方法比較的也是記憶體位址,是以我們要重寫

equals

方法,正如 String 源碼中所寫的那樣。

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;
}           

這樣當我們

a == b

時是判斷 a 和 b 是否是同一個對象,

a.equals(b)

則是比較 a 和 b 的内容是否相同,這應該很好了解。

JDK 中不止 String 類重寫了equals 方法,還有資料類型 Integer,Long,Double,Float等基本也都重寫了

equals

方法。是以我們在代碼中用 Long 或者 Integer 做業務參數的時候,如果要比較它們是否相等,記得需要使用

equals

方法,而不要使用

==

因為使用

==

号會有意想不到的坑出現,像這種資料類型很多都會在内部封裝一個常量池,例如 IntegerCache,LongCache 等等。當資料值在某個範圍内時會直接從常量池中擷取而不會去建立對象。

如果要使用

==

,可以将這些資料包裝類型轉換為基本類型之後,再通過

==

來比較,因為基本類型通過

==

比較的是數值,但是在轉換的過程中需要注意 NPE(NullPointException)的發生。

Object中的HashCode

equals 方法能比較兩個對象的内容是否相等,是以可以用來查找某個對象是否在集合容器中,通常大緻就是逐一去取集合中的每個對象元素與需要查詢的對象進行

equals

比較,當發現某個元素與要查找的對象進行equals方法比較的結果相等時,則停止繼續查找并傳回肯定的資訊,否則,傳回否定的資訊。

但是通過這種比較的方式效率很低,時間複雜度比較高。那麼我們是否可以通過某種編碼方式,将每一個對象都具有某個特定的碼值,根據碼值将對象分組然後劃分到不同的區域,這樣當我們需要在集合中查詢某個對象時,我們先根據該對象的碼值就能确定該對象存儲在哪一個區域,然後再到該區域中通過

equals

方式比較内容是否相等,就能知道該對象是否存在集合中。

通過這種方式我們減少了查詢比較的次數,優化了查詢的效率同時也就減少了查詢的時間。

這種編碼方式在 Java 中就是 hashCode 方法,Object 類中預設定義了該方法, 它是一個 native 修飾的本地方法,傳回值是一個 int 類型。

/**
 * 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}.
 * ...
 * 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™ 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

,還在這份檔案中我們搜尋

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 也許和記憶體位址有關系,但不是直接代表記憶體位址的,具體需要看虛拟機版本和設定。

equals和hashCode

equals 和 hashCode 都是 Object 類擁有的方法,包括 Object 類中的 toString 方法列印的内容也包含 hashCode 的無符号十六進制值。

public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}           

由于需要比較對象内容,是以我們通常會重寫 equals 方法,但是重寫 equals 方法的同時也需要重寫 hashCode 方法,有沒有想過為什麼?

因為如果不這樣做的話,就會違反 hashCode 的通用約定,進而導緻該類無法結合所有基于散列的集合一起正常工作,這類集合包括 HashMap 和 HashSet。

這裡的通用約定,從 Object 類的 hashCode 方法的注釋可以了解,主要包括以下幾個方面,

  • 在應用程式的執行期間,隻要對象的 equals 方法的比較操作所用到的資訊沒有被修改,那麼對同一個對象的多次調用,hashCode 方法都必須始終傳回同一個值。
  • 如果兩個對象根據 equals 方法比較是相等的,那麼調用這兩個對象中的 hashCode 方法都必須産生同樣的整數結果。
  • 如果兩個對象根據 equals 方法比較是不相等的,那麼調用者兩個對象中的 hashCode 方法,則不一定要求 hashCode 方法必須産生不同的結果。但是給不相等的對象産生不同的整數散列值,是有可能提高散清單(hash table)的性能。

從理論上來說如果重寫了 equals 方法而沒有重寫 hashCode 方法則違背了上述約定的第二條,相等的對象必須擁有相等的散列值。

但是規則是大家默契的約定,如果我們就喜歡不走尋常路,在重寫了 equals 方法後沒有覆寫 hashCode 方法,會産生什麼後果嗎?

我們自定義一個 Student 類,并且重寫了 equals 方法,但是我們沒有重寫 hashCode 方法,那麼當調用 Student 類的 hashCode 方法的時候,預設就是調用超類 Object 的 hashCode 方法,根據随機數傳回的一個整型值。

public class Student {

    private String name;

    private String gender;

    public Student(String name, String gender) {
        this.name = name;
        this.gender = gender;
    }

    //省略 Setter,Gettter
    
    @Override
    public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof Student) {
            Student anotherStudent = (Student) anObject;

            if (this.getName() == anotherStudent.getName()
                    || this.getGender() == anotherStudent.getGender())
                return true;
        }
        return false;
    }
}           

我們建立兩個對象并且設定屬性值一樣,測試下結果:

public static void main(String[] args) {

    Student student1 = new Student("小明", "male");
    Student student2 = new Student("小明", "male");

    System.out.println("equals結果:" + student1.equals(student2));
    System.out.println("對象1的散列值:" + student1.hashCode() + ",對象2的散列值:" + student2.hashCode());
}           

得到的結果

equals結果:true
對象1的散列值:1058025095,對象2的散列值:665576141           

我們重寫了 equals 方法,根據姓名和性别的屬性來判斷對象的内容是否相等,但是 hashCode 由于是調用 Object 類的 hashCode 方法,是以列印的是兩個不相等的整型值。

如果這個對象我們用 HashMap 存儲,将對象作為 key,熟知 HashMap 原理的同學應該知道,HashMap 是由數組 + 連結清單的結構組成,這樣的結果就是因為它們 hashCode 不相等,是以放在了數組的不同下标,當我們根據 Key 去查詢的時候結果就為 null。

public static void main(String[] args) {

    Student student1 = new Student("小明", "male");
    Student student2 = new Student("小明", "male");
    
    HashMap<Student, String> hashMap = new HashMap<>();
    hashMap.put(student1, "小明");

    String value = hashMap.get(student2);
    System.out.println(value); 
}           

輸出結果

null           

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

這裡如果不好了解,猿友可以将 Student 類換成 String 類思考下,String 類是我們常常作為 HashMap 的 Key 值使用的,試想如果 String 類隻重寫了 equals 方法而沒有重寫 HashCode 方法,這裡将某個字元串

new String("s")

作為 Key 然後 put 一個值,但是再根據

new String("s")

去 Get 的時候卻得到 null 的結果,這是難以讓人接受的。

是以無論是理論的約定上還是實際程式設計中,我們重寫 equals 方法的同時總要重寫 hashCode 方法,請記住這點。

雖然 hashCode 方法被重寫了,但是如果我們想要擷取原始的 Object 類中的哈希碼,我們可以通過

System.identityHashCode(Object a)

來擷取,該方法傳回預設的 Object 的 hashCode 方法值,即使對象的 hashCode 方法被重寫了也不影響。

public static native int identityHashCode(Object x);           

總結

如果 HashCode 不是記憶體位址,那麼 Java 中怎麼擷取記憶體位址呢?找了一圈發現沒有直接可用的方法。

後來想想也許這是 Java 語言編寫者認為沒有直接擷取記憶體位址的必要吧,因為 Java 是一門進階語言相對于機器語言的彙編或者 C 語言來說更抽象并隐藏了複雜性,因為畢竟是在 C 和 C++ 的基礎上進一步封裝的。而且由于自動垃圾回收機制和對象年齡代的問題,Java 中對象的位址是會變化的,是以擷取實際記憶體位址的意義不大。

當然以上是部落客本人自己的觀點,如果猿友有其他不同的意見或見解也可以留言,大家一起共同探讨。