JDK1.7的ConcurrentHashMap
ConcurrentHashMap基于Hashtable的缺陷而生。同样是为了解决高并发问题,Hashtable只是在HashMap的基础上对容易出现线程同步问题的操作例如put,get加上了一个Synchronized,效率极其低下。
ConcurrentHashMap适用于高并发环境,结构上与HashMap大不相同。
ConcurrentHashMap结构
ConcurrentHashMap与HashMap不同。内部由Segment数组构成。在初始化ConcurrentHashMap的时候只会在Segment[]的第0个位置初始化一个Segment对象作为模板,其余位置只有在节点存储在这位置时才会初始化。Segment对象内部会存储一个HashEntry数组,HashEntry与HashMap中的Entry大同小异,同样是链表方式存储。
ConcurrentHashMap构造函数拥有三个参数:
第一个规定Segment中HashEntry数组容量总和(比如有16个Segment,取16就意味着每个Segment中的HashEntry容量理论为1),默认16,
第二个是加载因子,用于扩容,默认0.75。
第三个是并发级别,决定Segment数组的容量,默认16,最大值2的16次方。与HashMap不同,ConcurrentHashMap中确定数组容量的这个2的幂次方数采用的方式是循环(HashMap采用的是Integer.highestOneBit((n-1)<<1),用法与确定位置有关。
HashEntry数组容量=HashMap数组容量总和/最高并发数向下取整。最小值为2,大于2仅取2的幂次方数。Segment数组大小不变永恒为大于或等于并发级别数的2的幂次方数。
ConcurrentHashMap中threshold为Segment对象所有,其值=Segment对象中HashEntry数组的容量*加载因子。扩容在ConcurrentHashMap中为局部扩容,也就是说哪个Segment对象达到了扩容条件就对该Segment对象中的HashEntry数组进行扩容。
对于想要存放的key,value值,会先用hashcode计算存储在那个Segment中(Segme),再计算存放在Segment的HashEntry的哪个位置。
put过程
ConcurrentHashMap的put会计算两次下标,第一次是计算Segment[]中的位置,第二次是Segment中HashEntry中的位置。确定好Segment[]中的位置后,会查看该位置的Segment是否初始化:
如果没有初始化,就会使用CAS的方式去创建一个Segment对象。
如果初始化好了,就直接拿这个Segment对象来存。
之后计算HashEntry[]的位置,依旧是使用头插法上锁进行链表节点存储。
Segment[]位置计算
对于ConcurrentHashMap来说,在Segment[]和HashEntry[]中的位置需要使用不同的算法。Segment[]位置决定于sshift属性,该属性存储的是大于等于并发级别的2的幂次方数1在第几位。
其中ssize为Segment[]数组大小,也就是大于等于并发级别的第一个2的幂次方数。sshift则记录的就是ssize的1在第几位。
例如:现在并发级别等于17,那么sszie=32,Segment数组容量为32,sshift=6(32=10 0000); 之后在计算Segment数组位置的时候,就会将处理过的hash值右移32-sshift位,目的是为了保留hash值的高sshift位,用于与数组容量-1取&获得下标值。即:Segment[]中的下标=key的hash值 >> (32 - sshift) & ssize-1;
为什么这样做呢?ConcurrentHashMap采用的是双数组存储,如果两个数组下标算法相同,当在HashEntry[].length==Segment[].length时,该Segment中的所有结点就会存储在其HashEntry数组中的同一位置,其他位置绝对不会存储结点。就会存在一种现象:在该Segment中的结点在被get时效率会非常非常低。
所以Segment[]中的位置用高位hash判断,HashEntry[]中的位置低位hash判断。HashEntry的下标=key.hash值 & HashEntry[].length;
CAS的方式创建一个Segment
ConcurrentHashMap为了提高高并发情况时的效率,选择了多check+CAS自璇的方式对一个位置上的Segment进行创建。
首先解析一下CAS方法:compareAndSwap____(),参数有四个:
第一个参数为目标对象;
第二个参数为目标对象中的变量的偏移量;
第三个参数为变量是否等于该参数的值;
第四个参数为将该变量赋值的目标值。
返回值为boolean,true表示成功设置,flase表示没有设置成功。
CAS方法是原子性的,针对内存上的该变量进行修改,而不是线程缓冲区的。
但是业务过程复杂不上锁无法保证全程都是原子性的。所以引入了自璇
getObjectVolatile方法作用是拿出内存中目标变量的值,第一个参数为目标对象,u为其偏移量。
当拿到的目标值为null,就把刚创建的Segment放进去。while保证了当if不成立(因为其他线程比你快放进去)时,重新进入while的时候,其他线程把这个Segment给remove掉,也能创建个新的,保证不会空指针。CAS方法可以保证放是原子性的,但是不能保证得到false的线程在下一瞬间这个Segment不被删除。
put中使用的锁ReentrantLock
Segment的put方法会使用一个锁,ReenTrantLock提供了一个tryLock方法,该方法与lock不同,它会给出一个boolean返回值,为true则获取到了锁,false则没获取到但是不会阻塞。lock方法如果获取不到锁会一直阻塞。tryLock优势在于可以在获取不到锁的时候先去进行别的准备工作。
put方法使用了while(!tryLock())的方式去获取锁,如果拿到锁会进入正常的put工作,没有拿到的话会进入一个逻辑:
根据电脑的CPU核数进行一个重试,多核CPU重试64次,单核重试1次。第一次重试时会遍历一次HashEntry目标位置的链表。如果存在Key相同的,就什么都不做开始尝试拿到锁,如果不存在key相同的就new一个next为null的Entry,然后去尝试拿到锁。当然,ConcurrentHashMap是针对高并发环境而生,高并发环境下,没拿到锁的时候,链表肯定有可能会发生变化。所以,在偶数次尝试的时候,Segment会对比一下刚刚缓存的链表头节点与现在的链表头结点是否一致,不一致则重新初始化,一致就继续等锁。
思路很好理解,但是,这段代码其实没有实际作用(?),在拿到锁之后,Segment一样会这样验证一次。这也是出于安全考虑。所以这段代码的想法很美好,但是并没有起到实际的作用。所以,这段代码也就是为了防止循环次数过多导致消耗大量CPU资源。并没有什么卵用。之后JKD1.8也优化了这个点。
扩容
ConcurrentHashMap扩容是局部的,也就是哪个Segment需要扩容就扩哪个。在ConcurrentHashMap中,不像HashMap,在特定情况下会rehash提高散列性,它的hash值不会改变。扩容思路与HashMap一致,也是通过New原先2倍大小的新数组之后进行转移。在转移过程中添加了一条优化:
它会对单个Segment的链表进行两次遍历,第一次遍历存储一个HashEntry变量lastRun的值,再记录一个int变量lastIndex值。这两个变量一个存储该链表中最后一条移动至同一位置的链表头和其在新数组的存储位置。想通过这样的方式一次移动一条很长的链表以达成优化的目的。
假设当前链表一共有1,2,3,4,5,6,7,8个结点,1,2在新数组中处于同一位置,3,4,6在新数组中处于同一位置,7,8在新数组中处于同一位置,那么我们只需要移动1,3,7这三个头结点和单独的5结点就可以完成工作。但是实现过程中存在局限性,所以只能移动最后一条,也就是7和8。Segment只会对结点7进行记录。
具体过程:e = 1 lastRun=e lastindex=1的新下标,之后用index代替。
e = 2,e.index=lastindex;
e=3,e.index!=lastindex,lastRun=e,index=e.index;
e=4,e.index=lastindex;
e=5,index!=lastindex,lastRun=e,index=e.index;
e=6,index!=lastindex,lastRun=e,index=e.index;
e=7,index!=lastindex,lastRun=e,index=e.index;
e=8,index=lastindex;
可见最后,我们只能拿到最后一条要存储至同一位置的链表7,8
第一次遍历会确定这条链表的头,第二次遍历进行转移,但是转移前会先将这条一定会转移到同一位置的链表先转移到新数组。
作者似乎是想通过这样的思路进行全面优化,但是实现过程又太复杂了所以放弃了,只做了这么一步。原先O(n)的复杂度被强行弄成了O(2n)似乎也没快多少,也不知道这么迷的操作到底是做什么,可能大多数情况下会快不少?
size()
与传统HashMap不同,为了避免高并发环境下的脏读,size也选择了尝试算法,思路为:通过对所有Segment中的结点进行统计,在统计结束后观察现在的modCount和开始时缓存的modCount是否一致,如果一致就返回,不一致就重新统计,直至达到重试次数上限后加锁进行统计或正确为止。
总结
ConcurrentHashMap使用了很多CAS操作,直接对内存中的内容进行修改或取,而不是拿到线程缓冲区进行修改。当然在put方面也使用了锁。相较于Hashtable,省略了很多synchronized的使用,也就是减少了锁的使用频率,极大地提升了实际效率。