头条创作挑战赛概述 HashMap作为Java程序员使用频率非常高的容器,同时,同时也是面试官非常爱问的,里面的知识点满满,需要我们对它的实现机制有个深入的理解,本文主要通过jdk8带领大家剖析下HashMap。HashMap简介 HashMap最早出现在JDK1。2中,底层基于散列算法实现,它是一个keyvalue结构的容器。是一个keyvalue的映射容器,key不重复jdk8中的HashMap基于数组链表红黑树实现不保证键值的顺序可以存入null值非线程安全,多线程环境下可能存在问题 以上是HashMap的类结构图:继承了AbstractMap,实现了Map接口,提供了key,value结构格式访问的方法实现了Cloneable接口,表示HashMap支持clone实现了Serializable接口,表示HashMap支持序列化核心机制底层实现机制 jdk8中的HashMap底层数据才有数组链表红黑树的方式实现。扩容机制 HashMap底层是一个数组,Java中的数组是固定的,随着我们往HashMap中添加元素,发现数组长度不够了,这时候就需要进行扩冲容量的操作,和扩容相关的参数有两个一个是初始容量initialCapacity,另一个负载因子loadFactor。通过这两个设定这两个参数,可以进一步影响阈值大小。扩容的阈值threshold等于容量负载因子(thresholdcapacityloadFactor)。 名称 用途 initialCapacity HashMap初始容量 loadFactor 负载因子 threshold 当前HashMap所能容纳键值对数量的最大值,超过这个值,则需扩容快速失败机制 HashMap遍历使用的是一种快速失败机制,它是Java非安全集合中的一种普遍机制,这种机制可以让集合在遍历时,如果有线程对集合进行了修改、删除、增加操作,会触发并发修改异常。 它的实现机制是在遍历前保存一份modCount,在每次获取下一个要遍历的元素时会对比当前的modCount和保存的modCount是否相等。 快速失败也可以看作是一种安全机制,这样在多线程操作不安全的集合时,由于快速失败的机制,会抛出异常。这样可以避免由于并发修改导致一些未知的问题,通过提前失败提高性能。源码剖析成员变量 成员变量可以说明HashMap的底层数据结构。底层存储的数据结构,是一个Node数组transientNodeK,V〔〕table;遍历用到的entrySettransientSetMap。EntryK,VentrySet;hashmap的元素数量transientintsize;修改次数,用于快速失败机制transientintmodCount;发生扩容的阈值intthreshold;扩容使用的负载因子serialfinalfloatloadFactor; 我们再来看下Node的数据结构,实现了Map。Entry接口。 很明显是一个链表的结构,红黑树也是基于这个数据结构构建得到。构造方法 有参构造函数源码如下,关键是tableSizeFor这个方法publicHashMap(intinitialCapacity,floatloadFactor){if(initialCapacity0)thrownewIllegalArgumentException(Illegalinitialcapacity:initialCapacity);if(initialCapacityMAXIMUMCAPACITY)initialCapacityMAXIMUMCAPACITY;if(loadFactor0Float。isNaN(loadFactor))thrownewIllegalArgumentException(Illegalloadfactor:loadFactor);this。loadFactorloadFactor;根据tableSizeFor获取扩容阈值this。thresholdtableSizeFor(initialCapacity);}staticfinalinttableSizeFor(intcap){intncap1;nn1;nn2;nn4;nn8;nn16;return(n0)?1:(nMAXIMUMCAPACITY)?MAXIMUMCAPACITY:n1;} 该方法的作用总结起来就一句话:找到大于或等于cap的最小2的幂。至于为啥要这样,后面再解释。我们先来看看tableSizeFor方法的图解: 可以理解为把cap低位的二进制位通过右移全部变为1,最后再1,就是2的幂次方了。 此时这里的阈值threshold不是初始容量负载因子,不必在意,这只是临时的,真正设置threshold在后面put方法中。put方法 其实整个向map中插入数据的流程,大家多少都应知道一些,整个流程如上图所示,我们现在通过源码解读理解这个过程中的细节。 put方法对外暴露的接口,添加的入口publicVput(Kkey,Vvalue){核心是调用putVal方法,参数的hash方法是计算key的hash值returnputVal(hash(key),key,value,false,true);} hash方法staticfinalinthash(Objectkey){inth;采用位运算获取最终的hashreturn(keynull)?0:(hkey。hashCode())(h16);} 这段代码叫做扰动函数,也是hashMap中的hash运算,主要分为下面几步:key。hashCode()获取key的hashCode值,如果不进行重写的话返回的是根据内存地址得到的一个int值。key。hashCode()获取到的hashCode无符号右移16位并和原hashCode进行,这样做的目的是为了让高位与低进行混合,让两者都参与运算,以便让hash值分布更加均匀。 putVal方法finalVputVal(inthash,Kkey,Vvalue,booleanonlyIfAbsent,booleanevict){NodeK,V〔〕tab;NodeK,Vp;intn,i;如果数组为空,进行resize()初始化,后面详细分析resize方法if((tabtable)null(ntab。length)0)n(tabresize())。length;(n1)hash相当于取模,获取数组的索引位置如果计算的位置上Node不存在,直接创建节点插入if((ptab〔i(n1)hash〕)null)tab〔i〕newNode(hash,key,value,null);else{如果计算的位置上Node存在,链表或者红黑树处理NodeK,Ve;Kk;如果已存在的key和传入的key一模一样,则需要覆盖if(p。hashhash((kp。key)key(key!nullkey。equals(k))))ep;如果index位置元素已经存在,且是红黑树elseif(pinstanceofTreeNode)将元素插入到红黑树中e((TreeNodeK,V)p)。putTreeVal(this,tab,hash,key,value);else{否则如果是链表的情况,对链表进行遍历,并统计链表长度for(intbinCount0;;binCount){如果节点链表的next为空if((ep。next)null){找到节点链表中next为空的节点,创建新的节点插入p。nextnewNode(hash,key,value,null);如果节点链表中数量超过TREEIFYTHRESHOLD(8)个,转化为红黑树if(binCountTREEIFYTHRESHOLD1)1for1st树化操作treeifyBin(tab,hash);break;}判断节点链表中的key和传入的key是否一样if(e。hashhash((ke。key)key(key!nullkey。equals(k))))如果一样的话,退出break;pe;}}如果存在相同key的节点e不为空if(e!null){existingmappingforkeyVoldValuee。value;onlyIfAbsent表示是否仅在oldValue为null的情况下更新键值对的值if(!onlyIfAbsentoldValuenull)设置新的值e。valuevalue;afterNodeAccess(e);返回老的结果returnoldValue;}}modCount;当前大小大于临界大小,扩容if(sizethreshold)resize();afterNodeInsertion(evict);returnnull;} putVal方法主要做了这么几件事情:当桶数组table为空时,通过扩容的方式初始化table。查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值。如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树。判断键值对数量是否大于阈值,大于的话则进行扩容操作。 resize()方法 当HashMap中的键值对数量超过扩容阈值时,则进行扩容,先阐述清楚几个概念:容量:表示HashMap中数组的长度扩容阈值:表示HashMap中数组有值的数量超过这个阈值,则需要进行扩容处理,扩容阈值等于容量负载因子。finalNodeK,V〔〕resize(){NodeK,V〔〕oldTabtable;现有容量的大小,等于数组的长度,如果数组为空,返回0intoldCap(oldTabnull)?0:oldTab。length;现有的扩容阈值intoldThrthreshold;newCap表示新的容量,newThr新的扩容阈值intnewCap,newThr0;如果现有容量大于0,表示已经初始化过了if(oldCap0){如果现有容量已经大于最大容量。结束扩容,直接返回if(oldCapMAXIMUMCAPACITY){thresholdInteger。MAXVALUE;returnoldTab;}否则,如果扩大两倍之后的容量小于最大容量,且现有容量大于等于初始容量16elseif((newCapoldCap1)MAXIMUMCAPACITYoldCapDEFAULTINITIALCAPACITY)新的扩容阀值扩大为两倍,左移1相当于乘以2newThroldThr1;doublethreshold}否则如果当前容量等于0,但是当前扩容阈值0,调用有参构造函数会到这里elseif(oldThr0)initialcapacitywasplacedinthreshold进入这里,新的容量等于当前的扩容阈值,newCapoldThr;否则如果当前容量等于0,并且挡墙扩容阈值0,调用无参构造函数进入这里else{新的容量等于默认容量newCapDEFAULTINITIALCAPACITY;新的扩容阈值等于默认负载因子0。75默认容量1612newThr(int)(DEFAULTLOADFACTORDEFAULTINITIALCAPACITY);}如果新的扩容阈值等于0if(newThr0){设置新的扩容阈值等于新的容量负载因子floatft(float)newCaploadFactor;newThr(newCapMAXIMUMCAPACITYft(float)MAXIMUMCAPACITY?(int)ft:Integer。MAXVALUE);}设置hashmap对象的扩容阈值位新的扩容阈值thresholdnewThr;SuppressWarnings({rawtypes,unchecked})初始化数组NodeK,V〔〕newTab(NodeK,V〔〕)newNode〔newCap〕;设置hashmap对象的桶数组为newTabtablenewTab;下面时rehash的过程如果旧的桶数组不为空,则遍历桶数组,并将键值对映射到新的桶数组中if(oldTab!null){遍历老的数组for(intj0;joldCap;j){NodeK,Ve;如果数组索引位置不为空if((eoldTab〔j〕)!null){oldTab〔j〕null;如果节点下面没有链表或者红黑树if(e。nextnull)用新数组容量取模,设置到新数组中newTab〔e。hash(newCap1)〕e;如果节点是红黑树elseif(einstanceofTreeNode)需要对红黑树进行拆分((TreeNodeK,V)e)。split(this,newTab,j,oldCap);如果节点是红黑树else{preserveorderNodeK,VloHeadnull,loTailnull;NodeK,VhiHeadnull,hiTailnull;NodeK,Vnext;遍历链表,并将链表节点按原顺序根据高低位分组do{nexte。next;if((e。hasholdCap)0){if(loTailnull)loHeade;elseloTail。nexte;loTaile;}else{if(hiTailnull)hiHeade;elsehiTail。nexte;hiTaile;}}while((enext)!null);将分组后的链表映射到新桶中if(loTail!null){loTail。nextnull;newTab〔j〕loHead;}if(hiTail!null){hiTail。nextnull;newTab〔joldCap〕hiHead;}}}}}returnnewTab;} 这个resize方法大致做了如下的事情:计算新桶数组的容量newCap和新阈值newThr。根据计算出的newCap创建新的桶数组,桶数组table也是在这里进行初始化的。将键值对节点重新映射到新的桶数组里。如果节点是TreeNode类型,则需要拆分红黑树。如果是普通链表节点,则节点按原顺序进行分组。 这边在将链表节点进行rehash用了一个非常好的设计理念,扩容后长度为原hash表的2倍,于是把hash表分为两半,分为低位和高位,如果能把原链表的键值对,一半放在低位,一半放在高位,而且是通过e。hasholdCap0来判断,这个判断有什么优点呢? 举个例子:n16,二进制为10000,第5位为1,e。hasholdCap是否等于0就取决于e。hash第5位是0还是1,这就相当于有50的概率放在新hash表低位,50的概率放在新hash表高位。 链表树化treeifyBin jdk8中会将节点链表在一定的条件下转换成红黑树,主要是因为红黑树的搜索查询性能更好,会将时间复杂度从O(n)变成O(logn),代码如下将普通节点链表转换成树形节点链表finalvoidtreeifyBin(NodeK,V〔〕tab,inthash){intn,index;NodeK,Ve;桶数组容量小于MINTREEIFYCAPACITY,优先进行扩容而不是树化if(tabnull(ntab。length)MINTREEIFYCAPACITY)resize();elseif((etab〔index(n1)hash〕)!null){hd为头节点(head),tl为尾节点(tail)TreeNodeK,Vhdnull,tlnull;do{将普通节点替换成树形节点TreeNodeK,VpreplacementTreeNode(e,null);if(tlnull)hdp;else{p。prevtl;tl。nextp;}tlp;}while((ee。next)!null);将普通链表转成由树形节点链表if((tab〔index〕hd)!null)将树形链表转换成红黑树hd。treeify(tab);}} 根据代码得出,在扩容过程中,树化要满足两个条件:链表长度大于等于8桶数组容量大于等于64,当桶数组容量比较小时,键值对节点hash的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。get方法 get方法相对来说就简单很多了,源码如下:publicVget(Objectkey){NodeK,Ve;调用getNode方法,hash(key)方法上面讲过,获取key对应的hash值return(egetNode(hash(key),key))null?null:e。value;}finalNodeK,VgetNode(inthash,Objectkey){NodeK,V〔〕tab;NodeK,Vfirst,e;intn;Kk;定位键值对所在桶的位置if((tabtable)!null(ntab。length)0(firsttab〔(n1)hash〕)!null){根据hash算法找到对应位置的第一个数据,如果是指定的key,则直接返回if(first。hashhashalwayscheckfirstnode((kfirst。key)key(key!nullkey。equals(k))))returnfirst;if((efirst。next)!null){如果该节点为红黑树,则通过树进行查找if(firstinstanceofTreeNode)return((TreeNodeK,V)first)。getTreeNode(hash,key);如果该节点是链表,则遍历查找到数据do{if(e。hashhash((ke。key)key(key!nullkey。equals(k))))returne;}while((ee。next)!null);}}returnnull;} 大致逻辑如下:根据hash值查找到指定位置的数据。校验指定位置第一个节点的数据是key是否为传入的key,如果是直接返回第一个节点,否则继续查找第二个节点。如果数据是TreeNode(红黑树结构),直接通过红黑树查找节点数据并返回。如果是链表结构,循环查找所有节点,返回数据。如果没有找到符合要求的节点,返回null。 这里前调用下通过(n1)hash相当于取模运算,即可算出桶的在桶数组中的位置,这是什么道理呢? 举个例子说明吧,假设hash185,n16。计算过程示意图如下: 1001换成10进制就是9,185165,这个前提成立时n必须是2的幂次方。总结 本篇文章大致讲解了HashMap的源码和以及核心机制,其中里面还有很多细节和内容,需要大家花时间去自我学习。