1.Overview
在本文中,我们将从 Java Collections Framework
中探索最流行的 Map
接口实现。
在开始学习其实现之前,重要的是要指出 List
和 Set
集合接口继承 Collection
但 Map
不是。
简而言之,HashMap
按键存储值,并提供用于以各种方式添加,检索和操作存储数据的 API
。其实现基于哈希表的原理,一开始听起来有点复杂但实际上很容易理解。
键值对存储在所谓的 bucket
中,这些 bucket
一起构成所谓的表,实际上是一个内部数组。
一旦我们知道对象被存储或将被存储的关键字,存储和检索操作就会在恒定的时间内发生,O(1)
在尺寸合适的 hash map
中。
要了解 hash maps
是如何工作的,需要了解HashMap
使用的存储和检索机制。我们将重点关注这些问题。
最后,HashMap
相关问题在面试中非常常见,因此这是准备面试或准备面试的可靠方法。
2.The put() API
为了将值存储在哈希映射中,我们调用有两个参数的 put
API
;一个键和相应的值:
V put(K key, V value);
当一个值被添加到某个键下的映射中时,将调用该键对象的 hashCode()
API
来检索所谓的初始散列值。
为了看到这一点,让我们创建一个可以充当关键的对象。我们只创建一个属性作为哈希码来模拟哈希的第一阶段:
public class MyKey {
private int id;
@Override
public int hashCode() {
System.out.println("Calling hashCode()");
return id;
}
// constructor, setters and getters
}
我们现在可以使用这个对象来映射哈希映射中的一个值:
@Test
public void whenHashCodeIsCalledOnPut_thenCorrect() {
MyKey key = new MyKey(1);
Map<MyKey, String> map = new HashMap<>();
map.put(key, "val");
}
在上面的代码中没有什么发生,但要注意控制台的输出。事实上,hashCode
方法被调用:
Calling hashCode()
接下来,内部调用 hash map
的 hash()
API
使用初始哈希值计算最终哈希值。
最终的哈希值最终归结为内部数组中的索引或我们称之为存储桶位置的索引。
HashMap
的哈希函数如下所示:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们在这里应该注意的只是使用键对象中的哈希码来计算最终的哈希值。
在 put
函数中,最终的哈希值是这样使用的:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
请注意,这里调用了内部 putVal
函数,并将最终散列值作为第一个参数。
有人可能会问,为什么在这个函数里面再次使用这个键,因为我们已经用它来计算散列值了。
原因是 hash maps store both key and value in the bucket location as a Map.Entry object 。
如前所述,所有 Java
集合框架接口都扩展了 Collection
接口,但 Map
不扩展。比较我们之前看到的 Map
接口的声明和 Set
接口的声明:
public interface Set<E> extends Collection<E>
原因是 maps do not exactly store single elements as do other collections but rather a collection of key-value pairs 。
所以 Collection
的接口的通用方法,比如 add
,toArray
,在涉及到 Map
时没有任何意义。
我们在最后三段中介绍的概念是最受欢迎的Java Collections Framework面试问题之一。所以,值得了解。
hash map
的一个特殊属性是它接受 null
values and null
keys:
@Test
public void givenNullKeyAndVal_whenAccepts_thenCorrect(){
Map<String, String> map = new HashMap<>();
map.put(null, null);
}
在 put
操作期间遇到 null
键时,它会自动分配 0 的最终哈希值。这意味着它成为底层数组的第一个元素。
这也意味着,当 key
为空时,不存在哈希操作,因此不调用 key
的 hashCode
API
,最终避免空指针异常。
在 put
操作中,当我们使用之前已经使用过的一个键来存储一个值时,它返回与该键相关的以前的值:
@Test
public void givenExistingKey_whenPutReturnsPrevValue_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("key1", "val1");
String rtnVal = map.put("key1", "val2");
assertEquals("val1", rtnVal);
}
否则,它返回 null
:
@Test
public void givenNewKey_whenPutReturnsNull_thenCorrect() {
Map<String, String> map = new HashMap<>();
String rtnVal = map.put("key1", "val1");
assertNull(rtnVal);
}
当 put
返回 null
时,它也可能意味着与键关联的前一个值为空,而不一定是一个新的键值映射:
@Test
public void givenNullVal_whenPutReturnsNull_thenCorrect() {
Map<String, String> map = new HashMap<>();
String rtnVal = map.put("key1", null);
assertNull(rtnVal);
}
containsKey
API
可以用来区分这些场景,我们将在下一小节中看到。
3.The get API
要检索已存储在hash map
中的对象,我们必须知道存储它的 key
。我们调用 get
API
并将 key
对传递给它:
@Test
public void whenGetWorks_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("key", "val");
String val = map.get("key");
assertEquals("val", val);
}
在内部,使用相同的哈希原则。
调用 key
对象的 hashCode()
API
以获取初始哈希值:
@Test
public void whenHashCodeIsCalledOnGet_thenCorrect() {
MyKey key = new MyKey(1);
Map<MyKey, String> map = new HashMap<>();
map.put(key, "val");
map.get(key);
}
这一次,MyKey
的 hashCode()
API
被调用两次;一次用于放置,一次用于获得:
Calling hashCode()
Calling hashCode()
然后通过调用内部 hash()
API
来获取最终散列值,从而重新调整此值。
正如我们在前一节看到的那样,这个最终的散列值最终归结为一个 bucket location
或内部 array
的索引。
存储在该位置的值对象然后被检索并返回到调用函数。
当返回值为空时,可能意味着键对象不与 hash map
中的任何值相关联:
@Test
public void givenUnmappedKey_whenGetReturnsNull_thenCorrect() {
Map<String, String> map = new HashMap<>();
String rtnVal = map.get("key1");
assertNull(rtnVal);
}
或者它可能仅仅意味着 key
被显式映射到空实例:
@Test
public void givenNullVal_whenRetrieves_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("key", null);
String val=map.get("key");
assertNull(val);
}
为了区分这两种情况,我们可以使用 containsKey
API
,我们将 key
传递给它,并且当且仅当在 hash map
中为指定键创建映射时,才返回 true
:
@Test
public void whenContainsDistinguishesNullValues_thenCorrect() {
Map<String, String> map = new HashMap<>();
String val1 = map.get("key");
boolean valPresent = map.containsKey("key");
assertNull(val1);
assertFalse(valPresent);
map.put("key", null);
String val = map.get("key");
valPresent = map.containsKey("key");
assertNull(val);
assertTrue(valPresent);
}
对于上述测试中的两种情况,get
API
调用的返回值为 null
,但我们能够区分哪一个是哪个。
4.Collection Views In HashMap
HashMap
提供了三个视图,使我们能够将其键和值视为另一个集合。我们可以得到一组map的所有键:
@Test
public void givenHashMap_whenRetrievesKeyset_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Set<String> keys = map.keySet();
assertEquals(2, keys.size());
assertTrue(keys.contains("name"));
assertTrue(keys.contains("type"));
}
The set is backed by the map itself
. 因此,对集合所做的任何更改都会反映在map中:
@Test
public void givenKeySet_whenChangeReflectsInMap_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
assertEquals(2, map.size());
Set<String> keys = map.keySet();
keys.remove("name");
assertEquals(1, map.size());
}
我们还可以获取值的集合视图:
@Test
public void givenHashMap_whenRetrievesValues_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Collection<String> values = map.values();
assertEquals(2, values.size());
assertTrue(values.contains("baeldung"));
assertTrue(values.contains("blog"));
}
与键集一样,此集合中所做的任何更改都将反映在基础映射中。
最后,我们可以获得 map
中所有条目的集合视图:
@Test
public void givenHashMap_whenRetrievesEntries_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Set<Entry<String, String>> entries = map.entrySet();
assertEquals(2, entries.size());
for (Entry<String, String> e : entries) {
String key = e.getKey();
String val = e.getValue();
assertTrue(key.equals("name") || key.equals("type"));
assertTrue(val.equals("baeldung") || val.equals("blog"));
}
}
请记住,hash map
特别包含无序元素,因此我们在测试每个循环中的项和项值时假定有任何顺序。
很多时候,您将像上一个示例一样在循环中使用集合视图,更具体地说,使用它们的迭代器。
请记住所有上述视图的迭代器都是快速失败的。
在创建迭代器之后,如果在地图上进行了任何结构修改,则将引发并发修改异常:
@Test(expected = ConcurrentModificationException.class)
public void givenIterator_whenFailsFastOnModification_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Set<String> keys = map.keySet();
Iterator<String> it = keys.iterator();
map.remove("type");
while (it.hasNext()) {
String key = it.next();
}
}
唯一允许的结构修改是通过迭代器本身执行的remove操作:
public void givenIterator_whenRemoveWorks_thenCorrect() {
Map<String, String> map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Set<String> keys = map.keySet();
Iterator<String> it = keys.iterator();
while (it.hasNext()) {
it.next();
it.remove();
}
assertEquals(0, map.size());
}
关于这些集合视图的最后要记住的是迭代的性能。与linked hash map
和tree map
相比,hash map
执行效果差。
5.HashMap Performance
hash map
的性能受两个参数影响:初始容量(Initial Capacity
)和负载因子(Load Factor
)。
容量是 bucket
的数量或底层 array
的长度,初始容量就是创建时的容量。
简而言之,加载因子或LF是在调整大小之前添加一些值之后 hash map
应该是多么充分的度量。
默认初始容量为 16
,默认加载因子为 0.75
。我们可以创建一个 hash map
,其中包含初始容量和LF的自定义值:
Map<String,String> hashMapWithCapacity=new HashMap<>(32);
Map<String,String> hashMapWithCapacityAndLF=new HashMap<>(32, 0.5f);
Java
团队设置的默认值在大多数情况下都得到了很好的优化。
但是,如果您需要使用自己的值,这非常好,您需要了解性能影响,以便您知道自己在做什么。
当 hash map
条目的数量超过LF和容量的乘积时,将发生 rehashing
,即创建另一个内部 array
,其大小为初始大小的两倍,并且所有条目将移动到新 array
中的新 bucket location
。
初始容量低会降低空间成本,但会增加重新哈希的频率。Rehashing
显然是一个非常昂贵的过程。所以通常情况下,如果您预计会有很多条目,您应该设置相当高的初始容量。
另一方面,如果您将初始容量设置得太高,您将在迭代时间内支付成本。正如我们在前一节中看到的那样。
因此,初始容量高对于大量条目是有利的,加上几乎没有迭代。 较低的初始容量适用于具有大量迭代的少数条目。
6.Collisions in the HashMap
碰撞,或更具体地说,HashMap
中的哈希码冲突,是两个或多个密钥对象产生相同的最终哈希值并因此指向相同的桶位置或数组索引的情况。
出现这种情况是因为根据 equals
和 hashCode
协定,Java
中的两个不等对象可以具有相同的哈希码。
它也可能因为底层数组的大小有限而发生,也就是说,在调整大小之前。该阵列越小,碰撞的可能性就越大。
也就是说,值得一提的是 Java
实现了哈希码冲突解决技术,我们将通过一个例子来看。
请记住,是键的哈希值决定对象将存储的 bucket
。因此,如果任意两个键的哈希码发生冲突,它们的条目仍将存储在同一个 bucket
中。
默认情况下,实现使用链表作为 bucket
实现。
在碰撞的情况下,初始恒定时间O(1)put
和 get
操作将在线性时间O(n)中发生。
这是因为在找到具有最终散列值的 bucket location
之后,将使用等于 API
将该位置处的每个密钥与提供的密钥对象进行比较。
要模拟这种冲突解决技术,让我们稍微修改一下我们之前的密钥对象:
在上面的测试中,我们创建了三个不同的键 - 一个具有唯一的 ID
,另外两个具有相同的 ID
。
由于我们使用 id
作为初始哈希值,因此在使用这些键存储和检索数据时肯定会发生冲突。
除此之外,由于我们之前看到的碰撞解决技术,我们期望正确检索每个存储的值,因此最后三行中的断言。
当我们运行测试时,它应该通过,表明冲突已经解决,我们将使用生成的日志记录来确认确实发生了冲突:
storing value for k1
Calling hashCode()
storing value for k2
Calling hashCode()
storing value for k3
Calling hashCode()
Calling equals() for key: MyKey [name=secondKey, id=2]
retrieving value for k1
Calling hashCode()
retrieving value for k2
Calling hashCode()
retrieving value for k3
Calling hashCode()
Calling equals() for key: MyKey [name=secondKey, id=2]
请注意,在存储操作期间,仅使用 hash code
将 k1
和 k2
成功映射到其值。
但是,k3
的存储并不那么简单,系统检测到 bucket location
已经包含了 k2
的映射。因此,使用 equals
比较来区分它们,并创建 linked list
以包含两个映射。
其 key
哈希到同一个 bucket location
的任何其他后续映射,将遵循相同的路由并最终替换 linked list
中的一个节点,或者如果 equals
比较,对所有现有节点返回 false
,则将会被添加到它的头部;
同样,在检索期间,k3
和 k2
使用 equals
比较,以识别应该检索其值的正确的 key
。
最后,从 Java 8
开始,在给定 bucket laocation
的冲突数超过某个阈值后,linked list
会在冲突解决中动态替换为 balanced binary search trees
。
这种改变提供了性能提升,因为在发生冲突的情况下,存储和检索发生在O(log n)
中。
本节在技术面试中非常常见,特别是在提出基本的存储和检索问题之后。
7.Conclusion
在本文中,我们探讨了 Java Map
接口的 HashMap
实现。