HashMap源码深度剖析,手把手带你分

在前面的两篇文章哈希表的原理和行代码带你写自己的HashMap(如果你阅读这篇文章感觉有点困难,可以先阅读这两篇文章)当中我们仔细谈到了哈希表的原理并且自己动手使用线性探测法实现了我们自己的哈希表MyHashMap。在本篇文章当中我们将仔细分析JDK当中HashMap的源代码。

首先我们需要了解的是一个容器最重要的四个功能增删改查,而我们也是主要根据这四个功能进行展开一步一步的剖析HashMap的源代码。在正式进行源码分析之前,先提一下:在JDK当中实现的HashMap解决哈希冲突的办法是使用链地址法,而我们自己之前在文章行代码带你写自己的HashMap当中实现的MyHashMap解决哈希冲突的办法是线性探测法,大家注意一下这两种方法的不同。

HashMap源码类中关键字段分析

下面字段表示默认的哈希表的长度,也就是HashMap底层使用数组的默认长度,在HashMap当中底层所使用的的数组的长度必须是2的整数次幂,这一点我们在文章行代码带你写自己的HashMap已经仔细做出了说明。

/***Thedefaultinitialcapacity-MUSTbeapoweroftwo.*/staticfinalintDEFAULT_INITIAL_CAPACITY=14;//aka16

这个字段表示哈希表当中数组的最大长度,HashMap底层使用的数组长度不能超过这个值。

/***Themaximumcapacity,usedifahighervalueisimplicitlyspecified*byeitheroftheconstructorswitharguments.*MUSTbeapoweroftwo=.*/staticfinalintMAXIMUM_CAPACITY=;

字段DEFAULT_LOAD_FACTOR的作用表示在HashMap当中默认的负载因子的值。

/***Theloadfactorusedwhennonespecifiedinconstructor.*/staticfinalfloatDEFAULT_LOAD_FACTOR=0.75f;

在实际情况当中我们并不是当HashMap当中的数组完全被使用完之后才进行扩容,因为如果数组快被使用完之后,再加入数据产生哈希冲突的可能性就会很大,因此我们通常会设置一个负载因子(loadfactor),当数组的使用率超过这个值的时候就进行扩容,即当(数组长度为L,数组当中数据个数为S,负载因子为F):

S≥L×F

TREEIFY_THRESHOLD这个字段主要表示将链表(在JDK当中是采用链地址法去解决哈希冲突的问题)变成一个红黑树(如果你不了解红黑树,可以将其认为是一种平衡二叉树)的条件,在JDK1.8之后JDK中实现HashMap不仅采用链地址法去解决哈希冲突,而且链表满足一定条件之后会将链表变成一颗红黑树。而将链表变成一颗红黑树的必要条件是链表当中数据的个数要大于等于TREEIFY_THRESHOLD,请大家注意是必要条件不是充分条件,也就是说满足这个条件还不行,它还需要满足另外一个条件,就是哈希表中数组的长度要大于等于MIN_TREEIFY_CAPACITY,MIN_TREEIFY_CAPACITY在JDK当中的默认值是64。

/***Thebincountthresholdforusingatreeratherthanlistfora*bin.Binsareconvertedtotreeswhenaddinganelementtoa*binwithatleastthismanynodes.Thevaluemustbegreater*than2andshouldbeatleast8tomeshwithassumptionsin*treeremovalaboutconversionbacktoplainbinsupon*shrinkage.*/staticfinalintTREEIFY_THRESHOLD=8;/***Thesmallesttablecapacityforwhichbinsmaybetreeified.*(Otherwisethetableisresizediftoomanynodesinabin.)*Shouldbeatleast4*TREEIFY_THRESHOLDtoavoidconflicts*betweenresizingandtreeificationthresholds.*/staticfinalintMIN_TREEIFY_CAPACITY=64;

UNTREEIFY_THRESHOLD表示当在进行resize操作的过程当中,红黑树当中的节点个数小于UNTREEIFY_THRESHOLD时,就需要将一颗红黑树重新恢复成链表。

