JAVA-数据结构-HashMap底层原理
大家可能听说过Hash表,也可能都使用过Java集合框架中的HashMap。那么HashMap的底层原理了解吗?HashMap底层是什么数据结构?是怎么工作的?对于这些面试过程中经常出现的问题你知道答案吗?通过这篇文章相信你一定可以得到答案。
首先我们要了解HashMap的存在意义是什么?在一些常见的数据结构(线性、树、图、Hash)中又有什么优势。这里面的知识很多,今天这篇文章我不做详细分析,这些不同的数据结构之间一个比较大的区别是查找效率。
一个长度为10的线性数据结构在查找数据时如果从前到后依次查找,最差的情况需要经过10次才能查找到对应的数据。
一个二叉平衡树假如有N个节点,那么最差的情况需要经过logN次才能查找到对应的数据。
但是**Hash表结构理论情况下,只需要1次就能查找到对应的数据**。
Hash表在存储数据时,会通过hash算法计算出该数据的hash值,通过hash值决定该数据在Hash表中的地址,当我们查找这个数据时,再次通过hash算法计算出该数据的hash值(两次计算的结果一定相同,这是算法的必要条件),然后通过该hash值直接从Hash表对应的位置获取数据。那如果按照这个理论,为什么我们前面说在理论情况下,只需要一次就能找到数据呢?
这涉及到了hash算法的一个弊端,hash算法通过数据计算出hash值时,不同的数据可能会得到一个相同的hash值。假如:我们先向Hash中存储了一个数据D1,通过D1计算出的hash值为123,然后将D1保存在了Hash表的123这个位置。然后我们又向Hash表中存储了一个数据D2,巧了!通过hash算法对D2进行运算以后得到的hash值也是123,这种情况就是所谓的Hash冲突或者说Hash碰撞。那产生了hash冲突以后怎么办呢,数据是肯定要存的?但是怎么存?
对于hash冲突有4种解决方案,分别是开放定址法、再hash法、链地址法、开设公共溢出区,这里我们先简单了解一下这4种解决方案。
开放定址法指当发生hash冲突时,将产生的hash值再次经过hash计算得到新的地址,如果还是冲突则继续通过hash计算得到地址,直到没有冲突为止。这种方案采用的是同一个hash函数。
再hash法和开放定址法类似,不过每一次都会采用一个新的hash算法计算出新的hash值。
链地址法指在产生hash冲突时,就在计算出的地址上使用一个链表来存储所有hash值等于该地址的数据。
公共溢出区是指将将hash表分为两部分,基本表和溢出表,当发生hash冲突时,就将数据保存在溢出区。
那么回到JAVA中的HashMap,Java中的HashMap的底层正是hash表,采用了链地址法来解决hash冲突问题,也就是说在hash表中并不会直接存储某个数据,而是以链表的方式来存储hash值等于该地址的所有数据,并且为了提高链表的检索效率,当链表长度大于等于8时,链表结构会转化为红黑树。接下来就让我们通过Java的HashMap源码逐句进行解析来分析这个过程。请各位看官慢慢看以下的源码解析。
/*调用HashMap的put方法:put(key,value);
put方法中调用了putVal(hash(key), key, value, false, true);
hash(key)参数一:通过键计算出键的hash值?
key参数二:新增时传入的键
value参数三:新增时传入的值
其他两个boolean参数,通过源码在来分析
putVal方法的所有源码:*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//hash表示key计算出来的hash值
//key 表示键
//value 表示值
//定义空的Node数组,是为了保存HashMap中的hash表(存储数据的数组)
//定义了一个空的Node对象p,用来存储从数组中的hash位置所找到的第一个数据
//定义int变量n,表示数组长度
//定义int变量i,表示通过hash值所计算出的数组下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
//先将当前HashMap中的table(原始数组)赋值给我们定义的空的Node数组tab
//然后判断tab如果为null或者tab的长度等于0
if ((tab = table) == null || (n = tab.length) == 0)
//进行数组的初始化n表示数组的长度,resize()是数组初始化和扩容的方法
n = (tab = resize()).length;
//代码执行到此处保证HashMap中拥有一个存储数据的数组
//通过hash值计算出该数据在数组中的下标
//通过下标i取出存放在该位置的数据Node,将Node保存到了变量p中
//判断该位置的Node数据p是否为NULL
if ((p = tab[i = (n - 1) & hash]) == null)
//如果该位置没有存储任何数据,就将键值对数据封装为一个Node对象,保存在数组的该位置
//newNode是hashMap中的一个方法,在该方法中实例化了一个Node对象
//而一个Node对象包含了4个属性,分别是hash值,key,value,next(下一个节点数据)
//Node对象记录的hash值是添加该数据时计算出的hash值
//key就是键值对的键
//value就是键值对的值
//将Node对象保存到数组的该位置
tab[i] = newNode(hash, key, value, null);
//如果代码执行的是上述if,说明数据没有发生hash冲突,所以数据直接保存到对应的位置
//如果执行的是else,那么说明通过key计算出的数组的存储位置已经保存了至少一个Node了,说明发生了Hash冲突
else {
//发生了Hash冲突主要有两种情况
//情况1:存了两个相同的数据(不需要去存储)
//情况2:存储了两个不相同的数据,但是hash值相同(需要存)
//所以接下来需要进行判断,新增的数据和原来节点中的数据是否是同一个数据
//如果是同一个数据就不新增
//如果不是同一个数据就需要新增
//p表示的是从数组中取出来的原有Node数据
//定义了一个节点对象e
//定义了一个键的对象k
Node<K,V> e; K k;
//判断当p节点中的hash值和当前新数据的hash值相同且key相等(==或者equals)
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//发现原有的Node中的数据和新增的数据时同一个数据,就不新增
//将原有Node p赋值给Node e,主要用于后续判断
e = p;
//如果执行if说明对象相同,反之说明对象不同
//在JDK1.8以后数组位置存储的可能是一个链表也可能是一个红黑树
//else if就是在判断p节点类型如果是一个TreeNode,说明该位置的数据结构已经转为红黑树
else if (p instanceof TreeNode)
//如果是红黑树,那么就按照红黑树的方式来新增一个Node数据
//如果新增成功返回NULL
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//如果是链表结构,需要找到链表中的下一个NULL节点,将新数据保存到NULL中
//定义死循环
for (int binCount = 0; ; ++binCount) {
//先从当前结点(默认是p)开始遍历,取出头结点的下一个节点保存到e
//判断e是否为NULL
if ((e = p.next) == null) {
//如果为NULL说明下一个节点没有数据,就将新数据封装为Node对象
//保存到链表的该位置
p.next = newNode(hash, key, value, null);
//判断当前链表的长度是否超过一个阈值(大于等于8)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//将链表结构转为红黑树结构
treeifyBin(tab, hash);
//结束循环
break;
}
//如果当前节点的next节点不为NULL,也要判断next节点和新增的数据是否相同
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//如果当前节点的下一个节点和新增的数据重复,结束循环不在新增
break;
//用下一个节点覆盖当前节点,继续下一次循环
p = e;
}
}
//代码执行到此处,新增逻辑已经结束
//三种情况
//直接保存在数组的hash位置
//保存在数组的hash位置对应的链表或红黑树中
//头结点重复或者和子节点重复,不会执行新增
//如果e为null说明新增成功 e不为null说明数据重复
if (e != null) { // existing mapping for key
//将原来的值保存在一个变量oldValue中
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//用新的value值覆盖原始的value值
e.value = value;
afterNodeAccess(e);
//如果进行覆盖,返回的是原来被覆盖的数据
//如果执行覆盖,数据总长度没有发生变化不需要进行扩容
return oldValue;
}
}
//如果不执行if (e != null) 说明e为NULL 说明新增了数据
++modCount;
//判断长度超过一定的值就进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
//新增成功返回的值null
return null;
相信通过以上代码,大家一定已经明白了JAVA中HashMap的执行过程。 当面试官问到这个问题时,就是你展现实力的时候了。