1. HashMap概述:
HashMap是基于哈希表的Map接口的非同步实现(他与Hashtable类似,但Hashtable是线程安全的,所以是同步的实现),此实现提供可选的映射操作,允许使用null值和null键,但他并非有序。
2. HashMap数据结构与实现原理:
在jdk1.7和jdk1.8中,HashMap的数据结构是有所差别的,进行一些优化来解决冲突问题,下面我们就分别从两个版本的角度来分析一下他的改动与区别
(一)jdk1.7版本
在jdk1.7版本的时候采用的是数组+链表的形式,也就是采用了拉链法。
将哈希冲突值加入到每个数组的链表中,他的插入采用的是头插法的形式(这种方法最大的弊端就是会使插入值产生环,从而无限循环,后面我们将详细讲解这种方法的弊端操作),在进行hash值计算的时候,jdk1.7则采用的是9次扰动(4次位运算+5次异或运算)的方式进行处理(因为本人目前暂时用的jdk1.8所以无法利用源码进行讲解,望见谅),除此之外在扩容上也有所不同,在jdk1.7中采用的全部按照原来的方式进行计算(即hashCode ->> 扰动函数 ->> (h&length-1)),而在jdk1.8中则采用按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量),下面让我们来详细讲解一下jdk1.8。
(二)jdk1.8版本
而jdk1.8的版本则采用数组+链表+红黑树的方式,如下图:
这种方法可以大大优化了哈希冲突的问题,减少了搜索时间,当添加的数目达到阈值的时候可以将链表转换为红黑树的形式,而当红黑树的节点小于6的时候就会从红黑树转化为链表的形式(如果不理解红黑树的话,可以参考我的上一篇文章),而在进行插入值的时候则采用的是尾插法的形式,这种方法解决了成环的情况发生
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
上面代码便是对数组进行put操作时,节点的判断以及添加,里面有几个核心的方法让我们一个一个来分析:
resize():
这个方法顾名思义------扩容,他在jdk1.8的时候进行两次调用,第一次是在数组进行初始化的时候对其进行扩容,而第二次则是在数组满的时间进行的扩容,一次是开始,一次则是快结束的时候,让我们点进去来详细查看一下他在内部所作的操作及含义:
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; //定义老数组 int oldCap = (oldTab == null) ? 0 : oldTab.length; //判断老数组是否为空,来实现初始化扩容操作 int oldThr = threshold; //使初始容量暂时为创建数组时产生的容量,默认为16 int newCap, newThr = 0; //进行初始化赋值 if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // 双重容量,也可以理解为进行扩容 } else if (oldThr > 0) // 初始容量处于阈值 newCap = oldThr; else { //零初始阈值表示使用默认值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; //进行与加载因子的乘积,此时表示在自动扩容之前,数组达到多满的一种度量,当超过这个乘积值的时候才进行扩容,加载因子的默认值为0.75 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
从中我们可以看出,扩容方法对数组初始化以及为空都进行了详细的判断,许多人看到这里都会问为什么加载因子的默认值为0.75呢?那么这个问题我们会在接下来的面试典型题中进行简单的解释。让我们回到上面的put方法中继续分析。
hash(key)
这个方法也是我们的重点,他是为了保证充分利用数组的每个位置(下标)并大大解决哈希冲突问题而诞生的,在jdk1.7中我们提到了计算hash的时候进行了9次扰动,而在jdk1.8中我们仅仅使用了2次扰动就进行了hash值的计算,
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
从上面代码可以看出他是通过key.hashCode()进行高16位和低16位进行了一次异或运算得到的,减少了hash的碰撞问题。
两个主要的方法分析完了,让我们来整体描述一下put的方法整体流程,在这个执行流程中,可以清晰的看出值的插入以及两个核心方法的使用(这里我引用了一张我认为画的比较全的流程图,进行讲解)
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
至此我们的讲解完毕,下面来讲述一下面试过程中所遇到的一些面试题。
面试过程中的典型问题
1.为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?
因为key.hashCode调用的是key键值类型自带的哈希函数,他返回的是一个int类型的散列值,我们都知道int类型的-2147483648~2147483647**大约有40亿的空间,而在我们的数组中容量仅仅为16个,所以就会造成数组无法承载值的情况,因为必须是均匀分布才能有效地避免哈希碰撞的问题,所以就要对其进行取模运算。
2.HashMap默认加载因子为什么选择0.75?
因为提高空间利用率和 减少查询成本的折中,主要是泊松分布,0.75的话碰撞最小,因为在hashmap中影响性能的因为有俩个,一个是初始容量,一个便是加载因子,如果加载因子小的话就会造成空间利用率低,并提高了rehash的次数,为了保证最大程度上减少rehash的次数,0.75是最适宜的标准数也是折中的一种考虑。
3.jdk1.7插入数据方式有什么弊端?
上面我提到了形成环,那么到底是为什么呢,因为在使用的过程中,如果一个线程在插入一个节点的时候,另一个线程也在插入节点,而且两个线程插入的都是对方线程上的几点,这样当进行头插的时候会使链表发生反转,便形成了环状,造成了死循环,如下图:
最后
感谢你看到这里,看完有什么的不懂的可以在评论区问我,觉得文章对你有帮助的话记得给我点个赞,每天都会分享java相关技术文章或行业资讯,欢迎大家关注和转发文章!