哈希算法-第2篇-hash key的要求-List为什么不能做HashMap的Key?

201 阅读7分钟

在Java中,HashMap 是一种基于哈希表的数据结构,它要求键(Key)必须满足以下条件:

  1. 不可变性(Immutability) :键对象必须是不可变的,或者在作为键期间其状态不能发生变化。这是因为 HashMap 依赖于键的哈希码(hashCode)和相等性(equals)来判断键的唯一性和存储位置。如果键对象在插入后发生了变化,可能会导致哈希码改变,从而无法正确找到或删除对应的值。

  2. 一致性(Consistency) :在HashMap这个数据结构中,键对象的 hashCode()equals() 方法必须保持一致。也就是说,如果两个键通过 equals() 方法比较为相等,那么它们的 hashCode() 必须相同;反之,如果 hashCode() 相同,equals() 不一定返回 true,但 hashCode() 不同则 equals() 必须返回 false

上面的话比较晦涩,容易让人迷糊,再通俗的解释一下:

对于Hash类型的数据结构而言,计算一个值的hash值,然后放到对应的桶里去,应该说,哈希值相等的,应该放到一个桶里,但是值本身不一定相等,这样说就里理顺了。

为什么要强调哈希键值的一致性?

问题的核心在于 equalshashCode 是两个独立的方法,默认实现并不保证这种一致性!

默认实现:

class Person {
    String name;
    int age;
    
    // 只重写了 equals,忘记重写 hashCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }
    
    // 没有重写 hashCode,使用 Object 的默认实现
    // 默认的 hashCode() 返回的是对象内存地址的哈希值
}

问题演示

public static void main(String[] args) {
    Person p1 = new Person("Alice", 25);
    Person p2 = new Person("Alice", 25); // 内容完全相同
    
    // equals 比较:true(因为我们重写了)
    System.out.println(p1.equals(p2)); // true
    
    // hashCode 比较:false(因为使用默认实现)
    System.out.println(p1.hashCode() == p2.hashCode()); // false
    
    // 在 HashMap 中会出现问题
    Map<Person, String> map = new HashMap<>();
    map.put(p1, "Value1");
    
    // 这里可能返回 null!
    String value = map.get(p2); // 可能为 null
    System.out.println(value); // 可能输出 null
}

为什么会这样?

Object 类的默认实现:

  • equals(): 比较对象引用(==),即内存地址
  • hashCode(): 基于内存地址计算,这一点很重要。

当我们只重写 equals 时:

// 我们告诉程序:这两个对象在逻辑上是相等的
p1.equals(p2) == true

// 但 hashCode 仍然基于不同的内存地址
p1.hashCode() != p2.hashCode()

正确的做法

class Person {
    String name;
    int age;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }
    
    @Override
    public int hashCode() {
        // 必须使用与 equals 相同的字段
        return Objects.hash(name, age);
    }
}

因为这种错误很常见难以调试

  1. 编译不会报错
  2. 运行时可能不立即崩溃
  3. 在某些情况下能"正常工作",在某些情况下失败
  4. 问题具有隐蔽性

因此,强调这个一致性是因为:

  • 默认实现不保证一致性
  • 只重写一个方法很容易忘记另一个
  • 违反这个规则会导致难以发现的 bug
  • HashMap 等集合严重依赖这个约定

所以这不是多余的强调,而是对常见陷阱的重要提醒!

实际上,在其他语言里面,也是类似的。

谁会拿List做HashMap的键?

首先,一般我们下意识里觉得这个不对,但是为什么也说不上来。什么场景更是说不上来的,然而,还真有场景

虽然不常见,但在这些特定领域或复杂数据建模中,确实合理:

1. 表示复合键(Composite Key)

你想用一组值作为唯一标识,比如数据库中的联合主键。

2. 动态长度的键

List 的优势是长度可变。不像 Pair<A,B> 固定两个元素,你可以有:

  • ["A"]
  • ["A","B"]
  • ["A","B","C"]

比如表示路径、分类层级、操作序列等, 比如文件系统里的路径,多级目录。

3. 函数式编程或 DSL 场景

在一些配置、规则引擎、工作流系统中,List 可能代表一个“动作序列”或“条件链”,作为缓存的键。

问题不在于“想法”,而在于实现方式是否安全

直接用可变 List 作键 = ❌ 自杀式操作。

为什么 List 不能轻易作为 HashMap 的键?

