最近参加工作室面试,一直被问到一些关于Object类的,赶紧恶补一下,本篇是关于从源码角度理解 Java 的 hashCode 和 equals 方法的。
测试用例
这里新建一个很普通的测试类和一个测试方法。
我们在 HashTest 里面重写了 toString 方法,在输出对象的时候更容易区分。
class HashTest {
Integer i;
String data;
public HashTest(Integer i, String data) {
this.i = i;
this.data = data;
}
@Override
public String toString() {
return "HashTest{i=" + i + ", data='" + data + "\'";
}
}
...
public void test () {
HashTest hashTest1 = new HashTest(1, "");
HashTest hashTest2 = new HashTest(2, "");
HashTest hashTest3 = new HashTest(1, "");
HashSet<HashTest> hashSet = new HashSet<>();
hashSet.add(hashTest1);
hashSet.add(hashTest2);
hashSet.add(hashTest3);
hashSet.stream().forEach((e)->System.out.println(e));
}
/**
* HashTest{i=2, data=''}
* HashTest{i=1, data=''}
* HashTest{i=1, data=''}
*/
这里就出现了一个很神奇的现象,在本来不允许存在多个相同对象的 HashSet 中居然存在两个相同的对象 ( HashTest{i=1, data=’’} )
寻找问题
既然要寻找哪里出了问题,我们就要以程序的角度去看待问题。
HashSet 不允许有多个相同对象,那 HashSet 是怎么确认相不相同的呢?
Java 有一个口号 “ Everything is an object. " 从一个层面上说明了所有对象都基于 Object 类,都具有共同的 Object 的属性,在翻找 Object 类的方法后,不难发现一个 equals 方法。
下面是对前一个示例的拓展。
System.out.println("hashTest1.equals(hashTest2) = " + hashTest1.equals(hashTest2));
System.out.println("hashTest1.equals(hashTest3) = " + hashTest1.equals(hashTest3));
/**
* hashTest1.equals(hashTest2) = false
* hashTest1.equals(hashTest3) = false
*/
可以看到通过 equals 函数比较出来的 hashTest1 和其他对象(包括内容一样的 hashTest3)都不一样,这就不难明白 HashSet 把这三个对象当做均不一样的对象来保存。
探索 equals 方法
为了了解 Object 类默认是怎么实现,我截取了一段关于 equals 方法的源码
/**
* Indicates whether some other object is "equal to" this one.
* ···
*/
public boolean equals(Object obj) {
return (this == obj);
}
从源码中不难看出,Java 默认的比较只是对引用地址进行了比较,并没有对具体内容进行比较,所以造成了以上的 HashSet 存在多个相同对象的现象。
既然默认提供的 equals 方法不行,那我们就自己重写来实现。
class HashTest {
...
@Override
public boolean equals(Object obj) {
if(obj instanceof HashTest){
if(((HashTest) obj).data.equals(this.data) && ((HashTest) obj).i.equals(this.i)){
return true;
}
}
return false;
}
}
/**
* hashTest1.equals(hashTest2) = false
* hashTest1.equals(hashTest3) = true
*/
重新运行 equals 方法,发现回到了我们预期的情况。
重写 equals 方法时的规定
从 Java 官方的注解中可以发现:
The equals method implements an equivalence relation on non-null object references:
- It is reflexive: for any non-null reference value x, x.equals(x) should return true.
- It is symmetric: for any non-null reference values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
- It is transitive: for any non-null reference values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
- It is consistent: for any non-null reference values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
- For any non-null reference value x, x.equals(null) should return false.
翻译成中文就是,你重写了这个方法后要满足自反性、对称性、传递性、一致性。
- 自反性:自己比较自己永远是真的
- 对称性:既然A等于B,那么B等于A
- 传递性:A等于B、B等于C,所以A等于C
- 一致性:多次比较结果不变
下面对我们刚才自己重写的 equals 方法进行检测。
// 自反性
System.out.println("自反性 hashTest1.equals(hashTest1) = " + hashTest1.equals(hashTest1));
// 对称性
System.out.println("对称性 hashTest1.equals(hashTest2) = " + hashTest1.equals(hashTest2));
System.out.println("对称性 hashTest2.equals(hashTest1) = " + hashTest2.equals(hashTest1));
// 传递性
System.out.println("传递性 hashTest1.equals(hashTest2) = " + hashTest1.equals(hashTest2));
System.out.println("传递性 hashTest2.equals(hashTest3) = " + hashTest2.equals(hashTest3));
System.out.println("传递性 hashTest1.equals(hashTest3) = " + hashTest1.equals(hashTest3));
/**
* 自反性 hashTest1.equals(hashTest1) = true
* 对称性 hashTest1.equals(hashTest2) = true
* 对称性 hashTest2.equals(hashTest1) = true
* 传递性 hashTest1.equals(hashTest2) = true
* 传递性 hashTest2.equals(hashTest3) = true
* 传递性 hashTest1.equals(hashTest3) = true
*/
可以看出我们写的 equals 方法满足自反性、对称性、传递性。
探索 HashSet 添加的实现
在前文我们找到了 Java 底层去实现比较两个对象的方法,但回到 HashSet 上来又是怎么实现的呢。
同样的,我们在源码中找到 HashSet 的 add 方法。
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable
{
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
···
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
···
}
可以看到 add 方法实际上是调用了 HashSet 所封装的 HashMap 的 put 方法,那么我们进到 put 方法看看。
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
···
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
···
}
可以看到 put 方法又调用了另外一个 putVal 方法,并多了一些参数,因此我们又找到 putVal 方法。
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)
从注释中可以看出 putVal 需要 hash、key、value 等参数,剩下的参数都可以从注释中清楚地了解到,但这里为什么多了一个 hash 参数,它又和 HashMap( HashSet )的 ’Hash’ 有什么关系?
在一番 Google 之后了解到 hash 在 HashMap( HashSet )中扮演了一个索引的角色。在 HashMap 执行添加操作时会先检测是否存在传入的哈希值,如果存在则比较 equals 方法;如果不存在则直接插入。
( 具体的putVal 方法实现分析和 HashMap 类的分析以后会补上 )
因此我们回到上一层,可以发现这个哈希是通过一个 hash 的方法计算的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
不难看出,如果传入的对象非空,就通过调用其自身的 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
* java.util.HashMap.
*/
@HotSpotIntrinsicCandidate
public native int hashCode();
从源码中可以看到,hashCode 是一个 native (由 JNI 实现的)方法。返回一个对象的哈希值,用于与哈希表有关的类。
那么我们回到最初最初的例子,执行 hashCode 方法,看看结果是什么。
public void test () {
HashTest hashTest1 = new HashTest(1,"");
HashTest hashTest2 = new HashTest(2,"");
HashTest hashTest3 = new HashTest(1,"");
System.out.println("hashTest1.hashCode = " + hashTest1.hashCode() + " " + hashTest1);
System.out.println("hashTest2.hashCode = " + hashTest2.hashCode() + " " + hashTest2);
System.out.println("hashTest3.hashCode = " + hashTest3.hashCode() + " " + hashTest3);
}
/**
* hashTest1.hashCode = 1802598046 HashTest{i=1, data=''}
* hashTest2.hashCode = 788117692 HashTest{i=2, data=''}
* hashTest3.hashCode = 1566723494 HashTest{i=1, data=''}
*/
程序输出的 hashCode 来看,三个对象都不一样。
但是对象相同时哈希值必须相同,对象不同时哈希值有可能相同(取决于哈希值计算算法)。
在这里程序调用的是 Object 类默认的 hashCode 方法,对于 Object 来说这三个对象肯定是不一样的,但是这并不是我们想要的结果,所以我们需要自己来重写默认的 hashCode 方法。
class HashTest {
...
@Override
public int hashCode() {
return data.hashCode() ^ i.hashCode();
}
}
/**
* hashTest1.hashCode = 1 HashTest{i=1, data=''}
* hashTest2.hashCode = 2 HashTest{i=2, data=''}
* hashTest3.hashCode = 1 HashTest{i=1, data=''}
*/
在这里我选择的计算方式是 data.hashCode() ^ i.hashCode()。
这样看来,就满足了对象相同时哈希值必须相同的要求。
public void test () {
HashTest hashTest1 = new HashTest(1, "");
HashTest hashTest2 = new HashTest(2, "");
HashTest hashTest3 = new HashTest(1, "");
HashSet<HashTest> hashSet = new HashSet<>();
hashSet.add(hashTest1);
hashSet.add(hashTest2);
hashSet.add(hashTest3);
hashSet.stream().forEach((e)->System.out.println(e));
}
/**
* HashTest{i=1, data=''}
* HashTest{i=2, data=''}
*/
这个时候我们再重新执行 add 方法就满足我们预期的期望了。
关于 hashCode 的小实验
既然 hashCode 同意对象不同时哈希值有可能相同,那如果我让所有的 hashCode 都为 1 会出现什么结果。
class HashTest {
...
@Override
public int hashCode() {
return 1;
}
}
public void test () {
HashTest hashTest1 = new HashTest(1, "");
HashTest hashTest2 = new HashTest(2, "");
HashTest hashTest3 = new HashTest(1, "");
HashSet<HashTest> hashSet = new HashSet<>();
hashSet.add(hashTest1);
hashSet.add(hashTest2);
hashSet.add(hashTest3);
hashSet.stream().forEach((e)->System.out.println(e));
}
/**
* HashTest{i=1, data=''}
* HashTest{i=2, data=''}
*/
重复前面的测试,结果依然满足预期。
但如果添加的量大了,结果就不是那么好看了。
下面是一段 hashCode 永远相同与否插入效率比较的程序。
class HashTest {
...
@Override
public int hashCode() {
return data.hashCode() ^ i.hashCode();
}
}
public void test () {
int testSize = 10000;
HashSet<HashTest> hashSet = new HashSet<>(testSize*2);
long start = System.currentTimeMillis();
for(int i = 0;i<testSize;i++) {
hashSet.add(new HashTest(i, UUID.randomUUID().toString()));
System.out.println("Using time: " + (System.currentTimeMillis() - start) + " ms");
}
}
/**
* Test size: 1000 Using time: 23 ms
* Test size: 10000 Using time: 52 ms
* Test size: 100000 Using time: 277 ms
* Test size: 1000000 Using time: 1719 ms
* Test size: 10000000 Using time: 15617 ms
*/
从结果可以看出,当数据规模达到千万级别是,HashSet还是可以在 15s 左右完成所有任务。
class HashTest {
...
@Override
public int hashCode() {
return 1;
}
}
···
/**
* Test size: 1000 Using time: 40 ms
* Test size: 10000 Using time: 1570 ms
* Test size: 20000 Using time: 5930 ms
* Test size: 30000 Using time: 15548 ms
*/
可以看出 hash 唯一时,当数据规模达到三万时就已经要 15s 了。
下图是 hashCode 永远相同与否插入时间的拟合图。
由图可知,在数据量为几百时,两者表现都差不多,但是一旦超过 1000 ,相同 hashCode 的曲线迅速飙高。
因为 hashCode 作为一个的索引,可以快速判重,当 hashCode 失效时只能一个个去执行 equals 方法,大大降低了效率。