HaspMap和ConcurrentHashMap
参考自:http://www.importnew.com/28263.html
HaspMap和ConcurrentHashMap(康科瑞特哈希迈普)
Java7 HashMap
不支持并发操作,HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。
capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。
loadFactor:负载因子,默认为 0.75。
threshold:扩容的阈值,等于 capacity * loadFactor
put 过程分析
(1)当插入第一个元素的时候,需要先初始化数组大小。
用户不指定容量的情况下,默认HashMap的容量是16。如果用户通过构造函数指定了容量,那么HashMap会选择大于该数字的第一个2的幂作为容量。(3->4、7->8、9->16)。
(2)如果 key 为 null,会将这个 entry(恩蠢) 放到 table[0] 中。
(3)如果 key 不为 null,求 key 的 hash 值。根据 key 的哈希值找到对应的数组下标,使用 key 的 hash 值对数组长度-1进行与运算,得到数组下标。计算方法:h & (length-1)。
(5)遍历一下对应下标处的链表,看是否有重复的 key 已经存在,如果有,直接覆盖,put 方法返回旧值。
(6)如果不存在重复的 key,先判断是否需要扩容,需要的话先扩容,然后再将这个新的数据插入到扩容后的数组的相应位置处的链表的表头。
数组扩容
在插入新值的时候,如果当前的 size 已经达到了阈值,并且要插入的数组位置上已经有元素,那么就会触发扩容,扩容后,数组大小为原来的 2 倍。
扩容就是用一个新的大数组替换原来的小数组,并将原来数组中的值迁移到新的数组中。
由于是双倍扩容,迁移过程中,会将原来 table[i] 中的链表的所有节点,分拆到新的数组的 newTable[i] 和 newTable[i + oldLength] 位置上。
例如原来数组长度是 16,那么扩容后,原来 table[0] 处的链表中的所有元素会被分配到新数组中 newTable[0] 和 newTable[16] 这两个位置。
get 过程分析
1、如果 key 为 null,只需遍历下 table[0] 处的链表就可以了。
1、根据 key 计算 hash 值。
2、找到相应的数组下标:hash & (length – 1)。
3、遍历该数组位置处的链表里的 entry,直到找到相等(==或equals)的 key,返回 key 对应的 value。
Java7 ConcurrentHashMap
支持并发操作。
(1)ConcurrentHashMap 是一个 Segment(塞个门特) 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
(2)ConcurrentHashMap 有 16 个 Segments,理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。
concurrencyLevel:并行级别、并发数、Segment 数,默认为16。
initialCapacity:初始容量,这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment。
loadFactor:负载因子,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的。
用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:
Segment 数组长度为 16,不可以扩容。
Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容。
这里初始化了 segment[0],其他位置还是 null。
当前 segmentShift 的值为 32 – 4 = 28,segmentMask 为 16 – 1 = 15,姑且把它们简单翻译为移位数和掩码。
put 过程分析
1、计算 key 的 hash 值,根据 hash 值找到 Segment 数组中的位置 j。
hash 是 32 位,无符号右移 segmentShift(28) 位,剩下低 4 位,然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的最后 4 位,也就是槽的数组下标。
2、对 segment[j] 进行初始化,
ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。
对于初始化的并发操作使用 CAS 进行控制。
1. java语言CAS底层如何实现?利用unsafe(昂森福)提供的原子性操作方法。
2.什么事ABA问题?怎么解决?当一个值从A变成B,又更新回A,普通CAS机制会误判通过检测。利用版本号比较可以有效解决ABA问题。
3、插入新值到 槽 s 中(Segment 内部的 put 操作,Segment 内部是由 数组+链表 组成的)
4、往该 segment 写入前,需要先获取该 segment 的独占锁。
在往某个 segment 中 put 的时候,首先会调用 node = tryLock() ? null : scanAndLockForPut(key, hash, value),也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。
扩容: rehash
segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry[] 进行扩容,扩容后,容量为原来的 2 倍。
首先,我们要回顾一下触发扩容的地方,put 的时候,如果判断该值的插入会导致该 segment 的元素个数超过阈值,那么先进行扩容,再插值
该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的。
get 过程分析
1、计算 hash 值,找到 segment 数组中的具体位置
2、槽中也是一个数组,根据 hash 找到数组中具体的位置
3、到这里是链表了,顺着链表进行查找即可
并发问题
添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题,我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。
put 操作的线程安全性。
初始化槽,使用了 CAS 来初始化 Segment 中的数组。
添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的UNSAFE.putOrderedObject。
扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。
remove 操作的线程安全性。
get 操作需要遍历链表,但是 remove 操作会”破坏”链表。
如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。
如果 remove 先破坏了一个节点,分两种情况考虑。
1、如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。
2、如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。
Java8 HashMap
(1)Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成
(2)根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)
(3)为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)
(4)Java7 中使用 Entry 来代表每个 HashMap 中的数据节点,Java8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。
(5)我们根据数组元素中,第一个节点数据类型是 Node 还是 TreeNode 来判断该位置下是链表还是红黑树的。
put 过程分析
1、第一次 put 值的时候,会触发下面的 resize(),类似 java7 的第一次 put 也要初始化数组长度。
第一次 resize 和后续的扩容有些不一样,因为这次是数组从 null 初始化到默认的 16 或自定义的初始容量。
2、找到具体的数组下标,如果此位置没有值,那么直接初始化一下 Node 并放置在这个位置就可以了。
3、如果数组该位置有数据,
首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点。
如果该节点是代表红黑树的节点,调用红黑树的插值方法。
如果是链表,插入到链表的最后面(Java7 是插入到链表的最前面)
如果新插入的值是链表中的第 9 个,会触发下面的 treeifyBin,也就是将链表转换为红黑树。
如果在该链表中找到了"相等"的 key(== 或 equals),此时 break,那么 e 为链表中[与要插入的新值的 key "相等"]的node
e!=null 说明存在旧值的key与要插入的key"相等",进行 "值覆盖",然后返回旧值
4、如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容。
Java7 是先扩容后插入新值的,Java8 先插值再扩容
数组扩容
当我们明确知道HashMap中元素的个数的时候,把默认容量设置成 expectedSize / 0.75F + 1.0F 是一个在性能上相对好的选择,但是,同时也会牺牲些内存
在已知HashMap中将要存放的KV个数的时候,设置一个合理的初始化容量可以减少扩容次数,有效提高性能。
get 过程分析
1、计算 key 的 hash 值,根据 hash 值找到对应数组下标: hash & (length-1)
2、判断数组该第一个位置处的元素是否刚好就是我们要找的,如果不是,走第三步
3、判断该元素类型是否是 TreeNode,如果是,用红黑树的方法取数据,如果不是,走第四步
4、遍历链表,直到找到相等(==或equals)的 key
Java8 ConcurrentHashMap
结构上和 Java8 的 HashMap 基本上一样,不过它要保证线程安全性,所以在源码上确实要复杂一些。
初始化
通过提供初始容量,计算了 sizeCtl,sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】。如 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,得到 sizeCtl 为 32。
初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的
put 过程分析
1、根据 key 计算哈希值。
2、如果数组是空的,进行初始化。
3、找该 hash 值对应的数组下标,得到第一个节点,
4、如果数组该位置(第一个节点)为空,用一次 CAS 操作将这个新值放入其中即可,这个 put 操作差不多就结束了。 如果 CAS 失败,那就是有并发操作,进到下一个循环就好了。
5、如果数组该位置(第一个节点)不为空,获取数组该位置的头结点的监视器锁
头节点的 hash 值大于 0,说明是链表,遍历链表,如果发现了"相等"的 key,判断是否要进行值覆盖,然后也就可以 break 了,如果没有相等的,到了链表的最末端,将这个新值放到链表的最后面。
头节点如果是红黑树,调用红黑树的插值方法插入新节点。
6、如果是链表,判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8,这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
扩容:tryPresize
扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍。
这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。
数据迁移:transfer
将原来的 tab 数组的元素迁移到新的 nextTab 数组中
get 过程分析
1、计算 hash 值
2、根据 hash 值找到数组对应位置: (n – 1) & h
3根据该位置处结点性质进行相应查找
如果该位置为 null,那么直接返回 null 就可以了
如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法
如果以上 3 条都不满足,那就是链表,进行遍历比对即可