此文章为cs-notes的总结版,可以看作为我对cs-notes的学习笔记,原文请看CS-Notes
HashMap
1.存储结构
内部包含了一个 Entry
类型的数组 table
。
transient Entry[] table;
Entry
包含 4
个字段:
int hashCode
K key
V value
Entry<K,V> next
可以看出,Entry
是一个链表。Entry
类型的数组中存放着多个链表。HashMap
使用的是拉链法解决冲突,同一个链表中存放 hashCode
和数组大小取模运算结果相同的 Entry
。
2.拉链法的工作原理
插入
- 新建一个
HashMap
,默认大小为16
; - 插入
<K1,V1>
键值对,先计算K1
的 HashCode ,假设为115,进行取模运算得到桶下标115mod
16=3
。 - 插入
<K2,V2>
键值对,先计算K2
的 HashCode ,假设为118,进行取模运算得到桶下标118mod
16=6
。 - 插入
<K3,V3>
键值对,先计算K3
的 HashCode ,假设为118,进行取模运算得到桶下标118mod
16=6
,插在<K2,V2>
前面。
注意点:链表的插入是以头插法的方式进行的,<K3,V3>
是插在<K2,V2>
链表的头部。
查找
查找分为 2
个步骤:
- 计算键值对所在的桶(下标,即上文我们计算出的
3
,6
); - 直接根据下标找到链表,在链表上顺序查找,显然时间复杂度与链表长度成正比。
3.put 操作
- 判断是否为
null
。HashMap
允许插入键为null
的键值对。但是因为无法调用null
的hashCode()
方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap
使用第0
个桶存放键为null
的键值对。 - 确定桶下标。
- 先找出是否已经存在键为
key
的键值对,如果存在的话就更新这个键值对的值为value
。 - 如果不存在,就使用链表的头插法。
4.确定桶下标
- 计算
hash
值 - 对
hash
值取模。如果能够保证capacity
是2
的n
次方,那么就将取模操作转换为位运算
(位运算的代价比取模运算小得多)。位运算
或者取模运算的结果就是桶下标。
5.扩容-基本原理
- 设
HashMap
的table
的长度为M
,需要存储的键值对的数量为N
,如果hash
函数满足均匀性要求,那么每条链表的长度为N/M
,因此平均查找的时间复杂度为O(N/M)
。 - 为了让查找的成本降低,应该使
N/M
尽可能小,因此要让M
尽可能大。HashMap
采用动态扩容,根据当前的N
来调整M
的值,使得空间和时间效率得到保证。 - 需要扩容时,将
capacity
扩大为原来的2
倍(保证capacity
为2
的n
次方,确定桶下标时,能够采用位运算,加快速度)。 - 将
oldTable
里面的所有键值对重新计算桶下标,再插入到newTable
里面,所以扩容是非常费时的。
6.扩容-重新计算桶下标
- 如果
capacity
为2
的n
次方,那么扩容时能够极大地降低重新计算桶下标的复杂度。
7.计算数组容量
- HashMap 构造函数允许用户传入的容量不是
2
的n
次方,因为它可以自动地将传入的容量转换为2
的n
次方。
8.链表转红黑树
- 从
JDK 1.8
开始,一个桶存储的链表长度大于等于8
时会将链表转换为红黑树。
9.与HashTable比较
HashTable
使用synchronized
来进行同步。HashMap
可以插入键值对为null
的Entry
。HashMap
的迭代器是fail-fast
迭代器。HashMap
不能保证随着时间的推移,Map
中的元素次序是不变的。
ConcurrentHashMap
1.存储结构
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
ConcurrentHashMap
和HashMap
实现上类似,最主要的差别是ConcurrentHashMap
采用了分段锁(Segment)
,每个分段锁维护着几个桶(HashEntry)
,多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是Segment
的个数)。- 默认的并发级别为
16
,也就是说默认创建16
个Segment
。
2.size操作
- 每个
Segment
维护了一个count
变量来统计该Segment
中的键值对个数。在执行size
操作时,需要遍历所有Segment
然后把count
累计起来。 - 由于
ConcurrentHashMap
使用的是分段锁,所以在计算一段的size
时,另外几段可能发生变化,这就使得size
发生变化。 ConcurrentHashMap
在执行size
操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。- 如果尝试的次数超过
3
次,就需要对每个Segment
加锁。
3.JDK 1.8 的改动
JDK 1.8
使用了 CAS(Compare And Swap) 操作来支持更高的并发度,在 CAS 操作失败时,使用内置锁synchronized
。JDK 1.8
的实现也在链表过长时会转换为红黑树。