/***Thebincountthresholdforuntreeifyinga(split)binduringa*resizeoperation.ShouldbelessthanTREEIFY_THRESHOLD,andat*most6tomeshwithshrinkagedetectionunderremoval.*/staticfinalintUNTREEIFY_THRESHOLD=6;

下列代码当中的table数组对象就是HashMap底层当中真正用于存储数据的数组。

/***Thetable,initializedonfirstuse,andresizedas*necessary.Whenallocated,lengthisalwaysapoweroftwo.*(Wealsotoleratelengthzeroinsomeoperationstoallow*bootstrappingmechanicsthatarecurrentlynotneeded.)*/transientNodeK,V[]table;

size表示哈希表中存储的key-value对象的个数,也就是放入了多少个键值对象。

/***Thenumberofkey-valuemappingscontainedinthismap.*/transientintsize;

threshold表示容器当中能够存储的数据个数的阈值,当HashMap当中存储的数据的个数超过这个值的时候,HashMap底层使用的数组就需要进行扩容。下列公式中Capacity表示底层数组的长度(2的整数次幂,注意与size进行区分)。

threshold=loadFactorCapacity

intthreshold;/***Theloadfactorforthehashtable.**

serial*/finalfloatloadFactor;

HashMap底层数组当中的节点类

在上篇哈希表的设计原理当中我们已经仔细说明,在HashMap当中我们是使用数组去存储具体的数据的,那么在我们的数组当中应该存储什么样的数据呢?假设在HashMap的数组当中存储的数据类型为Node,那么这个类需要有哪些字段呢?

首先一点我们肯定需要存储Value值,因为我们最终需要通过get方法从HashMap当中取出我们所需要的值。

第二点当我们通过get方法去取值的时候是通过Key(键值)去取的,当哈希值产生冲突的时候,我们不仅需要通过哈希值确定位置,还需要通过比较通过函数get传递的Key和数组当当中存储的数据的key是否相等,因此我们需要存储键值Key。

第三点为了避免重复计算哈希值(因为有的对象的哈希值计算还是比较费时间),我们可以使用一个字段去存储计算好的哈希值。

基于以上三点,在JDK当中的HashMap内部的节点类主要结构如下。

staticclassNodeK,VimplementsMap.EntryK,V{finalinthash;finalKkey;Vvalue;NodeK,Vnext;Node(inthash,Kkey,Vvalue,NodeK,Vnext){this.hash=hash;this.key=key;this.value=value;this.next=next;}publicfinalKgetKey(){returnkey;}publicfinalVgetValue(){returnvalue;}publicfinalStringtoString(){returnkey+"="+value;}publicfinalinthashCode(){returnObjects.hashCode(key)^Objects.hashCode(value);}publicfinalVsetValue(VnewValue){VoldValue=value;value=newValue;returnoldValue;}publicfinalbooleanequals(Objecto){if(o==this)returntrue;if(oinstanceofMap.Entry){Map.Entry?,?e=(Map.Entry?,?)o;if(Objects.equals(key,e.getKey())Objects.equals(value,e.getValue()))returntrue;}returnfalse;}}折叠

我们用下面两行代码说明上面类的结构:

HashMapString,Integermap=newHashMap();map.put("一无是处的研究僧",);

在上面的代码当中put函数的参数"一无是处的研究僧"就是上面Node类当中的key,就是Node类当中的value对象,上面的类当中的hash对象就是字符串"一无是处的研究僧"的哈希值,但是事实上他还需要经过一段代码的处理:

