大家好,我是小水珠。 在上一讲中我们提到过Collection接口,那么在Java容器类中,除了这个接口之外,还定义了一个很重要的Map接口,主要用来存储键值对数据。 HashMap作为我们日常使用最频繁的容器之一,相信你一定不陌生了。今天我们就从HashMap的底层实现讲起,深度了解下它的设计与优化。
一 常用数据结构
数组
链表
哈希表
树
二 HashMap实现结构
作为常用的Map类,它是基于哈希表实现的,继承了AbstractMap并且实现了Map接口。
哈希表将键的Hash值映射到内存地址,即根据键获取对应的值,并将其存储到内存地址。也就是说HashMap是根据键的Hash值来决定对应值得存储位置。通过这种索引方式,HashMap获取数据的速度非常快。
例如,存储键值对(x,"aa")时,哈希表会通过哈希函数f(x)得到"aa"的实现存储位置。
三 HashMap的重要性
从HashMap的源码中,我们可以发现,HashMap是由一个Node的数组组成,每个Node包含了一个key-value键值对。
Node类作为HashMap中的一个内部类,除了key,value两个属性外,还定义了一个next指针。当有哈希冲突时,HashMap会用之前数组当中相同哈希值对应存储的Node对象,通过指针指向新增的相同哈希值的Node对象的引用。
HashMap还有两个重要的属性:加载因子(loadFactor)和边界值(threshold)。在初始化HashMap时,就会涉及到这两个关键初始化参数。
四 HashMap添加元素优化
如果你不太清楚hash()以及(n-1)&hash的算法,就请你看下面的详述。
我们先来了解下hash()方法中的算法。如果我们没有使用hash()方法计算hashCode,而是直接使用对象的hashCode值,会出现什么问题呢?
假设要添加两个对象a和b,如果数组长度是16,这时对象a和b通过公式(n-1)&hash运算,也就是(16-1)&a.hashCode和(16-1)&b.hashCode,15的二进制为00000000000000000000000000001111,假设对象a的hashCode为1000010001110001000001111000000,对象b的hashCode为0111011100111000101000010100000,你会发现上述与运算结果都是0。这样的哈希结果就太让人失望了,很明显不是一个好的哈希算法。
但如果我们将hashCode值右移16位(h>>16代表无符号右移16位),也就是取int类型的一半,刚好可以将该二进制数对半切开,并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,能避免上面的情况发生。这就是hash()方法的具体实现方式,这样恰好保证(n-1)&hash的计算得到的索引值总是位于table数组索引之内。例如:hash=15,n=16时,结果为15;hash=17,n=16时,结果为1。
在得到Node的存储位置后,如果判断Node不在哈希表中,就新增一个Node,并添加到
哈希表中,整个过程我将用一张图来说明:
以下就是put的实现源码:
五 HashMap获取元素优化
我们在编码中也可以优化HashMap的性能,例如:重写key值的hashCode()方法,降低哈希冲突,从而减少链表的产生,高效利用哈希表,达到提高性能的效果。
六 HashMap扩容优化
在JDK1.7中,hashMap整个扩容过程就是分别取出数组元素,一般该元素是最后一个放入链表中的元素,然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的hash值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单项链表尾部变成扩容后单项链表的头部。
而在JDK1.8中,HashMap对扩容操作做了优化。由于扩容数组的长度是2倍关系,所以对于假设初始tableSize=4要扩容到8来说就是0100到1000的变化(左移一位就是2倍),在扩容中只用判断原来的hash值和左移动的一位(newtable)的值按位与操作是0或1就行,0的话索引不变,1的话索引变成原索引加上扩容前数组。
七 总结
HashMap通过哈希表数据结构的形式来存储键值对,这种设计的好处就是查询键值对的效率高。
以下是HashMap的数据结构图: