目录
HashMap的作用
Map是一种数据结构,用于存放Key-Value形式的数据。
HashMap是一种依赖Hash来实现数据检索的Map,借助它我们能够以接近O(1)的时间复杂度快速存取数据,效率极高。
HashMap工作中用到的非常多,之前也有看过源码,不过没有好好总结一下,今天以博客的方式做一个记录。
HashMap的基本结构
试想一下,如果让我们来设计HashMap,该怎么设计?
1、首先数据的形式是Key-Value的形式,需要用一个类绑定其关系,暂定这个类为Node。
Node{
Key,
Value
}
2、需要定义一个数组arr存储这些结点。
3、根据客户端提供的Key值,用hash函数计算出hash值,并转化为数组的下标,将Node放到指定下标的位置。
下标 = hash(key) % (数组的长度)
4、当客户端需要提取数据时,再通过给定的Key值计算出下标,取出对应的Node。
上述思路是可以实现的,但是还有一些细节没考虑到。
1、hash冲突
hash冲突指的是在Key不同的情况下,通过hash函数计算出来的hash值可能是相同的。
即使hash值不同,在“转化为数组下标”这一步还是可能得出相同的数组下标。即hash值不同,映射到的数组下标相同。
冲突的概率取决于hash函数的优劣以及数组的大小,但不管怎么样总是有可能出现冲突的,所以需要给出解决方案。
我们可以用hash桶的方案来解决这个问题。数组中放的不再只是一个坑位,而是一个桶。
上面提到,我们用自定义的类Node来存放Key-Value,同样的可以添加一个字段next,用于维护这种结点之间的横向关系。
Node{
Key,
Value,
Node next
}
当横向的链表越来越长时,检索的效率就越来越低,从O(1)越来越接近于O(n)。
为了优化这一点,jdk1.8中的HashMap做了优化,当这个链表的元素个数达到8时(对应于TREEIFY_THRESHOLD参数),会“进化”成红黑树(treeify)。
(本文专注于HashMap本身,红黑树的实现细节不讨论)
红黑树的查找效率为O(logn),比链表的O(n)更好,但仍低于随机读取的数组O(1)。所以依然要限制桶的大小,尽量依赖数组来做检索。
2、扩容问题
上面提到当横向的链表越来越长时,整个HashMap的效率就越来越低。因素主要有两个:hash函数不行、数组太小。
那好办,我们可以加大数组的长度,一开始就申请大量的的空间。第一次申请的空间叫做“初始容量”,对应于HashMap中的DEFAULT_INITIAL_CAPACITY参数(默认值为16)。
HashMap本身就是一种空间换时间的方案,这是一种无可奈何的妥协,但并不意味着空间就不重要。
综合考虑时间和空间等因素,以及当前容器内元素数量,我们需要在适当的情况下扩容,这是一种动态的过程。“适当的情况”是什么时候?这里主要看的是数组利用率,当数组利用率较高时,就需要对数组扩容了。HashMap中的加载因子(DEFAULT_LOAD_FACTOR,默认值为0.75),其实就是一个数组利用率指标,当数组的利用率达到这个指标时,就需要扩容了。
扩容的代价是比较大的,数组容量一改变,hash值与数组下标的映射就改变了,涉及到新空间的开辟以及元素的拷贝。
HashMap源码分析
带着上面的分析再来看源码,
继承关系
Map定义了接口,AbstractMap作为模板方法,实现了部分功能,剩余的功能延迟到了子类HashMap实现。
数组存储
HashMap中的定义了两种结构用于存储一组数据。
Node作为treefiy前的数据结点,支持链表结构。
TreeNode作为treefiy后的数据结点,封装着红黑树的一些操作。
初始化
HashMap的构造函数有四个,第二个和第三个最终都是调用的第一个。
传入两个参数,初始容量,加载因子。这两个参数上面都提到过,不赘诉。
threshold(阈值)用来表示当元素个数达到多少时,需要进行下一次扩容。
threshold = capacity * load factor
初始时的threshold是通过tableSizeFor来计算的
tableSizeFor是为了获取一个大于等于initialCapacity的值,且是2的整数幂次方。
实现方式很巧妙且高效,具体可以参考这篇博客:Java8 HashMap之tableSizeFor - 兴趣使然~ - 博客园
存取数据
存数据
找到put()方法
内部调用了putVal方法, putVal内容就比较多了。
这里可以看到数组下标的获取方法了
数组下标 = (n - 1) & hash
位运算效率更高,取代了取模运算。0&任何数 = 0,所以结果必然小于数组的长度,不会越界。
else中就是数组指定位置已经元素了,可能是hash冲突,也可能是hash值不同但是下标映射到了同一个位置。
可以看出,在比较key值是否重复时,调用了equals方法。所以将自定义对象作为key时,要记得重写equals方法以及hashCode。
这个方法的最后还有几行代码
取数据
扩容逻辑
主要对应于resize()方法
如果扩容前的数组有元素(非初始化),需要进行元素的拷贝
untreeify
treeify前面提到过,当一个桶的大小(链表长度)大于等于TREEIFY_THRESHOLD(默认为8)时,会进化为红黑树。
untreeify是一个退化的过程,当红黑树结点数量小于等于UNTREEIFY_THRESHOLD(默认为6)时,会退化为链表。触发的入库在remove()方法中
进入方法
如果结点时TreeNode,移除时会检测是否需要untreefiy
退化的逻辑就比较简单了,直接构建一个链表。
hash(Object key)的妙处
这里有一个有意思的东西,位运算,hash函数如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key.hashCode()才是真正获取hash码,这里对获取到的hash码做了一个处理。主要目的在于减少哈希冲突的概率。
hashCode ^ (hashCode >>> 16)
我们分两步来看:
先是无符号右移16位 (hashCode >>> 16)。int类型一共4个字节,共32位,无符号右移16位,得到结果为hashCode的高16位。
得到的结果做一个异或运算,相当于保持高16位不变,并且将hashCode的高16位特征融合进低16位中。为啥要这么做呢?
上面提到一个计算数组的公式
数组下标 = (n - 1) & hash
也就是说只有数组的长度达到16位以上时,才能用到hashCode高16位的特征,也就是数组的长度要达到65535,显然比较苛刻。
所以将高位的特征与低位相融合,即使数组比较小,也可以用上hashCode的高位,减少hash冲突的概率。
再问一个问题?为什么只用异或(^),而不是 & 和 |
因为我们的目的在于“提取特征”,&与| 都比较极端,不利于提取特征。
n & 0 = 0, n | 1 = 1。而异或运算的结果由运算符两边的数共同决定,故选用异或。
如有错误,欢迎指正!