面试部分难点梳理 - HashMap + CurrentHashMap
作者:互联网
HashMap
HashMap的继承体系
核心属性+构造方法
状态属性:
- DEFAULT_INITIAL_CAPACITY = 1 << 4; == 16 默认的初始长度
- MAXIMUN_CAPACITY = 1 << 30; Hash表的最大长度,其是由JVM决定的
- DEFAULT_LOAD_FACTORY = 0.75f ; 默认的负载因子大小
- TREEIFY_THRESHOLD = 8; 树化的最小链长
- UNTREEIFY_THRESHOLD = 6 ;树降级为链的链长
- MIN_TREEIFY_CAPACITY = 64 ; 链表转化为树时,数组的最小长度。
成员属性
- Node继承了Map.entry类,
- Node<K,V>[] table; Hash表的数组
- size; Hash表的长度
- modCount; 记录Hash表的修改次数,包括插入、删除等(替换不会计数)。
- loadFactory ;负载因子
- threshold ; 扩容阈值(开始时,其数值为tableSizeFor(initCapacity)),但是由于延迟初始化,其并没有真的在内存中分配空间,在后面初始化以后就会变成capacity*loadFactory。
- tableSizeFor()函数的目的就是把自定义输入的数值转化成最小的2的n次幂,通过对initCapacity-1 ,不断(对右移1,2,4,8,16位)进行位与操作,得到2的n次幂。
- 必须是initCapacity,否则在8、16、32等输入2的n次幂的情况下会扩大2倍,变成16、32、64,而不是8、16、32。
putVal()方法
- putVal一共分为4种情况。
Hash扰动处理
- 将Hash的高16位与低16位进行异或处理,得到扰动后的Hash数值
- 扰动后的Hash数值(hash),再将其和(n-1)进行位与操作,得到散列表中的index,在2进制环境下也就是对hash进行取模操作。
延迟初始化,
- 如果table为空或者,table的长度为0,这时才初始化数组,以节省内存开销。
第一种情况:Hash数组中没有元素
- 找到数组的指定索引位置,插入新节点
- 这时会跳出判断,++modCount,
第二种情况:Hash数组中有元素并正是我们要找的
- e表示找到了一个我们要找的相同的节点。
- 更改成为新值,同时返回旧值。
第三种情况:Hash数组中有元素,不是我们要找的找到,并且其数据结构是红黑树
- 判断数组中的第一个节点是否是红黑树,如果是,则执行红黑树的插入操作。返回e。
- 如果e不为空,则表示原来有值,修改成为新值,返回旧值。
第四种情况:Hash数组中有元素,不是我们要找的找到,并且其数据结构是链表
- 链表也有三种情况,
- 遍历当前链表,找到节点
- 遍历当前链表,没有找到节点,但是也没有超过树化阈值
这里用binCount来记录遍历次数,因为遍历是从0开始的,所以当binCount>=TREEIFY_THRESHOLD-1就可以了认为是可以树化了。
- 遍历当前链表,没有找到节点,达到树化阈值(见上图)
- 遍历当前链表,找到节点
上面的四种情况都会落到这里。
- 如果e!=null,表明找到了节点,则表示会进行修改,不会++modCount,会直接返回原值。
- 如果e==null,则会++modCount,返回null,表示是插入节点。
resize()方法
确定参数
- oldTab 表示扩容前的数组。
- oldCap 表示扩容前的长度,如果未被初始化则赋值为0
- oldThr 表示扩容前的扩容阈值。
- newCap表示扩容后的长度,newThr表示扩容后的扩容阈值,这一部分主要就是确定这两个数值,让后面根据这两个数值进行扩容处理。
- 分为4种情况
第一种情况:散列表已经初始化过了,正常扩容
- newCap = oldCap乘以2
- newCap不会超过
MAXIMUM_CAPACITY
,同时oldCap大于等于DEFAULT_INITIAL_CAPACITY
- 会触发正常扩容,
- 在这种状态下newCap 扩大为oldCap的二倍,newThr 也扩大为oldThr的二倍。
第二种情况:散列表没有被初始化过,但是有负载因子(对应HashMap的有参构造)
- newCap直接赋值成原来的负载因子即可。
- 其newThr的赋值在第四情况
第三种情况:散列表没有被初始化过,且没有负载因子(对应HashMap的无参构造)
- newCap 赋值
DEFAULT_INITIAL_CAPACITY
- newThr 赋值(int)
(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
- 按照默认参数赋值
第四种情况:散列表第一次被有参初始化时,或newCap大于MAXIMUM_CAPACITY或oldCap < DEFAULT_INITIAL_CAPACITY,时设定newThr。
- 还是按照公式
ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
- 如果超过最大值,则直接赋值Integer.MAX_VALUE,没有则赋值ft,是按照cap*thr得到的结果。
扩容操作
- 对于原来放到15的二进制可能是01111或者11111,因此01111在扩容后放到15中,11111在扩容后放到31中。当数组中有数据时分为三种情况。
让e作为节点遍历oldTab[j],同时oldTab[j] == null,辅助GC。
第一种情况:数组仅仅有一个数据
- 直接按照hash扰动取余放到新的数组中
第二种情况:数组有且数据结构为红黑树
- 调用split方法,拆解红黑树(后面说说红黑树)
第三种情况:数组有且数据结构为链表
- 和上面图表达的是一个意思,在上面的图中;
- 蓝色的放到15的链表为loHead和loTail;
- 绿色的放到31的链表为hiHead和hiTail;
- 遍历链表,将高位为0的放到低位链,高位为1的放高位链。
- 最后将15的数组的值赋值给低位置链的头,将31的数组的值赋值给高位置链的头。
get()和remove()
getNode()
- 在可以找到的情况下有三种情况
第一种情况:数组中相应索引的第一个元素就是
- 直接返回。
第二种情况:数组中响应索引的第一个元素不是,数据结构是红黑树,然后遍历红黑树
第三种情况:数组中响应索引的第一个元素不是,数据结构是链表,然后遍历链表
上面三种都没找到,那就没有返回null
removeNode()
- 这个方法分成两个步骤:查找node和删除node
查找
- 和上面的过程是一样的,判断头节点,判断红黑树,判断链表
- 这里就不赘述了
删除
- 删除也是分成三种情况
- 如果是树节点,则直接调用树的remove函数即可。
- 如果是头节点,则将table[index] 赋值给node.next。
- 如果是链表则,则p.next = node.next;(p是node的前置节点,node表示找到的节点)(没想通的看上面链表遍历的源代码)。
- 最后操作modCount++,修改长度等数据。
ConcurrentHashMap JDK1.7
- 视频
- 对于一个对象的HashCode来说,前几位时用来作为HashEntry的索引使用的,后面的几位时用来作为Segment数组的索引使用的。
构造函数
- ConcurrentHashMap内部有segment数组,segment数组中存放HashEntry数组,我们的数据是存放到HashEntry中的。
- 默认值是有segment数组长度是16,HashEntry数组默认长度是2,因此ConcurrentHashMap默认初始化以后的长度为32。
- 构造方法是没有多线程安全问题的
初始时只有第一个segment会初始化两个hashentry,后面的是不会被初始化的,只有在用到才会被初始化
Segment继承了ReentrantLock
- 一个Segment数组实际上是一个ReentrantLock数组。
- 不同锁之间是不影响写操作的
put()
- ConcurrentHashMap的Value不允许有null,key也不允许为null
- 如果当前位置没有HashEntry数组,则建立HashEntry数组,按照前面的模板,并计算是否超过阈值,进行扩容。
- 通过不断的使用UNSAFE。getObjectVolatile()方法取值,来判断当前的位置是否真的没有初始化,如果真的没有,则使用CAS操作,创建HashEntry数组。
- 尝试在需要修改的HashEntry上加锁,然后进行put操作
- 如果该位置为空,则直接加。如果不空,且发生Hash冲突,则拉链法,头插法插入。
- 对于没拿到锁的对象,则先查询,如果没有则创建一个HashEntry对象,在拿到锁之后就可以直接插入了,来节省时间。创建以后,一直自旋,等待锁。当等到锁以后,使用头插法,来插入。最后的节点插入到hashentry的最前面。如果不需要,则不创建,也不修改,等待锁的释放。
rehash()
- 详细的看视频吧。
- 首先rehash()时是对Segment中的HashEntry数组对象进行扩容。因此更加关注HashCode的前几个位。
- 这里是做了一个优化,如果某一个对象,及其以后所有的对象即使在rehash以后也在同一个数组索引下时,直接将其指针赋值给新的数组的对应位置,这样就可以减少重新计算和插入的次数。
- 对于在这个之前的对象,CurrentHashMap采用复制节点的方式,以头插法插入对应数组的索引的位置,而不是使用原来的节点。
获取集合长度size()
- 至少2次(判断last和sum),最多4次(进入锁了)才能返回数组长度。
- sum将整个CurrentHashMap的modCount求和,来作为标志,判断在获取集合长度的时候是否发生了并发。在求出sum后会将sum赋值给last,用这两个变量判断是否相等,来作为是否发生并发的标志。只有当last == sum的时候,才能表示没有线程对其发生并发操作。
- size等于所有segment数组中hashEntry数组的个数。
ConcurrentHashMap JDK1.8
- 扩容那个部分太难了,后面直接看视频吧,这里就记录一些关键点。
- 视频
- sizeCtl整个ConcurrentHashMap的核心,其表达的意思:
- sizeCtl为0,表示数组未初始化,且数组初始化容量为默认值16
- sizeCtl为正数,如果数组未初始化,那么sizeCtl表示的是数组的初始化容量。如果已经初始化,则表示数组的扩容阈值。
- sizeCtl为-1,表示数组正在进行初始化。
- sizeCtl为小于0,表示数组正在扩容,其值为-(n-1)时,表示有n个数组正在扩容,共同完成对数组的扩容。
初始化
- 默认的loadFactor是 0.75f 是写死的,无法修改。
- 如果构造函数传入loadFactor 也不会赋值给loadFactory,而是直接进入构造。
默认初始化(不推荐)
- 设定数组长度为16。没有真的初始化,仅仅是设定初始化函数。
- 不推荐空参构造 默认16 + 0.75f
带有初始容量的初始化
- 建议使用带一个初始初始值容量的初始化方式
- 同时sizeCtl 赋值为数组长度
- 注意这里的tableSizeFor传入的参数不是
initialCapacity
,而是initialCapacity + (initialCapacity >>> 1) + 1
,因此得出的表格长度正常的2倍。 - 如果initialCapacity = 16,对于HashMap长度为16,如果是CurrentHashMap则为32。
- 如果initialCapacity = 17,对于HashMap长度为32,如果是CurrentHashMap则为64。
全参初始化
- 和上面一样,都额外增加了空间
添加安全
- 和1.7一样,不允许空值空键。
数组初始化(多次自旋进行CAS)
- 如果数组没有被初始化,或者数组长度为0,则进行数组初始化
数值添加(四种情况)
第一种情况:数组当前索引处没有元素
- 对于元素,不需要上锁,仅通过CAS操作即可
第二种情况:数组当前索引处有元素,并且正在进行扩容,则启动多线程辅助扩容。
- 当某个索引处的链表>8,同时数组长度<64,会进行数组扩容。
- 将这个索引的插入forward节点,如果再向forward节点,会导致这个节点丢失。
- 对于forward节点的hash值为MOVED
对于下面两种情况是上锁的
- 其上锁的对象是f,也就是数组的第一个元素。
第三种情况:数组当前索引处有元素,元素数据结构是链表
- 前面需要判断一下是否为f,因为如果真的从链表转化成红黑树,f节点就不是了数组对应索引的第一个元素了。
- 从前到后进行遍历
- 这里依旧进行树化计数,但是是在后面进行判断是否树化,和树化操作。
- 找到了直接返回oldValue
- 如果没找到直接插入,就算已经到了链表长度也要插入
第四种情况:数组当前索引处有元素,元素数据结构是红黑树
- 不是链表结构,从红黑树找元素。
树化判断
维护集合长度
集合长度获取
-
addCount(long x, int check)维护集合长度
-
这个就不贴代码了,太难了,就放个逻辑在这里吧。
-
我们首先对baseCount进行CAS操作,操作成功,直接返回,如果操作失败,对CounterCell数组的Value尝试增加,如果还失败进入fullAddCount(x, uncontended);
-
在fullAddCount(x, uncontended);中,就是不断的自旋的在数组中的人一位置进行加1,如果CAS成功,则返回;如果失败,尝试对数组进行扩容,同时继续尝试随机位置加,直到加成功。CounterCell数组的长度最大为CPU的核心数。
-
每一次如果都CAS失败,则会找一个新的数组位置加,直到加成功
-
则最终的HashMap的总长度为:baseCount + CounterCell_value + CounterCell_value+…+
扩容安全
- 依旧是在AddCount()方法内,
- 第一个扩容的线程,nt 表示新的数组
- 协助扩容的进程
- 数组迁移是从后面,向前面迁移,每个线程都会领取一定的任务去进行迁移。
- 多个线程会负责整个数组的数据迁移,每个线程最少负责16个数组数据的迁移
- advancetrue表示当前线程已经完工,finishingtrue表示所有线程已经完工
- 新的数组会分配给多个线程一定数量的数据迁移,等到所有的线程的数据迁移都完成了,就表示数据迁移完成了。将新的数组赋值给table。
- 如果原数组这个位置已经被迁移过了,原数组这个位置会放置一个forwardNode,其hash为MOVED
- 每个线程的迁移和1.7是一样的,都是采用了取巧的方式
- 新的阈值计算: 2n-(n/2) = (3/2)*n = 0.75 * 2n 和 负载因子是0.75的是一样的
协助扩容的两种情况
- 插入时插到了hash==MOVED的forwardNode,这时会帮助扩容。
- 插入成功了,但是在计算集合长度时,发现别的线程在扩容,这时会帮助扩容。
标签:扩容,初始化,HashMap,难点,链表,CurrentHashMap,数组,长度,节点 来源: https://blog.csdn.net/paleatta/article/details/123626434