在Java中,HashMap 是一种基于哈希表的数据结构,它要求键(Key)必须满足以下条件:
-
不可变性(Immutability) :键对象必须是不可变的,或者在作为键期间其状态不能发生变化。这是因为
HashMap依赖于键的哈希码(hashCode)和相等性(equals)来判断键的唯一性和存储位置。如果键对象在插入后发生了变化,可能会导致哈希码改变,从而无法正确找到或删除对应的值。 -
一致性(Consistency) :在HashMap这个数据结构中,键对象的
hashCode()和equals()方法必须保持一致。也就是说,如果两个键通过equals()方法比较为相等,那么它们的hashCode()必须相同;反之,如果hashCode()相同,equals()不一定返回true,但hashCode()不同则equals()必须返回false。
上面的话比较晦涩,容易让人迷糊,再通俗的解释一下:
对于Hash类型的数据结构而言,计算一个值的hash值,然后放到对应的桶里去,应该说,哈希值相等的,应该放到一个桶里,但是值本身不一定相等,这样说就里理顺了。
为什么要强调哈希键值的一致性?
问题的核心在于 :equals 和 hashCode 是两个独立的方法,默认实现并不保证这种一致性!
默认实现:
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);
}
}
因为这种错误很常见且难以调试:
- 编译不会报错
- 运行时可能不立即崩溃
- 在某些情况下能"正常工作",在某些情况下失败
- 问题具有隐蔽性
因此,强调这个一致性是因为:
- 默认实现不保证一致性
- 只重写一个方法很容易忘记另一个
- 违反这个规则会导致难以发现的 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 的键,可能会导致以下问题:
- 哈希码不一致:
List的hashCode()方法是基于其内容的。如果List的内容发生变化,其哈希码也会随之改变。这会导致HashMap无法正确找到或删除对应的值,因为HashMap使用的是插入时的哈希码来定位存储位置。 - 违反契约:
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 无法正确找到对应的值。
再看这样一个更简单的例子
- Hash(键List A) = 1, HashMap[键List A] = {3,4,5}
- HashMap[键List A].append( item X )
- Hash(List A) != 1, 值 {3,4,5} 丢失!!
List hashcode的计算方法
在 Java 中,List 接口的实现类(如 ArrayList、LinkedList 等)的 hashCode() 方法是通过遍历列表中的所有元素,并根据每个元素的哈希码计算得出的。具体来说,List 的 hashCode() 实现遵循以下规则:
List.hashCode() 的计算规则
List 的 hashCode() 方法通常基于以下公式计算:
s = 1
for (E e : list) {
s = 31 * s + (e == null ? 0 : e.hashCode())
}
return s
详细解释:
-
初始值:
- 计算开始时,
s被初始化为1。
- 计算开始时,
-
遍历元素:
-
对于列表中的每一个元素
e,执行以下步骤:- 如果元素
e为null,则其哈希码视为0。 - 否则,使用元素
e的hashCode()方法获取其哈希码。 - 更新
s的值为31 * s + 当前元素的哈希码。
- 如果元素
-
-
返回结果:
- 遍历完所有元素后,
s即为列表的最终哈希码。
- 遍历完所有元素后,
为什么选择 31?
- 性能优化:31 是一个奇素数,且
31 * i可以被优化为(i << 5) - i,这在某些 JVM 实现中可以提高计算效率。 - 减少冲突:选择一个适当的素数有助于减少不同对象产生相同哈希码的概率,从而提高哈希表的性能。
示例代码
以下是一个简化的 ArrayList 的 hashCode() 实现示例:
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;
}
非用不可的话
-
使用不可变列表:
为了避免上述问题,建议在使用
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 的哈希码 -
自定义键类:
- 另一种方法是创建一个不可变的自定义键类,封装
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"); - 另一种方法是创建一个不可变的自定义键类,封装
总结
List 的 hashCode() 方法通过遍历列表中的每个元素,并结合元素的哈希码和乘数 31 来计算最终的哈希值。这种计算方式确保了哈希码的分布均匀性,但由于 List 的可变性,使用时需谨慎,避免在作为键后修改列表内容。
推荐的做法是使用不可变的列表或自定义不可变键类,以确保哈希码的一致性和 HashMap 的正确性。