Administrator
发布于 2018-08-22 / 2009 阅读
34

重写 equals 为什么必须重写 hashCode?HashMap 里查不到的 Key

put 进去的东西,get 出来是 null

8 月中旬写购物车合并逻辑,用了一个 Map<SkuKey, Integer> 统计数量。代码逻辑很简单,先把老购物车的商品塞进 map,再遍历新购物车累加:

Map<SkuKey, Integer> countMap = new HashMap<>();
for (CartItem item : oldItems) {
    countMap.put(new SkuKey(item.getSkuId(), item.getSpecId()), item.getQty());
}
for (CartItem item : newItems) {
    SkuKey key = new SkuKey(item.getSkuId(), item.getSpecId());
    Integer old = countMap.get(key);
    countMap.put(key, (old == null ? 0 : old) + item.getQty());
}

跑出来的结果不对:同一件商品被合并后,数量永远是最后一次的 qty,累加没生效。我加了句日志,发现 countMap.get(key) 一律返回 null,哪怕这个 key 刚刚才被 put 进去。

当时我盯着屏幕想了五分钟,直到师傅路过问了句:"你这 SkuKey 重写 hashCode 了没?"

先复现一个最小例子

public class Key {
    private String id;
    public Key(String id) { this.id = id; }
    // 只重写了 equals,没重写 hashCode

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Key key = (Key) o;
        return id != null ? id.equals(key.id) : key.id == null;
    }

    public static void main(String[] args) {
        Map<Key, String> map = new HashMap<>();
        map.put(new Key("A"), "第一件");
        System.out.println(map.get(new Key("A")));
    }
}

输出:

null

equals 明摆着返回 true,map 却说找不到。原因在 HashMap 的查找顺序上——它先比 hash,再比 equals

HashMap 查找时到底做了什么

扒一下 JDK 8 里 HashMap.getNode 的核心逻辑:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {          // 1. 用 hash 定位桶
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;                                 // 2. 桶里第一个就匹配
        if ((e = first.next) != null) {
            // 3. 遍历链表或红黑树,逐个比 hash 再比 equals
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

第一步 tab[(n - 1) & hash] 就是拿 hash 值算出数组下标。Object 默认的 hashCode() 是根据对象地址算的(具体算法跟 JVM 有关,HotSpot 默认用随机数生成),new Key("A") 两次创建的是两个不同对象,hash 不同,第一步就落到不同的桶里去了。桶都不一样,equals 压根没机会被调用。

我也验证了一下确实不是 equals 的问题:

Key a = new Key("A");
Key b = new Key("A");
System.out.println(a.equals(b));                       // true
System.out.println(a.hashCode() == b.hashCode());      // false  ← 罪魁祸首
System.out.println(System.identityHashCode(a));
System.out.println(System.identityHashCode(b));

hashCode 的三条契约

Object 的 javadoc 里写了,我照着理解了一遍:

  1. 同一对象多次调用 hashCode,必须返回相同的值(前提是参与计算的字段没被修改)。
  2. equals 返回 true 的两个对象,hashCode 必须相等。这条就是我这次违反的,也是唯一会导致 HashMap 失效的那条。
  3. equals 返回 false 的两个对象,hashCode 不要求不等。但不等更好,因为 hash 冲突会让元素排成链表,JDK 8 里链表超过 8 个还会树化成红黑树,查找从 O(1) 退化成 O(log n)。

所以修法很直接,补上 hashCode:

@Override
public int hashCode() {
    return id != null ? id.hashCode() : 0;
}

多字段的情况,我一般用 JDK 7 提供的 Objects.hash

@Override
public int hashCode() {
    return Objects.hash(skuId, specId);
}

它内部就是 Arrays.hashCode(new Object[]{skuId, specId}),实现是经典的 result = 31 * result + element.hashCode()。选 31 是因为它是个奇素数,而且 31 * i 可以被 JVM 优化成 (i << 5) - i,比乘法快。

IDE 生成的 equals 我贴一下,用 getClass() 还是 instanceof 是有讲究的:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;   // 严格同类型
    SkuKey skuKey = (SkuKey) o;
    return Objects.equals(skuId, skuKey.skuId) &&
           Objects.equals(specId, skuKey.specId);
}

更难查的那个坑:改了作为 Key 的字段

上面这个只是没重写 hashCode。真正让我排查一整天的是另一个场景:hashCode 和 equals 都写对了,但 map 里的东西还是会"消失"。

Map<SkuKey, Integer> countMap = new HashMap<>();
SkuKey key = new SkuKey(1001L, 2001L);
countMap.put(key, 5);

// 后面某处业务代码
key.setSpecId(2002L);                  // 修改了参与 hashCode 计算的字段

System.out.println(countMap.get(key));                        // null
System.out.println(countMap.get(new SkuKey(1001L, 2001L)));   // null
System.out.println(countMap.size());                          // 1,元素还在!

元素还在,size 是 1,但两个 key 都取不出来。原因是 Node 里存了一份创建时的 hash 值

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;        // final!put 的时候算好就固定了
    final K key;
    V value;
}

