1. 概述
在本教程中,我们将学习如何使用字节数组作为HashMap中的键。由于HashMap的工作方式,不幸的是,我们不能直接这样做。我们将找出原因并研究解决该问题的几种方法。
2. 为HashMap设计一个好的Key
2.1 HashMap是如何工作的
HashMap使用哈希机制来存储和检索自身的值。当我们调用put(key, value)方法时,HashMap会根据键的hashCode()方法计算哈希码。此哈希用于标识最终存储值的存储桶:
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方法都有应遵守的约定。在HashMaps的上下文中,有一个方面特别重要:彼此相等的对象必须返回相同的hashCode。但是,返回相同hashCode的对象不需要彼此相等。这就是为什么我们可以在一个桶中存储多个值。
2.3 不变性
HashMap中键的hashCode不应该改变。虽然这不是强制性的,但强烈建议键不可变。如果对象是不可变的,则无论hashCode方法的实现如何,它的hashCode都没有机会更改。
默认情况下,哈希是根据对象的所有字段计算的。如果我们想要一个可变键,我们需要覆盖hashCode方法以确保在其计算中不使用可变字段。为了维护契约,我们还需要更改equals方法。
2.4 有意义的相等
为了能够成功地从Map中检索值,相等性必须有意义。在大多数情况下,我们需要能够创建一个新的键对象,该对象将等于Map中的某个现有键。出于这个原因,对象身份在这种情况下不是很有用。
这也是为什么使用原始字节数组不是一种选择的主要原因,Java中的数组使用对象标识来确定相等性。如果我们创建以字节数组为键的HashMap,我们将仅能够使用完全相同的数组对象来检索值。
让我们创建一个以字节数组为键的简单实现:
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();
3. 使用现有容器
我们可以使用现有的类来代替字节数组,这些类的相等性实现基于内容,而不是对象标识。
3.1 String
字符串相等性基于字符数组的内容:
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也是不可变的,基于字节数组创建String非常简单。我们可以使用Base64方案轻松地对String进行编码和解码:
String key1 = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3});
String key2 = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3});
现在我们可以创建一个以String作为键而不是字节数组的HashMap。我们将以类似于前面示例的方式将值放入Map中:
Map<String, String> map = new HashMap<>();
map.put(key1, "value1");
map.put(key2, "value2");
然后我们可以从Map中检索一个值。对于这两个键,我们将获得相同的第二个值。我们还可以检查键是否真正相等:
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
与String类似,List#equals方法将检查其每个元素是否相等。如果这些元素有一个合理的equals()方法并且是不可变的,则List将作为HashMap键正常工作。我们只需要确保我们使用的是不可变的List实现:
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));
请注意,Byte对象的List将比byte基本类型数组占用更多的内存。因此,该解决方案虽然方便,但在大多数情况下并不可行。
4. 实现自定义容器
我们还可以实现自己的包装器以完全控制哈希码计算和相等性。这样我们就可以确保解决方案很快并且不会占用大量内存。
让我们创建一个包含私有final字节数组字段的类。它没有setter,它的getter将制作一个防御性副本以确保完全不变:
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);
}
最后,我们可以将包装器用作HashMap中的键:
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中使用字节数组作为键的不同问题和解决方案。首先,我们研究了为什么我们不能使用数组作为键。然后我们使用一些内置容器来缓解这个问题,最后实现了我们自己的包装器。
与往常一样,本教程的完整源代码可在GitHub上获得。