由于HashMap的广泛使用性,因此我深入了解了一下HashMap。
1.结构
hashmap底层结构在不同jdk版本会有所不同
1.7为(数组+链表)1.8为(数组+链表+红黑树)
由上图看出,hashmap的存储首先是一个数组存放在一段连续的内存空间中,然后每个内存中存储了一个entry而每一个entry中存放了当前数据的key,hash,,value,及下一节点next也就是链表的方式
上图为hashmap的Node类实现了Map.Entry
2.构造原因
hashmap的创建原因:依据我现有的知识认为,数据结构本质为数据在内存空间中的存储方式,无论以什么样的方式存储,每一种数据结构本质上就两点,存与读。那为什么会有hashmap呢,我认为只是为了检索效率。也就是读取速度。
2.1 检索算法
提到检索,大家肯定能想到一些常用的检索算法,顺序查找,二分查找,插值查找,树表查找等等等等。
算法 | 时间复杂度 |
顺序查找 | o(n) |
二分查找 | o(logn) |
插值查找 | o(logn) |
树表查找 | (二叉平衡树)o(logn) |
但是每一种检索算法貌似都可能会有一些问题,顺序就不说了复杂度较高,二分的话虽然不稳定但是还可以,但是要有序,在数据不变的情况下还是可以的。插值查找也是基于二分的。树查找目前仅有二叉平衡树的检索效率较高。
既然思索了这么多,那么有没有一种可以查找一次就能查到数据的一种数据结构呢,其实就是数组,链表这种在已知索引的情况下我就可以去当前索引下直接读取。
数据结构 | 检索速度 | 插入速度 | 原因 |
数组 | 快 | 慢 | 在内存中连续存储,检索时cpu缓存可以将连续内存读入缓存进行遍历。速度快,但是由于连续空间插入时会导致后面所有的数据后移,所以插入较慢。 |
链表 | 慢 | 快 | 在内存中随机存储,检索时只能在内存中遍历,所以较慢,插入时不影响当前结构,仅需要改变插入前后的连接即可,所以较快。 |
数组和链表相比的区别就是,数组查询快,增删慢,但我们这里主要就是检索效率,所以还是得用数组,那么问题来了,我们如何能从一堆无序的数据中取到我们想要的数据呢,很简单,第一想法应该是我们把对应数据存放的索引记录下来这样我们就可以先找到索引,然后再根据索引找到对应的数据,问题又来了,我们如何存放这个索引,并且能根据我想要的这个数据取到这个索引,那么我们可以将数据给定不重复的序号那么每个序号对应数据也就是key-value的结构,那么如何将这样的数据记录他们的索引呢,我们可以将key值进行某种计算然后得出索引,并将key,value存入到该索引下,取值的时候再通过key值进行取值,将key再次进行计算得出索引,然后从索引中取出该数据。
2.2 构造方案
那现在数据存取的方式已经找到了,那么这个某种计算应该是什么计算呢,能够将key计算后均匀分布在一段连续数据上面,这里就用到了散列函数,散列函数有一个不可解决的问题,散列冲突,就是不同的key经过计算后得到了同一个值,也就是同一个索引,那这个时候怎么做呢,我们可以将重复索引的放到同一个索引下,或者通过再次计算重新计算索引,等等方式,hashmap选择通过将重复索引放到同一索引下,然后通过链表的方式上一数据连接下一数据,问题来了那么这里为什么不使用数组再去存放呢,因为第一无法预测碰撞次数,无法申请内存。
通过这一系列操作后,简单hashmap基本构造完毕。
2.3 散列函数
散列函数(哈希) | 介绍 |
直接定址法 | 取关键字或关键字的某个线性函数值为哈希地址:H(key) = key 或 H(key) = a·key + b 注意:由于直接定址所得地址集合和关键字集合的大小相同。因此,对于不同的关键字不会发生冲突。实际使用中可以在key值为连续数字时使用。 |
相乘取整法 | 首先用关键字key乘上某个常数A(0 < A < 1),并抽取出key.A的小数部分;然后用m乘以该小数后取整。 注意:该方法最大的优点是m的选取比除余法要求更低。比如,完全可选择它是2的整数次幂。虽然该方法对任何A的值都适用,但对某些值效果会更好。Knuth建议选取 0.61803……。 |
平方取中法 | 取关键字平方后的中间几位为哈希地址。 通过平方扩大差别,另外中间几位与乘数的每一位相关,由此产生的散列地址较为均匀。这是一种较常用的构造哈希函数的方法。 将一组关键字(0100,0110,1010,1001,0111) |
除留余数法 | 取关键字被数p除后所得余数为哈希地址:H(key) = key MOD p (p ≤ m)。 注意:这是一种最简单,也最常用的构造哈希函数的方法。它不仅可以对关键字直接取模(MOD),也可在折迭、平方取中等运算之后取模。值得注意的是,在使用除留余数法时,对p的选择很重要。一般情况下可以选p为质数或不包含小于20的质因素的合数。 |
随机数法 | 选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random (key) ,其中random为随机函数。通常,当关键字长度不等时采用此法构造哈希函数较恰当。 |
折叠法 | 将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址,这方法称为折叠法(folding) |
2.4 散列冲突
类别 | 解决方案 | 介绍 |
闭散列法 | 线性探测 | 向后依次探测index+1,index+2…位置,看是否冲突,直到不冲突为止,将元素添加进去 |
闭散列法 | 二次探测 | 不探测index的后一个位置,而是探测2^i位置,比如探测2^0位置上时发生冲突,接着探测2^1位置,依此类推,直至冲突解决 |
闭散列法 | 双散列法(再哈希法) | 在发生哈希冲突后,使用另外一个哈希算法产生一个新的地址,直到不发生冲突为止。这个应该很好理解。 再哈希法可以有效的避免堆积现象,但是缺点是不能增加了计算时间和哈希算法的数量,而且不能保证在哈希表未满的情况下,总能找到不冲突的地址 |
开散列法 | 链地址法 | 链表法就是在发生冲突的地址处,挂一个单向链表,然后所有在该位置冲突的数据,都插入这个链表中。插入数据的方式有多种,可以从链表的尾部向头部依次插入数据,也可以从头部向尾部依次插入数据,也可以依据某种规则在链表的中间插入数据,总之保证链表中的数据的有序性(hashmap使用方案) |
3.new HashMap
简单了解几个hashmap的参数及成员
参数/成员 | 用途 | 建议 |
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } | hash算法 获取key的hashcode然后右移16位再与hashcode进行异或计算 | |
Node<K,V>[] table | 初始化数组空间 | |
size | 实际存储的key-value键值对的个数 | |
loadFactor | 负载因子,当数组使用达到当前容量*负载因子的值之后进行扩容 | 建议给稍大于需要空间的初始化数量 |
initialCapacity | new hashmap入参 | 大小建议为需要的大小/负载系数+1 |
threshold | 阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后, threshold一般为 capacity*loadFactory | |
modCount | HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时, 如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作), 需要抛出异常ConcurrentModificationException |
4.map.put
hashmap在创建对象时并不会初始化数组空间,仅会初始化负载因子loadFactor以及阈值threshold
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) 所以put的第一步首先是判断数组容量,首先进行扩容resize。 然后进行hash判断,当前数组hash与数组容量进行与运算,获取当前节点在数组中的地址,如果当前地址内容为空, 则创建当前节点赋值给数组的当前地址
如果当前地址不为空创建节点对象e
如果当前节点中key与新的key hash相等且值相等或完全相等时节点对象e给值为当前节点对象(哈希冲突且key值相等)
当哈希冲突key值不同时 如果当前节点对象为TreeNode(JDK1.8版本) e为构造的树的子节点(树为红黑树) 如果当前节点对象不为TreeNode e为构造的链表的下一个节点
4.1 扩容
final Node<K,V>[] resize() 扩容条件: 当前数组为空
当前数组容量超过阈值 本质上扩容是为了降低哈希冲突,也就是目前设置当容量超过阈值,这个时候说明当前数组已经基本充满,若是在不扩容的 情况下继续使用,后续还有大量数据的情况下将会产生大量的hash冲突,其中将会产生大量的链表转红黑树,构造性能将会 下降明显,但是扩容也是非常损耗性能的一种场景,将会将当前数组容量扩充一倍,但是已存入的数据将会通过计算散列在 新的数组里。所以构造时建议给定初始容量。
4.2 红黑树
为什么使用红黑树:
因为在存在特殊场景会导致较为严重的hash冲突,这个时候hashmap的性能将会急剧下降,为了降低这一问题,当链表长度大于8时将会自动转成红黑树,为什么是8呢源码中释义当链表长度大于8的概率已然是千分之一,并且红黑树占用空间是链表的两倍,所以不直接使用红黑树,而且一般情况下,冲突过多已经会扩容,所以总的考虑为了应对性能的下降,所以选择使用红黑树作为应对策略,但是也没有必要直接使用,数量较小的时候性能差异不大所以综合选择了8。