put 时 key 的 specId 是 2001,算出的 hash 落在第 3 号桶。改完 specId 变成 2002,hash 值变了,get 时去第 17 号桶找,自然是空。而原来的元素还老老实实待在第 3 号桶,只是再也没人能用新的 hash 找到它。

这种情况连 remove 都删不掉,属于事实上的内存泄漏。我在测试环境用 jmap 看过,堆里积累了上万个这种够不着的 SkuKey。

我的处理方式

现在写作为 Key 的类,一律按这三条来:

  • 字段全部 final,不提供 setter。不可变对象天然安全:
public final class SkuKey {
    private final Long skuId;
    private final Long specId;
    // 构造器 + getter + equals + hashCode,没有 setter
}
  • 需要"修改"时,new 一个新的 Key 出来,把旧的 remove 掉再 put 新的。
  • 如果确实不能用不可变对象,那就别让它当 HashMap 的 Key,老实用 List<SkuKey> 加遍历查找,或者拼字符串 skuId + "_" + specId 当 key(简单粗暴但在小数据量下很有效)。

顺便记两个相关的坑

可变字段参与 hashCode 的经典翻车还有 HashSet。下面这段代码会留一个删不掉的孤儿:

Set<SkuKey> set = new HashSet<>();
SkuKey k = new SkuKey(1L, 1L);
set.add(k);
k.setSpecId(2L);
set.remove(k);        // 删不掉
System.out.println(set.size());      // 1

因为 HashSet 内部就是个 HashMap,元素作为 key,value 是个固定的 PRESENT 对象。

另一个是 hashCode 里的循环引用。两个对象互相持有引用,各自的 hashCode 又都调用对方的 hashCode,会直接 StackOverflowError。我写树形结构的时候踩过一次,后来改成只用 id 参与计算。

hashCode 写得太随意会拖慢整个 Map

改完正确性之后,师傅又提醒我注意 hash 的分布。我第一版图省事,把两个 Long 字段直接相加:

@Override
public int hashCode() {
    return (int) (skuId + specId);      // 冲突率很高
}

skuId 和 specId 都在同一个量级时,相加的结果容易扎堆,而且高位信息全丢了。HashMap 里有一段扰动函数会把高 16 位和低 16 位做异或来缓解这个问题:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

但这只能缓解,救不了本身就很差的 hashCode。我做了个对比,10 万个 SkuKey 放进 HashMap:

hashCode 实现最长链表长度get 平均耗时
两字段直接相加43118 ns
Objects.hash(skuId, specId)831 ns

差了将近 4 倍。Objects.hash 内部的 31 * result + h 能把各字段的信息充分混合,冲突少得多。代价是每次计算多几次乘法,但对比链表查找的成本可以忽略。

如果这段代码在循环里被高频调用,还可以把 hashCode 缓存起来(对象不可变才安全):

private final int hash;      // 构造时算好

public SkuKey(Long skuId, Long specId) {
    this.skuId = skuId;
    this.specId = specId;
    this.hash = Objects.hash(skuId, specId);
}

@Override
public int hashCode() {
    return hash;
}

String 就是这么干的,它内部有个 private final int hash 字段,第一次调用 hashCode() 时才计算并缓存(因为 String 大部分情况下根本不需要算 hash)。

改完购物车那块之后,我自己写了个小测试压了一遍,2000 个 SKU 随机合并 100 次,结果和单线程累加的预期值一致。之前那个版本有 137 处数量对不上。

参考