HashMap相关知识

1.Overview

在本文中,我们将从 Java Collections Framework 中探索最流行的 Map 接口实现。

在开始学习其实现之前,重要的是要指出 ListSet 集合接口继承 CollectionMap 不是。

简而言之,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 maphash() 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 的接口的通用方法,比如 addtoArray,在涉及到 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 为空时,不存在哈希操作,因此不调用 keyhashCode 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);
}

这一次,MyKeyhashCode() 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 maptree 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 中的哈希码冲突,是两个或多个密钥对象产生相同的最终哈希值并因此指向相同的桶位置或数组索引的情况。

出现这种情况是因为根据 equalshashCode 协定,Java 中的两个不等对象可以具有相同的哈希码。
它也可能因为底层数组的大小有限而发生,也就是说,在调整大小之前。该阵列越小,碰撞的可能性就越大。

也就是说,值得一提的是 Java 实现了哈希码冲突解决技术,我们将通过一个例子来看。

请记住,是键的哈希值决定对象将存储的 bucket。因此,如果任意两个键的哈希码发生冲突,它们的条目仍将存储在同一个 bucket 中。

默认情况下,实现使用链表作为 bucket 实现。

在碰撞的情况下,初始恒定时间O(1)putget 操作将在线性时间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 codek1k2 成功映射到其值。

但是,k3 的存储并不那么简单,系统检测到 bucket location 已经包含了 k2 的映射。因此,使用 equals 比较来区分它们,并创建 linked list 以包含两个映射。

key 哈希到同一个 bucket location 的任何其他后续映射,将遵循相同的路由并最终替换 linked list 中的一个节点,或者如果 equals 比较,对所有现有节点返回 false,则将会被添加到它的头部;

同样,在检索期间,k3k2 使用 equals 比较,以识别应该检索其值的正确的 key

最后,从 Java 8 开始,在给定 bucket laocation 的冲突数超过某个阈值后,linked list 会在冲突解决中动态替换为 balanced binary search trees

这种改变提供了性能提升,因为在发生冲突的情况下,存储和检索发生在O(log n)中。

本节在技术面试中非常常见,特别是在提出基本的存储和检索问题之后。

7.Conclusion

在本文中,我们探讨了 Java Map 接口的 HashMap 实现。

来源

The Java HashMap Under the Hood

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值