深入理解Java HashMap

深入理解Java HashMap

本文我们探索java集合框架中最受欢迎Map接口实现类。首先我们需要指出,List和Set集合接口继承自Collection,但Map不是。

简单地说,HashMap通过键存储值,并提供api以各种方式添加、检索和操作存储数据。其实现是基于哈希表的原理,听起来有些复杂,但实际比较好理解。

键-值对存储在所谓的桶中(bucket),这些桶构成了所谓的表,这实际上是一个内部数组。一旦我们知道对象存储的键,存储和检索操作时间为常量,哈希表的时间复杂度为O(1)。

为了更好地理解hashMap工作原理,需要理解HashMap运用的存储于检索机制,下面我们主要讲解这些关键点。HashMap相关问题在面试中很常见,所以理解好对你面试也非常有帮助。

put方法

往haspMap中存储值,通过put方法,需要两个参数:key和对应的值:
V put(K key, V value);

当一个值被添加到map中与key进行映射时,key对象的hashCode()方法被调用来生成被称为初始散列值。为了演示这个,我们创建仅包含单独的属性作为哈希代码来模拟哈希的第一个阶段:

public class MyKey {
    private int id;

    @Override
    public int hashCode() {
        System.out.println("Calling hashCode()");
        return id;
    }

    // constructor, setters and getters 
}

现在使用该对象作为key在hashmap中映射一个值:

@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()方法被调用,通过初始哈希值计算最终哈希值。最终的哈希值实际为内部数组中的索引或我们称为bucket位置的索引。

HashMap中hash方法代码大概如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里我们要注意的是,仅从key对象中使用hashCode来计算最终的哈希值。在put方法内部,最终哈希值是这样被使用的:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

调用内部的putVal方法,并使用最终哈希值作为第一个参数。有人可能奇怪为什么key在putVal再次被使用,因为已经使用其计算哈希值了。

原因是hash map在bucket位置存储key-value对作为Map.Entry对象。

前面我们提到,所有java集合框架接口都继承自Collection接口,但Map不是。和Set接口声明比较下:

public interface Set<E> extends Collection<E>

因为map存储键值对,而其他集合存储单个值。所以,一般collection接口方法如add,toArray对map来说没有意义。
上面我们讨论的问题,是java集合框架中面试最常见的问题,值得你去理解并掌握。

hash map特殊之处还有,其接受null值和null键:

@Test
public void givenNullKeyAndVal_whenAccepts_thenCorrect(){
    Map<String, String> map = new HashMap<>();
    map.put(null, null);
}

当在put操作中遇到null键,其自动分配一个常量哈希值为0,意味着它成为后台数组的第一个元素。同时,key为null,不执行哈希操作,也避免出现null指针异常。

在调用put方法时,如果使用key,之前已经使用过,则返回之前关联的值:

@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,也可能意味着之前关联key值为null,不一定意味着是新的键值对插入。

@Test
public void givenNullVal_whenPutReturnsNull_thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String rtnVal = map.put("key1", null);

    assertNull(rtnVal);
}

containsKey方法可以用来区分这两种场景,下面章节我们会看到。

get方法

为了检索存储在hash map中的对象,我们必须知道对象关联的key。get方法通过传入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方法获得初始哈希值:

@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方法被调用了两次,一个是put,另一次是get:

Calling hashCode()
Calling hashCode()

该值被内部hash方法重新哈希获得最终哈希值。如前节所示,最终哈希值最终绑定到桶位置或内部数组位置索引。

存储在指定位置的对象被检索到,然后被调用方法返回。当返回对象为null,可能意味着key对象没有关联hash map中任何一个值:

@Test
public void givenUnmappedKey_whenGetReturnsNull_thenCorrect() {
    Map<String, String> map = new HashMap<>();

    String rtnVal = map.get("key1");

    assertNull(rtnVal);
}

或也可能为key对象关联一个null 对象:

@Test
public void givenNullVal_whenRetrieves_thenCorrect() {
    Map<String, String> map = new HashMap<>();
    map.put("key", null);

    String val=map.get("key");

    assertNull(val);
}

为了区分这两种场景,我们可以使用containsKey方法,如果特定key映射存在,则返回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方法返回值为null,但我们可以通过containsKey方法进行区分。

HashMap中集合视图

HashMap提供了三种视图,使我们可以将其所有键、值作为另一种集合。我们可以获得map所有key作为一个Set集合:

@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"));
}

返回的set实际是map的视图,所以对set的任何改变会反映至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"));
}

和key的Set视图一样,任何值集合视图改变也会反映至底层的map。
最后,我们能获得map中所有项(键值对)的set集合视图:

@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包含无序元素,因此,在测试每个循环中的条目的键和值时,我们假定与顺序无关。
大多数情况下,我们遍历map的集合视图,如上面最后示例。特定情况下,使用迭代器(iterators)。需要注意的是:迭代器对上面所有视图都会失败

在迭代器已经被创建之后,如果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();
    }
}

仅允许的结构性修改是通过迭代器执行的删除操作:

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());
}

关于这些集合视图,最后要记住的是迭代的性能。这是hash map相对于其对应的linked hash map 和 tree map执行性能的比较差的地方。

在一个hash map上发生迭代,在最坏的情况下O(n),其中n是其容量和条目数之和。

HashMap性能

HashMap性能受两个参数影响:初始容量及负载因子(客座率)。容量是桶的数量或后台数组的长度,初始容量即创建时的容量。

