Java HashMap解释
1. HashMap 的内部数据结构
数组 + 链表/红黑树
2. HashMap 允许空键空值么
HashMap 最多只允许一个键为 Null(多条会覆盖),但允许多个值为 Null
3. 影响 HashMap 性能的重要参数
初始容量:创建哈希表(数组)时桶的数量,默认为 16
负载因子:哈希表在其容量自动增加之前可以达到多满的一种尺度,默认为 0.75
4. HashMap 的工作原理
HashMap 是基于 hashing 的原理,我们使用 put(key, value)存储对象到 HashMap 中,使用 get(key)从 HashMap 中获取对象
5. HashMap 中 put()的工作原理
6.HashMap 的底层数组长度为何总是2的n次方
HashMap 根据用户传入的初始化容量,利用无符号右移和按位或运算等方式计算出第一个大于该数的 2 的幂。
- `使数据分布均匀,减少碰撞
- 当 length 为 2 的 n 次方时,h&(length - 1) 就相当于对 length 取模,而且在速度、效率上比直接取模要快得多
1.8 中做了哪些优化优化?
数组+链表改成了数组+链表或红黑树
链表的插入方式从头插法改成了尾插法
扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
- 在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容;
7.HashMap线程安全方面会出现什么问题
- 在 jdk1.7 中,在多线程环境下,扩容时会造成环形链或数据丢失。
- 在 jdk1.8 中,在多线程环境下,会发生数据覆盖的情况
8. 为什么 HashMap 的底层数组长度为何总是 2 的 n 次方
这里我觉得可以用逆向思维来解释这个问题,我们计算桶的位置完全可以使用 h % length,如果这个 length 是随便设定值的话当然也可以,但是如果你对它进行研究,设计一个合理的值得话,那么将对 HashMap 的性能发生翻天覆地的变化。
没错,JDK 源码作者就发现了,那就是当 length 为 2 的 N 次方的时候,那么,为什么这么说呢?
第一:当 length 为 2 的 N 次方的时候,h & (length-1) = h % length
为什么&效率更高呢?因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高
第二:当 length 为 2 的 N 次方的时候,数据分布均匀,减少冲突
此时我们基于第一个原因进行分析,此时 hash 策略为 h & (length-1)。
我们来举例当 length 为奇数、偶数时的情况:
从上面的图表中我们可以看到,当 length 为 15 时总共发生了 8 次碰撞,同时发现空间浪费非常大,因为在 1、3、5、7、9、11、13、15 这八处没有存放数据。
这是因为 hash 值在与 14(即 1110)进行&运算时,得到的结果最后一位永远都是 0,那么最后一位为 1 的位置即 0001、0011、0101、0111、1001、1011、1101、1111 位置处是不可能存储数据的。这样,空间的减少会导致碰撞几率的进一步增加,从而就会导致查询速度慢。
而当 length 为 16 时,length – 1 = 15, 即 1111,那么,在进行低位&运算时,值总是与原来 hash 值相同,而进行高位运算时,其值等于其低位值。所以,当 length=2^n 时,不同的 hash 值发生碰撞的概率比较小,这样就会使得数据在 table 数组中分布较均匀,查询速度也较快。
如果上面这句话大家还看不明白的话,可以多试一些数,就可以发现规律。当 length 为奇数时,length-1 为偶数,而偶数二进制的最后一位永远为 0,那么与其进行 & 运算,得到的二进制数最后一位永远为 0,那么结果一定是偶数,那么就会导致下标为奇数的桶永远不会放置数据,这就不符合我们均匀放置,减少冲突的要求了。
那么可能钻牛角尖的同学还会问,那 length 是偶数不就行了么,为什么一定要是 2 的 N 次方,这不就又回到第一点原因了么?JDK 的工程师把各种位运算运用到了极致,想尽各种办法优化效率。
9. 那么为什么默认是 16 呢?怎么不是 4?不是 8?
关于这个默认容量的选择,JDK 并没有给出官方解释,那么这应该就是个经验值,既然一定要设置一个默认的 2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。
太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。
所以,16 就作为一个经验值被采用了。
10. HashMap 线程安全方面会出现什么问题
1.put 的时候导致的多线程数据不一致
比如有两个线程 A 和 B,首先 A 希望插入一个 key-valu 对到 HashMap 中,首先计算记录所要落到的 hash 桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程 A 的时间片用完了,而此时线程 B 被调度得以执行,和线程 A 一样执行,只不过线程 B 成功将记录插到了桶里面,假设线程 A 插入的记录计算出来的 hash 桶索引和线程 B 要插入的记录计算出来的 hash 桶索引是一样的,那么当线程 B 成功插入之后,线程 A 再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程 B 插入的记录,这样线程 B 插入的记录就凭空消失了,造成了数据不一致的行为。
2.resize 而引起死循环
这种情况发生在 HashMap 自动扩容时,当 2 个线程同时检测到元素个数超过 数组大小 × 负载因子。此时 2 个线程会在 put()方法中调用了 resize(),两个线程同时修改一个链表结构会产生一个循环链表(JDK1.7 中,会出现 resize 前后元素顺序倒置的情况)。接下来再想通过 get()获取某一个元素,就会出现死循环。
如果还不明白的话看这两篇文章就可以:
11. 为什么 1.8 改用红黑树
比如某些人通过找到你的 hash 碰撞值,来让你的 HashMap 不断地产生碰撞,那么相同 key 位置的链表就会不断增长,当你需要对这个 HashMap 的相应位置进行查询的时候,就会去循环遍历这个超级大的链表,性能及其地下。java8 使用红黑树来替代超过 8 个节点数的链表后,查询方式性能得到了很好的提升,从原来的是 O(n)到 O(logn)。
12. 1.8 中的扩容为什么逻辑判断更简单
元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色),因此新的 index 就会发生这样的变化:
因此,我们在扩充 HashMap 的时候,不需要像 JDK1.7 的实现那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引+oldCap”,可以看看下图为 16 扩充为 32 的 resize 示意图:
这个设计确实非常的巧妙,既省去了重新计算 hash 值的时间,而且同时,由于新增的 1bit 是 0 还是 1 可以认为是随机的,因此 resize 的过程,均匀的把之前的冲突的节点分散到新的 bucket 了。这一块就是 JDK1.8 新增的优化点。有一点注意区别,JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8 不会倒置。