1. 引言
本教程将介绍如何在 HashMap 中使用字节数组(byte array)作为键。由于 HashMap 的工作原理限制,我们不能直接这样使用。我们将深入探讨原因,并提供几种解决方案。
2. 设计一个适合 HashMap 的键
2.1. HashMap 工作机制简析
HashMap 使用 哈希机制 来存储和检索数据。当我们调用 put(key, value)
方法时,HashMap 会基于键对象的 hashCode()
方法计算出哈希值,然后根据这个哈希值确定存储桶(bucket),最终将值放入该桶中:
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry 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;
}
当我们通过 get(key)
方法获取值时,也会进行类似操作:首先计算哈希值定位到桶,再通过 equals()
方法逐一比较桶中的键是否匹配,从而找到对应值:
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
for (Entry e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
2.2. equals()
与 hashCode()
的契约关系
equals 和 hashCode 方法之间存在明确的契约,其中最关键的一条是:相等的对象必须返回相同的哈希码。不过反过来不成立,即相同哈希码的对象未必相等,这允许在一个桶中存储多个不同键。
2.3. 键的不可变性
✅ 建议键对象保持不可变,虽然不是强制要求,但能避免因对象状态变化导致哈希码变动的问题。一旦对象不可变,其 hashCode()
值就不会改变,无论其实现方式如何。
默认情况下,哈希码是基于所有字段计算的。如果要用可变对象做键,需要重写 hashCode()
方法,排除那些可能变动的字段;同时为了维持一致性,也应调整 equals()
方法。
2.4. 合理的 equals 实现
要从 Map 中正确取回值,键之间的相等性判断必须有意义。通常我们需要能够创建一个新的键对象,让它与已存在的键“相等”,因此使用对象身份(identity)判断并不合适。
⚠️ 这也是为什么不能直接使用原始字节数组作为键的原因:Java 中的数组使用对象引用比较(identity-based equality)。也就是说,即使两个字节数组内容完全一样,它们也不会被认为是相等的。
来看一个 naive 的例子:
byte[] key1 = {1, 2, 3};
byte[] key2 = {1, 2, 3};
Map<byte[], String> map = new HashMap<>();
map.put(key1, "value1");
map.put(key2, "value2");
结果你会发现:
String retrievedValue1 = map.get(key1);
String retrievedValue2 = map.get(key2);
String retrievedValue3 = map.get(new byte[]{1, 2, 3});
assertThat(retrievedValue1).isEqualTo("value1");
assertThat(retrievedValue2).isEqualTo("value2");
assertThat(retrievedValue3).isNull(); // ❌ 取不到任何值!
📌 因为每次 new 出来的数组都是不同的对象,哪怕内容一样,也无法命中已有键!
3. 使用现成容器类替代字节数组
既然原生数组不适合作为键,我们可以考虑使用其他封装类,这些类的 equals()
是基于内容而非引用的。
3.1. 字符串 String
String 类型天然满足我们的需求,它的 equals()
方法是基于字符数组的内容进行逐个比对的:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = count;
if (n == anotherString.count) {
char v1[] = value;
char v2[] = anotherString.value;
int i = offset;
int j = anotherString.offset;
while (n-- != 0) {
if (v1[i++] != v2[j++])
return false;
}
return true;
}
}
return false;
}
✅ 此外,String 是不可变的,而且很容易从 byte 数组构造出来。例如可以借助 Base64 编码转换:
String key1 = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3});
String key2 = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3});
然后就可以正常用作 Map 的键了:
Map<String, String> map = new HashMap<>();
map.put(key1, "value1");
map.put(key2, "value2");
测试一下:
String retrievedValue1 = map.get(key1);
String retrievedValue2 = map.get(key2);
assertThat(key1).isEqualTo(key2); // ✅ 成立
assertThat(retrievedValue1).isEqualTo("value2");
assertThat(retrievedValue2).isEqualTo("value2");
这种方式简单粗暴,适用于大多数场景。
3.2. List 容器
List 也有类似的特性:其 equals()
方法会对每个元素进行逐一比较,只要元素本身支持合理的 equals 判断,就可以作为 Map 键使用。
⚠️ 但要注意,使用 List<Byte>
会带来较大的内存开销 —— 每个 byte 被包装成 Byte 对象,远不如原生数组紧凑。
示例代码如下:
List<Byte> key1 = ImmutableList.of((byte)1, (byte)2, (byte)3);
List<Byte> key2 = ImmutableList.of((byte)1, (byte)2, (byte)3);
Map<List<Byte>, String> map = new HashMap<>();
map.put(key1, "value1");
map.put(key2, "value2");
assertThat(map.get(key1)).isEqualTo(map.get(key2)); // ✅ 相等
所以除非你特别在意语义清晰或者性能不是瓶颈,否则不太推荐这种做法。
4. 自定义包装类
如果你追求极致性能或更小的内存占用,完全可以自己写一个包装类来控制哈希码和相等性逻辑。
4.1. 实现自定义 BytesKey 包装类
我们定义一个 final 类,内部持有一个 final 的 byte 数组字段,并且提供防御性拷贝保证不可变性:
public final class BytesKey {
private final byte[] array;
public BytesKey(byte[] array) {
this.array = array;
}
public byte[] getArray() {
return array.clone(); // 防御性拷贝
}
}
接着重写 equals()
和 hashCode()
方法,利用 Arrays
工具类实现:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BytesKey bytesKey = (BytesKey) o;
return Arrays.equals(array, bytesKey.array);
}
@Override
public int hashCode() {
return Arrays.hashCode(array);
}
现在就可以愉快地把它作为 Map 的键使用了:
BytesKey key1 = new BytesKey(new byte[]{1, 2, 3});
BytesKey key2 = new BytesKey(new byte[]{1, 2, 3});
Map<BytesKey, String> map = new HashMap<>();
map.put(key1, "value1");
map.put(key2, "value2");
测试获取值:
String retrievedValue1 = map.get(key1);
String retrievedValue2 = map.get(key2);
String retrievedValue3 = map.get(new BytesKey(new byte[]{1, 2, 3}));
assertThat(retrievedValue1).isEqualTo("value2");
assertThat(retrievedValue2).isEqualTo("value2");
assertThat(retrievedValue3).isEqualTo("value2"); // ✅ 都能命中
这种方式兼顾了性能和安全性,适合高性能场景。
5. 总结
本文我们分析了为什么不能直接将字节数组作为 HashMap 的键使用,并提供了三种可行方案:
- 使用 String + Base64 编码:最简单直接;
- 使用 *List
*:语义清晰但内存开销大; - 自定义 BytesKey 包装类:性能最优,可控性强。
每种方法都有适用场景,选择哪种取决于你的具体需求。完整源码可以在 GitHub 找到。