Java8中的HashMap和ConcurrentHashMap相较于Java7中的有比较大的改动,最主要是通过引入了红黑树来提升索引效率.
HashMap是Java中的一个常用的数据结构, 它是一个用来存储key-value键值对的集合, 每一个key-value键值对被称为一个Entry, 这些Entry分散存储在一个数组中,这个数组就是HashMap的主干. HashMap数组元素初始值为Null.
对于HashMap的常用方法有 Get和Put
调用hashMap.put('apple', 0), 插入一个key为'apple'的元素, 这个时候利用哈希函数来确定位置(index)
index = Hash('apple')
假设 index = 2 那么结果如下:
因为数组长度是有限的,所以会出现entry冲突情况, 如下:
这种情况下,链表可以作为解决方案.
HashMap数组的每一个位置不止是一个Entry对象,也是一个链表的头节点, 当新来的Entry映射冲突时以头插法方式插入对应位置链表即可,如下图
当查找数据时,首先将输入key做一次Hash映射,得到对应的index
index = Hash('apple')
由于冲突存在, 所以有可能匹配到多个Entry, 这时就需要顺序查找链表节点,找到那个节点key值为'apple'的Entry.
前面数据插入时选择的头插法原因是认为数据查找时后插入的Entry被查找的可能性更大
HashMap默认的初始长度是16, 而且如果需要扩容, 扩容后长度必须为2的幂, 初始长度选择16是为了让从Key映射到index的Hash算法使得数据均匀分布
前面 index = Hash(key)
实际 index = HashCode(key) & (Length-1)
例子:
1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。
而如果HashMap的长度不是2的幂, 比如10
那重复上面的步骤
另一个HashCode值
再换一个
很明显 ,当长度为10时, 上述三个index就一样,也就是冲突了.所以说,当HashMap长度不是2的幂时, index冲突的概率更高, 这样不符合Hash算法均匀分布的原则
当HashMap数组长度为2的幂时,只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的
接下来讨论, 高并发场景下HashMap工作方式
HashMap的容量是有限的,当HashMap数组达到一定饱和度时, key映射位置发生冲突的概率就会慢慢增大, 这时候就需要Resize
是否需要Resize由两个因素决定
1, Capacity , 即HashMap的当前数组长度
2, LoadFactor , 负载因子,默认为0.75f
HashMap Resize条件为: HasgMap.size >= Capacity*LoadFactor
第一步(扩容): 创建一个新Entry空数组, 长度为原来的2倍
第二步(ReHash): 遍历原来的Entry数组, 将所有的Entry重新Hash到新数组.
上述流程在单线程下执行没有问题, 但多线程环境下Rehash操作可能会形成链表环形, 这种情况下如果查找一个不存在的key, 且 index = Hash(key)恰好等于环形链表位置,则查找会出现死循环, 所以HashMap是非线程安全的
接下来, ConcurrentHashMap数据结构是可以保证线程安全的,同时实现高性能读写. 其实保证线程安全的还有HashTable和Collections.synchronizedMap, 但是这两者有存在性能问题, 在读写阶段,他们是通过给整个集合加锁来保证线程安全的. 加锁会导致同一时间其他操作阻塞.
在ConcurrentHashMap中, 存在一个名为Segment的概念. Segment本身就相当于一个HashMap对象, 它包含一个HashEntry数组, 数组中的每一个HashEntry既是一个键值对(Entry), 也是一个链表的头节点.
单个Segment结构如下:
在ConcurrentHashMap集合中有2^N个Segment保存在一个名为Segments的数组中, 所以ConcurrentHashMap的结构如下:
可以把ConcurrentHashMap理解为一个二级哈希表, 结构这样设计的好处是可以采用锁分段技术segment之间的读写操作互不影响
ConcurrentHashMap并发读写情况:
(1) 不同Segment的并发写入 ==========> 可以并发执行
(2) 同一个Segment的一写一读 ==========>可以并发执行
(3) 同一个Segment的并发写入 ==========>需要加锁
ConcurrentHashMap中的每个Segment各自有自己的锁, 在保证线程安全的同事降低了锁的粒度, 让并发效率大大提升
ConcurrentHashMap的Get 和Put方法
Get方法:
1.为输入的Key做Hash运算,得到index值。
2.通过index值,定位到对应的Segment对象
3.再次通过index值,定位到Segment当中数组的具体位置。
Put方法:
1.为输入的Key做Hash运算,得到index值。
2.通过index值,定位到对应的Segment对象
3.获取可重入锁
4.再次通过index值,定位到Segment当中数组的具体位置。
5.插入或覆盖HashEntry对象。
6.释放锁
问题: 每个Segment各自有锁, 那ConcurrentHashMap调用size()获取总元素个数时, 如何确定拿到的是正确的数据? 还有, 如果在统计过程中, 已经统计过的Segment瞬间插入新的元素, 如何处理?
ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:
1.遍历所有的Segment。
2.把Segment的元素数量累加起来。
3.把Segment的修改次数累加起来。
4.判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。
5.如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。
6.再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
7.释放锁,统计结束。
本文出处: 程序员小灰