对HashMap和ConcurrentHashMap的理解

JDK7的HashMap(非线程安全)

底层是数组加链表,默认数组大小为16,负载因子为0.75

Put流程:

添加元素时,会计算hash值定位到对应的数组位置,若该位置不存在元素则直接插入,若存在元素则判断key值是否已存在,若存在则覆盖其旧值,若不存在则头插法插入到链表头部。HashMap允许key和value为null,且null默认存在于数组的第0个位置上

扩容:

若当前数组大小大于阈值(数组大小乘以负载因子),则进行扩容。扩容为原来的2倍大小。即定义一个大小为原来两倍大小的数组,遍历每个元素将其转移至新数组上,之前同一链表上的元素可能在新数组上的位置会不相同。同时由于使用头插法则会导致原来链表上的元素顺序发送反转。之后返回创建的新数组。若数组大小大于最大值则无法扩容。若多个线程一起扩容则会导致同一位置上的元素迁移后出现循环链表的情况,导致get和put时出现死循环。头插法是导致其出现循环链表的一个原因。
扩容的主要目的就是为了减少链表数量,让其分布更加均匀。

如何解决并发扩容的问题:

防止HashMap的扩容即可,若能够确定HashMap的存储数量就可通过设置初始容量和负载因子控制阈值,让HashMap不扩容。

modCount的含义:

即记录该HashMap修改的次数,每次修改HashMap其值都会加一,也是在HashMap迭代器中直接对HashMap进行修改会导致异常的原因。即创建迭代器时会记录当时的modCount,遍历时会对比当前的modCount和之前记录的modCount,若不相同则会报错,类似与CAS的思想。可调用迭代器自带的方法对HashMap进行修改则不会报错。

JDK8的HashMap(非线程安全)

JDK8的HashMap增加了红黑树能够提高插入和查询的效率,插入到链表的方法改为了尾插法。若链表长度大于8且数组长度大于等于64则链表转为红黑树。若红黑树的数量小于6则变为链表。

Put流程:

同样计算hash值,但是计算方式更加简化。判断数组是否存在不存在则对数组进行初始化操作,之后根据hash值得到数组下标,判断当前数组下标的元素是否为空,为空则初始化一个对象。如果不为空则查找链表或红黑树判断key是否存在,若存在则更新value值,若不存在则插入该值。若此时是链表则使用尾插法插入到链表中。若链表长度大于8,则将链表改为红黑树。若为红黑树则插入到红黑树当中。链表转为红黑树之前还会进行判断,判断当前数组大小是否小于64,若小于则扩容,若大于再转为红黑树。链表转换为红黑树之前会先转为双向链表,之后再遍历链表插入到红黑树中。若插入到红黑树中要先判断要插入的位置,先计算hash值若hash值相同则使用compareTo()进行比较,若还是相同则比较ClassName,若还一致则使用tieBreakOrder进行比较,判断该值要往左子树还是右子树进行插入。

扩容:

比较当前大小是否大于阈值,与JDK1.7不同的是不用判断table[bucketIndex]是否为null。扩容大小同样扩容到原来的两倍。针对链表元素扩容,则判断当前元素的hash值和旧数组的长度进行与操作然后判断结果是否为0,若为0的则组成一条链表,若不为0的组成另一条链表,然后将两条链表分别转移至新数组不同的下标上。关于红黑树的扩容,与链表相似,计算得到两个链表,如果链表的数量小于6则由红黑树转为链表,否则若只有一个链表则直接将原来的红黑树的引用转移到新数组对应的位置。若有两个链表且当前链表的元素数量大于等于6则还是保持红黑树,因此要对该链表进行树化。

Remove:

移除时会先找到其位置,当要从红黑树中移除时,若红黑树满足根节点为空或这右子树为空或左子树为空或左孩子的左子树为空,则会转变为链表(相当于红黑树的大小为6个时)再删除。若不能转为链表则直接从红黑树中删除。

JDK7的ConcurrentHashMap(线程安全)

默认并发级别和初始容量均为16,负载因子为0.75,key和value不能为null,同样使用头插法。支持多线程同时扩容。

若想要HashMap线程安全可以使用HashTable,HashTable在put和get方法上加了synchronized锁保证线程安全,但查询和增加效率较低。因此更多的使用ConcurrentHashMap。ConcurrentHashMap则使用的分段锁,提高其查询和增强的效率。JDK7的ConcurrentHashMap的数组由Entry变为Segment数组,其中Segment数组中又包含Entry数组,相当于将原来的数组分割成多个Segment。
ConcurrentHashMap多了一个“并发级别”的参数,表示Segment数组的大小,原来的初始容量则表示总的Entry的数组大小。将总的Entry数组数量/并发级别,则得到每个Segment中Entry的数量。并发级别的值和容量类似,找到大于等于其设定值的一个2的幂次方的值。假设设定值为17,则初始值为2^5=32。
注意: Segment中数组的容量最小为2,若小于2则默认为2,若大于2则先得到c=初始容量/并发级别,之后判断c*并发级别是否小于初始容量,若小于则c++,直到其大于等于初始容量。若c不为2的幂次方数则要将其向上变为2的幂次方数

