如何设计线程安全的 HashMap.docx
- 文档编号:5983261
- 上传时间:2023-01-02
- 格式:DOCX
- 页数:14
- 大小:22KB
如何设计线程安全的 HashMap.docx
《如何设计线程安全的 HashMap.docx》由会员分享,可在线阅读,更多相关《如何设计线程安全的 HashMap.docx(14页珍藏版)》请在冰豆网上搜索。
如何设计线程安全的HashMap
如何设计线程安全的HashMap?
问题:
如何设计线程安全的HashMap?
回答:
具体可以参见java5里的ConcurrentHashMap之实现细节
ConcurrentHashMap是Java5中支持高并发、高吞吐量的线程安全HashMap实现。
实现原理
锁分离(LockStripping)
ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。
它使用了多个锁来控制对hash表的不同部分进行的修改。
ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hashtable,它们有自己的锁。
只要多个修改操作发生在不同的段上,它们就可以并发进行。
有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。
这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。
这可以确保不会出现死锁,因为获得锁的顺序是固定的。
不变性是多线程编程占有很重要的地位,下面还要谈到。
/**
*Thesegments,eachofwhichisaspecializedhashtable
*/
finalSegment[]segments;
不变(Immutable)和易变(Volatile)
ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。
如果使用传统的技术,如HashMap中的实现,如果允许可以在hash链的中间添加或删除元素,读操作不加锁将得到不一致的数据。
ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的。
HashEntry代表每个hash链中的一个节点,其结构如下所示:
staticfinalclassHashEntry{
finalKkey;
finalinthash;
volatileVvalue;
finalHashEntrynext;
}
可以看到除了value不是final的,其它值都是final的,这意味着不能从hash链的中间或尾部添加或删除节点,因为这需要修改next引用值,所有的节点的修改只能从头部开始。
对于put操作,可以一律添加到Hash链的头部。
但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。
这在讲解删除操作时还会详述。
为了确保读操作能够看到最新的值,将value设置成volatile,这避免了加锁。
其它
为了加快定位段以及段中hash槽的速度,每个段hash槽的的个数都是2
,这使得通过位运算就可以定位段和段中hash槽的位置。
当并发级别为默认值16时,也就是段的个数,hash值的高4位决定分配在哪个段中。
但是我们也不要忘记《算法导论》给我们的教训:
hash槽的的个数不应该是2
,这可能导致hash槽分配不均,这需要对hash值重新再hash一次。
(这段似乎有点多余了)
这是重新hash的算法,还比较复杂,我也懒得去理解了。
这是定位段的方法:
finalSegmentsegmentFor(inthash){
returnsegments[(hash>>>segmentShift)segmentMask];
}
数据结构
关于Hash表的基础数据结构,这里不想做过多的探讨。
Hash表的一个很重要方面就是如何解决hash冲突,ConcurrentHashMap和HashMap使用相同的方式,都是将hash值相同的节点放在一个hash链中。
与HashMap不同的是,ConcurrentHashMap使用多个子Hash表,也就是段(Segment)。
下面是ConcurrentHashMap的数据成员:
publicclassConcurrentHashMapextendsAbstractMap
implementsConcurrentMap,Serializable{
/**
*Maskvalueforindexingintosegments.Theupperbitsofa
*keyshashcodeareusedtochoosethesegment.
*/
finalintsegmentMask;
/**
*Shiftvalueforindexingwithinsegments.
*/
finalintsegmentShift;
/**
*Thesegments,eachofwhichisaspecializedhashtable
*/
finalSegment[]segments;
}
所有的成员都是final的,其中segmentMask和segmentShift主要是为了定位段,参见上面的segmentFor方法。
每个Segment相当于一个子Hash表,它的数据成员如下:
staticfinalclassSegmentextendsReentrantLockimplementsSerializable{
privatestaticfinallongserialVersionUID=2249069246763182397L;
/**
*Thenumberofelementsinthissegmentsregion.
*/
transientvolatileintcount;
/**
*Numberofupdatesthatalterthesizeofthetable.Thisis
*usedduringbulk-readmethodstomakesuretheyseea
*consistentsnapshot:
IfmodCountschangeduringatraversal
*ofsegmentscomputingsizeorcheckingcontainsValue,then
*wemighthaveaninconsistentviewofstateso(usually)
*mustretry.
*/
transientintmodCount;
/**
*Thetableisrehashedwhenitssizeexceedsthisthreshold.
*(Thevalueofthisfieldisalways(int)(capacity*
*loadFactor).)
*/
transientintthreshold;
/**
*Theper-segmenttable.
*/
transientvolatileHashEntry[]table;
/**
*Theloadfactorforthehashtable.Eventhoughthisvalue
*issameforallsegments,itisreplicatedtoavoidneeding
*linkstoouterobject.
*@serial
*/
finalfloatloadFactor;
}
count用来统计该段数据的个数,它是volatile,它用来协调修改和读取操作,以保证读取操作能够读取到几乎最新的修改。
协调方式是这样的,每次修改操作做了结构上的改变,如增加/删除节点(修改节点的值不算结构上的改变),都要写count值,每次读取操作开始都要读取count的值。
这利用了Java5中对volatile语义的增强,对同一个volatile变量的写和读存在happens-before关系。
modCount统计段结构改变的次数,主要是为了检测对多个段进行遍历过程中某个段是否发生改变,在讲述跨段操作时会还会详述。
threashold用来表示需要进行rehash的界限值。
table数组存储段中节点,每个数组元素是个hash链,用HashEntry表示。
table也是volatile,这使得能够读取到最新的table值而不需要同步。
loadFactor表示负载因子。
实现细节
修改操作
先来看下删除操作remove(key)。
publicVremove(Objectkey){
inthash=hash(key.hashCode());
returnsegmentFor(hash).remove(key,hash,null);
}
整个操作是先定位到段,然后委托给段的remove操作。
当多个删除操作并发进行时,只要它们所在的段不相同,它们就可以同时进行。
下面是Segment的remove方法实现:
Vremove(Objectkey,inthash,Objectvalue){
lock();
try{
intc=count1;
HashEntry[]tab=table;
intindex=hash(tab.length1);
HashEntryfirst=tab[index];
HashEntrye=first;
while(e!
=null(e.hash!
=hash||!
key.equals(e.key)))
e=e.next;
VoldValue=null;
if(e!
=null){
Vv=e.value;
if(value==null||value.equals(v)){
oldValue=v;
//Allentriesfollowingremovednodecanstay
//inlist,butallprecedingonesneedtobe
//cloned.
++modCount;
HashEntrynewFirst=e.next;
for(HashEntryp=first;p!
=e;p=p.next)
newFirst=newHashEntry(p.key,p.hash,
newFirst,p.value);
tab[index]=newFirst;
count=c;//write-volatile
}
}
returnoldValue;
}finally{
unlock();
}
}
整个操作是在持有段锁的情况下执行的,空白行之前的行主要是定位到要删除的节点e。
接下来,如果不存在这个节点就直接返回null,否则就要将e前面的结点复制一遍,尾结点指向e的下一个结点。
e后面的结点不需要复制,它们可以重用。
下面是个示意图,我直接从这个网站上复制的(画这样的图实在是太麻烦了,如果哪位有好的画图工具,可以推荐一下)。
删除元素之前:
删除元素3之后:
第二个图其实有点问题,复制的结点中应该是值为2的结点在前面,值为1的结点在后面,也就是刚好和原来结点顺序相反,还好这不影响我们的讨论。
整个remove实现并不复杂,但是需要注意如下几点。
第一,当要删除的结点存在时,删除的最后一步操作要将count的值减一。
这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改。
第二,remove执行的开始就将table赋给一个局部变量tab,这是因为table是volatile变量,读写volatile变量的开销很大。
编译器也不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量没有多大影响,编译器会做相应优化。
接下来看put操作,同样地put操作也是委托给段的put方法。
下面是段的put方法:
Vput(Kkey,inthash,Vvalue,booleanonlyIfAbsent){
lock();
try{
intc=count;
if(c++>threshold)//ensurecapacity
rehash();
HashEntry[]tab=table;
intindex=hash(tab.length1);
HashEntryfirst=tab[index];
HashEntrye=first;
while(e!
=null(e.hash!
=hash||!
key.equals(e.key)))
e=e.next;
VoldValue;
if(e!
=null){
oldValue=e.value;
if(!
onlyIfAbsent)
e.value=value;
}
else{
oldValue=null;
++modCount;
tab[index]=newHashEntry(key,hash,first,value);
count=c;//write-volatile
}
returnoldValue;
}finally{
unlock();
}
}
该方法也是在持有段锁的情况下执行的,首先判断是否需要rehash,需要就先rehash。
接着是找是否存在同样一个key的结点,如果存在就直接替换这个结点的值。
否则创建一个新的结点并添加到hash链的头部,这时一定要修改modCount和count的值,同样修改count的值一定要放在最后一步。
put方法调用了rehash方法,reash方法实现得也很精巧,主要利用了table的大小为2
,这里就不介绍了。
修改操作还有putAll和replace。
putAll就是多次调用put方法,没什么好说的。
replace甚至不用做结构上的更改,实现要比put和delete要简单得多,理解了put和delete,理解replace就不在话下了,这里也不介绍了。
获取操作
首先看下get操作,同样ConcurrentHashMap的get操作是直接委托给Segment的get方法,直接看Segment的get方法:
Vget(Objectkey,inthash){
if(count!
=0){//read-volatile
HashEntrye=getFirst(hash);
while(e!
=null){
if(e.hash==hashkey.equals(e.key)){
Vv=e.value;
if(v!
=null)
returnv;
returnreadValueUnderLock(e);//recheck
}
e=e.next;
}
}
returnnull;
}
get操作不需要锁。
第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count变量,通过这种机制保证get操作能够得到几乎最新的结构更新。
对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是volatile的,也能保证读取到最新的值。
接下来就是对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null。
对hash链进行遍历不需要加锁的原因在于链指针next是final的。
但是头指针却不是final的,这是通过getFirst(hash)方法返回,也就是存在table数组中的值。
这使得getFirst(hash)可能返回过时的头结点,例如,当执行get方法时,刚执行完getFirst(hash)之后,另一个线程执行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。
这是可以允许,通过对count变量的协调机制,get能读取到几乎最新的数据,虽然可能不是最新的。
要得到最新的数据,只有采用完全的同步。
最后,如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次。
这似乎有些费解,理论上结点的值不可能为空,这是因为put的时候就进行了判断,如果为空就要抛NullPointerException。
空值的唯一源头就是HashEntry中的默认值,因为HashEntry中的value不是final的,非同步读取有可能读取到空值。
仔细看下put操作的语句:
tab[index]=newHashEntry(key,hash,first,value),在这条语句中,HashEntry构造函数中对value的赋值以及对tab[index]的赋值可能被重新排序,这就可能导致结点的值为空。
这种情况应当很罕见,一旦发生这种情况,ConcurrentHashMap采取的方式是在持有锁的情况下再读一遍,这能够保证读到最新的值,并且一定不会为空值。
VreadValueUnderLock(HashEntrye){
lock();
try{
returne.value;
}finally{
unlock();
}
}
另一个操作是containsKey,这个实现就要简单得多了,因为它不需要读取值:
booleancontainsKey(Objectkey,inthash){
if(count!
=0){//read-volatile
HashEntrye=getFirst(hash);
while(e!
=null){
if(e.hash==hashkey.equals(e.key))
returntrue;
e=e.next;
}
}
returnfalse;
}
跨段操作
有些操作需要涉及到多个段,比如说size(),containsValaue()。
先来看下size()方法:
publicintsize(){
finalSegment[]segments=this.segments;
longsum=0;
longcheck=0;
int[]mc=newint[segments.length];
//Tryafewtimestogetaccuratecount.Onfailuredueto
//continuousasyncchangesintable,resorttolocking.
for(intk=0;k
check=0;
sum=0;
intmcsum=0;
for(inti=0;i
sum+=segments[i].count;
mcsum+=mc[i]=segments[i].modCount;
}
if(mcsum!
=0){
for(inti=0;i
check+=segments[i].count;
if(mc[i]!
=segments[i].modCount){
check=-1;//forceretry
break;
}
}
}
if(check==sum)
break;
}
if(check!
=sum){//Resorttolockingallsegments
sum=0;
for(inti=0;i
segments[i].lock();
for(inti=0;i
sum+=segments[i].count;
for(inti=0;i
segments[i].unlock();
}
if(sum>Integer.MAX_VALUE)
returnInteger.MAX_VALUE;
else
return(int)sum;
}
size方法主要思路是先在没有锁的情况下对所有段大小求和,如果不能成功(这是因为遍历过程中可能有其它线程正在对已经遍历过的段进行结构性更新),最多执行RETRIES_BEFORE_LOCK次,如果还不成功就在持有所有段锁的情况下再对所有段大小求和。
在没有锁的情况下主要是利用Segment中的modCount进行检测,在遍历过程中保存每个Segment的modCount,遍历完成之后再检测每个Segment的modCount有没有改变,如果有改变表示有其它线程正在对Segment进行结构性并发更新,需要重新计算。
其实这种方式是存在问题的,在第一个内层for循环中,在这两条语句sum+=segments[i].count;mcsum+=mc[i]=segments[i].modCount;之间,其它线程可能正在对Segment进行结构性的修改,导致segments[i].count和segments[i].modCount读取的数据并不一致。
这可能使size()方法返回任何时候都不曾存在的大小,很奇怪javadoc居然没有明确标出这一点,可能是因为这个时间窗口太小了吧。
size()的实现还有一点需要注意,必须要先segments[i].count,才能segments[i].modCount,这是因为segment[i].count是对volatile变量的访问,接下来segments[i].modCount才能得到几乎最新的值(前面我已经说了为什么只是“几乎”了)。
这点在containsValue方法中得到了淋漓尽致的展现:
publicbooleancontainsValue(Objectvalue){
if(value==null)
thrownewNullPointerException();
//SeeexplanationofmodCountuseabove
finalSegment[]segments=this.segments;
int[]mc=newint[segments.length];
//Tryafewtimeswithoutlocking
for(intk=0;k
intsum=0;
intmcsum=0;
for(inti=0;i
intc=segments[i].count;
mcsum+=mc[i]=segments[i].modCount;
if(segments[i].containsValue(value))
returntrue;
}
booleancleanSweep=true;
if(mcsum!
=0){
for(inti=0;i
intc=segments[i].count;
if(mc[i]!
=segments[i].modCount){
cleanSweep=false;
break;
}
}
}
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 如何设计线程安全的 HashMap 如何 设计 线程 安全