常常被忽略的hashCode方法
(一)概述
我们在编写Java程序时,一定会发现Object对象中有一个奇怪的方法:hashCode方法。我们平时很少会主动去调用这个方法,甚至可能有人完全没听说过这个方法。那么这个方法是干什么用的呢?我们可以来看官方文档对他的解释:
hashCode通用约定:
- 调用运行Java应用程序中的同一对象,hashCode方法必须始终返回相同的整数。这个整数不需要在不同的Java应用程序中保持一致。
- 根据equals(Object)的方法来比较,如果两个对象是相等的,两个对象调用hashCode方法必须产生相同的结果。
- 根据equals(Object)的方法来比较,如果两个对象是不相等的,那么两个对象调用hashCode方法并不一定产生不同的整数的结果。但是,程序员应该意识到给不相等的对象产生不同的整数结果将有可能提高哈希表的性能。
public native int hashCode();
在Object类中,我们不难发现hashCode方法是由本地方法实现的,也就是说我们无法从Java语言级别查看这个方法的具体实现(本地方法实现要去阅读Java虚拟机的源码)。下面我们会从Java语言级别来讲这个方法究竟有什么用。
(二)equals和hashCode
我相信许多朋友在学Java时一定在书上看见过这样一个说法:在重写equals方法之后一定要同时重写一个与之匹配的hashCode方法,那么原因是什么呢?
我们可以先回想一下Java中的容器类,大多数的数据结构都是通过equals方法来判断他们是否包含一个元素,比如:
List<String> list = Arrays.asList("a", "b", "c");
boolean contains = list.contains("b");
这个变量contains结果是true,因为,虽然”b”是不相同的实例,但是他们是相等的。通过比较实例的每个元素,然后将比较结果赋值给contains是比较浪费的,虽然整个类的数据结构进行了优化,能够提升性能。他们通过使用一种快捷的方式进行比较,从而代替通过比较实例所包含的每个元素。而快捷比较仅需要比较下面这些方面:即通过比较哈希值,它可以将一个实例用一个整数值来代替。哈希码相同的实例不一定相等,但相等的实例一定具有有相同的哈希值。这些数据结构经常通过这种这种技术来命名,可以通过Hash来识别他们的。其中,HashMap是其中最著名的代表。
如果hashCode作为快捷方式来确定对象是否相等,那么只有一件事我们应该关心:相等的对象应该具有相同的哈希码,这也是为什么如果我们重写了equals方法后,我们必须创建一个与之匹配的hashCode实现的原因。下面我们看一个具体的代码:
public class Test {
private int a;
public Test(int a) {
this.a = a;
}
public boolean equals(Object object){
if (object == null){
return false;
}
if (object == this){
return true;
}
if (!(object instanceof Test)){
return false;
}
Test temp = (Test) object;
return temp.a == this.a;
}
public int hashCode(){
// 故意写了一个很容易发生碰撞的哈希函数
return a % 11;
}
}
我们在这个Test类中重写了equals方法,很显然,当两个Test对象的变量a相等时,我们判断这两个对象是相等的。那么我们就有必要去重写他的hashCode方法,来满足同样的判断条件。这里我故意写了一个很容易发生碰撞的哈希函数。不难发现,当a的值相等时,hashCode方法的返回值也必然相等。但是,当a的值不相等时,hashCode方法的返回值也可能相等(比如11和22,返回值都是0)。但是黑锅不全是这个简单的哈希函数背的,因为即使再复杂的哈希函数也会出现这样的情况(不懂的可以搜索哈希函数的定义,优秀的哈希函数会使哈希结果均匀分布于所有的槽内,以降低碰撞的概率)。不过,官方文档是允许这样的情况存在的:根据equals(Object)的方法来比较,如果两个对象是不相等的,那么两个对象调用hashCode方法并不一定产生不同的整数的结果。也就是说我们这段代码是没有任何问题的。
那么这个hashCode方法的作用是什么呢?简单来说就是部分替代equals的功能,看代码:
public static void main(String[] args) {
Set<Test> set = new HashSet<>();
set.add(new Test(1));
set.add(new Test(1));
System.out.println(set);
}
最后打印的结果:[Test@1]。对,只有一个元素,符合我们的预期。我们都知道,集合中不能出现相同的元素。那么集合是如何判断两个元素是相同的呢?首先调用对象的hashCode方法,如果不相等,则这两个对象一定不相等。如果相等,因为存在哈希碰撞的可能性,就会调用equals方法进行再次判断,如果equals方法都认为两个对象时相同的,则两个对象一定是相同的,否则就是不同的。这里我们会注意到一个细节,如果hashCode方法直接判断两个对象不相等,就没有equals方法什么事了,因为两个对象会直接被判定为不相等。下面我们尝试将上面的Test类的hashCode方法删去,再跑一遍main方法。
我们发现输出变成了:[Test@29453f44, Test@5cad8086],很显然集合中有两个对象,对象中的a变量的值都是1。是不是有点大跌眼镜的感觉?为什么对象相等的识别失效了?因为我们去掉了重写的hashCode方法,集合只能调用Object类中的默认的hashCode方法。而默认的hashCode方法返回的是对象所在的地址(暂时这么理解,因为远远要比这个复杂),这两个对象是不同的对象,地址必然不相等,所以在hashCode方法中直接就判定两者不相同,所以两个都被加入集合中。
我们发现,如果我们不重写hashCode方法,结果可能是灾难性的,很可能所有依赖hashCode方法来判断相等性的容器都会失效,首当其冲的就是我们最常用的两个容器:hashMap和hashSet。
(三)默认hashCode揭秘
上面我们直接将默认的hashCode方法说成了返回对象所在的地址,其实是非常不严谨的(不过便于理解可以这么来思考),因为不同的虚拟机有不同的实现方式,下面我们来看HotSpot虚拟机的实现:
static inline intptr_t get_next_hash(Thread * Self, oop obj) {
intptr_t value = 0;
if (hashCode == 0) {
//由具体的操作系统生成的随机数
value = os::random();
} else if (hashCode == 1) {
//基于对象地址生成hash码
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 {
//使用线程随机值计算
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;
return value;
}
这段代码位于src/share/vm/runtime/synchronizer.cpp。我们完全不用去读懂这个源码,我们只要记住一句话:在hashCode方法没有被重写的情况下,不同的对象(即使内部的属性全部相等)可以认为拥有不同的哈希值,因为不同对象拥有相同哈希值的概率极低。
2020年12月7日