对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 其值就是对象的内存地址。

但是 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 中对象的地址是会变化的,因此获取实际内存地址的意义不大。

当然以上是博主本人自己的观点,如果猿友有其他不同的意见或见解也可以留言,大家一起共同探讨。

posted @ 2019-08-12 09:11 JaJian 阅读(...) 评论(...) 编辑 收藏