HashMap
前言
HashMap
在Map中作为使用最频繁的HashMap,它不仅是工作中我们的最爱,也是面试官的最爱;因为Map相关的源码方法太多了,并且比较晦涩,所以这次我们采取一问一答的方式来学习HashMap,下面问题为loger平时面试爱问的问题,以及一些网上搜集的大厂面试题
正文
平时在工作中是否有使用过HashMap,能谈一下他的数据结构和实现原理吗?
ps : 首先一定要回答用过...,没用过的话应该就直接让你出门右转了. 在回答这个问题时一定要全面,HashMap 在 Java8 的时候是一个分界线, 之前和之后有很大不同
好的, HashMap在工作中属于比较常用的, 它的负载因子是0.75f, 默认大小是16, 扩容机制是2倍扩容,扩容时主要分为两步: 创建一个新的空Entry, 长度为原数组的2倍,然后遍历原来的数组, 将原数组重新Hash到新数组, 此步骤叫ReHash
HashMap在 Java8 之前和 Java8之后有很大不同,我在这里分别讲一下:
-
Java8 之前:
Java8 之前 HashMap 的数据结构为 数组+链表 的形式实现的,数组中存储了 key-value 的键值对(Entry),在进行插入(put)操作时会根据key的hash计算一个index值,index表示在数组中插入的位置;而hash存在概率性,有可能不同 key 计算 hash出来的index值是一样的,就形成了链表;get方法同理,根据 key 计算 hash 出index然后获取值;
-
Java8 之后:
Java8 之后 HashMap 的数据结构为 数组+链表+红黑树, 当链表长度超过8时会自动转化为红黑树, 当小于6时重新变为链表, 数组中的key-value 实例叫法变为Node
有了解过为什么HashMap的默认负载因子是0.75f吗?
首先我们要了解负载因子是什么, 实际上负载因子就相当于一个扩容机制的阈值, 达到了阈值就要进行扩容,比如说当前我的HashMap容量设置的是100, 负载因子为0.75f, 那么 100 * 0.75 = 75 也就是说当实际容量达到了75我们就要进行扩容了. 回到问题, HashMap是一个数据结构, 数据结构的目的是要节省空间和时间,负载因子的存在也是为了这个目的, 其实这个问题在HashMap的源码中已经解答了, 请看 :
HashMap doc注释
其大体意思就是 : 在理想情况下随机哈希码, bins(这里我理解为hash桶)中的节点出现频率遵循泊松分布, 对照注释中给出的测试, 我们可以得出这样的结论 - 当用0.75作为负载因子时, 单个hash桶内元素个数为8的概率小于千分之一, 这也是为什么当元素大于8时要转变为红黑树, 而如果负载因子初始值大了, 虽然可以减少扩容次数, 但是同时会导致散列冲突可能性变大; 如果负载因子初始值小了, 虽然可以减少散列冲突的可能性, 但扩容的次数就会变多. 而这个0.75就是经过种种权衡而产生的
为什么要进行 ReHash 而不是直接复制数组呢?
我们先来看一下源码中Hash的计算公式在 putVal() 方法中的 tab[i = (n - 1) & hash]) 可以理解为index = (Length - 1 ) & hash, 我们将源码的算法复原, 带入一下实际场景我们可以发现为什么要进行 ReHash :
Hash公式测试
原始长度16经过运算后index为4, 但是新长度32运算出来的值为20, Hash规则和长度存在联系, 长度扩大Hash规则随之改变,所以不能直接复制数组, 而要重新进行 Hash 操作
为什么默认初始容量要是16, 我想用15不行吗?
为啥用16? 你还想用15? 你这么牛逼你咋不上天呢?
咳咳...我们还是看一下Hash计算index的公式 index = (Length - 1 ) & hash
那么 hash 是怎么来的呢? 依旧是带入源码 :
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们可以看到, hash = key.hashCode() 同 key.hashCode() 的高16位做异或运算, 此处为什么要做位运算而不是直接取 hashCode 呢?
我们带着这个疑问先往下走, 将默认初始容量16带入公式 index = (16 - 1) & hash , 也就是说要方到的位置是 [0-15] 同 hash 做 & 运算而产生的结果, 可以发现在做 & 运算的时候仅仅是 Length - 1 的二进制位是有效的, 怎么理解呢? 写一个伪代码就清晰明了了 :
测试
我们可以看到,loger进行hash后的二进制码是 : 110001001011110100011010100, length - 1 也就是15的二进制码是 : 1111
index = 110001001011110100011010100 & 1111 = 0100, 转为十进制 index 就是4
回到携带的疑问, 为什么做位运算,不直接取 hashCode ?
经过上面的测试我们发现在做 & 运算的时候有效位是 Length - 1 的二进制位, 那么如果 key的 hash 值在二进制高位变化很大, 低位变化很小, 进行 & 运算就会导致计算时有很多相同的 hash 值, 而设计者很巧妙的将 key 的 hash 值高位也做了运算, 与高16位异或运算, 此时的低位实际上是高位与低位的结合, 增加了随机性, 减少了碰撞冲突的可能
至于为什么用16不用别的......在阿里巴巴的手册中也有提到集合初始化,指定初始值大小
阿里规范
这个是为了避免初始化容量不对造成的频繁扩容问题, 这里没有找到我想要那个版本的阿里规范...其实它还推荐了最好设置容量位2的幂, 因为在使用16时, Length - 1的二进制位全是1,也就是说 index 值的结果完全取决于 hashcode 的后几位的值, 只要输入的 hashCode 本身分布均匀, hash 算法的结果就是均匀的, 也就是说是为了实现均匀分布, 为什么选16而不是32可能是设计者经过测试权衡后的结果.
刚才你有提到HashMap存在链表结构,那么这个链表是怎么插入的呢?
链表的插入方法也随着 Java 的版本发生过改变,在 Java8 前采取的是头插法, 新插入的节点总是插入在链表的头部; 在 Java8 之后改为尾插法;
那么为什么要变为尾插法呢?
是这样的,如果想了解为什么变为尾插法我们先要知道HashMap的扩容机制, HashMap在到达容量限制时会进行扩容, 我们还是举一个栗子
假设有一个容量大小为2的集合, 负载因子默认0.75, 我们现在要加入 1, 2, 3 下图为理想状态 :
但是因为扩容机制的原因, 事实上我们在放入第二个元素时就要进行扩容了, 因为头插法的原因, 新加入元素会放在链表的头部, 并且经过ReHash后同一链表的上的元素可能被放到数组的其他位置, 所以它可能就变成了下面这个样子
我们可以看到, 指针的位置是会发生变化的, 这时我们带多线程的场景, 如果我用不同的线程去操作, 分别插入 1, 2, 3 那么会怎么样?
想信你已经知道答案了, 因为头插法会改变链表的顺序, 所以就会出现环形链表, 陷入无限循环...
你确定尾插法就不会引起死循环吗? 为什么?
因为它是尾插法啊, 所谓尾插法就是新元素放在链表的尾部, 我们看下面的图 :
可以看到尾插法保证了链表的原顺序, 在扩容时并没有改变链表顺序, 所以说环形链表的情况并不会出现
牛啊小伙子! 那既然出现不了环形链表是不是我就可以把HashMap用在多线程了?
当然不能啊! 这是专业的面试官吗? ...
我们可以查看一下源码, 就会发现HashMap中的操作都没有进行同步操作, 也就是说我们无法保证 上一秒 put 进去的值, 下一秒 get 出来还是原值 所以说线程还是不安全的, 如果想用线程安全的Map的话推荐使用 ConcurrentHashMap 牛的一匹
结尾
在面试的过程中 HashMap 可能会迟到, 但是只有很小的几率会不问, 所以一定要牢牢掌握, 本篇就到这里
点关注不迷路, 喜欢的朋友可以关注我的公众号, 回复 888 获取我的相关个人网站地址, 更多精彩技术分享等你来看