简而言之,负载因子或客座率(LF)是一个度量,在添加一些值之前,该hash map的大小应该重新扩展至多大。

缺省的初始容量为16,负载因子为0.75。我们能自定义这两个参数来创建hash map。

Map<String,String> hashMapWithCapacity=new HashMap<>(32);
Map<String,String> hashMapWithCapacityAndLF=new HashMap<>(32, 0.5f);

Java团队设置的默认值在大多数情况下都能得到了较好的优化。但是,如果你需要自己设定值,这很好,但你需要了解性能影响情况,以便您知道您在做什么。

当hash map的记录项数量超过负载因子和容量的乘积时,则发送重新哈希。即创建另一个内部数组,其大小是初始容量的两倍,所有记录都移到新数组中的新bucket位置。

低初始容量减少了空间成本,但增加了重新哈希的频率。重新哈希显然是一个非常昂贵的过程。因此,作为一个规则,如果您预期有较多记录条目,您应该设置一个相当高的初始容量。

另一方面,如果您将初始容量设置得过高,您将在迭代时支付成本。正如我们在前一节中看到的。

因此,高初始容量对于大量的条目是有好处的,并且几乎没有迭代。低初始容量适合于多次迭代的条目。

HashMap键冲突

冲突,或者更确切地说,HashMap中的hashcode冲突,是两个或多个键对象产生相同的最终哈希值,从而指向相同的bucket位置或内部数组索引的情况。

这种情况可能发生,因为根据equals和hashCode约定,Java中的两个不相等的对象可以具有相同的哈希值。它也可能发生,因为底层数组的大小有限,即在调整大小之前。数组越小,冲突几率就越高。

因此,值得一提的是,Java实现了一个哈希值冲突解决方案,我们将通过示例来了解它。

请记住,确定对象存储在bucket中位置的是key对象的哈希值。因此,如果任意两个键的哈希值发生冲突,它们的记录条目仍然会存储在同一个bucket中。缺省使用linked list作为bucket的实现。

put方法时间复杂度为常量时间O(1),get方法在冲突情况下,时间复杂度为线性时间O(n)。这时因为使用最终哈希值找到bucket的位置后,bucket中每个key都需要和指定key进行equals方法比较。

为了模拟这个冲入技术,让我们修改之前key对象:

public class MyKey {
    private String name;
    private int id;

    public MyKey(int id, String name) {
        this.id = id;
        this.name = name;
    }

    // standard getters and setters

    @Override
    public int hashCode() {
        System.out.println("Calling hashCode()");
        return id;
    } 

    // toString override for pretty logging

    @Override
    public boolean equals(Object obj) {
        LOG.debug("Calling equals() for key: " + obj);
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        MyKey other = (MyKey) obj;
        if (id != other.id)
            return false;
        if (name == null) {
            if (other.name != null)
                return false;
        } else if (!name.equals(other.name))
            return false;
        return true;
    }

}

注意,我们简单返回id属性作为哈希值——为了强制造成冲突。同时,我们在equals和hashCode方法增加日志记录代码,为了确定代码确实被调用。现在继续存储并检索一些对象,并示例冲突情况:

@Test
public void whenCallsEqualsOnCollision_thenCorrect() {
    HashMap<MyKey, String> map = new HashMap<>();
    MyKey k1 = new MyKey(1, "firstKey");
    MyKey k2 = new MyKey(2, "secondKey");
    MyKey k3 = new MyKey(2, "thirdKey");

    System.out.println("storing value for k1");
    map.put(k1, "firstValue");
    System.out.println("storing value for k2");
    map.put(k2, "secondValue");
    System.out.println("storing value for k3");
    map.put(k3, "thirdValue");

    System.out.println("retrieving value for k1");
    String v1 = map.get(k1);
    System.out.println("retrieving value for k2");
    String v2 = map.get(k2);
    System.out.println("retrieving value for k3");
    String v3 = map.get(k3);

    assertEquals("firstValue", v1);
    assertEquals("secondValue", v2);
    assertEquals("thirdValue", v3);
}

在上面的测试中,我们创建了三个不同的key,一个有唯一id,另外两个有相同的id。因此,我们使用id作为初始哈希值,则在通过key存储或检索对象时,一定会发生冲突。

由于我们之前描述的冲突解决技术,我们期望每个存储的值都能被正确地检索,因此在最后三行中的断言都正确返回。当我们运行测试时,它应该是通过的,表明冲突被解决了,我们通过产生的日志可以确认冲突确实发生了:

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]

注意,在存储操作过程中,仅使用哈希值,k1和k2通过其哈希值成功地映射到对应对象。然而,k3的存储并不是那么简单,系统检测到它的bucket位置已经包含了k2的映射。因此,使用equals比较来区分它们,并创建一个链表来包含两个映射。

任何其他后续的映射,其key的哈希值映射到相同的bucket位置将遵循相同的方式,并最终替换链表中的一个节点,如果通过equals方法和所有节点比较返回false,则将其添加到列表的头部。同样地,在检索过程中,k3和k2相比之下都是相等的,识别正确的key的值应该被检索。

最后需要说明的是,从Java 8中,在一个给定的bucket位置的冲突数超过某个阈值后,在冲突解决过程中,链表被动态地替换为平衡的二叉搜索树。由于其时间复杂度为O(log n),因此这种更改提供了性能提升。

总结

在本文中,我们讨论了Java Map接口的HashMap实现。这些内容在技术面试中非常常见,尤其是基本的存储和检索问题。

猜你喜欢

转载自blog.csdn.net/neweastsun/article/details/80232752