本文我们探索java集合框架中最受欢迎Map接口实现类。首先我们需要指出,List和Set集合接口继承自Collection,但Map不是。
简单地说,HashMap通过键存储值,并提供api以各种方式添加、检索和操作存储数据。其实现是基于哈希表的原理,听起来有些复杂,但实际比较好理解。
键-值对存储在所谓的桶中(bucket),这些桶构成了所谓的表,这实际上是一个内部数组。一旦我们知道对象存储的键,存储和检索操作时间为常量,哈希表的时间复杂度为O(1)。
为了更好地理解hashMap工作原理,需要理解HashMap运用的存储于检索机制,下面我们主要讲解这些关键点。HashMap相关问题在面试中很常见,所以理解好对你面试也非常有帮助。
往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 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 extends Collection
因为map存储键值对,而其他集合存储单个值。所以,一般collection接口方法如add,toArray对map来说没有意义。
上面我们讨论的问题,是java集合框架中面试最常见的问题,值得你去理解并掌握。
hash map特殊之处还有,其接受null值和null键:
@Test
public void givenNullKeyAndVal_whenAccepts_thenCorrect(){
Map map = new HashMap<>();
map.put(null, null);
}
当在put操作中遇到null键,其自动分配一个常量哈希值为0,意味着它成为后台数组的第一个元素。同时,key为null,不执行哈希操作,也避免出现null指针异常。
在调用put方法时,如果使用key,之前已经使用过,则返回之前关联的值:
@Test
public void givenExistingKey_whenPutReturnsPrevValue_thenCorrect() {
Map map = new HashMap<>();
map.put("key1", "val1");
String rtnVal = map.put("key1", "val2");
assertEquals("val1", rtnVal);
}
否则,返回null:
@Test
public void givenNewKey_whenPutReturnsNull_thenCorrect() {
Map map = new HashMap<>();
String rtnVal = map.put("key1", "val1");
assertNull(rtnVal);
}
当put方法返回值为null,也可能意味着之前关联key值为null,不一定意味着是新的键值对插入。
@Test
public void givenNullVal_whenPutReturnsNull_thenCorrect() {
Map map = new HashMap<>();
String rtnVal = map.put("key1", null);
assertNull(rtnVal);
}
containsKey方法可以用来区分这两种场景,下面章节我们会看到。
为了检索存储在hash map中的对象,我们必须知道对象关联的key。get方法通过传入key对象获取相应对象:
@Test
public void whenGetWorks_thenCorrect() {
Map 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 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 map = new HashMap<>();
String rtnVal = map.get("key1");
assertNull(rtnVal);
}
或也可能为key对象关联一个null 对象:
@Test
public void givenNullVal_whenRetrieves_thenCorrect() {
Map map = new HashMap<>();
map.put("key", null);
String val=map.get("key");
assertNull(val);
}
为了区分这两种场景,我们可以使用containsKey方法,如果特定key映射存在,则返回true:
@Test
public void whenContainsDistinguishesNullValues_thenCorrect() {
Map 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提供了三种视图,使我们可以将其所有键、值作为另一种集合。我们可以获得map所有key作为一个Set集合:
@Test
public void givenHashMap_whenRetrievesKeyset_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Set 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 map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
assertEquals(2, map.size());
Set keys = map.keySet();
keys.remove("name");
assertEquals(1, map.size());
}
我们也能或所有值的集合视图:
@Test
public void givenHashMap_whenRetrievesValues_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Collection 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 map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Set> entries = map.entrySet();
assertEquals(2, entries.size());
for (Entry 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 map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Set keys = map.keySet();
Iterator it = keys.iterator();
map.remove("type");
while (it.hasNext()) {
String key = it.next();
}
}
仅允许的结构性修改是通过迭代器执行的删除操作:
public void givenIterator_whenRemoveWorks_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "baeldung");
map.put("type", "blog");
Set keys = map.keySet();
Iterator 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性能受两个参数影响:初始容量及负载因子(客座率)。容量是桶的数量或后台数组的长度,初始容量即创建时的容量。
简而言之,负载因子或客座率(LF)是一个度量,在添加一些值之前,该hash map的大小应该重新扩展至多大。
缺省的初始容量为16,负载因子为0.75。我们能自定义这两个参数来创建hash map。
Map hashMapWithCapacity=new HashMap<>(32);
Map hashMapWithCapacityAndLF=new HashMap<>(32, 0.5f);
Java团队设置的默认值在大多数情况下都能得到了较好的优化。但是,如果你需要自己设定值,这很好,但你需要了解性能影响情况,以便您知道您在做什么。
当hash map的记录项数量超过负载因子和容量的乘积时,则发送重新哈希。即创建另一个内部数组,其大小是初始容量的两倍,所有记录都移到新数组中的新bucket位置。
低初始容量减少了空间成本,但增加了重新哈希的频率。重新哈希显然是一个非常昂贵的过程。因此,作为一个规则,如果您预期有较多记录条目,您应该设置一个相当高的初始容量。
另一方面,如果您将初始容量设置得过高,您将在迭代时支付成本。正如我们在前一节中看到的。
因此,高初始容量对于大量的条目是有好处的,并且几乎没有迭代。低初始容量适合于多次迭代的条目。
冲突,或者更确切地说,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 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实现。这些内容在技术面试中非常常见,尤其是基本的存储和检索问题。