Put流程:

先判断value是否为空,为空则报异常,根据key值计算哈希值然后使用与操作得到对应的Segment数组的位置,如果该位置为null,则以初始化时创建的S0对象作为原型创建一个Segment对象到该位置(需要使用CAS保证并发安全),然后调用该Segment对象的put方法,先对Segment加锁(使用tryLock,若没获取到锁则继续自旋等待获取锁并在等待过程中判断要插入的Entry是否存在,如果不存在则先创建Entry对象,以便获取锁后不用再创建Entry对象。若存在了则不创建Entry对象)然后根据key值找到该Segment中要插入的对应的Entry。然后再和HashMap的put方法类似,判断该Entry是否存在,如果不存在则创建并存入该键值对,如果存在则判断该key是否在该Entry中存在如果不存在则表示哈希碰撞使用头插法,若存在则修改对应的值。

扩容:

JDK7的ConcurrentHashMap的扩容是针对Segment对象中的Entry数组进行扩容,判断Entry数组中元素的数量是否大于阈值,若大于则进行扩容。和HashMap扩容类似,同样创建一个新的Entry数组大小为原来的两倍,得到每个元素在新数组的位置,之后进行数据转移。之后返回新的Entry数组。

Size()方法问题:

为了防止在获取size过程中有其他线程对ConcurrentHashMap进行修改导致size前后不一致,会使用modCount字段,结合多次循环进行判断,两次循环前后的modCount一致则表示在获取size过程中ConcurrentHashMap并未发生修改可以返回,否则则继续循环。若循环次数过多则会对每个Segment进行加锁防止修改,再返回size大小。

JDK8的ConcurrentHashMap(线程安全)

初始容量为16,负载因子为0.75,阈值为12。与JDK7的ConcurrentHashMap不同,取消了Segment,与HashMap类似只有数组。对每个数组下标的对象进行加锁。

Put流程:

和JDK8的HashMap类似,先计算key的hash值找到对应数组的下标,若数组为空则要初始化数组,若不为空则根据下标查找到数组对应位置是否为空,如果为空则根据当前值初始化一个对象存入当前数组下标的位置(使用CAS避免并发问题)。若数组对应下标不为空则判断当前下标对象的hash值是否为-1,为-1则代表当前的ConcurrentHashMap正在扩容,当前线程要帮助其扩容。否则对该下标对象加锁(Synchronized),如果该下标元素有链表则尾插法插入链表或这更新旧值,如果插入后超过8个节点且数组长度大于64则将链表改为红黑树。若下标元素是红黑树则插入元素或更新旧值。对链表转换成红黑树的树化操作同样会对该下标进行加锁,与JDK8的HashMap类似先改为双向链表再转为红黑树。区别则在于ConcurrentHashMap使用了Treebin对象代表整个红黑树,防止红黑树因插入值后导致根节点发生改变而导致并发问题。

数组的初始化:

先再判断数组是否为空,再判断sizeCtl是否小于0,若小于0则表示数组已经初始化成功了当前线程让出CPU,若等于0则尝试使用CAS对sizeCtl减1,若减成功则进行数组初始化操作,sizeCtl的值改为默认阈值12(默认容量16*0.75)。

addCount()方法(size+1操作):

有一个baseCount属性用于作为size的基数,若在并发情况下多个线程均对baseCount进行加1操作,使用CAS进行加+1操作,若操作成功则继续,若未成功则该线程会生成一个随机数之后和数组长度进行与操作,得到线程在CounterCell数组的下标值,之后再将对应下标的CounterCell中的value使用CAS进行加1操作。这样能够分散锁的竞争提高效率。若多个线程对应到同一个CounterCell则同样会竞争。ConcurrentHashMap的size()则是baseCount加上所有CounterCell数组中value值。初始化CounterCell数组时同样使用CAS进行创建,默认大小为2,并选择一个位置创建CounterCell对象并存入该线程的值。若线程未竞争到CAS初始化CounterCell则该线程再取CAS竞争baseCount。让每个线程都不空闲。当collide为true时CounterCell数组会扩大到原来的两倍。当CounterCell数组发生变化或者数组大小大于等于CPU核心数则CounterCell数组不再扩容。

扩容:

和之前的一致,新建一个数组且数组大小为之前的两倍。为了支持多线程扩容,有一个步长(默认16),决定每个线程一次转移的数组元素个数,若有多个线程,各自转移步长的元素个数,为防止冲突则要判断准备转移的元素是否被其他线程转移,若存在则根据步长继续往前判断。转移是从原数组的右往左转移。单线程转移元素和JDK8的HashMap类似,若红黑树元素少则会转为链表,否则还是保持红黑树。如果在原数组的某个元素扩容时有一个线程想要put到当前位置,则会判断该元素位置上是否有一个ForwardingNode对象表示正在扩容,则该线程不进行put而去帮助原数组转移元素。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值