《集合》之hashmap
数据结构:
HashMap在底层数据结构上采用了数组+链表+红黑树,通过 散列映射 来存储 键值对 数据
put操作
1)判断数组是否为空,为空进行初始化
2)不为空,则计算 key 的 hash 值,通过(n - 1) & hash计算哈希槽;
3)查看哈希桶是否存在数据,没有数据就构造一个 Node节点 存放在 table[index] 中;
4)存在数据,说明发生了hash冲突, 继续判断key是否相等,
相等,用新的value替换原数据;
若不相等,判断当前节点类型是不是树型节点,
如果是树型节点,创造树型节点插入红黑树中;
不是红黑树,创建普通Node加入链表中;如果链表长度大于 8,数组长度大于等于64,将链表转换为红黑树;数组长度小于64扩容。
5)插入完成之后判断当前节点数是否大于阈值,若大于,则扩容为原数组的二倍
哈希函数
hash函数是先拿到 key 的hashcode,是一个32位的值,然后让hashcode的高16位和低16位进行异或操作。该函数也称为扰动函数,
做到尽可能降低hash碰撞。
容量为什么始终都是2^N
数组下标的计算方法是 (n - 1) 与 hash ,速度比取模运算快
索引值在容量中,不会超出数组长度
尽量把数据分配均匀,较少碰撞,让 HashMap 存取高效。
扩容
- 数组为空,或者数组的长度为0时,扩容
- 链表长度大于8,而数组长度小于64,会引发扩容
- 默认的负载因子是0.75,如果数组中已经存储的元素个数大于数组长度的75%,将会引发扩容操作。
创建一个长度为原来数组长度 两倍 的新数组。
重新对原数组中的Entry对象进行哈希运算,以确定他们各自在新数组中的新位置。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只要看原来的hash值新增的那个bit是1还是0就好了,
是0的话索引没变,是1的话索引变成“原索引+ n”
负载因子0.75
提高空间利用率和 减少查询成本的折中,主要是泊松分布
加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;
加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数。
为什么不直接使用红黑树,而先使用链表再转红黑树
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD)
“因为树节点的大小是链表节点大小的两倍,所以只有在容器中包含足够的节点保证使用才用它”,
显然尽管转为树使得查找的速度更快,但是在节点数比较小的时候,此时对于红黑树来说内存上的劣势会超过查找等操作的优势,
自然使用链表更加好,但是在节点数比较多的时候,综合考虑,红黑树比链表要好。
为什么是8,而不是9不是10
Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
理想情况下,在随机哈希码下,哈希表中节点的频率遵循泊松分布,可以看到为8的时候概率就已经很小了,再往后调整并没有很大意义。
当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,可能导致不均匀的数据分布。
理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,作者还给出了泊松分布的公式
这里取
可以看到,链表长度达到8个元素的概率为0.00000006,几乎是不可能事件
tableSizeFor
(不考虑大于最大容量的情况)是返回大于输入参数且最近的2的整数次幂的数。比如10,则返回16。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
JDK1.7死循环
扩容后链表中的节点在新的hash桶使用头插法插入,新的hash桶会倒置原hash桶中的单链表。那么在多个线程同时扩容的情况下就可能导致产生一个存在闭环的单链表,从而导致死循环。
在JDK1.8中,采用的是尾插法,保证了链表的顺序与之前一致。而且在1.8中链表过长时会转换为红黑树,在转换为红黑树前,也是先根据尾插法生成新链表再进行转换。
JDK1.8死循环
红黑树成环,两个红黑树节点的父亲节点相互引用才可以导致无法走出这个for语句。
HashMap遍历
第一种:遍历HashMap的entrySet键值对集合
1.通过HashMap.entrySet()得到键值对集合;
2.通过迭代器Iterator遍历键值对集合得到key值和value值;
第二种:遍历HashMap键的Set集合获取值;
1.通过HashMap.keySet()获得键的Set集合;
2.遍历键的Set集合获取值;
第三种:遍历HashMap“值”的集合;
1.通过HashMap.values()得到“值”的集合
2.遍历“值”的集合;
JDK7 存在死循环和数据丢失问题
数据丢失:
-
并发赋值被覆盖: 在
createEntry
方法中,新添加的元素直接放在头部,使元素之后可以被更快访问,但如果两个线程同时执行到此处,会导致其中一个线程的赋值被覆盖。 -
已遍历区间新增元素丢失: 当某个线程在
transfer
方法迁移时,其他线程新增的元素可能落在已遍历过的哈希槽上。遍历完成后,table 数组引用指向了 newTable,新增元素丢失。 -
新表被覆盖: 如果
resize
完成,执行了table = newTable
,则后续元素就可以在新表上进行插入。但如果多线程同时resize
,每个线程都会 new 一个数组,这是线程内的局部对象,线程之间不可见。迁移完成后resize
的线程会赋值给 table 线程共享变量,可能会覆盖其他线程的操作,在新表中插入的对象都会被丢弃。
死循环: 扩容时 resize
调用 transfer
使用头插法迁移元素,虽然 newTable 是局部变量,但原先 table 中的 Entry 链表是共享的,问题根源是 Entry 的 next 指针并发修改,某线程还没有将 table 设为 newTable 时用完了 CPU 时间片,导致数据丢失或死循环。
JDK8 在 resize
方法中完成扩容,并改用尾插法,不会产生死循环,但并发下仍可能丢失数据。可用 ConcurrentHashMap 或 Collections.synchronizedMap
包装成同步集合。
1.7和1.8区别
jdk7 数组+单链表 jdk8 数组+(单链表+红黑树)
jdk7 链表头插 jdk8 链表尾插
头插法是操作速度最快的,找到数组位置就直接找到插入位置了
jdk8之前hashmap这种插入方法在并发场景下如果多个线程同时扩容会出现循环列表。
jdk8开始hashmap链表在节点长度达到8之后会变成红黑树,这样一来在数组后节点长度不断增加时,遍历一次的次数就会少很多很多(否则每次要遍历所有)
相比头插法而言,尾插法操作额外的遍历消耗已经小很多了,也可以避免之前的循环列表问题。
(同时如果变成红黑树,也不可能做头插法了)
jdk7 先扩容再插入 jdk8 先插入再扩容
jdk7先扩容,然后使用头插法,直接把要插入的Entry插入到扩容后数组中,头插法不需要遍历扩容后的数组或者链表。
jdk8如果要先扩容,由于是尾插法,扩容之后还要再遍历一遍,找到尾部的位置,然后插入到尾部。
jdk7 计算hash运算多 jdk8 计算hash运算少
jdk7 受rehash影响 jdk8 调整后是(原位置)or(原位置+旧容量)