/***这个key是put函数传进来的key*

paramkey*

return*/staticinthash(Objectkey){inth;//调用对象自己实现的hashCode方法//key.hashCode()="一无是处的研究僧".hashCodereturn(key==null)?0:(h=key.hashCode())^(h16);}

上面的函数之所以要将对象的哈希值右移16,是因为我们的数组的长度一般不会超过,因为已经是一个比较大的值了,因此当哈希值与2n1进行操作的时候,高位通常没有使用到,这样做的原理是可以充分利用数据哈希值当中的信息。

tableSizeFor函深入剖析

/***Returnsapoweroftwosizeforthegiventargetcapacity.*//***返回第一个大于或者等于capacity且为2的整数次幂的那个数*

paramcapacity*

return*/staticfinalinttableSizeFor(intcap){intn=cap-1;n

=n1;n

=n2;n

=n4;n

=n8;n

=n16;//如果最终得到的数据小于0则初始长度为1//如果长度大于我们所允许的最大的容量则将初始长度设置为我们//所允许的最大的容量//MAXIMUM_CAPACITY=;return(n0)?1:(n=MAXIMUM_CAPACITY)?MAXIMUM_CAPACITY:n+1;}

因为我们需要底层使用的数组table的长度是2的整数次幂,而我们之后在初始化函数当中会允许用户输入一个数组长度的大小,但是用户输入的数字可能不是2的整数次幂,因此我们需要将用户输入的数据变成2的整数次幂,我们可以将用户输入的数据变成大于等于这个数的最小的2的整数次幂。

比如说如果用户输入的是12我们需要将其变成16,如果输入的是28我们需要将其变成32。我们可以通过上面的函数做到这一点。

上面的代码还是很难理解的,让我们一点一点的来分析。首先我们使用一个2的整数次幂的数进行上面移位操作的操作!

从上图当中我们会发现,我们咋一个数的二进制数的32位放一个1,经过移位之后最终32位的比特数字全部变成了1。根据上面数字变化的规律我们可以发现,任何一个比特经过上面移位的变化,这个比特后面的31个比特位都会变成1,像下图那样:

因此上述的移位操作的结果只取决于最高一位的比特值为1,移位操作后它后面的所有比特位的值全为1,而在上面函数的最后,如果最终的容量没有大于我们设置的最大容量MAXIMUM_CAPACITY,我们返回的结果就是上面移位之后的结果+1。又因为移位之后最高位的1到最低位的1之间的比特值全为1,当我们+1之后他会不断的进位,最终只有一个比特位置是1,因此它是2的整数倍。

在tableSizeFor函数当中,给初始容量减了个1,这样做的原因是让这个函数的返回值大于等于传入的参数capacity:

tableSizeFor(4)==4//就是当传入的数据已经是2的整数次幂的时候也返回传入的值tableSizeFor(3)==4tableSizeFor(5)==8

HashMap构造函数分析

首先我们先看一下几个构造函数的代码:

publicHashMap(intinitialCapacity){//指定初始容量的构造函数this(initialCapacity,DEFAULT_LOAD_FACTOR);}publicHashMap(){this.loadFactor=DEFAULT_LOAD_FACTOR;//allotherfieldsdefaulted}publicHashMap(intinitialCapacity,floatloadFactor){if(initialCapacity0)thrownewIllegalArgumentException("Illegalinitialcapacity:"+initialCapacity);//如果大于允许的最大容量,就将数组的长度这是为最大容量if(initialCapacityMAXIMUM_CAPACITY)initialCapacity=MAXIMUM_CAPACITY;if(loadFactor=0

Float.isNaN(loadFactor))thrownewIllegalArgumentException("Illegalloadfactor:"+loadFactor);this.loadFactor=loadFactor;//这里本来应该将threshold的值设置为数组长度的*loadfactor,//但是在HashMap的源代码当中//并没有一个变量存储数组的长度,因为数组的长度直接array.length//就可以得到,因此也没必要,而在HashMap当中,使用懒加载//只有在使用put函数的时候才申请数组因此需要一个变量存储数组的长度//而此时threshold并没有使用,因此可以临时用于存储数组的长度//在后面申请数组是,将threshold更新为数组长度*loadfactorthis.threshold=tableSizeFor(initialCapacity);}

HashMap的构造函数整体来说比较简单,但是上面代码当中最后一行很容易让人迷惑,具体原因在上面的注释当中已经说明了,大家可以阅读一下。

HashMap的增删改查函数分析

put函数分析——“增改”

publicVput(Kkey,Vvalue){returnputVal(hash(key),key,value,false,true);}staticfinalinthash(Objectkey){inth;return(key==null)?0:(h=key.hashCode())^(h16);}

在put函数当中首先计算参数key的哈希值,然后调用putVal函数真正的将输入插入到数据当中,为了方便大家于都代码,代码解释在代码当中对应的位置。

在正式阅读这个代码之前我们先分析这个函数的流程:

finalVputVal(inthash,Kkey,Vvalue,booleanonlyIfAbsent,booleanevict){//我们先只管前面三个参数,后的参数可以先不管NodeK,V[]tab;NodeK,Vp;intn,i;//这里是首次调用函数putVal的时候这个if条件会通过//因为第一次调用这个函数的时候还没有申请数组所以table==nullif((tab=table)==null

(n=tab.length)==0)//进行扩容n=(tab=resize()).length;//如果计算出的下标对应数据还没有村数据直接将数据加入到数组//当中即可//这行代码不仅会将tab[i=(n-1)hash]的结果赋值给p//(p=tab[i=(n-1)hash])这行代码的返回值也是tab[i=(n-1)hash]if((p=tab[i=(n-1)hash])==null)tab[i]=newNode(hash,key,value,null);else{//如果对应位置当中已经存在数据了//即产生了哈希冲突,要采用链地址法进行解决NodeK,Ve;Kk;//如果传入的哈希值和对应下标的数据的哈希值相等//而且两个key相等,这个if语句的条件就满足了//然后将对应下标的数据赋值给e然后在后续的代码当中//更新e当中的value为putVal函数传入的value//即e.value=value;if(p.hash==hash((k=p.key)==key

(key!=nullkey.equals(k))))e=p;elseif(pinstanceofTreeNode)//如果p是一个红黑树节点,就在红黑树当中放入数据//在本篇文章当中我们不仔细去讨论这个函数,因为红黑树//的操作比较复杂,我们之后再专门写一篇关于红黑树的文章来讲解这个问题e=((TreeNodeK,V)p).putTreeVal(this,tab,hash,key,value);else{//这里就是链表的操作了for(intbinCount=0;;++binCount){//如果e.next==null说明已经遍历到最后一个节点了//需要将新加入的if((e=p.next)==null){p.next=newNode(hash,key,value,null);//如果节点数超过TREEIFY_THRESHOLD就需要进行后续的操作//在treeifyBin函数当中会有一个判断,如果数组的长度大于//MIN_TREEIFY_CAPACITY就将链表变成红黑树,否则直接进行扩容if(binCount=TREEIFY_THRESHOLD-1)//-1for1sttreeifyBin(tab,hash);break;}//如果找到相同的key就跳出去if(e.hash==hash((k=e.key)==key