List 是一个可变集合,它的内容可以随时被修改。如果将 List 作为 HashMap 的键,可能会导致以下问题:

  1. 哈希码不一致ListhashCode() 方法是基于其内容的。如果 List 的内容发生变化,其哈希码也会随之改变。这会导致 HashMap 无法正确找到或删除对应的值,因为 HashMap 使用的是插入时的哈希码来定位存储位置。
  2. 违反契约HashMap 依赖于键的 hashCode()equals() 方法的一致性。如果 List 的内容发生变化,hashCode()equals() 的结果可能会不一致,从而违反 HashMap 的契约。

示例

import java.util.HashMap;
import java.util.List;
import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        HashMap<List<String>, String> map = new HashMap<>();
        List<String> key = Arrays.asList("a", "b");
        map.put(key, "value");

        // 修改 key 的内容
        key.add("c");

        // 由于 key 的哈希码发生了变化,无法正确获取值
        System.out.println(map.get(key)); // 输出: null
    }
}

在上面的例子中,key 是一个 List,在插入 HashMap 后,key 的内容被修改了。由于 List 是可变的,修改后其哈希码发生了变化,导致 HashMap 无法正确找到对应的值。

再看这样一个更简单的例子

  1. Hash(键List A) = 1, HashMap[键List A] = {3,4,5}
  2. HashMap[键List A].append( item X )
  3. Hash(List A) != 1, 值 {3,4,5} 丢失!!

List hashcode的计算方法

在 Java 中,List 接口的实现类(如 ArrayListLinkedList 等)的 hashCode() 方法是通过遍历列表中的所有元素,并根据每个元素的哈希码计算得出的。具体来说,ListhashCode() 实现遵循以下规则:

List.hashCode() 的计算规则

ListhashCode() 方法通常基于以下公式计算:

s = 1
for (E e : list) {
    s = 31 * s + (e == null ? 0 : e.hashCode())
}
return s
详细解释:
  1. 初始值

    • 计算开始时,s 被初始化为 1
  2. 遍历元素

    • 对于列表中的每一个元素 e,执行以下步骤:

      • 如果元素 enull,则其哈希码视为 0
      • 否则,使用元素 ehashCode() 方法获取其哈希码。
      • 更新 s 的值为 31 * s + 当前元素的哈希码
  3. 返回结果

    • 遍历完所有元素后,s 即为列表的最终哈希码。
为什么选择 31?
  • 性能优化:31 是一个奇素数,且 31 * i 可以被优化为 (i << 5) - i,这在某些 JVM 实现中可以提高计算效率。
  • 减少冲突:选择一个适当的素数有助于减少不同对象产生相同哈希码的概率,从而提高哈希表的性能。

示例代码

以下是一个简化的 ArrayListhashCode() 实现示例:

public int hashCode() {
    int hashCode = 1;
    for (E e : this) {
        hashCode = 31 * hashCode + (e == null ? 0 : e.hashCode());
    }
    return hashCode;
}

具体实现示例

ArrayList 为例,其 hashCode() 方法的实际实现如下:

public int hashCode() {
    int h = 1;
    for (E e : this)
        h = 31 * h + (e == null ? 0 : e.hashCode());
    return h;
}

非用不可的话

  1. 使用不可变列表

    为了避免上述问题,建议在使用 List 作为键时,先将其转换为不可变的列表。 例如,可以使用 Collections.unmodifiableList 或者创建一个不可变的自定义列表类。

    List<String> original = new ArrayList<>(Arrays.asList("a", "b"));
    List<String> immutableKey = Collections.unmodifiableList(original);
    Map<List<String>, String> map = new HashMap<>();
    map.put(immutableKey, "value");
    
    // 现在即使尝试修改 original,也不会影响 immutableKey 的哈希码
    
  2. 自定义键类

    • 另一种方法是创建一个不可变的自定义键类,封装 List 并确保其哈希码在对象生命周期内不变。
    public final class ImmutableListKey {
        private final List<String> list;
    
        public ImmutableListKey(List<String> list) {
            this.list = List.copyOf(list); // Java 10+: creates an unmodifiable copy
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof ImmutableListKey)) return false;
            ImmutableListKey that = (ImmutableListKey) o;
            return Objects.equals(list, that.list);
        }
    
        @Override
        public int hashCode() {
            return list.hashCode();
        }
    }
    
    // 使用自定义键类
    List<String> original = Arrays.asList("a", "b");
    ImmutableListKey key = new ImmutableListKey(original);
    Map<ImmutableListKey, String> map = new HashMap<>();
    map.put(key, "value");
    

总结

ListhashCode() 方法通过遍历列表中的每个元素,并结合元素的哈希码和乘数 31 来计算最终的哈希值。这种计算方式确保了哈希码的分布均匀性,但由于 List 的可变性,使用时需谨慎,避免在作为键后修改列表内容。

推荐的做法是使用不可变的列表或自定义不可变键类,以确保哈希码的一致性和 HashMap 的正确性。