[jdk源码阅读]Java HashMap的设计思想

目录

HashMap的作用

HashMap的基本结构

HashMap源码分析

继承关系

数组存储

初始化 

存取数据

扩容逻辑

untreeify

hash(Object key)的妙处


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。而异或运算的结果由运算符两边的数共同决定,故选用异或

如有错误,欢迎指正!

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值