集合类中的hashCode和equals是两个比较容易混淆的接口。我在准备面试的时候就经常被绕晕,后来把区别索性死记硬背了下来。最近又用到了这一知识,才下决心搞明白它。
先说一下我用到的场景。有一个自定义的类Event,其中包含了若干字段:
public class Event {
String systemId;
String logType;
/* 其他接口 */
...
}
我需要用个Set来收集Event对象:
Set<Event> set = new HashSet<Event>();
这里有以下几个要求:
当Event中systemId和logType两个字段(String类型)相同时,判断Event对象相同
当set中已包含相同Event对象的时候,Set.contains接口返回true
向set添加Event时,当set中包含有相同Event时,不再重复添加
第一次尝试:修改equals
既然判断Event对象是依靠systemId和logType两个字段,那我直接改写equals接口不就好啦!
equals接口改写如下:
@Override
public boolean equals(Object o) {
if (!(o instanceof Event)) {
return false;
}
return ((Event)o).systemId.equals(systemId)
&& ((Event)o).logType.equals(logType);
}
然而测试发现,当set内有字段相同的Event对象时,contains并不能返回true;而向set添加具有相同字段的Event时,并不能避免重复添加;第一次尝试失败。
错误探究
为了探究错误原因,翻出HashSet源码了解一下。
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 contains(Object o) {
return map.containsKey(o);
}
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
...
}
HashSet里面使用一个HashMap来实现其功能,加入HashSet的值都作为key被添加到HashMap中去。
再看HashMap源码:
public boolean containsKey(Object key) {
return getEntry(key) != null;
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
在HashMap中寻找一个key,共分为两个步骤:
根据key的哈希值找到key所在的Entry
Entry其实是一个链表,遍历次链表,通过key的equals接口寻找相同的对象
若找到相同对象,则返回此Entry,否则返回null
向HashMap中添加一个key时,也是安装相似的步骤:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
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;
}
不同的是步骤3,当找到相同key的时候,用新key替换;若没有相同key,则把此key添加上。
这里我们可以看到若想在HashMap中找到相同的对象,不仅要保证二者能通过equals验证,还要有相同的哈希值,这个哈希值就是通过hashCode接口来定义的。
第二次尝试:修改hashCode
在第一次尝试工作的基础上,再次重构hashCode:
@Override
public int hashCode() {
return this.toString().hashCode();
}
@Override
public String toString() {
return "System ID: " + systemId + ", Log type: " + logType;
}
测试结果符合最终要求。
总结
通过上述探讨我们可以看到,equals和hashCode其实是两个截然不同的概念,及Comparision和Hashing。前者是单纯的对象比较,后者则是在做哈希寻址的时候为确定对象的地址提供参数。因为相同的对象一定要保证在同一个哈希slot中,故引出了以下原则:equals相同的时候,一定要保证hashCode相同;反之,hashCode相同的时候,equals不一定相同。这是因为哈希值毕竟是根据对象的属性计算出的一个整形结果,所以不同对象有可能哈希值相同,这种情况下两个不同的对象就会寻到同一个slot,即哈希冲突,在哈希冲突情况下,就要靠equals来判断冲突的对象是否相同了。而当修改equals的时候,一定要同时修改hashCode,否则Set根本不会将两个“相同”对象放到同一个slot里,更没机会调用equals啦!我之所以走了这么多弯路,就是因为改写equals的时候没有同时修改hashCode。不过话说回来,不犯这么愚蠢的错误,也不会了解这么多啦。愚蠢的错误中往往发现真理呀!