本文无废话, 全干货, 由 hashCode 方法开始讲起, 带你完全重新认识 hashCode 方法, 并囊括 HashMap 所有可能的八股文知识, 附带源码详细解读; 然后将会讲解阿里面试问题, 进行知识提炼与提升, 希望大家都能认真看完;
hashCode
Object 的 hashCode 方法, JDK1.8 的默认实现是通过线程状态和移位异或的算法计算出来的, 并不是内存地址; 内存地址是老掉牙的版本的默认实现, 这个问题不要再踩坑了, 说内存地址必挂, 必挂, 必挂 !!!
Java 的哈希算法要求返回一个 32 位长的哈希码, 即一个 int;
Integer 的哈希算法, 返回的就是 Integer 底层的 value值;
Byte 和 Character 的 hashCode , 就是把底层的 value 强转成 int 返回;
Long 的 hashCode, 因为 long 的长度已经超过了32 , 所以截取出 32 位, 并通过移位异或的方式把高位数据带入到低位中, 增加扰动;
Float 和 Double 的 hashCode, 转成对应的 IEEE754 标准的二进制表示, 用 int 或 long 保存; 然后依据 Integer 和 Long 的规则再 hash; 例如 Double, 取二进制表示后 return (int)(value ^ (value >>> 32));
String 的 hashCode, 从最左边的字符到最右边的字符给不同的权值, 每一位的权值是 31 的若干次方, 最高位是 0 次方; 将每一位的权值乘以每一位字符的整型值, 相加得到最终结果 (空字符串的哈希值是0);
如果要用数组的 hashCode, 应该使用 Arrays.hashCode(), 而不是直接调用 数组对象的 hashCode 方法;
Arrays.hashCode(), 其原理和 String 的 hashCode 基本一致, 以 31^k 作为权值, 对每一个元素, 先调用该 hashCode方法, 再乘以每个元素各自的权值; 下标为 n - 1 的元素权值为1, 下标越小权值越大;
下标0的元素有特殊处理, = 权值 * ( 31 + 0号元素的hashCode )
HashMap
- LinkedHashMap是HashMap的子类, 在HashMap的基础上, 维护了一个双向链表, 该双向链表在记录插入顺序和记录访问顺序之间二选一, 总之就是用来记录顺序, 实现按顺序的迭代; LinkedHashMap 返回的迭代器, 是在双向链表上移动的;
- HashMap底层实现是Node数组 + 链表 + 红黑树TreeNode, Node是内部类, 实现了Map.Entry, 表示一个键值对;
Hash值
- 每个Node有一个成员
int hash
来记录这个键值对的哈希值, 这个值由key.hashCode()
经过移位异或计算得出; 如果key是null, hash = 0;
- 多加一步移位异或, 是为了增加扰动;
- 计算下标时, 本应通过取模来做, 但考虑到效率较低, 用取与代替, 并且将数组长度设置为2的整数次幂, 来保证取模和 n - 1 取与的结果一致; 另外长度为 2 的整数次幂, 在扩容时, 50% 的结点不需要移动位置, 后面讲低位树会讲到;
HashMap的成员
遍历
- 三种遍历方式: entrySet(), values(), keySet(), 和 ArrayList 类似, 这三个方法所返回的集合上的 Iterator 的移动都是直接在原本的HashMap对象上进行的;
这里可能有同学会看不懂, 关于 ArrayList 和 Iterator, 后面会再出专门的文章讲解;
- 这仨方法的返回值, 并不重要, 重要的是在返回值上取到的 Iterator, 为什么三种方式的遍历结果有的能拿到value, 有的能拿到整个 Entry ? 归根结底在于 Iterator 的 next 方法的返回值不同; 这仨的 iterator 都继承自HashIterator:
- entrySet() 返回一个Set\, 实际上也就是\, set.iterator() 返回一个迭代器, 该迭代器利用Node的next指针, 遍历一个下标位置的链表, 指向null了就 table[slot++] 遍历下一个链表, 从而完整遍历Node数组; 其next方法返回Node结点的引用;
- values原理一样的, 只不过它的iterator.next方法返回的是node.value
- KeySet也是一样, 它的iterator.next方法返回的是node.key
- 这三个视图的iterator都有expectedModCount, 作用原理和ArrayList一样
扩容
- 初始容量:
除直接从集合构造外, 其余构造函数不会分配实际内存空间, 都是在首次添加元素的时候分配空间;
无参构造, threshold = 0; 分配空间时容量设为16;
有参构造指定了 initialCap 时, 最终会调用tableSizeFor
方法, 取一个 >= initialCap 的 2 ^ n 作为容量, 最大为 1<<30;
从集合 c 构造, 由 c.size() 除以自己的 loadFactor 装填因子, 再向上取整, 算一下容量最小min是多少, 然后调用tableSizeFor 返回一个 >=min 的 2^n, 然后立即分配空间(并调用putVal方法将源集合 c 中的元素复制过来) - 扩容机制: 首次扩容时, 按存到 threshold 里的容量扩容, 并更新 threshold 为阈值;
其它时候, 当元素个数大于 threshold 时扩容, 直接double原来的阈值threshold; 逻辑上, capacity也一并double - HashMap底层数组由len扩容到2*len的时候, 原本在旧数组x位置的元素: 要么重新散列到x位置, 要么在len+x的位置; 原理后面会提到;
HashMap源码解读
面试: 红黑树转化的阈值是多少?
- 单个量表长度
大于等于
9 时转化为红黑树 (仅当桶数大于等于
64时, 因为当数组长度过小的时候, 在添加数据的过程中, 数组会反复扩容, 导致红黑树拆开又变为链表, 反复在红黑树和链表间转化效率不高/意义不大) - 记住是 > 8, 也就是 >= 9;
- 为什么是8?
这是在时间和空间上的一个权衡 (因为虽然红黑树在结点多的时候查找更快, 但红黑树结点的大小约为链表结点的两倍); HashCode 算法设计理想时, 不同长度链表的出现概率满足泊松分布, 当 Load Factor 为 0.75 时, 链表长度为 9 的可能小于千万分之一, 几乎是不可能发生的;
因此, 在 hash 算法设计良好的哈希表中, 很少会有红黑树; - 扩容时, 中序遍历原本的红黑树中的结点, 将他们重新散列到两个位置, 拆分后新构建的红黑树节点总数
小于等于6
时转化为链表, 两个阈值不一样是为了避免频繁的转化; - 以容量从 4 -> 8 为例, 原本在下标2的一棵树, 里面的hash肯定都是xxxx xx10的形式, 扩容后, 新下标由低三位决定, 原本下标为2的 entry 的 Hash 值有两种情况, xxxx x110 和xxxx x0110, 第三位的值有两种情况, 0或1, 如果是0, 说明在新数组的下标没变(低位树), 是1说明新下标为4+2(高位树)
- 不仅拆分高低树时会发生反树化, 删除结点时, 如果红黑树根节点为null, 或root.left为null, 或root.right为null, 或root.left.left为null, 都会转回链表
面试: 什么时候认为两个键值对重复?
- 两个键值对的 key 满足 (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
- 需要注意Node有四个成员, hash, key, value, next; 当往哈希表中存放key-value时, 存放时刻key的hash值被保存在Node的hash成员中, 以后再比较时并不会去计算Node中key的hash值, 而是直接用Node.hash
假设我们的key是一个Student类对象student, 并且重写了hashCode和equals方法, 那么把student作为key放到表中以后再修改student的某一字段, 就可能导致student的hashCode变化, 这时hash(student) != Node.hash, 就没办法找到我们以前插入的key-value了 - 归根结底, 哈希表是用来查找的, 不要存放可能改变的key
面试: 为什么JDK1.8之后往链表插入新键值对的时候改为尾插法
面试: 都知道 HashMap线程不安全, 你能举些例子吗?
覆盖
- 或者说是元素丢失问题
- 两个线程同时调用put方法, 计算得到了相同的index, 两个线程又同时通过了这个位置为null的判断, 就会发生覆盖问题; 本来应该拉链法解决冲突的, 现在位置上只有一个元素;
异常
- 当链表长度 > 8时, 将链表转为红黑树, 结点类型由Node转为TreeNode
- 线程一插入的时候按Node普通链表去遍历查找插入位置
- 线程二插入完成导致树化
- 切回线程一, 本来按Node处理, 但是现在已经变成了TreeNode的结构, 抛出异常
扩容时问题
- resize方法扩容后复制元素时
- 假设两个线程同时调用put方法, 最终都进入了resize方法, 线程1先让table = newTab1;
- 此时切换到线程2, 执行resize方法一开始的 oldTab = table, 现在线程2的 oldTab 直接没有元素了, 复制个寂寞