(key!=nullkey.equals(k))))break;p=e;}}//当存在一个对象的key和传进这个函数的key相同的话//就需要进行value的更新,相当于将新的value替换掉旧的//valueif(e!=null){//existingmappingforkeyVoldValue=e.value;if(!onlyIfAbsent

oldValue==null)e.value=value;afterNodeAccess(e);returnoldValue;}}++modCount;//如果容器当中数据的数量大于阈值的话就进行扩容if(++sizethreshold)resize();afterNodeInsertion(evict);//这个函数在HashMap没啥用,他的函数体为空returnnull;}折叠

resize扩容函数分析

finalNodeK,V[]resize(){NodeK,V[]oldTab=table;//旧数组的数组长度intoldCap=(oldTab==null)?0:oldTab.length;//旧的扩容的阈值intoldThr=threshold;intnewCap,newThr=0;if(oldCap0){if(oldCap=MAXIMUM_CAPACITY){threshold=Integer.MAX_VALUE;returnoldTab;}elseif((newCap=oldCap1)MAXIMUM_CAPACITYoldCap=DEFAULT_INITIAL_CAPACITY)newThr=oldThr1;//doublethreshold}elseif(oldThr0)//initialcapacitywasplacedinthresholdnewCap=oldThr;else{//zeroinitialthresholdsignifiesusingdefaultsnewCap=DEFAULT_INITIAL_CAPACITY;newThr=(int)(DEFAULT_LOAD_FACTOR*DEFAULT_INITIAL_CAPACITY);}if(newThr==0){floatft=(float)newCap*loadFactor;newThr=(newCapMAXIMUM_CAPACITYft(float)MAXIMUM_CAPACITY?(int)ft:Integer.MAX_VALUE);}//上面的代码主要是计算得到新的阈值newThr和数组长度newCapthreshold=newThr;

SuppressWarnings({"rawtypes","unchecked"})//开辟新的数组空间NodeK,V[]newTab=(NodeK,V[])newNode[newCap];table=newTab;//现在需要将旧数组当中的数据加入到新数组if(oldTab!=null){for(intj=0;joldCap;++j){NodeK,Ve;if((e=oldTab[j])!=null){oldTab[j]=null;//e.next==null表示只有一个数据,并没有形成2个//数据以上的链表,因此可以直接加入到心得数组当中if(e.next==null)newTab[e.hash(newCap-1)]=e;elseif(einstanceofTreeNode)//如果节点是红黑树节点,则在将红黑树当中的节点加入到新数组当中((TreeNodeK,V)e).split(this,newTab,j,oldCap);else{//preserveorder//链表的代码比较复杂,大家可以看下面的分析NodeK,VloHead=null,loTail=null;NodeK,VhiHead=null,hiTail=null;NodeK,Vnext;do{next=e.next;if((e.hasholdCap)==0){if(loTail==null)loHead=e;elseloTail.next=e;loTail=e;}else{if(hiTail==null)hiHead=e;elsehiTail.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;}}}}}returnnewTab;}折叠

扩容时链表数据的下标分析

为了解释上面的链表的从旧数组移动到新数组的过程,我们先通过下面的例子来分析一下:

现在有一个哈希表在工作时候的情况,在进行扩容之前他的结构如下图所示:

在扩容之前数组的长度等于8,那么乘以2倍扩容之后,数组的长度应该变成16,而且链表当中的数据需要进行重新的操作,再将其放在新的数组当中,扩容重新进行操作之后数组的情况如下图所示:

从上面的两张图我们可以发现,与元素的哈希值进行运算的数组长度减1的二进制数表示会多出一个1,即:

=7=

=7=

如果数据的哈希值对应的位置也是1比如上图当中数据2、4、6的情况,那么我们在确定数据在新数组当中的位置的时候不需要重新进行运算,只需要在旧数组的位置加上原数组的长度就是数据在新数组当中的位置。为什么?

从上图我们可以发现扩容前后与key的哈希值进行操作的数据的二进制数只是在高位增加了一个1,因此我们直接将原数组的下标加上这个高位1对应的10进制数(这个十进制数对应就是原数组的长度)就得到的数据在新数组的下标。而如果哈希值的二进制表示当中相应的高位的比特值为0,那么扩容前后他在数组当中的位置是没有发生变化的。

而能进行上面谈到的操作的数据需要满足一个特点就是数据的哈希值对应的高位也是1,才能进行这个操作。这也是下面代码的if判断的内容:

//和数组的长度进行操作看看高位是不是0if((e.hasholdCap)==0){//如果对应的高位为0if(loTail==null)loHead=e;elseloTail.next=e;loTail=e;}else{//如果对应的高位为1if(hiTail==null)hiHead=e;elsehiTail.next=e;hiTail=e;}

链表扩容代码变量分析

上面的代码涉及四个节点loTail和loHead,hiTail和hiHead的相关操作,首先我们先弄清楚这四个变量的含义是什么。

从上面扩容前后链表当中的数据下标分析我们可以知道,一个链表在扩容之后会放在新数组的两个位置,如果链表数据在旧数组下标为x的位置,旧数组的长度为L,那么扩容之后数据在新数组的位置分别为x和x+L的位置,整个的扩容过程和loTail和loHead,hiTail和hiHead的指向如下图所示:

loTail和loHead新数组当中下标为x的链表的表尾和表头,hiTail和hiHead表示下标为x+L的链表的表尾和表头。

看到现在相信你已经能看懂下面的代码了:

NodeK,VloHead=null,loTail=null;NodeK,VhiHead=null,hiTail=null;NodeK,Vnext;do{next=e.next;if((e.hasholdCap)==0){if(loTail==null)loHead=e;elseloTail.next=e;loTail=e;}else{if(hiTail==null)hiHead=e;elsehiTail.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;}

get函数分析——“查”

如果你已经看懂了put和resize函数,这个函数就很简单了。

首先计算数据在数组当中的下标值(n-1)hash。

如果下标中第一个节点的key就等于参数传入的key,就直接返回数据。

如果节点是红黑树当中的节点就通过红黑树进行查找,否则就是链表节点,然后通过链表的方式查找。

找到相同的key数据,将结果返回。

publicVget(Objectkey){NodeK,Ve;return(e=getNode(hash(key),key))==null?null:e.value;}finalNodeK,VgetNode(inthash,Objectkey){NodeK,V[]tab;NodeK,Vfirst,e;intn;Kk;if((tab=table)!=null(n=tab.length)0(first=tab[(n-1)hash])!=null){if(first.hash==hash//alwayscheckfirstnode((k=first.key)==key

(key!=nullkey.equals(k))))returnfirst;if((e=first.next)!=null){if(firstinstanceofTreeNode)return((TreeNodeK,V)first).getTreeNode(hash,key);do{if(e.hash==hash((k=e.key)==key

(key!=nullkey.equals(k))))returne;}while((e=e.next)!=null);}}returnnull;}

remove函数分析——“删”

整个函数分成一下两个步骤:

先找到要删除的节点。

删除找到的节点。

大家在理解上面“增改查”三个操作之后,下面的代码很容易理解了,下面代码有注释帮助大家理解。

publicVremove(Objectkey){NodeK,Ve;return(e=removeNode(hash(key),key,null,false,true))==null?null:e.value;}finalNodeK,VremoveNode(inthash,Objectkey,Objectvalue,booleanmatchValue,booleanmovable){//matchValue这个参数如果为true表示传入的参数value//和查找到的数据的value相等才进行删除NodeK,V[]tab;NodeK,Vp;intn,index;//先找到节点if((tab=table)!=null(n=tab.length)0(p=tab[index=(n-1)hash])!=null){NodeK,Vnode=null,e;Kk;Vv;if(p.hash==hash((k=p.key)==key

(key!=nullkey.equals(k))))node=p;elseif((e=p.next)!=null){if(pinstanceofTreeNode)node=((TreeNodeK,V)p).getTreeNode(hash,key);else{do{if(e.hash==hash((k=e.key)==key

(key!=nullkey.equals(k)))){node=e;break;}p=e;}while((e=e.next)!=null);}}//删除节点if(node!=null(!matchValue

(v=node.value)==value

(value!=nullvalue.equals(v)))){if(nodeinstanceofTreeNode)((TreeNodeK,V)node).removeTreeNode(this,tab,movable);elseif(node==p)tab[index]=node.next;elsep.next=node.next;++modCount;--size;afterNodeRemoval(node);returnnode;}}returnnull;}折叠

总结

本篇文章主要跟大家一起分析了HashMap当中主要的源代码,主要涉及四个操作增删改查,但是没有仔细分析关系红黑树的部分,因为红黑树涉及的部分比较多,本篇文章已经比较长了,以后专门写一篇文章仔细分析红黑树的部分。

在HashMap当中有很多写的很巧妙的代码,比如说tableSizeFor函数,扩容的时候两条链表的操作,这些设计都非常巧妙,希望大家有所收获。我是LeHung,我们下期再见!!!

来源:


转载请注明:http://www.aierlanlan.com/tzrz/5099.html

  • 上一篇文章:
  •   
  • 下一篇文章: