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的执行过程。 当面试官问到这个问题时,就是你展现实力的时候了。